Compare commits

..
1631 Commits
Author SHA1 Message Date
zooqueen 4d9aabb7bd release: 8.5.50 — the manifest says what the registry already carries
Hanzo CI/CD / cicd (push) Successful in 5m49s
CI/CD / cicd (push) Successful in 5m49s
The version in package.json had sat at 8.5.35 while CI published through
8.5.48 (the tag hanzoai/cloud pins today): a main push derives its number as
max(declared, published) + a patch, so the registry floor carried the series
alone and the manifest drifted fifteen patches behind it.

That matters for a hand-cut tag, because a tag build publishes the tag
VERBATIM with no monotonic check — cutting the next number after the last git
tag would have republished a semver that already exists, over different bytes,
and orphaned anything pinned to it. Saying the number here puts the human's
say-so back above the floor and makes the next release the one nobody is
holding.
2026-08-05 01:38:35 -07:00
zooqueen 30acbc52d0 assistant: the send carries a credential, and the card stops lying about why
Hanzo CI/CD / cicd (push) Successful in 7m52s
CI/CD / cicd (push) Successful in 7m55s
Four defects, all measured on the live console with wire captures.

The completions request carried NO Authorization header at all. `streamChat`
reached for a bare `fetch` because a stream cannot go through the parsing
helpers that consume the body — and in doing so it left the one file where
identity is attached, so every send answered 401 while the identical
non-streaming call succeeded. So the client grows the door that was missing,
`restStream`: same `authedFetch`, same `baseHeaders`, raw `Response` back. The
gateway stream, text-to-speech and the guide's SSE step-runner all take it, and
a test drives the real path and reads the Bearer off the wire.

Preferences saved to `/v1/ai/preferences`, which is the AI gateway's casibase
handler and authenticates on a casibase SESSION — no Bearer can satisfy it, so
every save was refused "Please sign in first" while the user was signed in. They
now ride `/v1/prefs`, the per-user plane on cloud, which reads exactly the
credential the console already sends. That plane also SERVES a read, which the
old one never did: preferences were recovered from a snapshot of them carried in
the identity token, and a merge had to guess from two timestamps which side was
newer. Reading the document removes the guess, and the ordering machinery with
it.

The 401 card said "Your session expired" to people whose session was live. The
shared classifier can only see the status; whether this session is live is a
fact only the app holds, so the assistant decides it — a refusal with an
unexpired token reads as a surface that refused a signed-in caller, and "sign in
again" is kept for when it is true.

On a desktop the composer could not be clicked. Docking opened the sheet AND the
column and hid one with CSS, but the sheet is a modal dialog and hiding its
content does not stop it being modal: it covered the viewport, the column
painted inert beneath it, and every click landed on the dialog. The two shapes
now meet in one fact — the dock choice, a viewport that can hold a column, and a
page that is not already a composer — and exactly one of them mounts.

Also: the cross-app launcher sat in the lg-only topbar group, so on a phone the
grid of other Hanzo apps was unreachable. It moves to the account drawer, whose
trigger is on every viewport — one launcher, reachable everywhere.
2026-08-05 01:33:28 -07:00
hanzo-dev c1a8c8495d the tenant is the org, and "Team" is that org's members — say both
Hanzo CI/CD / cicd (push) Successful in 8m35s
CI/CD / cicd (push) Successful in 8m36s
Nothing here was ever a team. The referral, store, affiliate and author copy
called a customer organization "a team" ("when a new team signs up", "every
time a team runs your project"). The onboarding step called the tenant "your
workspace" in its title and "your team" in its blurb, while the org picker one
control away said "organization" — three words for one thing.

And Settings > Team was not a team either: its own description reads
"Organization members and roles", its index label already said "Members", and
the sibling button already said "Manage members". Only the nav label held out,
sitting in brand-scope as "Team, organization, and profile" — as if the two
were different things.

So: the tenant is an organization everywhere a human reads it, and the page
listing the people in it is Members.

Left alone: the `/team` route id (it is a link people already have), the `team`
plan tier, the helpdesk's HD Team doctype, and the tracker's real Team entity
whose boards are keyed ENG-1 — none of those are tenancy. "Teammate" and
"invite your team" stay too; those are people.
2026-08-05 01:17:31 -07:00
zooqueen a6bc25943f lists: numbered pages, and one stale claim corrected
Hanzo CI/CD / cicd (push) Successful in 4m51s
CI/CD / cicd (push) Successful in 4m51s
The observability footer offered Prev and Next and nothing else, so a reader 40
pages into a trace list reached page 1 by pressing Prev forty times. Pagination
from @hanzo/ui/product gives numbered jumps with an ellipsis. What stays in
Pager is the sentence only this surface can write — the row range — because only
it knows the shape of the meta its endpoints return.

ModelSelector's header said the package's selector was "the shadcn/Tailwind
build" and that the console, running Tamagui, could not use it. That stopped
being true: at 8.0.56 @hanzo/ui/models imports @hanzo/gui and
@hanzogui/lucide-icons-2, with no Radix and no class strings. Two real things
still block the swap and the comment now names them — the entry shape
(CatalogEntry vs ModelCatalogEntry, which decides family grouping) and the row
logos (this one draws curated brand marks through ui/ProviderLogo; the package's
draws none). A stale reason is worse than no reason: it retires a question that
is actually still open.

playground/ModelSelect had its import placed above 'use client' by the sweep
that moved it onto the package. Next reads the directive only as the first
statement, so the file had quietly stopped being a client module.
2026-08-04 23:33:49 -07:00
zooqueen 70b99204ac secrets: a masked field that is actually masked, and one copy button
`secure` on a gui Input is React Native's spelling, and the WEB build drops it:
`secureTextEntry` becomes type="password" only in @hanzogui/core's native.cjs —
grep the esm/cjs builds and the prop is not there at all. So every field in this
console that asked to be masked rendered its value in plain text, and the prop
made it look handled.

Five of them were secrets:

  AdminModule       a user's initial password
  KmsModule         a KMS secret value
  ConnectionsModule a provider API key
  AccountsTab       an AI provider key
  platform-apps     an app env secret — on a panel whose own caption promises
                    "never stored or shown in plaintext"

All five now use SecretInput from @hanzo/ui/product, which sets BOTH spellings
(`masked()` — neither alone is safe) and adds a deliberate reveal. ApiKeysModule
joins them: it printed a freshly minted key as bare selectable monospace, and
SecretInput's read-only mode copies while still masked, which removes the reason
to unmask at all.

The copy control was written seven times — sentry, code, git, webhooks,
Kubernetes, api-keys, playground, verify — each with its own confirmation window
(1200, 1400, 1500, 2000ms) and its own error behavior, one of which logged the
clipboard failure with the value in it. They are one import now. git/parts
re-exports it so its four views keep a single import site, and CopyRef stays as
the wrapper that knows how to spell a `repo file:line` reference.

The shared control also reports what was copied by LENGTH, never by content.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:30:50 -07:00
zooqueen b88389e91c ui: delete the shadow fork, and reach for the package that replaced it
src/components/ui/ was a ~30-file copy of components that were hoisted OUT of
this console into @hanzo/ui/product (hanzoai/ui#36). The hoist landed; the copy
never left. Both halves kept getting edits, so the console has been shipping the
half nobody else could see.

Twenty modules go back to the package, 278 call sites now import
'@hanzo/ui/product', and 2,311 lines leave the tree. Everything the package
gained while the copy sat here arrives with them — chiefly the interaction
instrumentation: DataTable, PrimaryButton, ConfirmDelete, EmptyState, Segmented
and SearchInput now report what a user did through @hanzogui/telemetry, which
console already depends on, with no wiring and no provider to mount.

BackendStateCard needs two effects a presentational layer cannot have — sign in
again (401) and add credits (402). The package asks its host for them, so
src/entry/host.tsx answers once at the dashboard root and all 106 cards below
render the right affordance unchanged. One answer, not 106.

The package's components name their CSS classes without the hz- prefix
(`skeleton`, `row`, `tnum`, `fade-up`, `drag`), so app/layout.tsx now imports
@hanzo/ui/styles/motion.css. Without it a DataTable's skeleton, row hover and
tabular figures render unstyled — the classes simply would not match. Console's
own hz- twins stay in globals.css; 250+ call sites in console markup still name
them.

Four things did NOT come back, because the package version is behind the copy
rather than ahead of it, and swapping them would ship a visible regression:

  ProviderLogo — draws curated per-family brand marks (BRAND_MARK, inline
    Slack/GitHub, brand-colored monograms). The package knows two providers and
    renders initials for everything else.
  ProductIcon — picks its glyph color with contrastText(). The package hardcodes
    #ffffff, which is unreadable on 3 of console's 19 product accents and
    invisible on #FAFAFA.
  Donut, Metric, Charts — read console's theme: var(--color4/9/12) rather than
    hardcoded dark hex, and the monochrome RAMP rather than the package's
    eight-color SERIES. Swapping repaints every chart in the console.
  SlideOver, Toast — take their layer from lib/z.ts. The package hardcodes
    zIndex 1000 and 100000, which are two of the exact literals that ladder was
    introduced to eliminate.

Field and Filters shrink to the part that is genuinely console's:
FieldOptionSelect (a value/label picker the package has no twin for) and the
Filters bar (it takes a List, so it is app-coupled by construction). color.ts
keeps only tileRadius, which encodes console's radius scale.

ThemeToggle and HanzoMark are deleted outright — nothing imported either; the
mark everything actually uses is ui/Loader's BrandMark.

Reorder.test.ts and combobox/filter.test.ts go with the code they tested: 3222
tests to 3210. Their assertions cannot follow the logic upstream, because
@hanzo/ui/product publishes no node-resolvable subpath for its pure functions —
importing the barrel drags @hanzogui/next-theme into vitest and fails on
next/script. A consumer therefore cannot guard the contract it now depends on.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:23:59 -07:00
zooqueen d78a71e50d deps: @hanzo/ui 8.0.56 — the release that carries the product set
The console's own src/components/ui/ is a copy of components that were hoisted
into @hanzo/ui/product (hanzoai/ui#36) and never deleted here. 8.0.56 is the
release where every one of them lands published, so the copy has somewhere to
go. Typecheck clean and 3222 tests green on the bump alone — nothing in this
tree reaches an API that moved between 8.0.38 and 8.0.56.
2026-08-04 22:42:35 -07:00
zooqueen f8d83250f7 models: reach first, and the chrome carries only navigation
Hanzo CI/CD / cicd (push) Successful in 6m0s
CI/CD / cicd (push) Successful in 6m0s
The Models page listed every family in order but gave the user no way back to
the models they actually use: every visit started at the top of a long list.
A Recent + Suggested chip strip now sits above the families — recents are the
user's own trail (recorded where a model is genuinely exercised: a chat turn
sent, a catalog detail opened; account-persisted, so it follows them across
devices), suggestions are one live rung per pinned family, which puts the
house default first by construction. Both hide while a search owns the page,
and neither ever shows a fabricated chip.

The topbar slims to what navigation needs: status, docs, the cross-app
launcher, and the account drawer trigger — now on every viewport. The network
chip leaves the bar: production is the default environment, so switching is a
deliberate act you open the drawer for, not chrome you wear. The theme toggle
leaves too — the account menu already carries theme, and two controls for one
setting is one too many. Alerts stay at /alerts and in the drawer.

Chat's stale copy caught up with the Enso default: the surface no longer
greets you with the name of a family it stopped defaulting to.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen a7a7fc9ef6 console: the cross-app launcher it never had
Console had no way to reach another Hanzo surface. The two "switchers" in its
topbar are about where you are INSIDE console — ContextSwitcher picks the org
and project, ScopeSwitcher picks the network — and the LayoutGrid in the sidebar
lists console's own products. A fullscreen launcher existed once and was removed
as a duplicate of that product filter, which it was; nothing replaced the part
it was actually for.

@hanzogui/shell has shipped HanzoAppLauncher the whole time, and chat already
wears it. It is inline-styled and React-only, so it drops into a Tamagui topbar
untouched — no wrapper, no theme bridge. quickSwitchKey={false} because
CommandPalette.tsx:806 already binds ⌘K; the shell's prop doc names Console as
the reason that opt-out exists, so this integration was designed for and then
never landed.

Also takes shell to 8.1.1, where the panel is portalled out of its host's
stacking context. This topbar does not clip it today, but the version that
cannot be clipped by a container the component never sees is the one to pin.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen 7c6305c455 toast: memoize the context value so raising a toast cannot raise another
The provider built its context value fresh on every render and passed it
straight to Provider. Its own state changes on every toast, so each toast
handed every useToast() consumer a new identity. An effect that both
depends on the toast api and raises a toast therefore re-triggered itself:
raising one rendered the provider, which handed the effect a new api,
which raised another. The OAuth return did exactly that and stacked twelve
identical cards down the viewport.

Stripping the query params could not have stopped it. router.replace is
asynchronous, so the params are still readable on every render in between
-- and the effect calls load(), which causes exactly those renders. The
integrations return now latches once per mount; stripping the params
covers the NEXT mount, which is the case it can actually address.

The callbacks were already stable, so memoizing the object is the whole
fix, and it makes depending on the toast api safe everywhere, not just
here.

Proven by render, since no unit test can see a render loop: reverted, the
new spec counts 12 toasts; fixed, it counts 1.
2026-08-04 21:48:19 -07:00
zooqueen 15da85d0ac the assistant answers as Enso, and asks for anything
The console preselected zen5-flash and greeted you with 'Message
zen5-flash…' — the machine's model id where an invitation belongs, and
a stand-in family where Hanzo's own belongs. Enso left limited preview,
so the house family is what an unchosen caller should get: DEFAULT_MODEL
is enso-flash, the family's free rung, which keeps the property the old
default was chosen for (a trial or welcome balance still answers on the
first message). The placeholder now says what the box is for.

The preselect's fallback follows: the house family, then the first
model discovery offers.
2026-08-04 21:48:19 -07:00
hanzo-dev d20f0859f2 fix(entry): an anonymous console visitor starts the IAM hop, not a second landing
Hanzo CI/CD / cicd (push) Successful in 4m45s
CI/CD / cicd (push) Successful in 4m45s
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>
2026-08-04 20:12:20 -07:00
hanzo-dev 53375e44ed test: make the rescued gui-8 guard true on this lineage
Hanzo CI/CD / cicd (push) Successful in 5m3s
CI/CD / cicd (push) Successful in 5m4s
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 6ca6cc8fe1)
2026-08-04 18:59:00 -07:00
hanzo-dev 87abc48add fix(gui): the gui 8 props that silently rendered nothing
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 19e6d2e250)
2026-08-04 18:59:00 -07:00
hanzo-dev 8153606ac8 wip: preserve agent work interrupted by session limit
(cherry picked from commit bac7cc5bf68eb03973ab0bec5d28fcc23b2a2ee8)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit e077a2285a)
2026-08-04 18:59:00 -07:00
hanzo-dev c4ac4eff3f wip: preserve agent work interrupted by session limit
(cherry picked from commit c256eb39aab2280121bfec766fdea3ea92f71091)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 775d9ab840)
2026-08-04 18:59:00 -07:00
hanzo-dev 922ba7d676 wip: preserve in-flight telemetry + event bump before 8.x convergence
(cherry picked from commit 0045bac1727d1251991f1106b8f2bccb1acb34ac)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 909388da5e)
2026-08-04 18:59:00 -07:00
hanzo-dev 15b8cff948 refactor(commerce): render the shared CommerceResource, not a second copy
@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 4dbae2115d)
2026-08-04 18:59:00 -07:00
hanzo-dev bbff6b3a82 feat(tracker): Linear-grade standalone tracker at tracker.hanzo.ai
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 613abbabb5)
2026-08-04 18:59:00 -07:00
zeekayandClaude Fable 5 83e6eb4667 Read design tokens from the published package, not a vendored copy
Hanzo CI/CD / cicd (push) Successful in 5m16s
CI/CD / cicd (push) Successful in 5m16s
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>
2026-08-04 15:53:28 -07:00
zeekayandhanzo-dev 32c88caa12 Merge branch 'lda' into mrg
Hanzo CI/CD / cicd (push) Successful in 6m35s
CI/CD / cicd (push) Successful in 6m36s
# Conflicts:
#	LICENSE

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:18:13 -07:00
hanzo-dev b4c71e1aac Merge branch 'fix/restore-upstream-license'
Hanzo CI/CD / cicd (push) Successful in 7m16s
CI/CD / cicd (push) Successful in 7m17s
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.
2026-08-04 12:16:05 -07:00
hanzo-dev c8295794fa Merge remote-tracking branch 'origin/ux/console-fab-apps-nav' into try-ux
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:08:51 -07:00
hanzo-dev 40da2fc17d o11y metrics: follow the read to its own name
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>
2026-08-04 11:38:17 -07:00
hanzo-dev d09d3cae1b the assistant has one home, and the app directory is one you can walk
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>
2026-08-04 10:38:14 -07:00
hanzo-dev 0207df0759 fix(license): move vendored MIT notices out of LICENSE into NOTICE
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>
2026-08-04 09:59:29 -07:00
zooqueen a0a489986d onboarding: Continue keeps its place, and no step can strand you
Hanzo CI/CD / cicd (push) Successful in 4m41s
CI/CD / cicd (push) Successful in 4m42s
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.
2026-08-04 06:57:10 -07:00
hanzo-dev b6e35ee042 legal: console is MIT OR Apache-2.0 (HIP-0137)
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.
2026-08-04 01:28:13 -07:00
hanzo-dev 06e416365b retire the ML Pipelines (Kubeflow) product — its whole backend is gone
Hanzo CI/CD / cicd (push) Successful in 5m28s
CI/CD / cicd (push) Successful in 5m29s
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.
2026-08-04 00:32:39 -07:00
zooqueen da268976b8 auth: a refusal is not always a failure
Hanzo CI/CD / cicd (push) Successful in 4m59s
CI/CD / cicd (push) Successful in 4m59s
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.
2026-08-03 22:21:07 -07:00
zooqueen 2271c29297 profile: a photo you can change, instead of one you can only look at
Hanzo CI/CD / cicd (push) Successful in 5m4s
CI/CD / cicd (push) Successful in 5m5s
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.
2026-08-03 18:03:31 -07:00
zooqueenandhanzo-dev 9da398459e deps: iam 0.21.6 — the SDK whose storage default survives prerender
Hanzo CI/CD / cicd (push) Successful in 4m54s
CI/CD / cicd (push) Successful in 5m24s
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>
2026-08-03 17:18:58 -07:00
zooqueen b9d31aa305 landing: the CTA starts the sign-in, instead of asking again
Hanzo CI/CD / cicd (push) Failing after 1m44s
CI/CD / cicd (push) Failing after 1m56s
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.
2026-08-03 17:07:58 -07:00
zooqueen 1b50c31a96 auth: one token store, and every tab can see it
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.
2026-08-03 17:05:27 -07:00
zooqueen 846069c98b onboarding: a refused create is not a complaint about the name
Hanzo CI/CD / cicd (push) Successful in 4m53s
CI/CD / cicd (push) Successful in 4m53s
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.
2026-08-03 13:48:19 -07:00
hanzo-dev 06b7f5416a ia: one trigger for the command palette, not two
Hanzo CI/CD / cicd (push) Successful in 5m25s
CI/CD / cicd (push) Successful in 5m26s
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>
2026-08-03 12:37:06 -07:00
hanzo-dev 80cbfcbbb1 ia: org and project are one question, so they are one control
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>
2026-08-03 12:36:44 -07:00
hanzo-dev 4656c316f4 build: react-native-svg — the dependency gui 8 needs and the bump forgot
`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>
2026-08-03 12:11:49 -07:00
zeekay d9bccc9e8d console: 400+ models, and two comments that cannot keep a number current
Hanzo CI/CD / cicd (push) Failing after 1m2s
CI/CD / cicd (push) Failing after 1m2s
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.
2026-08-03 09:45:29 -07:00
zooqueen ffd291fedf keys: two shapes — sk- authenticates, pk- does not
Hanzo CI/CD / cicd (push) Failing after 1m5s
CI/CD / cicd (push) Failing after 1m5s
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.
2026-08-02 13:31:55 -07:00
hanzo-dev 4da48f9274 billing: call the route names the server registers, and stop minting our own credit
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>
2026-08-01 22:07:39 -07:00
hanzo-dev 7a37a8fd92 analytics: identify carries the attributes that make the id legible
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.
2026-08-01 14:54:48 -07:00
hanzo-devandhanzo-dev 29c98ddf5b analytics: attribute the stream with the IAM bearer, identify by sub
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.
2026-08-01 12:33:07 -07:00
hanzo-dev f64de84a4e deps: @hanzo/event 0.3.8 — the release that redacts credentials from event URLs
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.
2026-08-01 12:25:22 -07:00
hanzo-dev ecb9be3a8b deps: @hanzo/event ^0.3.5 for page-stamped autocapture
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.
2026-08-01 10:53:23 -07:00
zooqueen 400a206d80 api: follow cloud onto /v1/o11y/reviews
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.
2026-07-31 17:35:47 -07:00
zooqueen 628e6fb469 api: follow cloud onto datasets/:name/items and rubrics
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.
2026-07-31 17:33:52 -07:00
zooqueen c1a7d48113 api: follow cloud off the spend-caps and block-storage compounds
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.
2026-07-31 17:28:26 -07:00
zooqueen e5f510b994 api: follow cloud off the load-balancers compound
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.
2026-07-31 17:24:43 -07:00
zooqueen 0a883aefaf api: follow cloud off the share-classes and equity-plans compounds
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.
2026-07-31 17:22:03 -07:00
zooqueen 0c097129d8 api: follow cloud off the rotate-secret compound
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.
2026-07-31 17:19:38 -07:00
zooqueen 3749f2669f api: /v1/billing/alerts — follow the rename off the compound 2026-07-31 16:21:05 -07:00
zeekayandClaude Opus 5 06d0681081 deps: unfreeze console's first-party pins; quarantine finance-ui 0.2.x
@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>
2026-07-31 15:49:51 -07:00
zeekay cdb6f9d091 deps: console onto gui 8.x — the shell fixes can finally reach production
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / cicd (push) Failing after 1m7s
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.
2026-07-31 14:35:28 -07:00
hanzo-dev 744164c4c5 merge: one host normalizer for the brand/admin-gate boundary
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.
2026-07-31 14:30:36 -07:00
hanzo-dev 454636c4e2 merge: subscribe the console to the DocType engine's change feed
feat/framework-realtime, additive: framework client gains a change-feed
subscription plus its types and tests. No existing surface changes.
2026-07-31 14:30:36 -07:00
hanzo-dev 8bdbc9116a telemetry: say why the console bakes no ingest key
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.
2026-07-31 10:46:36 -07:00
hanzo-dev 599f6f3a31 billing: Stripe is back in the connector list — Square stays our rail
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>
2026-07-30 21:52:02 -07:00
hanzo-dev f26c8fbcca billing: the UI stops naming a rail we do not use
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>
2026-07-30 21:22:43 -07:00
hanzo-dev 447825ef6d api: read the named total first; data2 is only the legacy fallback
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>
2026-07-30 12:32:55 -07:00
hanzo-dev 9915705d10 referrals: signup, one vocabulary
Hanzo CI/CD / cicd (push) Successful in 6m2s
CI/CD / cicd (push) Successful in 6m2s
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>
2026-07-30 10:02:01 -07:00
hanzo-dev b10c5ce18f identity: a refusal, a network error and an absence are three answers
IAM v1.33.31 made org scoping honour-or-refuse (internal/authz/authz.go Scope):
a non-SuperAdmin asking about a foreign owner is REFUSED, not silently re-pointed
at its own org. The confidential `hanzo-console` client is exactly the principal
that starts earning 403s, and this module read every failure as `null`.

`iamGetData` returned null when unconfigured, null on a thrown fetch, and null on
`!res.ok || status !== 'ok' || data == null` — so a 403 refusal, an unreachable
IAM, and a malformed envelope were indistinguishable from "no such row". IAM's
own getHandler (internal/compat/aliases.go) answers three DIFFERENT things from
one endpoint:

  hit      200 {status:"ok", data}
  absence  200 {status:"error", msg:"the entity does not exist"}   (httpx.Err)
  refusal  403 {status:"error", msg:"forbidden: …"}                (authz.Deny)

Collapsing them cost real behaviour, not just tidiness:

  - getMember is the invite flow's identity read. A refusal became "this
    invitation is no longer valid — the member was removed" (410), telling an
    invitee their membership was revoked when IAM merely would not answer, and it
    is the same null the single-use activation guard reads.
  - getUserKey is the API-key state read. A refusal became "no key", which is the
    exact regression its own docstring records: the page falls back to Create, the
    live key is hidden, and the user mints a duplicate over a key they can no
    longer revoke.

So: ONE transport (`iam`) that returns data or throws, carrying `absent` for the
one benign kind — the same contract and the same wire constants as the app's
client (ai-sdk lib/org/onboard.ts) and cloud's Go client (apps/account/iam.go
`do`), which is why `method` is explicit here: IAM has param-only POSTs. Over it
sit exactly two named policies: `iamGetOrAbsent` (absence is a legitimate answer
to null, everything else propagates) and `iamGetUser` (fail-SOFT, and safe
precisely because the admin gate admits only on positive evidence, so a lost
answer can only ever DENY). Soften where a lost answer closes a door, never where
it opens one.

That replaces FOUR transports — iamGetUser, iamCall, iamGetData, iamPostBody —
which had three different error conventions between them.

Also drops the org-onboarding half (getOrganization, createOrganization,
createUser, moveUserToOrg, IamOrganization): dead since onboarding moved server
side to cloud's POST /v1/iam/onboard (apps/account/account.go), which
OrgOnboarding.tsx calls via v1Url('iam/onboard'). Zero consumers — tsc --noEmit
over the whole app is the proof, not a grep. Deleted outright, no shim.

The one behaviour change beyond the above: the transport now refuses when the
confidential client is unconfigured instead of sending Basic Og== and letting IAM
401. Both throw; this one says why and skips the round-trip. Every route already
checks mintConfigured() first, so the three identity.test.ts cases that relied on
the old unguarded path now configure the client, which is the production
precondition they were always standing in for.

Tests: identity-refusal.test.ts pins all three answers plus the unreachable and
malformed cases for both live readers, and pins that a REFUSED claims read still
fails the admin gate CLOSED. 6 of its 11 fail against the code this replaces.
Suite 3141 passed / 8 skipped (was 3130 / 8); tsc --noEmit clean; next build
compiled successfully.
2026-07-28 23:30:58 -07:00
zooqueen d93e9148b2 docs: the identity server these comments describe is IAM, not a vendor
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.
2026-07-28 17:12:30 -07:00
zooqueen 6b881b2a8e docs: /zap is served — the reason recorded for preferring REST is stale
This file says "the cloud /zap WS face is NOT served (the edge returns SPA
HTML, 200 not a WS upgrade)". That was true when written and is not true
now. Measured 2026-07-28 on api.hanzo.ai, platform.hanzo.ai and
cloud.hanzo.ai: a WebSocket upgrade handshake returns 401 on all three —
not SPA HTML, not 200. A route that refuses an unauthenticated upgrade is
a route that exists.

Read as current, the paragraph says ZAP is unavailable when it is merely
gated, which is the kind of stale rationale that gets acted on. The REST
path it describes is still correct and still shipping, so nothing about
the code changes — only the reason.

Marked stale in place rather than deleted: the history explains why
Providers moved to REST, and removing it would lose that. The correction
names what was measured and where, so the next reader can re-check rather
than trust either version.

Found while sweeping for docs that actively mislead. The sibling case,
universe crs/console.yaml justifying replicas:2 with an admin.hanzo.ai
route that no longer exists, needs no fix — that CR has since been
retired with the standalone console.
2026-07-28 13:30:59 -07:00
hanzo-dev f85b08e44d console: name the fleet board by its one name
Cloud folded /v1/paas into /v1/platform — paas was a second name for the same
product, and one product gets one name. The operator fleet/drift board now lives
at /v1/platform/fleet.

No functional change here, and that is the point: the `platform` allowlist head
already admits every /v1/platform sub-path, so the new route was reachable the
moment cloud shipped it. What was stale was the PROSE — three comments still
pointed readers at `/paas/apps`, a route that no longer exists, and the
allowlist's own sub-path list did not mention the board it admits. A comment
that names a dead route is worse than no comment: it sends the next reader to
look for a surface that was deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 11:45:18 -07:00
hanzo-dev daef1ea32c console(framework): subscribe to the DocType engine's change feed
The generic renderer had no realtime at all — zero EventSource, zero subscribe.
This adds the client half of the ONE mechanism the engine now serves:

  changes.list(q)        one page of the feed  -> GET /v1/framework/changes
  changes.subscribe(q,h) the same query held open as SSE -> /v1/framework/stream
  presence.list(dt,name) the roster            -> GET /v1/framework/presence

changeQuery is ONE function for the poll and the stream, because server-side
they are ONE query — the stream is that query in a loop. A view renders current
state from records.list, keeps the cursor it gets back, and applies changes from
there.

No credential and no org leaves the browser. subscribe opens a same-origin
EventSource through the console's existing /v1 bearer proxy, which already
streams res.body straight through; the proxy mints the short-lived user-bound
token and the engine resolves the org from its owner claim. There is nothing a
page could send to name another tenant, which is the reason to prefer SSE here
over a WebSocket that would need its own auth path.

Resume is the browser's job: each frame's id IS the change's seq, so a dropped
connection reconnects with Last-Event-ID and misses nothing. onReset fires when
the cursor fell behind the server's retention window — refetch, THEN resume,
never patch on top of a state you never had.

Passing watching: '<DocType>/<name>' additionally declares presence for as long
as the connection lives, which is why presence needs no client->server channel.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:58:12 -07:00
hanzo-dev 7a722f0a3c feat(console): the shared components report through the console's ONE client
@hanzo/ui 8.0.20 instruments itself — DataTable, PrimaryButton, SlideOver,
ConfirmDelete, the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView,
OrgSwitcher, ThemeToggle, Toast and EmptyState now report what a user did. The
console renders all of them, so it gets the whole interaction vocabulary without
a line of app code — that is the point of instrumenting the component instead of
the app.

But those components emit through module-scope `track()`, which resolves an
AMBIENT client, and left alone that would have been a SECOND client: default host
api.hanzo.ai, no session cookie, its own anon id and its own batch. Cloud's
anonymous capability lane admits pageview and error only, so every component
event would have been DROPPED on arrival — a silent, plausible-looking nothing.

So `src/lib/event.ts` — the file that already declares itself the ONE console
client — registers `eventClient` as the ambient one. Now the provider's pageviews,
the error boundaries, and every shared component ride a single same-origin batch
that carries the session cookie, which is what makes their events CREDENTIALED and
attributable to the signed-in org. One client, one anon id, one stream, exactly as
that file's header always promised.

Also pins typescript back to 5.9.3. Under the 7.0.2 that main had taken, Next
15.5.19 stops honoring the tsconfig `paths` map and the build dies on every
alias — `Can't resolve '~/config'`, `'~/lib/event'`, `'~/components/ProductRoute'`
— i.e. main could not build at all, before and independent of this change. Adding
`baseUrl` does not help; only the TS pin does.

Build: 8.0.20 installs clean under pnpm 11 (8.0.19 could not — it shipped
`workspace:*`, fixed upstream) and `next build` completes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:22:39 -07:00
hanzo-dev 58ff94c159 ci: delete the GitHub puller — one direction, decided elsewhere
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.

It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 08:57:09 -07:00
hanzo-dev b489ab0fdb ci: pin .hanzo/workflows/build.yml@v1
v1 is the .hanzo/workflows era. There is no v2 — one tag, forward only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:31:05 -07:00
zandhanzo-dev 13c5b30cd6 build: regenerate the lockfile for @hanzo/ui ^8.0.17
Hanzo CI/CD / cicd (push) Failing after 47s
CI/CD / cicd (push) Failing after 47s
CI fails at install, before anything is built:

    [ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile"
      specifiers in the lockfile don't match specifiers in package.json:
      - @hanzo/ui (lockfile: ^8.0.11, manifest: ^8.0.17)

package.json was bumped without regenerating the lockfile, and `--frozen-lockfile`
refuses — correctly; that is what frozen means and the strictness is worth
keeping.

Resolves to 8.0.18, the newest release satisfying ^8.0.17.

Regenerated with pnpm 11.17.0, the version this repo declares in
`packageManager`, so the file matches what the builder's corepack will use. The
diff is large but it is not a format rewrite: lockfileVersion stays 9.0, the
change begins exactly at the @hanzo/ui specifier, and only 30 package entries
are added (2 removed) — lucide-react, sonner, tailwind-merge, react-remove-scroll
and friends, i.e. what @hanzo/ui pulls in between 8.0.11 and 8.0.18. The rest of
the churn is peer-hash suffixes on existing entries. packages and snapshots both
count 1060, so the file is self-consistent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:21:32 -07:00
zeekayandhanzo-dev 108977e0ca fix(deps): take @hanzo/iam 0.21.2 and delete the patch it supersedes
`patches/@hanzo+iam+0.13.6.patch` added a fallback that reads the token's
`exp` claim when the server omits `expires_in`, so session expiry is still
learned. It was written against 0.13.6; the dep has since moved to 0.21.1,
so patch-package refused it — and, because a version mismatch is a
warning-class failure there, `postinstall` still exited 0. The patch had
silently stopped applying with nothing failing: verified absent from the
installed 0.21.1, so the console was running without the fallback.

Fixed upstream instead (hanzo-js/iam 08476b4, released as 0.21.2) where it
belongs — one implementation, every consumer — so the patch is now
genuinely unnecessary, which is what patch-package itself reported.

Verified: 0.21.2 installed, the fallback present in node_modules, and
postinstall clean ("No patch files found"). typecheck 0 errors; 3136 tests
pass.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:04:04 -07:00
hanzo-dev 137432ad42 refactor(gui): import the ONE scale, stop declaring it
gui.config.ts declared the console's type/radius/space ladder. It is not the
console's ladder — it is the ladder every @hanzo/ui/product component is drawn at,
and the dedicated Hanzo Social app renders those same components. A private copy
would have forked the moment either side tuned a size.

It now ships with the components (@hanzo/ui/gui-config, 8.0.17); this file is the
console's import path onto it, so `~/gui.config` call sites are unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:30:51 -07:00
hanzo-dev 9bd12d7c28 refactor(social): render the shared surface, not the console's private copy
The whole social product lived HERE — a 601-line SocialModule plus its own copy of
the /v1/social contract in src/lib/api/social.ts. That is fine while the console is
the only host, and it stopped being true: social.hanzo.ai gets a dedicated app, and
the only way for a second app to render Publish was to copy 800 lines and let them
drift.

So the product moved to @hanzo/ui/product/social (SocialResource + createSocialApi)
and the console keeps only what is genuinely the console's:

- src/lib/api/social.ts is now the TRANSPORT binding — the four verbs on
  originV1Url('social/…'), through our own app/v1 user-bearer BFF — and re-exports
  the contract so call sites keep one import path. It binds
  @hanzo/ui/product/social/api, the React-free entry, so the console's data layer
  (and its node tests) never load a component tree.
- SocialModule.tsx is the mount: hand SocialResource the bound client. 23 lines.
- The tests split the same way the code did: the contract's normalizers and paths
  are tested in @hanzo/ui; what is tested here is what is ours — that a contract
  path resolves to this origin's /v1/social/… .

817 lines of module + client become 77. Nothing about the surface, the routes, or
the tenant isolation changes: the org is still resolved SERVER-SIDE from the bearer
owner claim, and social.hanzo.ai still boots straight into this product.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:20:42 -07:00
zeekayandhanzo-dev bc93b371f6 build: typescript ^7.0.2 (native compiler)
TypeScript 7 is the native Go compiler; the npm package is a shim resolving
a platform-specific native binary. The build is `next build`, so SWC does the
emit and tsc is typecheck-only — no built artifact changes.

Measured on this same config before and after: 0 errors on both tsc 5.9 and
tsc 7, so the compiler swap introduces nothing new.

Note: `pnpm install` exits non-zero here on an unrelated, pre-existing
postinstall failure — patches/@hanzo+iam+0.13.6.patch no longer applies
because the installed @hanzo/iam has moved past 0.13.6. That failure
reproduces with the previous package.json and is not caused by this change.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:07:47 -07:00
hanzo-dev 2dc202092b admin/kms: the org rides the identity channel, not the URL
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.
2026-07-27 21:22:49 -07:00
hanzo-dev eeeeab7074 merge origin/main 2026-07-27 21:03:30 -07:00
hanzo-dev 73cdffbefa console: one word for the platform-sudo gate — SuperAdmin
The gate had three names for one concept: the component was OperatorAccessRequired,
the headline said 'Operator access required', the body said 'an admin role', and the
predicate underneath was useIsSuperAdmin. Lux carried a fourth copy — its own
hardcoded 'Operator access required' whose body told a signed-in operator to
'sign in with an operator account', which this repo's own P1 rule forbids: a 403 is
signed-in-but-not-authorized, never a sign-in prompt.

The structure was already one-way — one component, one predicate across 27 call
sites. Only the naming forked. So: SuperAdminRequired, and the headline is a single
exported SUPERADMIN_REQUIRED that Lux's error card now shares, so the string cannot
drift again. The body states what the predicate actually tests — membership of the
reserved admin org.

Also adds an e2e for the research dashboard, written so it can fail. The console is
a SPA behind a catch-all: every path returns 200, so a status-code test passes after
the route is deleted. And /research is behind AuthGate, so an anonymous visitor sees
neither the dashboard nor the gate — measured, the body reads 'Sign in to your
account'. The two tests that run therefore prove the gate holds and that a nonsense
path renders no dashboard; each anchors on the shell having rendered first, because
an absence assertion is otherwise satisfied by a dead host. Verified: green against
cloud.hanzo.ai, both red against an unreachable one. Proving the dashboard paints
needs a SuperAdmin session, so that test is staged behind HANZO_PASSWORD rather than
faked green.
2026-07-27 21:03:22 -07:00
zeekayandhanzo-dev e25d198ea4 build: typescript ^7.0.2 (native compiler)
Hanzo CI/CD / cicd (push) Failing after 21s
CI/CD / cicd (push) Failing after 36s
TypeScript 7 is the native Go compiler; the npm package is a thin shim
that resolves a platform-specific native binary.

Safe here because the build does not emit through tsc — the bundler does,
so tsc is typecheck-only and the built artifact is unaffected. Gated on a
measured comparison of both compilers against this same config: TS7
introduces no errors TS 5.9 did not already report.

typescript now 5.9.3

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 19:11:44 -07:00
hanzo-dev 1bb596309a fix(social): carry a post's media instead of silently dropping it
Cloud's Post has always had `media []string` (store.go, always serialized as an
array, never null) but the console type and normalizePost omitted it, so the
field was invisible here — and destructively so: updatePost rebuilds the row
from the request body with `Media: normMedia(body.Media)`, meaning any PUT that
round-tripped a console-normalized Post would wipe the post's media. Nothing
calls posts.update today, so this is latent rather than live.

- Post.media + normalizePost, via one `strs` coercion helper that
  normalizeProviderCapability's inline duplicate now folds into.
- Show the URLs in the post detail drawer, so the data is visible rather than
  parsed and discarded. No upload affordance: cloud has no media endpoint, and
  inventing one would be dishonest.
- LLM.md: replace the stale "embed the Hanzo Social dashboard" TODO — it still
  described the retired standalone social-frontend as live and listed
  PostComposer as unextracted. It now records the cutover (social.hanzo.ai IS
  this console in social-only shell mode), the @hanzo/ui/product/social parts,
  and the exact publish blocker keeping SocialModule on its local copies.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit eb1d7a6056aa7d2daec80ce17dc7d60794aaaca1)
2026-07-27 18:50:04 -07:00
zooqueen 8c2476fff1 console: the meta description leaked Hanzo's name on Lux and Zoo
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.
2026-07-27 18:33:26 -07:00
zeekayandClaude Opus 5 a9464d7264 fix(nodes): probe luxd at /v1/bc/P and /v1/info -- /ext/ is a 404
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>
2026-07-27 18:15:49 -07:00
zeekayandhanzo-dev 95d5f0bbaa fix: declare CSS side-effect imports for TypeScript 7 (TS2882)
TypeScript 7 reports TS2882 for a side-effect import with no type
declaration; TS 5.x accepted them silently. Next.js resolves stylesheets
through its own loader, so `import './globals.css'` never reaches the
TypeScript module resolver — the ambient declaration tells the checker
they are legitimate rather than giving them a shape.

Closes the entire TS7 gap for this app:

    before   ts5.9 25 errors   ts7 32 errors   (7 unique to TS7, all TS2882)
    after    ts5.9 25 errors   ts7 25 errors   (0 unique to TS7)

No regression on 5.9. The remaining 25 are pre-existing on both compilers.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
zeekayandhanzo-dev 4d5cc48b4d fix: complete the TypeScript 7 tsconfig migration
The first pass left three classes of config that BOTH tsc 5.9 and tsc 7
reject. Each was proven against both compilers before changing:

  TS5090  A `paths` target must be relative once `baseUrl` is gone. The
          first pass skipped the "./" prefix wherever baseUrl pointed at
          the config's own directory, reasoning it was semantically
          equivalent. It is not — without baseUrl a non-relative target
          is rejected outright, by 5.9 as well as 7.

  TS5110  `moduleResolution: node16` requires `module: node16`. The first
          pass mapped commonjs projects to node16 resolution alone, which
          BROKE those configs for the current toolchain. Both are now set.

  TS5102  `downlevelIteration` is also removed in TS7; it was missing from
          the dead-flag list.

Verified: repos that tsc 7 previously refused (base-studio, js-sdk, kv-js)
now report zero config errors on tsc 7 AND tsc 5.9.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
zeekayandhanzo-dev 2f3495ce53 build: migrate tsconfig to TypeScript 7 (native compiler)
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.

`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).

`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.

The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
hanzo-dev be65648bd0 merge origin/main 2026-07-27 14:51:53 -07:00
hanzo-dev f54074fa14 merge: name our own surfaces, not the upstream projects behind them
# Conflicts:
#	src/components/products/store/logic.ts
#	src/lib/api/admin-o11y.ts
2026-07-27 14:47:21 -07:00
hanzo-dev 12fc3c53b9 console: name our own surfaces, not the upstream projects behind them
Every string a user or operator reads should name the Hanzo product, not the
OSS project it is built on. Fourteen places still read the other way:

- Deploy (registry + GitOpsModule): the catalog description, the `gcp`
  equivalence row, the page subtitle, and the empty-state bullet all said
  "No ArgoCD". The `gcp` field renders as an "Equivalent to" row on six
  surfaces, so an operator read "Deploy — Equivalent to: Cloud Deploy /
  ArgoCD" (which is also wrong: ArgoCD is not a GCP product). Now: the Hanzo
  operator reconciles.
- Functions overview: the health tile read "Fission health" — the same file
  that renders the tab already documents the rule ("never the OSS name").
  Now "Functions health".
- The three telemetry 501 cards (Metrics, Status, Lux Network) named the
  telemetry engine as ours in operator-visible body text; the Lux one did it
  on a Lux-branded surface. They now say "the telemetry store". The `VM_URL`
  env var is UNCHANGED — something reads it.
- Vector empty states (collections + product landing): the index a collection
  maps to is ours; it reads "Vector/Search index" now.
- App Store: provenance tags (`caprover`, `dokploy`, `casaos`, …) were kept
  out of the quick chips but `availableTags` still handed them to the "All
  tags" expander, so a customer browsing our store saw chips naming other
  marketplaces. They are dropped from the browsable set — the set itself is
  catalog-derived data and stays, and free-text search still matches it.
- admin-o11y doc comment: our own tables were described with the dead
  `signoz_*` prefix, stale since the o11y debrand landed.
- The GitHub-sync workflow called our forge "this Gitea" twice; endpoints.md
  called our backend "the casibase API".
- storage-fleet e2e fixture: `pvc-signoz` / service `signoz` were screenshotted
  into e2e-shots inside a Hanzo operator board.

Attribution is untouched. NOTICE, LICENSE, the registry's `upstream` rows, and
every provenance comment (Gitea/Casdoor/Temporal/ArgoCD/PocketBase/SeaweedFS/
Fission/Langfuse lineage notes) are left exactly as they are — they explain
where a design came from and are legally load-bearing.

Not changed, deliberately: the Datastore "Connect" snippet still names
`clickhouse-client`. It is a command a customer copies and runs, and
packages.hanzo.ai does not serve a `datastore-client` yet — renaming it would
hand people a command they cannot install. That one needs the client published
first.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 13:55:21 -07:00
zooqueen f6fb4ae8f9 refactor(shell): three Panels become one; the gate goes green on real defects
Follow-up to the scale/ladder pass. Every change here was found by RUNNING the
gate against the shell and reading what it caught, not by reading source.

THREE PANELS BECOME ONE. Adding a `Panel` for the settings surface made it the
third: `ui/Metric.tsx` had one for chart bodies (13 consumers) and
`overview/living/tiles.tsx` had a private one. Same concept — a rounded surface
with a quiet header — carrying three paddings, two title sizes and two names for
the header slot (`right` vs `actions`). Now one definition in `ui/Panel.tsx`,
one import path, one name per prop. The body is CONTENT (padded) or `rows`
(a `Row` list that pads and separates itself) — the flag names the content
shape, not a style, which is why it is a flag and not a fourth component.

That unification also fixed a real mobile bug. The tiles' `minW={340}` inside a
366px pane painted to x=432 at 390 wide, clipped with nothing to scroll it. A
pixel minimum bigger than its container is always wrong; `min(340px, 100%)` says
the actual intent — this wide, never wider than the space there is — with no
breakpoint to get backwards. Same class in the Resources header, where a bare
View is `flex-shrink: 0` so it held its 533px max-content width and never
wrapped: the v8.5.29 landing-footer defect, in a second place.

THE ACTIVE STEP MARKER WAS 3.36:1. White on `$color9` — below AA, and measured,
not eyeballed. On the monochrome ladder the CURRENT step should be the strongest
surface: `$color12` with `$color1` ink, ~18:1, and it reads as "you are here"
rather than as a mid grey.

`Monogram` (ui/Monogram.tsx) — an avatar's initials scale with their circle, so
they are a graphic, not app text, and the gate skips them. It has to be a real
DOM element: react-native-web DROPS unknown props, so `data-monogram` written
onto a Gui `<XStack>` looked applied in the source and was absent in the page.
It also has to sit around whole distributed components, because @hanzo/ui paints
the org mark itself — so the gate additionally requires text of at most three
characters, and a marker placed that widely still cannot exempt a label.

The product rail is now a real `<nav aria-label="Products">` landmark. It had no
role at all, so a screen-reader user could neither jump to the product list nor
skip past it. `display: contents` adds the landmark with zero layout effect.

GATE CORRECTIONS, each because the rule was wrong, never to make a failure go
away — every one is narrower or more truthful than what it replaced:
 - a closed drawer parks off screen BY DESIGN (nav at -320, account at +390), so
   an `aria-hidden` subtree is not content;
 - a wide DataTable already scrolls inside its own container, so painting past
   the edge only counts when nothing can scroll to it;
 - the rail mixes rows and columns, so "tops never decrease" was simply false —
   it now asserts every reachable row is inside the viewport;
 - @hanzo/gui pins its portal host to a hardcoded 105001 that no console config
   can reach. Excluded by its own class marker and REPORTED, rather than raising
   the ceiling and quietly letting our own literals back in.

Gate: 6/6 green. It failed 6/6 before these fixes, and each failure was a real
defect — GET STARTED, the monogram, 3.36:1, the clipped tile.
2026-07-27 12:23:04 -07:00
zooqueen e67dad275a feat(shell): one type/radius/spacing scale, one z ladder, zero all-caps
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.
2026-07-27 12:22:34 -07:00
zooqueen 702a5208cd feat(console): find and do — one persisted list view, pins that survive, a palette you can act in
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).
2026-07-27 11:52:44 -07:00
zooqueen a0ed4d8729 feat(shell): one account control, both switchers, at the foot of the rail
The console had THREE places to answer "who am I and which org am I in": an org
switcher at the top of the sidebar, an account popover at the bottom, and a third
menu in the phone drawer — with four ways to sign out between them. They are now
one control, mounted where the CTO asked for it, and it is the shared
`@hanzo/iam` UserMenu rather than a fourth thing built here.

Deleted: OrgSwitcher.tsx (a wrapper around the retiring shadcn @hanzo/ui), the
account popover and its two private helpers, the drawer's own theme/profile/
sign-out rows, and the wallet's duplicate sign-out. The shell is 160 lines
lighter and the wallet is a wallet again.

REACH. The SDK's own org state reads the token's memberships claim, which cannot
express what an admin console does. `@hanzo/iam` 0.21.1 takes an optional
`findOrgs`, so the switcher searches the console's EXISTING lazy, server-paged
cross-tenant list — the same `IamAdminApi.organizations` the full-page picker
uses, gated to a super admin, unchanged. A regular user is never asked for it and
sees their own org exactly as before. The reach was extended in the SDK, not
forked here.

MONEY. `adminOrgState` is a pure adapter and passes `org-scope.switchOrg` BY
REFERENCE — the console's one switch, which persists the scope and reloads so
every module refetches under the new `X-Org-Id`. No second switch, no header, no
billing call is added, so the ledger rule sits exactly where it sat.
`org-state.test.ts` pins the identity so a second switch cannot creep in later,
and the render spec asserts the write to `hanzo.console.org` really is what a
selection produces.

Two z-indexes joined the ladder they were ignoring, because the account control
needs them: a SlideOver was pinned at a literal 1000 and the rail flyout at 1000,
both ABOVE the popover rung, so on a phone the menu opened inside the account
sheet and the sheet swallowed it — present, measurable, and unclickable. They are
now `--z-modal` and `--z-dropdown` from `app/design/z.css`, whose own comment
already said a popover anchored in a sheet paints over it. The remaining literals
(tour, toast, detail pane) are the shell lane's.

Proven by rendering, not by status codes: e2e/account-menu.spec.ts opens the
control in the real signed-in shell and measures an opaque background, Geist, a
body fully inside the viewport at 1440 AND 390, rows padded and at 4.5:1, a hover
state that actually differs, zero uppercase nodes, the menu hit-testing to itself
over the sheet — and finds "Acme Industrial", a tenant that is nobody's
membership. tsc clean; 3093 unit tests green; next build and build:embed green.
2026-07-27 11:47:54 -07:00
zooqueen 3899017e31 feat(console): ONE level-2 nav — the registry declares it, the sidebar renders it
Clicking into a product revealed its options twice: the sidebar drilled in and
rendered the product's sub-nav from the registry, and the module ALSO rendered a
private `const TABS` strip. The two lists were written independently and
disagreed — /models showed eight rows in the rail and four tabs in the content,
and they did not agree on what the index is called ("Overview" vs "Catalog").
Eight products declared no sub-pages at all, so their real tabs lived only in the
content strip and the rail hid them.

The registry is the one source now. `CatalogEntry.indexLabel` names a product's
own index where it is a named surface (Models → Catalog, Tasks → Workflows, Team
→ Members); the eight missing `subpages` sets are declared, and the icons the
strips carried moved onto the declarations. `components/ui/SubNav.tsx` renders
that same declaration for the viewports where the sidebar is a drawer and hides
itself at lg+ where DrillNav owns level 2 — one declaration, two mounts.

Level is the URL and nothing else: `activeSubpage` reads it back, `subpageHref`
writes one URL per screen, and `subpageSlug` validates a segment against the
declaration (so a hand-typed tab cannot light a view the module does not render,
and an admin-only sub-page is never offered to a customer). 18 modules lost their
`TABS` plus their bespoke TabButton/TabBar/nav/path helpers.

Functions had two indexes — its '' route was a living-overview while the module
carried an older OverviewTab reachable only via a bogus URL, and the index
therefore had no level-2 nav on a phone. One component owns the product at every
level now; the dead OverviewTab is deleted. /crm/companies was a duplicate URL
for the screen /crm already renders; the index IS Companies.

Render-proven (e2e/level-2-nav.spec.ts, 5/5): at 1440 the content strip's
computed display is none while the rail is drilled; at 390 the strip is the one
nav, lists the same labels, every tab has a painted box inside the viewport, and
the body does not scroll sideways; a reload of /models/blend lands on Blend; Back
moves the level without dropping the drill or the account-backed pins; and a
sweep asserts all 18 converted products paint no second nav.

vitest 3093 passed (+9). tsc adds zero errors — the one it reports
(src/lib/event.ts `dsn`) is pre-existing local dep drift and reproduces on a
clean origin/main tree.
2026-07-27 11:37:52 -07:00
hanzo-dev bcc1aab571 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:28:14 -07:00
zooqueen 1e647907a0 fix(console): one paper, one leading — overlay elevation and display type (v8.5.32)
Hanzo CI/CD / cicd (push) Successful in 5m42s
CI/CD / cicd (push) Successful in 5m42s
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.
2026-07-27 09:22:05 -07:00
hanzo-dev 147ecd3227 feat(admin): one DigitalOcean fleet board — inventory, fill, safe reclaim
admin.hanzo.ai could show what we BILL but not what we RUN. The nodes, the
volumes, the load balancers were visible only in the DO console, with no
cross-reference to what Kubernetes actually mounts — so "is this orphaned?" had no
answer anyone could act on. `/v1/admin/infra` (already live in cloud) is that
answer; this is its UI.

IT REPLACES A SECOND BOARD RATHER THAN JOINING IT. While this sat on a branch,
`block-storage` shipped: the same DO volumes, listed with fill % beside this one's
inventory of them. One noun, two boards. The two BACKENDS genuinely answer
different questions about the same object — /v1/admin/infra knows what is
REFERENCED (and therefore safe to delete), /v1/admin/block-storage knows how FULL
it is — so the volumes tab now reads both and shows ONE row with a Fill column.
The block-storage catalog entry and its module are retired; its client survives as
the fill source. Two reads for one row is fine. Two boards for one noun is not.

The delete control is deliberately timid. It shows WHY something is or is not
deletable rather than only whether, because the honest answer is usually "not", and
it defaults to snapshot-first. The server re-proves deletability from a FRESH
cross-cluster scan before acting, so the button is a request, never a verdict — a
client that lied would still be refused. That check is the reason this exists: the
naive "no k8s tag" test would have proposed deleting 4.39 TiB of live cluster data,
and one unreachable cluster freezes every deletion rather than degrading.

Sorting went into the SHARED DataTable, so every admin board gets it — opt-in per
column, caller-owned comparator, `aria-sort` on the headers.

Applied to current main rather than merged: the branch is 843 commits behind, and
the three conflicts were a fused registry entry, a `tracker` id belonging to an
unrelated commit on the same old branch, and an aggregate-head list that simply
wanted both heads.

Typecheck adds ZERO errors (29 before, 29 after — all pre-existing). 3058 tests
pass, up from 3022; the 2 failures are pre-existing on main.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 09:06:10 -07:00
hanzo-dev 1991033f47 refactor(api): call the ai resource surface at /v1/ai/<resource>
hanzoai/ai replaced ~213 flat compound routes with one namespaced REST surface
generated from a single table, and DELETED the old ones. These are the console's
callers.

    get-stores        -> GET    /v1/ai/stores
    get-store         -> GET    /v1/ai/stores/{owner}/{name}
    add-store         -> POST   /v1/ai/stores
    update-store      -> PATCH  /v1/ai/stores/{owner}/{name}
    delete-store      -> DELETE /v1/ai/stores/{owner}/{name}
    get-cloud-usages  -> GET    /v1/ai/usages/cloud
    get-providers     -> GET    /v1/ai/providers
    …

Applied fresh to main rather than rebased. The branch this was developed on is 843
commits behind, and replaying a stale UI diff across that much drift would have
been guesswork; the migration is small and mechanical, so it is re-derived against
what main actually calls today.

Two things beyond the renames.

The identity moved from a query param into the path, as TWO segments. Every object
is keyed by the pair (owner, name), so `?id=acme/my-store` becomes
`/ai/stores/acme/my-store` with each part encoded separately (`memberOf`). Encoding
the pair as ONE segment does not work — the server decodes %2F back into a
separator before routing, so it would never match. There is a test for exactly that.

Updates are PATCH and deletes are DELETE, so client.ts gains `patch`/`del` and the
`cloudPatch`/`cloudDelete` twins, mirroring the existing helpers.

CLOUD_HEADS drops eight individual ROUTES (get-stores, add-store, …) for one
SERVICE head: `ai`. That is what a head-based allow-list is supposed to mean; it
enumerated routes only because the surface had no namespace to enumerate.

That collapse would have quietly widened one thing, so it does not. The
cross-tenant store listing was explicitly refused before, and granting `ai` would
have admitted it — REFUSED_SUBPATHS keeps that one refusal, scoped to that path and
NOT a blanket rule over every `global` sub-path (the Providers board really does
read its cross-tenant catalog, so a blanket rule would break a live surface while
claiming to preserve a property that never covered it).

Left alone on purpose: projects.ts, team.ts and admin.ts go through iamList/iamOne,
which prefix `iam/` — those are the IAM service's own routes under its own naming.

Typecheck adds ZERO errors (29 before, 29 after — all pre-existing). 3021 tests
pass; the 3 failures are pre-existing on main (oss-apps, store/logic) or flaky
(invite AEAD, passes 5/5 in isolation).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:52:42 -07:00
hanzo-devandhanzo-dev 431efac48f ci(sync): run the mirror nudge on our own runners
This job has been red on every push, with no steps and no log, because it never
got scheduled: it asks for a GitHub-hosted runner, this repo is private, and
private repos bill hosted minutes against a limit the org has reached. Public
repos are unaffected, which is why the identical workflow in hanzoai/world is
green — the difference is billing, not the file.

It is one 20-second curl. Our own pool runs it for nothing and sits inside the
network it is calling.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:49:57 -07:00
hanzo-devandhanzo-dev 601e0f819c chore: trim the pnpm config comments to what they need to say
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:43:14 -07:00
hanzo-devandhanzo-dev 9451a61910 build(console): relock after merging @hanzo/event 0.3.4
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:17:27 -07:00
hanzo-dev f9272ec880 Merge remote-tracking branch 'origin/main' into chore/pnpm
# Conflicts:
#	package-lock.json
2026-07-27 08:15:06 -07:00
hanzo-devandhanzo-dev fc5fe3c616 build: move console to pnpm, one lockfile, one pinned package manager
npm could never run `npm ci` here. The Dockerfile said so in a comment: @hanzo/gui
pulls a react-native tree whose platform and optional packages resolve differently
between npm versions, so a lockfile written by one npm failed under another, and
the build ran `npm install` — resolving the tree fresh every time and using the
committed lockfile as a suggestion. A lockfile nobody installs from is decoration,
which is how it drifted far enough that `npm ci` was already dead on main before
this change (react-native-worklets missing from the tree it claimed to describe).

pnpm records every platform in the lockfile, so the build installs exactly what is
committed and fails loudly rather than quietly resolving something else. Both
Dockerfiles now `corepack enable && pnpm install --frozen-lockfile`, corepack takes
the version from `packageManager`, and package-lock.json is gone. .gitignore is
reversed accordingly: pnpm-lock.yaml is the tracked one and every other manager's
lockfile is ignored, so a stray `npm install` cannot leave a second source of truth.

pnpm-workspace.yaml carries the two settings this needs, both documented in place.
Install scripts are denied unless named, and esbuild and sharp are named because the
app does not build without them. The release-age gate is excluded by SCOPE for
@hanzo/* rather than by version, because pnpm rewrites a per-version entry on every
bump, and a file that rewrites itself during install makes --frozen-lockfile fail in
CI — the exact determinism this change exists to get.

Verified on pnpm 11.17.0: frozen-lockfile install clean, tsc 0 errors, 3056 tests
passing, `pnpm build` compiles, and `pnpm build:embed` emits the real bundle cloud
fail-hards on (367KB index.html, 5.5M _next). node_modules stays self-contained —
every symlink is relative into node_modules/.pnpm — so the runner stage's
COPY --from=build of node_modules still resolves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:13:46 -07:00
hanzo-dev fcdd98a80a event: 0.3.4 — the console error plane now resolves its own DSN
0.3.4 carries the product -> DSN registry, so `product: 'console'` resolves the
hanzo-console project with no env var and no build argument. This is the release
that actually makes console.hanzo.ai report errors.

Lockfile edited SURGICALLY (4 lines: the range, version, resolved, integrity).
Do NOT regenerate it here: both `npm install` and `npm install --package-lock-only`
drop the optional-peer block `node_modules/expo/node_modules/react-native-worklets`,
which `npm ci` then needs — that removal is what broke CI once already and had to be
restored by hand. npm ci verified green against this lockfile.

tsc clean; 3048 tests pass.
2026-07-27 08:13:45 -07:00
hanzo-dev 0681460869 ci: nudge git.hanzo.ai to pull on push
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.

Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
2026-07-27 08:01:12 -07:00
hanzo-devandhanzo-dev c5134dd89f docs: stop describing the app launcher as a surface of its own
Apps and command search are one surface now — the launcher was folded into the
palette and its component is gone. The prose did not follow: thirty-seven places
still listed "nav/launcher/palette" as three things to gate, and CommandPalette
still named a launcher hook among the chrome hooks it composes, which no longer
exists.

Read literally, those comments send someone looking for a second surface to keep
in sync with this one. There isn't a second surface; that was the point of the
change.

Comments only — every changed line is inside a comment, tsc is clean, the suite is
3056 passing, and next build compiles.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 07:50:54 -07:00
hanzo-devandhanzo-dev 30d620ed1e fix(deps): restore the one lockfile entry that made npm ci fail
npm ci refused to install at all:

  npm error Missing: react-native-worklets@0.8.3 from lock file

expo depends on it, but the nested entry was absent from package-lock.json, so
the lockfile no longer described a complete tree and the reproducible-install
path was dead for everyone.

Image builds were unaffected, which is why this went unnoticed: the Dockerfile
runs npm install, which re-resolves and papers over the gap. Only npm ci — the
one command that installs exactly what is written down — could see it.

npm install writes back exactly the missing entry: 28 lines, one package added,
none removed, no version moved. npm ci now completes; tsc is clean, the suite is
3056 passing, and next build compiles.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:40:15 -07:00
hanzo-dev cbcf2466a6 event: wire the error plane (0.3.1 -> 0.3.3 + dsn)
The console reported ZERO errors. Two independent reasons, both silent:

  1. @hanzo/event was pinned ^0.3.1 — the version with NO envelope code at
     all. captureError() collected and dropped.
  2. createAnalytics() was never passed a dsn. The error plane authenticates
     independently of the event stream; with no dsn it is inert by design
     (fail-safe), so even 0.3.3 would have stayed dark.

This file also asserted the failure into existence: it documented /v1/event as
being lensed server-side into 'error tracking'. There is no such fan-out. That
claim is why nobody looked. Corrected to state the two-plane reality and the
no-dsn => inert contract explicitly.

dsn comes from NEXT_PUBLIC_HANZO_EVENT_DSN, the convention hanzoai/app and
hanzoai/hanzo.ai already use. Publishable by design — it ships in the bundle.

Still dark until a DSN is set: NEXT_PUBLIC_HANZO_EVENT_DSN is set on zero CRs
fleet-wide, and no 'console' Sentry project exists yet
(/v1/sentry/console/envelope/ -> 404). This makes the surface correct so the
plane lights up on config alone, with no further code change.

tsc clean; 3048 tests pass.
2026-07-26 23:35:31 -07:00
hanzo-devandhanzo-dev 8f011bea82 Merge the forge into GitHub — converge the two mains
Same drift as cloud and iam: git.hanzo.ai held 133 commits GitHub did not, so the
sync could never fast-forward. Merging the forge in from this side needs no forge
credentials and makes that push a fast-forward again.

Two files conflicted, and both resolve to GitHub's side because GitHub is the newer
one: parseBlueprint and fetchOssApps were lifted into @hanzo/ui/oss and are
re-exported from here, while the forge still carries the inline copies. Taking the
forge's would put a second implementation of the compose reader back in the tree.

The merge also brings the forge's "unify Apps and command search" refactor, which
deletes AppLauncher and folds it into CommandPalette. That deletion arrives with the
CommandPalette and dashboard changes that stop calling useAppLauncher, so nothing is
left dangling — the merged tree has no reference to it.

tsc reports the same 24 errors as clean main and vitest the same 3028 passing with
the same 2 files failing to collect; both are the stale local @hanzo/ui (8.0.8
installed, ^8.0.11 required, which is where the /oss subpath lives). The merge adds
nothing to either count.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:02:53 -07:00
hanzo-dev 9059e1c361 docs(o11y): the AI lens is ours — say o11y AI, not Langfuse
Two comments described the admin o11y board's generation stats as Langfuse's.
The backend surface is renamed to o11y_ai (hanzoai/cloud), and Langfuse is a
product we do not run; naming our own lens after it makes a reader carry that
history for nothing.
2026-07-26 22:56:08 -07:00
hanzo-dev 0fdea766d1 feat(store): an app detail page, on the ONE shared OSS module
Restores the App Store detail work (lost when main was reset past it) and, this
time, builds it on @hanzo/ui/oss instead of a local copy.

Clicking a card opened nothing; the only affordance was Deploy, so choosing what
to run in your own cloud was a decision made from a 300px tile. Cards now open
/store/:id, which answers what actually decides it — which containers start,
which images they pull, what ports they publish, what configuration they expect
— read from the blueprint's own docker-compose.yml, not restated from the tile.
Env KEYS only; the values are routinely secrets.

The catalog shape, normalizer, URL builders and compose reader no longer live
here. They are @hanzo/ui/oss (v8.0.11), shared with platform and the public
oss.hanzo.ai gallery — three copies of one format collapsed to one, verified
against the real corpus (400/400 blueprints parse, 812 services). This module
keeps only what is genuinely console-specific: claimPath, which names a console
route. The import paths are unchanged, so no call site moved.

The maker payout hook repeated on all ~1030 tiles; it now appears once in the
banner, plus on the detail page of an app you are actually looking at. Reworded
off "Built one of these?", which conceded we built none of it. Authors moves
Web3 → Dev: the audience is open-source developers, and a wallet is how the
payout arrives, not what the product is about.
2026-07-26 20:34:50 -07:00
hanzo-devandhanzo-dev cff833e0a2 chore: ignore the lockfiles npm does not write
package-lock.json is the tracked lockfile and the Dockerfile runs npm install,
so a pnpm or yarn lockfile left by a stray install is drift no one reads. Also
recovered from the rescue lineage, where it was written and never carried over.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 20:31:12 -07:00
hanzo-devandhanzo-dev 0d68a6277f feat(guide): land the getting-started guide module off the rescue branch
The guide work — the pitch hero, the product guide panel, the signal/spec/
registry/guard layer under lib/guide, and the chat, suggest and budget surface
on GuideModule — was written on the local main that predates the history
rewrite. That lineage survives only as origin/rescue/console-local-main, which
shares no ancestor with main, so nothing carried it across: of its 127 commits,
patch-id comparison finds 9 with no equivalent here, and this module is what
they add.

Taken as content rather than merged, because a merge has no base to work from
and a whole-tree diff would drag main backwards: main is newer on @hanzo/ui,
@hanzogui/shell, the sidebar workspace layout, BrandLogo, OrgSwitcher and two
e2e specs the rescue lineage never had. Only the additive side is here.

The eleven new files are additions main has nowhere. The five touched files
take the rescue version whole after confirming each one's diff is additive —
api/guide.ts is +104/-0, and GuideModule and guide/logic.ts each report one
deletion that is an import line being extended. dashboard.tsx is the exception
and got the three guide hunks by hand, leaving main's SidebarWorkspace alone.

Left behind deliberately: the upstream-attribution removal in
products/registry.tsx. Dropping an attribution field documented as carrying
license compliance is a decision of its own, not something to fold into landing
a feature.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 20:30:51 -07:00
hanzo-dev 3bcc8ec3ed console: landing, analytics module, and theme refresh
(cherry picked from commit 6318b90170f25972a0caec2136889762965eff30)
2026-07-26 20:10:16 -07:00
zeekay b286d48c7f fix(chrome): the console wears the ORG's identity, and the switcher is the account control's peer
Two defects in the cloud console's chrome, both fixed in the SHARED control
(@hanzo/ui 8.0.11) so every surface inherits them, not just this one.

The top-left mark showed the house H whenever an org had set no logo — a
customer's console showing OUR brand. It now renders `OrgMark` unconditionally:
the org's own logo when IAM carries one, else the org's MONOGRAM, the treatment
the account widget already gives a person. Never the house glyph, never the org
name as running text.

The org switcher was a caption beside a control. Its trigger is now the peer of
the account row — 44px tall, a 30px mark, the same type, the same hit area — and
`SidebarWorkspace` is a COLUMN so it stretches the sidebar's width the way the
account row does (a row container had shrunk it to its text).

One org-identity source: `useOrgLogo` (a URL) becomes `useOrgIdentity` (name,
display name, logo — one cached read), fed to BOTH the mark and the switcher's
new `current` prop, so the two slots can never disagree and a user with no
cross-tenant list still gets their own logo. The dead `BrandLogo` component,
a second copy of the same logo-else-mark decision, is gone.

`e2e/org-identity.spec.ts` measures it off the rendered boxes: without the change
the mark paints an SVG with no monogram and the switcher has no trigger to find.
2026-07-26 19:32:06 -07:00
hanzo-dev 07c1982b02 console: record why the datastore connect command names clickhouse-client
The datastore connect command is the one string on this page a user copies and
runs on their own machine, so it has to name a binary they can install. Upstream
clickhouse-client is installable and speaks the unchanged native protocol, so it
connects to a datastore instance as-is.

Our own datastore-client exists as a package definition (hanzoai/datastore
packages/datastore-client.yaml installs /usr/bin/datastore-client) but has no
serving repo: packages.hanzo.ai/{,deb/,rpm/stable/} all return 404. Renaming the
string today would print a command that cannot be installed.

The comment records the condition to flip it, so the next naming pass does not
change it blind.
2026-07-26 19:19:08 -07:00
zeekay 3b832c1a78 feat(store): an app detail page, and say the payout line once
Clicking a card opened nothing; the only affordance was Deploy, so choosing
what to run in your own cloud was a decision made from a 300px tile. Cards now
open /store/:id, which answers what actually decides it — which containers
start, which images they pull, what ports they publish, what configuration
they expect — read from the blueprint's own docker-compose.yml rather than
restated from the tile.

parseBlueprint is a small structural reader, not a YAML implementation: it
walks the services block by indentation and pulls only the four keys the page
shows. Pure and total, so it is unit-tested and safe on untrusted CDN content.
What it cannot read is absent, never guessed — a blueprint without compose says
so instead of rendering an empty table that implies nothing starts. Env KEYS
only; the values are frequently secrets.

The maker payout hook repeated on all ~1030 tiles. Saying it 1000 times is not
1000 times more persuasive, so it now lives once in the banner (plus the detail
page of an app you are actually looking at). Reworded off "Built one of these?",
which conceded we built none of it and framed our own store as other people's
work — the subject is the reader's project and what they earn from it.

Authors moves Web3 → Dev: the audience is open-source developers shipping code.
A wallet is how the payout arrives, not what the product is about; filing it
under Web3 hid it from everyone it is meant for.
2026-07-26 17:26:32 -07:00
zeekay 6116dae865 release(console): v8.5.31
Hanzo CI/CD / cicd (push) Successful in 6m19s
CI/CD / cicd (push) Successful in 6m20s
2026-07-26 17:17:30 -07:00
zeekay 781b8125ef merge: integrate forge main
# Conflicts:
#	package.json
2026-07-26 17:17:16 -07:00
zeekay 571926eba1 merge: sync canonical main 2026-07-26 17:15:35 -07:00
zeekay 9b7d8da187 feat(console): unify Apps and command search 2026-07-26 17:15:33 -07:00
zeekay d55c3ca41b merge: reconcile canonical main
# Conflicts:
#	.hanzo/workflows/cicd.yml
#	.hanzo/workflows/sync-from-github.yml
#	LLM.md
#	app/globals.css
#	e2e/polish-qa.spec.ts
#	package.json
#	src/components/PublicLanding.tsx
2026-07-26 17:09:49 -07:00
zeekay f8c120490f ci: resolve the reusable from .hanzo/workflows
The forge resolves `uses:` only under WORKFLOW_DIRS (.hanzo/workflows), so
pointing at hanzoai/ci/.github/workflows/build.yml@v1 failed outright:

  path ".github/workflows/build.yml" must be under a configured workflow directory

This repo has built NOTHING since that took effect. hanzoai/ci now publishes the
reusable from .hanzo/workflows/build.yml and tags it v2; a new tag rather than a
force-moved v1, because moving a floating tag is what desynced that repo's two
heads earlier today.
2026-07-26 16:57:13 -07:00
zeekay 83afeb4220 ci: resolve the reusable from .hanzo/workflows
The forge resolves `uses:` only under WORKFLOW_DIRS (.hanzo/workflows), so
pointing at hanzoai/ci/.github/workflows/build.yml@v1 failed outright:

  path ".github/workflows/build.yml" must be under a configured workflow directory

This repo has built NOTHING since that took effect. hanzoai/ci now publishes the
reusable from .hanzo/workflows/build.yml and tags it v2; a new tag rather than a
force-moved v1, because moving a floating tag is what desynced that repo's two
heads earlier today.

Assisted-by: Claude:claude-opus-5
2026-07-26 16:57:13 -07:00
hanzo-dev 52f7c9e245 fix(console): reachable footer links, ONE typeface, ONE sign-in on the anon landing
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.
2026-07-26 16:24:20 -07:00
zeekay 392fca98aa fix: point forge API calls at /v1 — /api/v1 is gone
The fork moved its API off /api to /v1, so every call built against
${{ github.server_url }}/api/v1/... now 404s. Verified live with a control:
/v1/version 200, /api/v1/version 404, a nonsense path 404.

This is the build-dispatch in sync-from-github, so a fast-forward from GitHub
was landing commits and then silently failing to trigger the build.
2026-07-26 15:51:37 -07:00
hanzo-dev ba22401f73 chore(git): pin npm as the one package manager
The Dockerfile runs `npm install` and package-lock.json is the tracked
lockfile, but a stray pnpm-lock.yaml showed up untracked and unignored — a
second lockfile that drifts from the real one and keeps polluting git status.
2026-07-26 13:09:24 -07:00
hanzo-dev 3b8e9d7e59 Merge branch 'blue/console-research'
Brings the Guide chat, next-quest suggestions and live budget surface into the
product module, and restores the type-check that the product guide panel broke.
2026-07-26 13:06:09 -07:00
hanzo-dev 0432a5f4d2 console: give the product guide panel its own route subscription
The panel was rendered from the Dashboard shell with a pathname the shell no
longer holds, so the app did not type-check. It now reads the route in a leaf of
its own, the way the breadcrumb bar does, which keeps the shell inert across
navigation.
2026-07-26 13:02:10 -07:00
zeekayandhanzo-dev f59f158eb1 chore: commit outstanding working-tree changes
2 files changed, 26 insertions(+), 2 deletions(-)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 12:58:37 -07:00
hanzo-dev 4f2b8c9fc6 Merge remote-tracking branch 'origin/main' into blue/console-research 2026-07-26 12:58:02 -07:00
hanzo-dev c22c187b11 Merge origin/main into the guide chat + budget work
Takes the monochrome sweep, the design-token sheets and the self-hosted
typeface from main, and adopts them in the new surface: the budget, chat
and suggestion UI now reads its icon weights from the tone map instead of
the chromatic hexes it was written with, so the Guide matches the rest of
the console chrome.
2026-07-26 12:57:03 -07:00
hanzo-dev 01b82acac1 products: drop the in-product upstream attribution and isolate the breadcrumb route subscription
OSS attribution belongs in the repo LICENSE and NOTICE, not the console UI,
so the overview spec no longer synthesizes an Upstream fact, the interstitial
no longer prints a forked-from line, and ProductUpstreamNote is removed.
Upstream stays catalog metadata.

Moves usePathname out of the Dashboard shell into a BreadcrumbsBar leaf, so a
navigation click re-renders the swapped page and the breadcrumb strip rather
than the whole shell.
2026-07-26 12:40:05 -07:00
hanzo-dev 832f14cbf7 guide: add the chat, suggest and budget surface
Adds the suggest, chat and funnel wire types plus their normalizers to the
guide API client, the usd / automatableSuggestions / topSuggestion helpers
to the pure logic module, and the chat + next-quest UI to GuideModule.

Suggestions carry an automatable flag, so the module offers a run action on
exactly the quests the Business AI can execute.
2026-07-26 12:39:39 -07:00
e1db9bd702 feat(console): native dynamic pitch + getting-started guides; drop upstream fork attribution (8.4.151) (#168)
Owner direction: "better pitch, not upstream attribution."

1) Remove the in-product OSS fork attribution. Delete ProductUpstreamNote
   (rendered "Built on open source — forked from X (MIT)" under every product)
   and every other surface that showed a "forked from … (license)" line:
   - ProductUpstreamNote.tsx + its DashboardShell mount (replaced by the guide panel)
   - ProductInterstitial's "Forked from X (license)." clause
   - the "Upstream" key-fact injected by overview/resolve.ts
   - the now-dead `upstream` catalog field + its 11 declarations in registry.tsx
   OSS license compliance stays in each repo's NOTICE/LICENSE — this is UI only.

2) Native, dynamic, personalized pitch + getting-started guides + tours. One
   subsystem, extending the existing OnboardingGate/OnboardingWizard, the
   first-run GuidedTour, and LivingOverview (no 3rd-party like Appcues):
   - src/lib/guide/: signals (real per-user facts: role, has-API-key, in-console
     usage), spec (pitch + dynamic getting-started steps whose done-state reads a
     REAL signal — never fabricated; unknown ⟹ not done), guard (per-product
     dismissal), registry (curated pitches for the flagship products).
   - src/components/guide/PitchHero + ProductGuidePanel: headline + value props
     above a getting-started checklist that checks itself off, personalizes which
     steps show (when-predicates), auto-hides once done/dismissed, and launches a
     spotlight tour generated from the INCOMPLETE steps (reuses GuidedTour; zero
     external scripts; works in the go:embed console under any CSP).
   - Mounted once at the top of the console content column (DashboardShell) for
     every product landing + the console home (replacing the standalone
     GetApiKeyCta). FirstRunTour now personalizes CONSOLE_TOUR via resolveTour.
   - Honest, accessible (@hanzo/gui v5 shorthands, reduced-motion-safe via FadeIn),
     org/identity-scoped, skipped on the admin/operator host.

Tests: +25 vitest (signals/spec/guard/registry). tsc clean, vitest 2876 pass,
next build green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-26 12:30:41 -07:00
Hanzo AI 5491971100 ci: replace the tag-replay estimate with the measured counts
The previous note guessed "~3 runs each (~1400 total)". Parsing on.push.tags
out of every workflow in every reachable tag tree gives the real shape over
646 tags (the forge holds 692): pipeline.yml 559, release.yml 547,
codespell.yml 263, build-image.yml 36, build-and-push.yml 21, cicd.yml 3 =
1429 runs, distributed 0:48 1:51 2:284 3:242 4:21 — so 2-3 per tag, not 3.

Also names the full set. The incident was reported as three workflows;
build-image.yml and build-and-push.yml are in those trees too, and a reader
scoping only the named three would still be surprised by 57 more runs.
2026-07-26 12:01:51 -07:00
Hanzo AI e555dd744f ci: record why main cannot gate a tag replay, and stop claiming the sync is inert
Two comments in .hanzo/workflows were false or missing, and both misled a
diagnosis of the ~707-run flood that starved the shared runner queue.

sync-from-github.yml said it was "inert until hanzoai/console stops being a
Gitea pull mirror". That conversion happened — the forge reports mirror: false,
has_actions: true — so the cron is live and fast-forwarding main every 10
minutes. It moves refs/heads/main and nothing else; say so, because "never
tags" is what keeps it from replaying the flood.

cicd.yml kept tags: ['v*'] but never said why a bulk tag push is different
from a hand-cut one. The forge resolves workflows from the PUSHED REF's own
tree (WORKFLOW_DIRS = .hanzo,.github,.gitea), and this repo's history is a
minio/console fork whose old trees still carry .github/workflows/{release,
pipeline,codespell}.yml. Replaying the ~692 tags enqueues ~3 runs each. No
edit on main can prevent that — main's tree is not consulted for a tag push —
so the note gives the only lever that works: close has_actions first, push,
reopen. Tags already present never re-fire.

No trigger changed: cicd.yml still builds main, PRs and hand-cut v* tags.
2026-07-26 11:58:58 -07:00
hanzo-dev 30b890957a console: a paywalled org is sent to plans, not to credits
With the paywall on, a gated route answers 402
{"error":"subscription_required"}. The console turned that into "Add credits"
and a button to /billing/credits — the wrong ask twice over. Credits do not
satisfy the paywall, so the next request 402s again and the user loops; and the
page that would actually fix it is never offered.

Two causes, both fixed:

The reason was thrown away before anyone could classify it. Both API clients
read only `msg` (the casibase envelope) and `message`/`msg` (Base), while the
plain-REST /v1 surfaces answer with `error`. So the message became "Request
failed (HTTP 402)" and the only signal left was the status — which the
classifier could read only as an unfunded balance.

And 402 was treated as one condition when it is two: no PLAN (subscribe) and no
BALANCE (top up). honestError now splits them on the machine-readable token, not
on prose, and ErrorState offers "See plans" -> /plans for the first while the
existing "Add credits" -> /billing/credits stays for the second.

Tests assert both branches and that a planless org is never offered credits.
Verified they catch the bug: against the previous classifier the two new
assertions fail and the four existing ones still pass. tsc clean; 1211 tests
across 96 files pass.
2026-07-26 11:05:40 -07:00
hanzo-dev 4f22839f59 console: a paywalled org is sent to plans, not to credits
With the paywall on, a gated route answers 402
{"error":"subscription_required"}. The console turned that into "Add credits"
and a button to /billing/credits — the wrong ask twice over. Credits do not
satisfy the paywall, so the next request 402s again and the user loops; and the
page that would actually fix it is never offered.

Two causes, both fixed:

The reason was thrown away before anyone could classify it. Both API clients
read only `msg` (the casibase envelope) and `message`/`msg` (Base), while the
plain-REST /v1 surfaces answer with `error`. So the message became "Request
failed (HTTP 402)" and the only signal left was the status — which the
classifier could read only as an unfunded balance.

And 402 was treated as one condition when it is two: no PLAN (subscribe) and no
BALANCE (top up). honestError now splits them on the machine-readable token, not
on prose, and ErrorState offers "See plans" -> /plans for the first while the
existing "Add credits" -> /billing/credits stays for the second.

Tests assert both branches and that a planless org is never offered credits.
Verified they catch the bug: against the previous classifier the two new
assertions fail and the four existing ones still pass. tsc clean; 1211 tests
across 96 files pass.
2026-07-26 11:05:40 -07:00
Hanzo AI 127a8160b9 fix(console): dark-theme the Square card iframe + PrimaryButton pay CTA
Salvaged isolated fix — the Square card rendered an off-brand white box on the
dark console; use-square-card.ts styles the iframe (near-black field, legible
text, focus/error tones) and BillingCredits uses PrimaryButton (drops theme=light).
2026-07-26 08:54:43 -07:00
Hanzo AI 36b15966b1 fix(console): dark-theme the Square card iframe + PrimaryButton pay CTA
Salvaged isolated fix — the Square card rendered an off-brand white box on the
dark console; use-square-card.ts styles the iframe (near-black field, legible
text, focus/error tones) and BillingCredits uses PrimaryButton (drops theme=light).
2026-07-26 08:54:43 -07:00
Hanzo AI 0667432eb6 ci: drop the re-added .gitea/workflows/ci.yml — .hanzo is canonical
A merge reintroduced the superseded self-contained .gitea CI; .hanzo/workflows/
cicd.yml is the one native pipeline. One workflow dir, .hanzo.
2026-07-26 08:40:02 -07:00
Hanzo AI 22c2c7dfea ci: drop the re-added .gitea/workflows/ci.yml — .hanzo is canonical
A merge reintroduced the superseded self-contained .gitea CI; .hanzo/workflows/
cicd.yml is the one native pipeline. One workflow dir, .hanzo.
2026-07-26 08:40:02 -07:00
zeekay 009f3fd267 fix(brand): render each brand's own mark, never a hardcoded Hanzo H
Two places still drew the Hanzo mark on non-Hanzo hosts, breaking the
white-label invariant this codebase states in several comments ("a lux/zoo/
pars host NEVER renders the Hanzo mark").

Loader.tsx's BrandMark upgrades to the brand's animated SVG on mount, but its
static branch hardcoded Hanzo's five-path H with aria-label="Hanzo". That
branch is not just first paint: ANIMATED covers hanzo/lux/zoo only, so a pars
host never leaves it and showed the Hanzo mark PERMANENTLY, labelled "Hanzo",
beside Pars text. It now reads the mark from the shared @hanzo/brand registry
— which already carries one per brand, PARS_MARK included — using exactly the
fields BrandLogo's BrandMark reads. One source, not a copy. The local
MARK_PATHS copy of Hanzo's geometry is deleted with its last use.

OnboardingWizard imported the genuinely hardcoded ui/HanzoMark and rendered it
directly beside "Set up {config.brandName}" — the wrong mark next to the right
name on every lux/zoo/pars host. Swapped to the host-derived BrandMark that
SidebarBrand already uses.

Found while investigating why kms.pars.network serves Hanzo branding. That
host has a separate edge-level cause still open; this is the code half, and it
also fixes the Pars mark everywhere else in the console.
2026-07-26 08:13:59 -07:00
zeekay c46063c411 fix(brand): render each brand's own mark, never a hardcoded Hanzo H
Two places still drew the Hanzo mark on non-Hanzo hosts, breaking the
white-label invariant this codebase states in several comments ("a lux/zoo/
pars host NEVER renders the Hanzo mark").

Loader.tsx's BrandMark upgrades to the brand's animated SVG on mount, but its
static branch hardcoded Hanzo's five-path H with aria-label="Hanzo". That
branch is not just first paint: ANIMATED covers hanzo/lux/zoo only, so a pars
host never leaves it and showed the Hanzo mark PERMANENTLY, labelled "Hanzo",
beside Pars text. It now reads the mark from the shared @hanzo/brand registry
— which already carries one per brand, PARS_MARK included — using exactly the
fields BrandLogo's BrandMark reads. One source, not a copy. The local
MARK_PATHS copy of Hanzo's geometry is deleted with its last use.

OnboardingWizard imported the genuinely hardcoded ui/HanzoMark and rendered it
directly beside "Set up {config.brandName}" — the wrong mark next to the right
name on every lux/zoo/pars host. Swapped to the host-derived BrandMark that
SidebarBrand already uses.

Found while investigating why kms.pars.network serves Hanzo branding. That
host has a separate edge-level cause still open; this is the code half, and it
also fixes the Pars mark everywhere else in the console.
2026-07-26 08:13:59 -07:00
zeekay 0f945b4b7a landing: a primary CTA that reads primary, a real h1, and Terms you can reach
Measured on cloud.hanzo.ai / console.hanzo.ai in a real browser.

The hero "Get started" computed to rgb(36,36,36) on rgb(204,204,204) -- dark
grey on black, LESS visual weight than the nav's white Sign in pill, so the
page's own primary action read as disabled. Beside it "Learn more" was
`chromeless`: no edge at all, not legibly a button. Both are now the house pill
pair -- white primary with dark text, hairline-bordered secondary, both
rounded-full. Measured after: rgb(242,242,242)/rgb(5,5,5) and a
1px rgb(56,56,56) border, radius 999px.

"The AI cloud, one platform" was a styled span; the primary product page had NO
h1 at all. It renders `h1` now -- same classes, byte-identical typography.

The footer's link clusters are Views, which are `flex-shrink: 0`, so they held
max-content width and their `flex-wrap` never engaged: at 390px "Terms" sat at
x 390->426 while documentElement clipped at 390, i.e. a legally-required link
that could not be scrolled to. They shrink now: body.scrollWidth 426 -> 390,
Terms lands at x 147. Those links were also the only underlined links on any
Hanzo surface -- now consistent.
2026-07-25 18:24:30 -07:00
zeekay 2fc59f4cad landing: a primary CTA that reads primary, a real h1, and Terms you can reach
Measured on cloud.hanzo.ai / console.hanzo.ai in a real browser.

The hero "Get started" computed to rgb(36,36,36) on rgb(204,204,204) -- dark
grey on black, LESS visual weight than the nav's white Sign in pill, so the
page's own primary action read as disabled. Beside it "Learn more" was
`chromeless`: no edge at all, not legibly a button. Both are now the house pill
pair -- white primary with dark text, hairline-bordered secondary, both
rounded-full. Measured after: rgb(242,242,242)/rgb(5,5,5) and a
1px rgb(56,56,56) border, radius 999px.

"The AI cloud, one platform" was a styled span; the primary product page had NO
h1 at all. It renders `h1` now -- same classes, byte-identical typography.

The footer's link clusters are Views, which are `flex-shrink: 0`, so they held
max-content width and their `flex-wrap` never engaged: at 390px "Terms" sat at
x 390->426 while documentElement clipped at 390, i.e. a legally-required link
that could not be scrolled to. They shrink now: body.scrollWidth 426 -> 390,
Terms lands at x 147. Those links were also the only underlined links on any
Hanzo surface -- now consistent.
2026-07-25 18:24:30 -07:00
zandhanzo-dev 7c064a5800 chore(sync): delete the per-repo sync.yml — superseded by the org webhook
A single GitHub ORG-level push webhook now posts to git.hanzo.ai/v1/sync for every
repo in the org (verified: real pushes to universe + gateway synced their mirrors
instantly), so a per-repo nudge is redundant.

It was also mostly theatre: HANZO_GIT_TOKEN exists on exactly ONE repo in the org
(hanzoai/app) and is not an org secret, so in every other repo this workflow hit its
fail-soft branch and did nothing while still reporting success.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 16:12:06 -07:00
zandClaude Opus 4.8 e5f50328b9 chore(sync): delete the per-repo sync.yml — superseded by the org webhook
A single GitHub ORG-level push webhook now posts to git.hanzo.ai/v1/sync for every
repo in the org (verified: real pushes to universe + gateway synced their mirrors
instantly), so a per-repo nudge is redundant.

It was also mostly theatre: HANZO_GIT_TOKEN exists on exactly ONE repo in the org
(hanzoai/app) and is not an org secret, so in every other repo this workflow hit its
fail-soft branch and did nothing while still reporting success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 16:12:06 -07:00
zeekayandhanzo-dev 42ab427ce7 ci: console is single-file on GitHub — the pipeline moved, verbatim
.github/workflows now holds exactly one file: the sync nudge, which runs zero
CI. cicd.yml moved to .hanzo/workflows unchanged — it is a 7-line caller over
hanzo.yml, so no build logic moved with it, and hanzo.yml still declares BOTH
console images.

.hanzo/workflows/deploy.yml is deleted, not merged. It hand-built the server
image a SECOND time (hanzo.yml already declares it) and could not have built it
at all: `buildctl-daemonless.sh` is absent from the image this fleet serves for
`hanzo-build-linux-amd64` — every label in that pool maps to
catthehacker/ubuntu:act-24.04 (universe:infra/k8s/git-runner/statefulset.yaml) —
and its `secrets.GIT_CLONE_TOKEN` exists on neither the repo nor the org. Its
`kubectl patch app` was futile too: cd.hanzo.ai's selfHeal restores the CR from
the universe pin on the next poll. Rollout stays a reviewed tag pin in
hanzoai/universe.

sync.yml's "why this repo is not yet single-file" note is replaced by what the
blocker actually was, since it is the thing that must be flipped for this to be
live: hanzoai/console has the forge Actions UNIT disabled (`has_actions: false`,
zero runs ever). Enabling the unit is the whole unblock — a pull-mirror sync DOES
fire Actions (hanzoai/git: services/mirror/mirror_pull.go calls
notify_service.SyncPushCommits; services/actions/notifier.go turns that into a
run) — so converting the mirror to canonical is a separate question about who
owns main, not a precondition for CI.

The cost of getting that order wrong is already on the record here: build-image.yml
was neutralized in favour of a native pipeline that could not run, and v8.5.23 /
8.5.24 shipped no image at all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 15:08:12 -07:00
zeekayandClaude Opus 5 3e6c00dbf4 ci: console is single-file on GitHub — the pipeline moved, verbatim
.github/workflows now holds exactly one file: the sync nudge, which runs zero
CI. cicd.yml moved to .hanzo/workflows unchanged — it is a 7-line caller over
hanzo.yml, so no build logic moved with it, and hanzo.yml still declares BOTH
console images.

.hanzo/workflows/deploy.yml is deleted, not merged. It hand-built the server
image a SECOND time (hanzo.yml already declares it) and could not have built it
at all: `buildctl-daemonless.sh` is absent from the image this fleet serves for
`hanzo-build-linux-amd64` — every label in that pool maps to
catthehacker/ubuntu:act-24.04 (universe:infra/k8s/git-runner/statefulset.yaml) —
and its `secrets.GIT_CLONE_TOKEN` exists on neither the repo nor the org. Its
`kubectl patch app` was futile too: cd.hanzo.ai's selfHeal restores the CR from
the universe pin on the next poll. Rollout stays a reviewed tag pin in
hanzoai/universe.

sync.yml's "why this repo is not yet single-file" note is replaced by what the
blocker actually was, since it is the thing that must be flipped for this to be
live: hanzoai/console has the forge Actions UNIT disabled (`has_actions: false`,
zero runs ever). Enabling the unit is the whole unblock — a pull-mirror sync DOES
fire Actions (hanzoai/git: services/mirror/mirror_pull.go calls
notify_service.SyncPushCommits; services/actions/notifier.go turns that into a
run) — so converting the mirror to canonical is a separate question about who
owns main, not a precondition for CI.

The cost of getting that order wrong is already on the record here: build-image.yml
was neutralized in favour of a native pipeline that could not run, and v8.5.23 /
8.5.24 shipped no image at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:08:12 -07:00
zeekayandhanzo-dev bc6f05801a fix(api): one endpoint — PaaS proxy aimed at a host that serves no /v1/paas
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>
2026-07-25 15:00:24 -07:00
zeekayandClaude Opus 5 13078c1bab fix(api): one endpoint — PaaS proxy aimed at a host that serves no /v1/paas
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: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:00:24 -07:00
zeekayandhanzo-dev 7e9882a8e1 console(lux): the board can no longer paint green over a frozen chain
LUX_QUERIES described the fleet only in relative terms — who is up, who is at what
height, who has peers. A fleet that has STOPPED satisfies every one of them: same
height, same hash, five up, four peers each. The Lux board therefore rendered a
full row of green over a chain that had not produced a block in two days.

Adds the twins of the four absolute cloud allowlist entries — tip age (measured
against our own clock, so it climbs while the chain sits still), height spread,
distinct-hash count at one height, and the number of validators reporting Ready
while their RPC is dead — plus the live firing alert set.

The alert set comes from vmalert's own remote-written state rather than being
re-derived in the UI, so the board cannot quietly disagree with what is actually
paging, and it arrives over the VM proxy that already exists instead of a second
data path.

Byte-for-byte drift guard extended to all five; 10/10 tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 14:48:35 -07:00
zeekayandClaude Opus 5 9f7042c11d console(lux): the board can no longer paint green over a frozen chain
LUX_QUERIES described the fleet only in relative terms — who is up, who is at what
height, who has peers. A fleet that has STOPPED satisfies every one of them: same
height, same hash, five up, four peers each. The Lux board therefore rendered a
full row of green over a chain that had not produced a block in two days.

Adds the twins of the four absolute cloud allowlist entries — tip age (measured
against our own clock, so it climbs while the chain sits still), height spread,
distinct-hash count at one height, and the number of validators reporting Ready
while their RPC is dead — plus the live firing alert set.

The alert set comes from vmalert's own remote-written state rather than being
re-derived in the UI, so the board cannot quietly disagree with what is actually
paging, and it arrives over the VM proxy that already exists instead of a second
data path.

Byte-for-byte drift guard extended to all five; 10/10 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:48:35 -07:00
Hanzo AI 5c110fa071 fix(console): the brand typeface never loaded in production — self-host it (8.5.26)
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.)
2026-07-25 14:37:30 -07:00
Hanzo AI e956f14f86 fix(console): the brand typeface never loaded in production — self-host it (8.5.26)
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.)
2026-07-25 14:37:30 -07:00
Hanzo AI c12aa4b146 fix(console): the sign-in button says "Hanzo" on a Lux console — name the active brand (8.5.25)
The heading directly above it already reads `config.brandName`, resolved from the host,
so the same screen said "Lux Cloud" and then offered "Log in with Hanzo" — a white-label
leak on every non-Hanzo console (lux.id, zoolabs.id, pars). The button now reads the
same value the heading does: one brand fact, one source, no second place to drift.

Rescued from feat/hanzo-appbar, which is otherwise superseded — its app-switcher landed
on main long ago and it carried a /zach personal page that is not a product surface.
Branch deleted; this was the only unmerged value on it.
2026-07-25 14:03:48 -07:00
Hanzo AI 1a67761582 fix(console): the sign-in button says "Hanzo" on a Lux console — name the active brand (8.5.25)
The heading directly above it already reads `config.brandName`, resolved from the host,
so the same screen said "Lux Cloud" and then offered "Log in with Hanzo" — a white-label
leak on every non-Hanzo console (lux.id, zoolabs.id, pars). The button now reads the
same value the heading does: one brand fact, one source, no second place to drift.

Rescued from feat/hanzo-appbar, which is otherwise superseded — its app-switcher landed
on main long ago and it carried a /zach personal page that is not a product surface.
Branch deleted; this was the only unmerged value on it.
2026-07-25 14:03:48 -07:00
zeekayandhanzo-dev 3a05fc669a fix(sync): the nudge runs on OUR pool — GitHub-hosted is billing-blocked
The first Sync to Hanzo Git run failed before executing a step: "The job was
not started because recent account payments have failed or your spending limit
needs to be increased." GitHub-hosted minutes are refused for this org, so a
nudge on ubuntu-latest is a coin flip on an invoice. Our ARC pool is free,
in-cluster, and demonstrably running this org's CI right now — and the nudge is
one bounded curl.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 13:46:53 -07:00
zeekayandClaude Opus 5 e7b54b92b1 fix(sync): the nudge runs on OUR pool — GitHub-hosted is billing-blocked
The first Sync to Hanzo Git run failed before executing a step: "The job was
not started because recent account payments have failed or your spending limit
needs to be increased." GitHub-hosted minutes are refused for this org, so a
nudge on ubuntu-latest is a coin flip on an invoice. Our ARC pool is free,
in-cluster, and demonstrably running this org's CI right now — and the nudge is
one bounded curl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:46:53 -07:00
zeekayandhanzo-dev f85350fae5 ci: put the console server image back on the pipeline that runs
console's Next.js server image (admin.hanzo.ai, operator CR crs/console.yaml)
has not been built since 2026-07-24, when build-image.yml was neutralized in
favour of a native pipeline that cannot execute: measured against the live
forge, hanzoai/console is a Gitea PULL MIRROR with Actions DISABLED
(`mirror: true`, `has_actions: false`, zero runs ever). Two releases shipped no
image — v8.5.23 and the current 8.5.24 — while the CR still pins v8.5.22.

  hanzo.yml            declares BOTH images now (console-embed + console), so
                       the ONE config drives whichever runner executes it. The
                       SOURCE_COMMIT build arg is not carried over: it defaults
                       to "" in the Dockerfile and no source reads it.
  cicd.yml             + `tags: ['v*']` — a hand-cut tag must produce its image
                       or it is a receipt for nothing, the precise drift the
                       retired workflow existed to prevent.
  sync.yml             new: nudge git.hanzo.ai, and state the blocker where the
                       next agent will look before "fixing" the law violation.
  .hanzo/…/sync-from-github.yml  new: the 10-min fast-forward, inert until the
                       forge copy stops being a mirror.
  build-image.yml      DELETED — it had been reduced to echoing a sentence.
  deploy.yml           runs-on → hanzo-build-linux-amd64, the label the runners
                       demonstrably answer to (same fix hanzoai/cloud took).

Tag shape changes with the builder, deliberately: the shared builder publishes
`sha-<sha7>-amd64` per main push plus the bare semver on a v* tag, not the
`:v<X.Y.Z>` receipt. Pin the CR to the sha tag — that is what hanzoai/cloud
does, and an immutable content tag cannot be re-pushed to different bytes the
way `:v8.4.118` once was.

cicd.yml stays on GitHub until the forge copy is converted (one act, no code
change: hanzoai/.github scripts/forge-migrate.sh convert --repo
hanzoai/console). It is a 7-line caller over hanzo.yml, so that move is a file
rename and nothing else.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 13:44:04 -07:00
zeekayandClaude Opus 5 7d1f5eb602 ci: put the console server image back on the pipeline that runs
console's Next.js server image (admin.hanzo.ai, operator CR crs/console.yaml)
has not been built since 2026-07-24, when build-image.yml was neutralized in
favour of a native pipeline that cannot execute: measured against the live
forge, hanzoai/console is a Gitea PULL MIRROR with Actions DISABLED
(`mirror: true`, `has_actions: false`, zero runs ever). Two releases shipped no
image — v8.5.23 and the current 8.5.24 — while the CR still pins v8.5.22.

  hanzo.yml            declares BOTH images now (console-embed + console), so
                       the ONE config drives whichever runner executes it. The
                       SOURCE_COMMIT build arg is not carried over: it defaults
                       to "" in the Dockerfile and no source reads it.
  cicd.yml             + `tags: ['v*']` — a hand-cut tag must produce its image
                       or it is a receipt for nothing, the precise drift the
                       retired workflow existed to prevent.
  sync.yml             new: nudge git.hanzo.ai, and state the blocker where the
                       next agent will look before "fixing" the law violation.
  .hanzo/…/sync-from-github.yml  new: the 10-min fast-forward, inert until the
                       forge copy stops being a mirror.
  build-image.yml      DELETED — it had been reduced to echoing a sentence.
  deploy.yml           runs-on → hanzo-build-linux-amd64, the label the runners
                       demonstrably answer to (same fix hanzoai/cloud took).

Tag shape changes with the builder, deliberately: the shared builder publishes
`sha-<sha7>-amd64` per main push plus the bare semver on a v* tag, not the
`:v<X.Y.Z>` receipt. Pin the CR to the sha tag — that is what hanzoai/cloud
does, and an immutable content tag cannot be re-pushed to different bytes the
way `:v8.4.118` once was.

cicd.yml stays on GitHub until the forge copy is converted (one act, no code
change: hanzoai/.github scripts/forge-migrate.sh convert --repo
hanzoai/console). It is a 7-line caller over hanzo.yml, so that move is a file
rename and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:44:04 -07:00
Hanzo AI d8d3562048 fix(console): hero overprinted itself on mobile — unitless line-height for display type
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
2026-07-25 13:36:18 -07:00
Hanzo AI ba8ed49a66 fix(console): hero overprinted itself on mobile — unitless line-height for display type
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
2026-07-25 13:36:18 -07:00
Hanzo AI 467cb4bbb8 release(console): v8.5.23 — brand+voice chrome, dead-CTA fix, monochrome, e2e proof
Cuts the semver for the waves already on main:
- chrome: white-label org logo, floating chat circle -> topbar brand-H + voice mic
  opening the right sidebar, freely resizable Developers dock, live Create-key
- fix: the landing's 'Open Console' was a self-link (read as 'login is broken');
  both header CTAs now resolve to the one sign-in surface (landing-surface.ts)
- monochrome: one semantic map (ui/tone.ts) across 3 passes; 15+ per-module colour
  ladders deleted; categorical scale split out (lib/theme/ramp.ts); vendor brand
  identity deliberately preserved. Exposed + fixed two real bugs: health identified
  by colour equality (degraded counted as healthy) and drifted vendor hues.
- proof: voice unit tests, chrome render spec, landing-surface + tone contracts.

tsc 0 errors; vitest 3013 passed / 8 skipped; build:embed green.
2026-07-25 12:27:54 -07:00
Hanzo AI b639005abc release(console): v8.5.23 — brand+voice chrome, dead-CTA fix, monochrome, e2e proof
Cuts the semver for the waves already on main:
- chrome: white-label org logo, floating chat circle -> topbar brand-H + voice mic
  opening the right sidebar, freely resizable Developers dock, live Create-key
- fix: the landing's 'Open Console' was a self-link (read as 'login is broken');
  both header CTAs now resolve to the one sign-in surface (landing-surface.ts)
- monochrome: one semantic map (ui/tone.ts) across 3 passes; 15+ per-module colour
  ladders deleted; categorical scale split out (lib/theme/ramp.ts); vendor brand
  identity deliberately preserved. Exposed + fixed two real bugs: health identified
  by colour equality (degraded counted as healthy) and drifted vendor hues.
- proof: voice unit tests, chrome render spec, landing-surface + tone contracts.

tsc 0 errors; vitest 3013 passed / 8 skipped; build:embed green.
2026-07-25 12:27:54 -07:00
Hanzo AI 2955f97310 feat(console): finish the monochrome sweep — one map, one module, weight not hue
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
2026-07-25 12:14:31 -07:00
Hanzo AI fe6bccad72 feat(console): finish the monochrome sweep — one map, one module, weight not hue
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
2026-07-25 12:14:31 -07:00
Hanzo AI 724368cb8f feat(console): finish the monochrome sweep — one tone map, weight not hue
The console chrome is monochrome: globals.css defines ONE greyscale ramp
(--color1…--color12, zero saturation) and StatusTag has always expressed
status by WEIGHT. Two prior passes converted most of the app; ~270 hardcoded
semantic hexes survived across 77 files, so a "failed" pill was still red on
one screen and greyscale on the next.

Sweep them all onto the ONE map (src/components/ui/tone.ts):
  greens -> positive · reds -> critical · ambers -> warning
  blues  -> neutral  · greys -> muted

WHY weight and not hue: an AlertTriangle beside "Degraded" and a CheckCircle
beside "Healthy" are already unambiguous, so colour only has to carry
EMPHASIS. Every icon and label is unchanged — only the colour moved.

- Deleted every per-module hex ladder and repointed it at the one map:
  affiliates/authors/referrals statusTone, gitops health+sync, sentry level/
  status/log-level, guide step state, budgets meter, inference phase,
  resource lifecycle, apm error rate, ai-accounts headroom, living-overview
  status/health/severity, tasks status, provider funding, Metric utilColor.
- ErrorsModule carried a byte-identical copy of sentry's LEVEL_COLOR +
  statusTone; deleted it and imported the one definition.
- Metric.tsx SERIES was still the old 8-hue rainbow while Charts.tsx had
  already gone monochrome. SERIES is now CHART_PALETTE itself — one
  categorical ramp, one place.
- The domain helpers that returned a colour but were named statusTone are
  now statusTone -> Tone + statusColor -> token, matching tone.ts's own
  vocabulary (a Tone is a meaning; a colour is an appearance).

New: src/components/ui/tone-var.ts. tone.ts speaks Tamagui tokens ($color12),
which a color=/bg= prop wants; a raw style={{…}} object and an SVG attribute
are plain CSS and need var(--color12). This is an ADAPTER over tone.ts, not a
second ladder — the emphasis order still lives in exactly one place.

BUG this exposed (living/logic.ts): healthTally identified a healthy row by
COLOUR EQUALITY (healthColor(h) === OK). Positive and warning deliberately
share $color11, so every degraded service started counting as healthy.
Split the vocabulary from the appearance — healthTone() is the semantic
decision, healthColor() is toneVar(healthTone()) — and the tally now asks the
tone. Colour is not an identity key in a monochrome system.

Vendor identity is untouched and stays chromatic: brand-marks.ts, brand.ts,
ProviderLogo.tsx are the MODEL VENDOR's own colours (Anthropic coral, Qwen
violet, Meta blue), shown on every brand host — that is their identity, not
our chrome. products/colors.ts (already greyscale) is likewise untouched.

Contract changes in tests, all deliberate: "distinct colour per verdict"
(budgets) and the per-hex tone assertions (affiliates/authors/referrals/
gitops/sentry/apm/links) no longer hold — a monochrome map shares tokens by
design. They now assert what actually matters: every tone is greyscale, and
the emphasis order is correct (critical outranks positive, unknown is never
dressed as failure).

tsc --noEmit clean · vitest 3011 passed (243 files) · build:embed green ·
zero target hexes left in src.
2026-07-25 11:54:52 -07:00
Hanzo AI 86b3f88685 feat(console): finish the monochrome sweep — one tone map, weight not hue
The console chrome is monochrome: globals.css defines ONE greyscale ramp
(--color1…--color12, zero saturation) and StatusTag has always expressed
status by WEIGHT. Two prior passes converted most of the app; ~270 hardcoded
semantic hexes survived across 77 files, so a "failed" pill was still red on
one screen and greyscale on the next.

Sweep them all onto the ONE map (src/components/ui/tone.ts):
  greens -> positive · reds -> critical · ambers -> warning
  blues  -> neutral  · greys -> muted

WHY weight and not hue: an AlertTriangle beside "Degraded" and a CheckCircle
beside "Healthy" are already unambiguous, so colour only has to carry
EMPHASIS. Every icon and label is unchanged — only the colour moved.

- Deleted every per-module hex ladder and repointed it at the one map:
  affiliates/authors/referrals statusTone, gitops health+sync, sentry level/
  status/log-level, guide step state, budgets meter, inference phase,
  resource lifecycle, apm error rate, ai-accounts headroom, living-overview
  status/health/severity, tasks status, provider funding, Metric utilColor.
- ErrorsModule carried a byte-identical copy of sentry's LEVEL_COLOR +
  statusTone; deleted it and imported the one definition.
- Metric.tsx SERIES was still the old 8-hue rainbow while Charts.tsx had
  already gone monochrome. SERIES is now CHART_PALETTE itself — one
  categorical ramp, one place.
- The domain helpers that returned a colour but were named statusTone are
  now statusTone -> Tone + statusColor -> token, matching tone.ts's own
  vocabulary (a Tone is a meaning; a colour is an appearance).

New: src/components/ui/tone-var.ts. tone.ts speaks Tamagui tokens ($color12),
which a color=/bg= prop wants; a raw style={{…}} object and an SVG attribute
are plain CSS and need var(--color12). This is an ADAPTER over tone.ts, not a
second ladder — the emphasis order still lives in exactly one place.

BUG this exposed (living/logic.ts): healthTally identified a healthy row by
COLOUR EQUALITY (healthColor(h) === OK). Positive and warning deliberately
share $color11, so every degraded service started counting as healthy.
Split the vocabulary from the appearance — healthTone() is the semantic
decision, healthColor() is toneVar(healthTone()) — and the tally now asks the
tone. Colour is not an identity key in a monochrome system.

Vendor identity is untouched and stays chromatic: brand-marks.ts, brand.ts,
ProviderLogo.tsx are the MODEL VENDOR's own colours (Anthropic coral, Qwen
violet, Meta blue), shown on every brand host — that is their identity, not
our chrome. products/colors.ts (already greyscale) is likewise untouched.

Contract changes in tests, all deliberate: "distinct colour per verdict"
(budgets) and the per-hex tone assertions (affiliates/authors/referrals/
gitops/sentry/apm/links) no longer hold — a monochrome map shares tokens by
design. They now assert what actually matters: every tone is greyscale, and
the emphasis order is correct (critical outranks positive, unknown is never
dressed as failure).

tsc --noEmit clean · vitest 3011 passed (243 files) · build:embed green ·
zero target hexes left in src.
2026-07-25 11:54:52 -07:00
Hanzo AI 80cd1f0cbc fix(console): the landing's 'Open Console' was a dead self-link — one way in
LIVE BUG (found by driving the real site headless): console.hanzo.ai renders the
anon marketing landing, whose shared @hanzogui/shell header carried the canonical
cloud.hanzo.ai CTAs — primary 'Open Console' -> https://console.hanzo.ai. Right
from anywhere else; from the console itself it is a SELF-LINK, so clicking it
re-rendered the same anonymous page. Nothing happened. To a signed-out visitor
that reads as 'login is broken'.

The header braids two orthogonal things: IDENTITY (brand, local nav, the Products
taxonomy) and NAVIGATION (its two CTAs). Identity was already correct, so only the
actions are re-pointed — at the ONE sign-in surface this page owns. Signing in IS
how you open the console, and a key is minted once you are in, so both CTAs have
exactly one honest destination and the page has one way in (the duplicate
sign-in button is dropped — it was a second control for the same thing).

Done as a pure value transform (src/lib/products/landing-surface.ts, base injected,
type-only import) rather than a fork, a new prop, or a second header: the shared
header keeps ONE definition, and the derivation is node-testable on its own. No env,
no flag, no branch — the same code path for every visitor.

landing-surface.test.ts (5) locks it: both CTAs land on /signin, neither is
cross-origin or external (the dead-button class can't come back), the primary is
labelled for what it does here, IDENTITY is untouched, and the canonical surface is
never mutated.

tsc --noEmit clean; vitest 5/5; npm run build:embed OK (static export, 30 route
handlers + 2 dynamic pages restored).
2026-07-25 10:58:37 -07:00
Hanzo AI 2e9faa2f6c fix(console): the landing's 'Open Console' was a dead self-link — one way in
LIVE BUG (found by driving the real site headless): console.hanzo.ai renders the
anon marketing landing, whose shared @hanzogui/shell header carried the canonical
cloud.hanzo.ai CTAs — primary 'Open Console' -> https://console.hanzo.ai. Right
from anywhere else; from the console itself it is a SELF-LINK, so clicking it
re-rendered the same anonymous page. Nothing happened. To a signed-out visitor
that reads as 'login is broken'.

The header braids two orthogonal things: IDENTITY (brand, local nav, the Products
taxonomy) and NAVIGATION (its two CTAs). Identity was already correct, so only the
actions are re-pointed — at the ONE sign-in surface this page owns. Signing in IS
how you open the console, and a key is minted once you are in, so both CTAs have
exactly one honest destination and the page has one way in (the duplicate
sign-in button is dropped — it was a second control for the same thing).

Done as a pure value transform (src/lib/products/landing-surface.ts, base injected,
type-only import) rather than a fork, a new prop, or a second header: the shared
header keeps ONE definition, and the derivation is node-testable on its own. No env,
no flag, no branch — the same code path for every visitor.

landing-surface.test.ts (5) locks it: both CTAs land on /signin, neither is
cross-origin or external (the dead-button class can't come back), the primary is
labelled for what it does here, IDENTITY is untouched, and the canonical surface is
never mutated.

tsc --noEmit clean; vitest 5/5; npm run build:embed OK (static export, 30 route
handlers + 2 dynamic pages restored).
2026-07-25 10:58:37 -07:00
Hanzo AI f98d9e8a22 revert(console): platform.hanzo.ai stays the per-org management dash (fleet admin lives on admin.hanzo.ai)
Reverts 445b9d4008. Per the CTO's host split:
  - platform.hanzo.ai (this console) = per-org management dash — the per-org PaaS
    deploy home (PlatformHome: deploy hero + App Store + Containers/Functions/Usage
    + your projects). So the platform shell goes back to the single-product face
    (rootId/home = 'platform').
  - admin.hanzo.ai = the FULL fleet admin — served by the separate operator SPA
    (hanzoai/admin, the 'Admin Console'), whose Infrastructure god-view already reads
    the /v1/paas apps drift board (every app's declared/running/drift). The fleet
    control plane belongs there, not on the customer-facing platform host.

A super-admin still reaches the fleet boards on console.hanzo.ai (the admin:true
nav), and the operator SPA owns the dedicated fleet-admin surface.
2026-07-25 10:02:22 -07:00
Hanzo AI 130ddc6501 revert(console): platform.hanzo.ai stays the per-org management dash (fleet admin lives on admin.hanzo.ai)
Reverts 90adc1a602. Per the CTO's host split:
  - platform.hanzo.ai (this console) = per-org management dash — the per-org PaaS
    deploy home (PlatformHome: deploy hero + App Store + Containers/Functions/Usage
    + your projects). So the platform shell goes back to the single-product face
    (rootId/home = 'platform').
  - admin.hanzo.ai = the FULL fleet admin — served by the separate operator SPA
    (hanzoai/admin, the 'Admin Console'), whose Infrastructure god-view already reads
    the /v1/paas apps drift board (every app's declared/running/drift). The fleet
    control plane belongs there, not on the customer-facing platform host.

A super-admin still reaches the fleet boards on console.hanzo.ai (the admin:true
nav), and the operator SPA owns the dedicated fleet-admin surface.
2026-07-25 10:02:22 -07:00
Hanzo AI 445b9d4008 fix(console): platform.hanzo.ai boots into the fleet control plane, not the per-org deploy home
The platform face was a single-product shell (rootId/home = 'platform' → the
per-org PaaS deploy home, PlatformModule). Per the CTO call — and the FOLLOW-UP the
OSS App Store commit explicitly flagged ('upgrade the platform face from
single-product to a MULTI-product nav … a separate CTO call') — platform.<brand> is
the fleet CONTROL PLANE: it must show every deployment of every app + manage all
services.

Retarget the platform shell to a full-catalog control-plane shape:
  rootId: 'platform' → null   (full admin nav — a global admin sees Deploy,
                                Applications, Clusters, Kubernetes, Tenants, Storage,
                                … every service; not a single-product scope)
  home:   'platform' → 'gitops' (boots into the Deploy fleet map — every operator
                                App CR's declared/running/latest/drift + builds/logs/
                                rollback), not the customer home.

Additive to the concurrent OSS-store work: the per-org deploy home (the 'platform'
product / PlatformHome) stays reachable as one nav item among many; only the LANDING
moves. isProductShell stays false for platform (a home alone never scopes the nav —
only a rootId does), so nav rendering is the full catalog exactly like console.
Auth unchanged (brand cloud app; a global admin resolves owner==='admin' on this
go:embed host exactly as on cloud.<brand>, so the admin fleet board works).

tsc clean; shell.test + config/index.test green (56); build:embed ✓ (go:embed gate).
2026-07-25 06:48:18 -07:00
Hanzo AI 90adc1a602 fix(console): platform.hanzo.ai boots into the fleet control plane, not the per-org deploy home
The platform face was a single-product shell (rootId/home = 'platform' → the
per-org PaaS deploy home, PlatformModule). Per the CTO call — and the FOLLOW-UP the
OSS App Store commit explicitly flagged ('upgrade the platform face from
single-product to a MULTI-product nav … a separate CTO call') — platform.<brand> is
the fleet CONTROL PLANE: it must show every deployment of every app + manage all
services.

Retarget the platform shell to a full-catalog control-plane shape:
  rootId: 'platform' → null   (full admin nav — a global admin sees Deploy,
                                Applications, Clusters, Kubernetes, Tenants, Storage,
                                … every service; not a single-product scope)
  home:   'platform' → 'gitops' (boots into the Deploy fleet map — every operator
                                App CR's declared/running/latest/drift + builds/logs/
                                rollback), not the customer home.

Additive to the concurrent OSS-store work: the per-org deploy home (the 'platform'
product / PlatformHome) stays reachable as one nav item among many; only the LANDING
moves. isProductShell stays false for platform (a home alone never scopes the nav —
only a rootId does), so nav rendering is the full catalog exactly like console.
Auth unchanged (brand cloud app; a global admin resolves owner==='admin' on this
go:embed host exactly as on cloud.<brand>, so the admin fleet board works).

tsc clean; shell.test + config/index.test green (56); build:embed ✓ (go:embed gate).
2026-07-25 06:48:18 -07:00
Hanzo AI 1c0fd191cc feat(console): native OSS App Store (1000+ one-click apps) + platform deploy home
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).
2026-07-25 00:28:39 -07:00
Hanzo AI 02e5478417 feat(console): native OSS App Store (1000+ one-click apps) + platform deploy home
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).
2026-07-25 00:28:39 -07:00
Hanzo AI dd2e5b9ce9 fix(console): platform.hanzo.ai wears the Platform control-plane face, not the catalog grid
shellFromHost had no `platform` case, so platform.hanzo.ai fell through to the
default `console` shell — the product-catalog home (the monochrome "provider
grid"), instead of the embedded PaaS control-plane (apps table / deploys / drift,
the `platform` module). console.hanzo.ai and platform.hanzo.ai are the SAME
cloud-served SPA (byte-identical HTML, title-only white-label), so the face is
chosen client-side by shellFromHost(window.location.hostname) — and platform had
no entry.

Add `platform` as a ShellId face, mirroring dns.<brand> exactly:
- isPlatformHost — strict `platform.` prefix predicate
- shellFromHost — the branch + NEXT_PUBLIC_PRODUCT_SHELL override
- PRODUCT_SHELLS — descriptor (rootId/home = 'platform' → boots into PlatformModule,
  the PaaS control plane); wordmark "Platform", indexes on Overview
platform.hanzo.ai is a Platform-faced alias of console.hanzo.ai (one shared cloud
backend, two entry points); console.hanzo.ai keeps Platform as one product among many.

tsc --noEmit clean (0 errors, whole project); shell.test.ts + config/index.test.ts
green (55 tests). Ships to platform.hanzo.ai via the next hanzoai/cloud release
embedding console@main (go:embed; CONSOLE_REF=main).
2026-07-25 00:03:01 -07:00
Hanzo AI 972dfdc5f7 fix(console): platform.hanzo.ai wears the Platform control-plane face, not the catalog grid
shellFromHost had no `platform` case, so platform.hanzo.ai fell through to the
default `console` shell — the product-catalog home (the monochrome "provider
grid"), instead of the embedded PaaS control-plane (apps table / deploys / drift,
the `platform` module). console.hanzo.ai and platform.hanzo.ai are the SAME
cloud-served SPA (byte-identical HTML, title-only white-label), so the face is
chosen client-side by shellFromHost(window.location.hostname) — and platform had
no entry.

Add `platform` as a ShellId face, mirroring dns.<brand> exactly:
- isPlatformHost — strict `platform.` prefix predicate
- shellFromHost — the branch + NEXT_PUBLIC_PRODUCT_SHELL override
- PRODUCT_SHELLS — descriptor (rootId/home = 'platform' → boots into PlatformModule,
  the PaaS control plane); wordmark "Platform", indexes on Overview
platform.hanzo.ai is a Platform-faced alias of console.hanzo.ai (one shared cloud
backend, two entry points); console.hanzo.ai keeps Platform as one product among many.

tsc --noEmit clean (0 errors, whole project); shell.test.ts + config/index.test.ts
green (55 tests). Ships to platform.hanzo.ai via the next hanzoai/cloud release
embedding console@main (go:embed; CONSOLE_REF=main).
2026-07-25 00:03:01 -07:00
Hanzo AI 8ec39ef5c6 test(console): prove the brand+voice chrome — voice.ts unit (8) + chrome e2e spec
- src/lib/voice.test.ts (8 passing): voiceSupported() false in node; useVoice exports;
  stubbed SpeechRecognition/speechSynthesis → supported flips, start()/speak() drive.
- e2e/chrome-brand-voice.spec.ts (Playwright, primeSession harness): no floating circle,
  topbar 'Chat with Hanzo' + 'Talk to Hanzo' controls, brand-H opens the docked sidebar,
  the Developers dock drag-handle + Create-key, 4 viewports (390/768/1280/1680, no h-scroll).
  Runs in CI; the sandbox chromium can't render the Tamagui SPA (documented repo limitation).
tsc --noEmit clean; vitest voice.test.ts 8/8.
2026-07-24 23:24:35 -07:00
Hanzo AI 630731c6e0 test(console): prove the brand+voice chrome — voice.ts unit (8) + chrome e2e spec
- src/lib/voice.test.ts (8 passing): voiceSupported() false in node; useVoice exports;
  stubbed SpeechRecognition/speechSynthesis → supported flips, start()/speak() drive.
- e2e/chrome-brand-voice.spec.ts (Playwright, primeSession harness): no floating circle,
  topbar 'Chat with Hanzo' + 'Talk to Hanzo' controls, brand-H opens the docked sidebar,
  the Developers dock drag-handle + Create-key, 4 viewports (390/768/1280/1680, no h-scroll).
  Runs in CI; the sandbox chromium can't render the Tamagui SPA (documented repo limitation).
tsc --noEmit clean; vitest voice.test.ts 8/8.
2026-07-24 23:24:35 -07:00
zeekayandhanzo-dev 75e89cb483 feat(console): complete monochrome pass — neutralize remaining $blue tokens, orange→amber
Follow-up to the design-token adoption: the remaining chromatic accents were
Tamagui theme TOKENS ($blue10/$blue11 links + icons, $blue3/$blue4 badge fills,
$orange10 severity), which the first pass's hex-focused sweep missed.

- $blue10 → $color11, $blue11 → $color12, $blue3/$blue4 → $color3/$color4
  (neutral ladder) across StorageModule, SearchModule, ContactModule, Tracker,
  embeddings, providers, platform-hub, StartupsModule and 8 more.
- $orange10 → $yellow10: severity/priority/waitlist states keep a sanctioned
  amber CAUTION hue (the design's allowed semantic), not a decorative orange.

Console chrome now uses zero chromatic accents — only the neutral ladder plus the
three permitted semantics (green/amber/red). tsc clean, next build ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-24 18:02:58 -07:00
zeekayandClaude Opus 4.8 eef684f05f feat(console): complete monochrome pass — neutralize remaining $blue tokens, orange→amber
Follow-up to the design-token adoption: the remaining chromatic accents were
Tamagui theme TOKENS ($blue10/$blue11 links + icons, $blue3/$blue4 badge fills,
$orange10 severity), which the first pass's hex-focused sweep missed.

- $blue10 → $color11, $blue11 → $color12, $blue3/$blue4 → $color3/$color4
  (neutral ladder) across StorageModule, SearchModule, ContactModule, Tracker,
  embeddings, providers, platform-hub, StartupsModule and 8 more.
- $orange10 → $yellow10: severity/priority/waitlist states keep a sanctioned
  amber CAUTION hue (the design's allowed semantic), not a decorative orange.

Console chrome now uses zero chromatic accents — only the neutral ladder plus the
three permitted semantics (green/amber/red). tsc clean, next build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:02:58 -07:00
zeekayandhanzo-dev b5544ba59b feat(console): Lux Network SuperAdmin board (Web3), brand-scoped lux
Multi-network validators (Lux primary/testnet/devnet + Pars/Osage/L2 coming-soon),
node/pod memory, 16 lux-* service health — real VM-hub telemetry via the gated
proxy, honest 0.0000-uptime note (tracker bug), zero cross-brand leak. Only on
console.lux.cloud. 10/10 shaper + 174 registry tests, tsc 0 errors. 8.4.156→8.4.157.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-24 17:53:39 -07:00
zeekayandClaude Opus 4.8 d1d2432827 feat(console): Lux Network SuperAdmin board (Web3), brand-scoped lux
Multi-network validators (Lux primary/testnet/devnet + Pars/Osage/L2 coming-soon),
node/pod memory, 16 lux-* service health — real VM-hub telemetry via the gated
proxy, honest 0.0000-uptime note (tracker bug), zero cross-brand leak. Only on
console.lux.cloud. 10/10 shaper + 174 registry tests, tsc 0 errors. 8.4.156→8.4.157.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:53:39 -07:00
zeekayandhanzo-dev 2d16a512fb feat(console): monochrome redesign — adopt hanzoai/design tokens, purge chromatic accents
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>
2026-07-24 17:52:29 -07:00
zeekayandClaude Opus 4.8 0ee6a9274a feat(console): monochrome redesign — adopt hanzoai/design tokens, purge chromatic accents
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: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:52:29 -07:00
Hanzo AI d7a9659686 feat(console): brand-forward chrome — white-label org logo, kill the chat circle for a small H + voice, resizable Developers dock + live Create-key
- SidebarBrand: the selected org's OWN logo leads the top-left chrome (useOrgLogo,
  cached, shared with BrandLogo); the Hanzo H is only the fallback (hanzo org's
  logo IS the Hanzo mark). White-label — a tenant sees their brand front and center.
- FloatingChat: removed the big floating circle that covered page content. The
  assistant now opens from the topbar: a SMALL brand-H (openChat → the right sidebar
  / dock on desktop, the sheet on phones) + a mic 'talk to Hanzo' (startVoice).
- Voice (new src/lib/voice.ts): a feature-detected Web Speech wrapper (SpeechRecognition
  STT + speechSynthesis TTS), SSR-safe, strict-clean. Wired INTO the one chat binding
  (AiApi.ragChatStream) — a mic in the composer (renders only when supported) + a
  voiceSignal from the topbar; a completed utterance sends a turn and the reply is
  spoken back. No new backend, nothing leaves the browser.
- Workbench: the Developers dock is now FREELY drag-resizable (top handle → any
  height, persisted per-user) instead of a binary toggle; the maximize button is a
  tall/compact preset. Overview tab: a live 'Create key' button mints the real hk-
  Cloud API key inline (KeysApi.create), shown once with a copy affordance.
- AuthorsModule: dropped the public 'earn 20%' number from every hero/CTA/badge; the
  real rate stays as ONE muted 'Your rate' dashboard detail (per CTO — no public %).
- SystemStatusBadge: degrade 'Checking…' → neutral 'Status' after the first probe
  returns nothing (the go:embed build prunes /system-status), never a permanent spinner.

tsc --noEmit clean; npm run build:embed ✓ (static export + 30 handlers restored).
2026-07-24 17:11:45 -07:00
Hanzo AI 0b10d82f9b feat(console): brand-forward chrome — white-label org logo, kill the chat circle for a small H + voice, resizable Developers dock + live Create-key
- SidebarBrand: the selected org's OWN logo leads the top-left chrome (useOrgLogo,
  cached, shared with BrandLogo); the Hanzo H is only the fallback (hanzo org's
  logo IS the Hanzo mark). White-label — a tenant sees their brand front and center.
- FloatingChat: removed the big floating circle that covered page content. The
  assistant now opens from the topbar: a SMALL brand-H (openChat → the right sidebar
  / dock on desktop, the sheet on phones) + a mic 'talk to Hanzo' (startVoice).
- Voice (new src/lib/voice.ts): a feature-detected Web Speech wrapper (SpeechRecognition
  STT + speechSynthesis TTS), SSR-safe, strict-clean. Wired INTO the one chat binding
  (AiApi.ragChatStream) — a mic in the composer (renders only when supported) + a
  voiceSignal from the topbar; a completed utterance sends a turn and the reply is
  spoken back. No new backend, nothing leaves the browser.
- Workbench: the Developers dock is now FREELY drag-resizable (top handle → any
  height, persisted per-user) instead of a binary toggle; the maximize button is a
  tall/compact preset. Overview tab: a live 'Create key' button mints the real hk-
  Cloud API key inline (KeysApi.create), shown once with a copy affordance.
- AuthorsModule: dropped the public 'earn 20%' number from every hero/CTA/badge; the
  real rate stays as ONE muted 'Your rate' dashboard detail (per CTO — no public %).
- SystemStatusBadge: degrade 'Checking…' → neutral 'Status' after the first probe
  returns nothing (the go:embed build prunes /system-status), never a permanent spinner.

tsc --noEmit clean; npm run build:embed ✓ (static export + 30 handlers restored).
2026-07-24 17:11:45 -07:00
Hanzo AI 8d7f136b82 treasury/authors: align the creator/revenue share default to the canonical 20%
The OSS author overview fallback (normalizeOverview) and the treasury policy
copy defaulted to 5% (500 bps). Align to the ONE canonical creator share, 20%
(2000 bps), matching cloud's authors.defaultShareBps and the treasury policy
default: a missing defaultShareBps now falls back to 2000, and the revenue-share
form hint/examples read 20%. Test updated for the 2000 fallback.
2026-07-24 16:10:53 -07:00
Hanzo AI abf86feeb4 treasury/authors: align the creator/revenue share default to the canonical 20%
The OSS author overview fallback (normalizeOverview) and the treasury policy
copy defaulted to 5% (500 bps). Align to the ONE canonical creator share, 20%
(2000 bps), matching cloud's authors.defaultShareBps and the treasury policy
default: a missing defaultShareBps now falls back to 2000, and the revenue-share
form hint/examples read 20%. Test updated for the 2000 fallback.
2026-07-24 16:10:53 -07:00
hanzo-dev 77b7efa2eb ci: neutralize build-image.yml -> sync-notice (native pipeline owns build+deploy)
Build+push of ghcr.io/hanzoai/console now lives in .hanzo/workflows/deploy.yml
(in-cluster BuildKit -> operator reconcile). GitHub Actions is mirror-only for
the app image. cicd.yml (distinct console-embed artifact) is left untouched.
2026-07-24 15:15:17 -07:00
hanzo-dev bebf85b921 ci: neutralize build-image.yml -> sync-notice (native pipeline owns build+deploy)
Build+push of ghcr.io/hanzoai/console now lives in .hanzo/workflows/deploy.yml
(in-cluster BuildKit -> operator reconcile). GitHub Actions is mirror-only for
the app image. cicd.yml (distinct console-embed artifact) is left untouched.
2026-07-24 15:15:17 -07:00
hanzo-dev 00c1077da4 ci: add native Hanzo deploy pipeline (.hanzo/workflows/deploy.yml)
Canonical build+deploy: Hanzo Git push -> in-cluster act_runner -> BuildKit
builds Dockerfile -> ghcr.io/hanzoai/console:<sha> -> kubectl patch app console
-> operator reconcile -> hanzocd. GitHub Actions reduced to mirror/sync-only.
2026-07-24 15:14:59 -07:00
hanzo-dev 5b5532a3fe ci: add native Hanzo deploy pipeline (.hanzo/workflows/deploy.yml)
Canonical build+deploy: Hanzo Git push -> in-cluster act_runner -> BuildKit
builds Dockerfile -> ghcr.io/hanzoai/console:<sha> -> kubectl patch app console
-> operator reconcile -> hanzocd. GitHub Actions reduced to mirror/sync-only.
2026-07-24 15:14:59 -07:00
zeekayandhanzo-dev 82509bbda9 feat(console): complete Webhooks product — Node/Go verifiers, live-subjects helper, footer Developers cluster
Additive completion on top of the committed single-file Webhooks product (997b93a4a):
- webhooks/verify.ts — pure, unit-tested signature-verification reference: the exact
  scheme (X-Webhook-Signature: t=<unix>,v1=hex(hmac_sha256(secret,"<t>.<body>"))) plus
  constant-time Node (node:crypto) + Go (crypto/hmac) verifiers and the live subject list.
- webhooks/VerifyCard.tsx — renders the scheme, the three delivery headers, and the
  copy-paste Node/Go snippets; wired into WebhooksModule with one import + one render.
- WebhooksModule: live-subjects helper (commerce.order.* · commerce.checkout.* · commerce.>,
  "more streams coming") under the create form's Events field.
- ConsoleFooter: brand-aware Developers cluster (Docs · API · Webhooks) — Webhooks opens
  the in-console product route, API/Docs point at the brand docs site.

tsc --noEmit clean; vitest 2953 passed / 8 skipped (+5 verify tests).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-24 12:40:26 -07:00
zeekayandClaude Opus 4.8 39551b5328 feat(console): complete Webhooks product — Node/Go verifiers, live-subjects helper, footer Developers cluster
Additive completion on top of the committed single-file Webhooks product (997b93a4a):
- webhooks/verify.ts — pure, unit-tested signature-verification reference: the exact
  scheme (X-Webhook-Signature: t=<unix>,v1=hex(hmac_sha256(secret,"<t>.<body>"))) plus
  constant-time Node (node:crypto) + Go (crypto/hmac) verifiers and the live subject list.
- webhooks/VerifyCard.tsx — renders the scheme, the three delivery headers, and the
  copy-paste Node/Go snippets; wired into WebhooksModule with one import + one render.
- WebhooksModule: live-subjects helper (commerce.order.* · commerce.checkout.* · commerce.>,
  "more streams coming") under the create form's Events field.
- ConsoleFooter: brand-aware Developers cluster (Docs · API · Webhooks) — Webhooks opens
  the in-console product route, API/Docs point at the brand docs site.

tsc --noEmit clean; vitest 2953 passed / 8 skipped (+5 verify tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:40:26 -07:00
zeekayandhanzo-dev 2c106bd19c feat(console): Webhooks product — config, security (secret rotation), test-send, delivery logs
Add a first-class Webhooks product (category Dev) over the live /v1/webhooks API,
mirroring the VpcModule/LoadBalancerModule idiom (restGet/restPost/restPatch/
restDelete + cloudProxyV1Url, PlatformError handling, enc() id-escaping):

- List: endpoints table — url, event chips, active/disabled status, 7d
  deliveries/failures usage (when present), row actions.
- Create: HTTPS url + comma/pattern events (* / commerce.> / agent.run.*) +
  description; reveal-once signing secret in a copyable callout.
- Enable/disable: inline PATCH {status} toggle.
- Security: rotate secret (confirm → reveal-once) + the X-Webhook-Signature
  HMAC-SHA256 scheme shown inline.
- Test: per-endpoint sync test-send, inline delivered/httpStatus/durationMs result
  (works while disabled).
- Logs: per-endpoint deliveries (expand + :view deep-link), newest-first, failed-
  only filter, manual refresh.

Sub-features (test/deliveries/rotate/usage counters) still landing from the cloud
lane DEGRADE GRACEFULLY — a 404 hides that one affordance, never an error card.
Register one CatalogEntry (routes '' + :view); the shell/router derive from the
catalog. Add the `webhooks` head to proxy-allow CLOUD_HEADS so the standalone /v1
bearer proxy forwards /v1/webhooks[/:id/{deliveries,test,rotate-secret}] (org from
the token owner); the go:embed console hits cloud /v1/webhooks natively. Retire the
workbench Webhooks stub to a read-only glance that deep-links into the product (one
place owns CRUD). Add a mocked-network Playwright render spec.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-24 12:32:37 -07:00
zeekayandClaude Fable 5 01c811533b feat(console): Webhooks product — config, security (secret rotation), test-send, delivery logs
Add a first-class Webhooks product (category Dev) over the live /v1/webhooks API,
mirroring the VpcModule/LoadBalancerModule idiom (restGet/restPost/restPatch/
restDelete + cloudProxyV1Url, PlatformError handling, enc() id-escaping):

- List: endpoints table — url, event chips, active/disabled status, 7d
  deliveries/failures usage (when present), row actions.
- Create: HTTPS url + comma/pattern events (* / commerce.> / agent.run.*) +
  description; reveal-once signing secret in a copyable callout.
- Enable/disable: inline PATCH {status} toggle.
- Security: rotate secret (confirm → reveal-once) + the X-Webhook-Signature
  HMAC-SHA256 scheme shown inline.
- Test: per-endpoint sync test-send, inline delivered/httpStatus/durationMs result
  (works while disabled).
- Logs: per-endpoint deliveries (expand + :view deep-link), newest-first, failed-
  only filter, manual refresh.

Sub-features (test/deliveries/rotate/usage counters) still landing from the cloud
lane DEGRADE GRACEFULLY — a 404 hides that one affordance, never an error card.
Register one CatalogEntry (routes '' + :view); the shell/router derive from the
catalog. Add the `webhooks` head to proxy-allow CLOUD_HEADS so the standalone /v1
bearer proxy forwards /v1/webhooks[/:id/{deliveries,test,rotate-secret}] (org from
the token owner); the go:embed console hits cloud /v1/webhooks natively. Retire the
workbench Webhooks stub to a read-only glance that deep-links into the product (one
place owns CRUD). Add a mocked-network Playwright render spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:32:37 -07:00
Hanzo AI c9b4318fc5 feat(console): add "Deploy OSS" and "Earn from your OSS" landing tiles
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.
2026-07-24 01:41:34 -07:00
Hanzo AI 6528eb32ab feat(console): add "Deploy OSS" and "Earn from your OSS" landing tiles
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.
2026-07-24 01:41:34 -07:00
Hanzo AI a68d1726bd refactor(console): useResourceList — DRY the /v1 collection modules
Edge + ServiceMesh each hand-rolled the identical rows/loading/error +
restGet(originV1Url) + interpretPlatformError boilerplate. Fold it into ONE
composable hook (useResourceList<T>(path, key)) over the canonical REST client
and PlatformError model. Modules become a one-line data seam; the hook is
reusable by any future /v1 collection view (Networks legacy raw-fetch is a
follow-up convert). Typecheck clean; behavior identical (empty+typed-error on
failure, never a blank page).
2026-07-24 01:28:48 -07:00
Hanzo AI b887f43266 refactor(console): useResourceList — DRY the /v1 collection modules
Edge + ServiceMesh each hand-rolled the identical rows/loading/error +
restGet(originV1Url) + interpretPlatformError boilerplate. Fold it into ONE
composable hook (useResourceList<T>(path, key)) over the canonical REST client
and PlatformError model. Modules become a one-line data seam; the hook is
reusable by any future /v1 collection view (Networks legacy raw-fetch is a
follow-up convert). Typecheck clean; behavior identical (empty+typed-error on
failure, never a blank page).
2026-07-24 01:28:48 -07:00
zeekay 7560573efb chore(cloud): bump @hanzogui/shell ^7.5.1 — polished mega-menu (single Products, complete hints, 3×2 flagship, focus ring, GCP-leak removed) 2026-07-24 00:09:46 -07:00
zeekay 7cb078953b chore(cloud): bump @hanzogui/shell ^7.5.1 — polished mega-menu (single Products, complete hints, 3×2 flagship, focus ring, GCP-leak removed) 2026-07-24 00:09:46 -07:00
zeekay f1aa569730 feat(cloud): unified @hanzogui/shell HanzoHeader + rich Products mega-menu on cloud.hanzo.ai public landing (hanzo brand) 2026-07-23 23:08:22 -07:00
zeekay df035c3fbe feat(cloud): unified @hanzogui/shell HanzoHeader + rich Products mega-menu on cloud.hanzo.ai public landing (hanzo brand) 2026-07-23 23:08:22 -07:00
zeekay 457b2daa6a fix(auth): guard the PKCE callback to exchange the code exactly once
console.hanzo.ai login was stuck on /auth/callback with 'Sign-in failed.' The
OAuth code is single-use and @hanzo/iam removes the PKCE verifier before the token
fetch, so React StrictMode's double-invoke (reactStrictMode:true) — or any
handleCallback identity change re-running the effect — fired a SECOND exchange that
found the code consumed / verifier gone and threw, surfacing the failure even though
the first exchange succeeded. Add a useRef run-once guard so the exchange fires once
per page load. Also drop the vestigial @hanzo/iam-js-sdk dep (unused; one way = @hanzo/iam).
2026-07-23 15:09:02 -07:00
zeekay 5da2a1a233 fix(auth): guard the PKCE callback to exchange the code exactly once
console.hanzo.ai login was stuck on /auth/callback with 'Sign-in failed.' The
OAuth code is single-use and @hanzo/iam removes the PKCE verifier before the token
fetch, so React StrictMode's double-invoke (reactStrictMode:true) — or any
handleCallback identity change re-running the effect — fired a SECOND exchange that
found the code consumed / verifier gone and threw, surfacing the failure even though
the first exchange succeeded. Add a useRef run-once guard so the exchange fires once
per page load. Also drop the vestigial @hanzo/iam-js-sdk dep (unused; one way = @hanzo/iam).
2026-07-23 15:09:02 -07:00
hanzo-dev aa5b967e74 feat(admin): Growth cockpit — observe + operate the Zen-of-Hanzo Guide engine
The SuperAdmin operator view (admin.hanzo.ai) that makes the whole growth-OS
backend observable AND editable at a glance, over the live cloud clients/guide
`/v1/guide/*` contracts (the `guide` head on the /v1 user-bearer BFF).

- src/lib/guide/client.ts — GuideBlueprintApi, modeled on lib/framework/client.ts:
  restGet/restPut/restPatch over cloudProxyV1Url('guide/...'), typed + defensively
  normalized to the blueprint/strategies/profile/suggest contracts. The core client
  already exposes restPatch (the plain-REST partial-edit seam), so the {enabled:false}
  lever rides the existing verb — no new seam. Pure strategyQuery shaping + normalizers
  unit-tested (client.test.ts, 12 tests).

- GrowthModule.tsx — three sections, a UI to SCAN and OPERATE (summary before detail,
  state encoded in form; semantic stage/on-track color separate from the brand accent):
  · Blueprint — the 64 archetype principles (hexagram + Sun Tzu concept + domain, each
    expandable to its tactics) and the journey (sections → steps, tool + deps), every
    item with a live enable/disable toggle (PATCH) + inline edit; version chip +
    Publish version + history.
  · Corpus — browse/filter the ~888 tactics by category/stage/workload + search, a
    count + category-coverage donut (the genome made legible), blog why/how/case-study.
  · Live state — the org's dogfood read: stage indicator (formed→launched→activated→
    scaling), signals, key metrics + funnel sparkline, and the ranked next-best moves
    (leverage/unlocks + automatable). <GrowthOrgOverview> is the drop-in seam for the
    future cross-org macro table.

Registered as catalog id `growth` (Observe, admin: true) in registry.tsx; the module
also gates on useIsSuperAdmin() over the authoritative server-side gate. Operator copy
+ toasts that say exactly what happened; honest loading/empty/error states, no fabrication.

Green: tsc --noEmit 0 errors; vitest 2945/2945 (234 files, +12); next build ✓;
build:embed ✓ (go:embed gate).
2026-07-23 13:33:43 -07:00
hanzo-dev 0a3738efab feat(admin): Growth cockpit — observe + operate the Zen-of-Hanzo Guide engine
The SuperAdmin operator view (admin.hanzo.ai) that makes the whole growth-OS
backend observable AND editable at a glance, over the live cloud clients/guide
`/v1/guide/*` contracts (the `guide` head on the /v1 user-bearer BFF).

- src/lib/guide/client.ts — GuideBlueprintApi, modeled on lib/framework/client.ts:
  restGet/restPut/restPatch over cloudProxyV1Url('guide/...'), typed + defensively
  normalized to the blueprint/strategies/profile/suggest contracts. The core client
  already exposes restPatch (the plain-REST partial-edit seam), so the {enabled:false}
  lever rides the existing verb — no new seam. Pure strategyQuery shaping + normalizers
  unit-tested (client.test.ts, 12 tests).

- GrowthModule.tsx — three sections, a UI to SCAN and OPERATE (summary before detail,
  state encoded in form; semantic stage/on-track color separate from the brand accent):
  · Blueprint — the 64 archetype principles (hexagram + Sun Tzu concept + domain, each
    expandable to its tactics) and the journey (sections → steps, tool + deps), every
    item with a live enable/disable toggle (PATCH) + inline edit; version chip +
    Publish version + history.
  · Corpus — browse/filter the ~888 tactics by category/stage/workload + search, a
    count + category-coverage donut (the genome made legible), blog why/how/case-study.
  · Live state — the org's dogfood read: stage indicator (formed→launched→activated→
    scaling), signals, key metrics + funnel sparkline, and the ranked next-best moves
    (leverage/unlocks + automatable). <GrowthOrgOverview> is the drop-in seam for the
    future cross-org macro table.

Registered as catalog id `growth` (Observe, admin: true) in registry.tsx; the module
also gates on useIsSuperAdmin() over the authoritative server-side gate. Operator copy
+ toasts that say exactly what happened; honest loading/empty/error states, no fabrication.

Green: tsc --noEmit 0 errors; vitest 2945/2945 (234 files, +12); next build ✓;
build:embed ✓ (go:embed gate).
2026-07-23 13:33:43 -07:00
hanzo-dev 82c2c242e1 chat: build-forward empty state — greeting + declarative prompts
Greeting 'How can I help?' -> 'What do you want to build?'. Suggested prompts are
now declarative {label, fill|href}: 'Build an app ->' LAUNCHES the hanzo.app
builder (config.appUrl/dev — the canonical cross-surface action, not a dead-end
chat prompt); the rest fill the composer with grounded, capability-showcasing
questions (launch a GPU, deploy an agent, pick a model, pricing).
2026-07-23 10:23:15 -07:00
hanzo-dev b74c5b070b chat: build-forward empty state — greeting + declarative prompts
Greeting 'How can I help?' -> 'What do you want to build?'. Suggested prompts are
now declarative {label, fill|href}: 'Build an app ->' LAUNCHES the hanzo.app
builder (config.appUrl/dev — the canonical cross-surface action, not a dead-end
chat prompt); the rest fill the composer with grounded, capability-showcasing
questions (launch a GPU, deploy an agent, pick a model, pricing).
2026-07-23 10:23:15 -07:00
hanzo-dev fdbcae7638 fe: fix hero line-box crush + make Geist actually load
- 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.
2026-07-23 09:55:44 -07:00
hanzo-dev 9933cf1d86 fe: fix hero line-box crush + make Geist actually load
- 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.
2026-07-23 09:55:44 -07:00
Hanzo AI b5f32b00e9 fix(cmdk): taller palette body (Raycast/Linear-style stable box)
The ⌘K palette collapsed to a ~2-row sliver when few results matched
(desktop body minH:120/maxH:420). Raise it to minH:340/maxH:560 so it reads
as a real command surface with room to breathe and shows more results before
scrolling. Footer/legend stays pinned (separate row); mobile full-screen
unchanged.
2026-07-23 02:16:54 -07:00
Hanzo AI 61968a6460 fix(cmdk): taller palette body (Raycast/Linear-style stable box)
The ⌘K palette collapsed to a ~2-row sliver when few results matched
(desktop body minH:120/maxH:420). Raise it to minH:340/maxH:560 so it reads
as a real command surface with room to breathe and shows more results before
scrolling. Footer/legend stays pinned (separate row); mobile full-screen
unchanged.
2026-07-23 02:16:54 -07:00
hanzo-dev 68e4bb5e87 console: keep the shell inert on navigation + drop in-product OSS upstream notices
Shell re-render (decomplect the route subscription):
- Dashboard no longer calls usePathname(); the route subscription is confined to
  the leaves that depend on it (SidebarNav for the active highlight, a new
  BreadcrumbsBar). A navigation click now re-renders only the swapped page content
  and those leaves — the topbar and sidebar chrome stay put (no flicker/lost state).

Upstream notices:
- Remove the per-product "Built on open source — forked from X" surfaces from the
  UI (the content-column note, the interstitial OSS-card clause, the overview
  "Upstream" fact). OSS attribution belongs in the repo LICENSE/NOTICE, not the
  product surface; permissive/copyleft licenses require the notice in source, not
  in-product. Upstream provenance stays catalog metadata for NOTICE generation.
- Delete the now-unused ProductUpstreamNote component.
- Drop the LibreChat upstream from the chat entry: the in-console chat is a native
  widget over /v1/chat/completions, not a LibreChat fork.

v8.4.154.
2026-07-23 01:57:34 -07:00
hanzo-dev 4186f47a31 console: keep the shell inert on navigation + drop in-product OSS upstream notices
Shell re-render (decomplect the route subscription):
- Dashboard no longer calls usePathname(); the route subscription is confined to
  the leaves that depend on it (SidebarNav for the active highlight, a new
  BreadcrumbsBar). A navigation click now re-renders only the swapped page content
  and those leaves — the topbar and sidebar chrome stay put (no flicker/lost state).

Upstream notices:
- Remove the per-product "Built on open source — forked from X" surfaces from the
  UI (the content-column note, the interstitial OSS-card clause, the overview
  "Upstream" fact). OSS attribution belongs in the repo LICENSE/NOTICE, not the
  product surface; permissive/copyleft licenses require the notice in source, not
  in-product. Upstream provenance stays catalog metadata for NOTICE generation.
- Delete the now-unused ProductUpstreamNote component.
- Drop the LibreChat upstream from the chat entry: the in-console chat is a native
  widget over /v1/chat/completions, not a LibreChat fork.

v8.4.154.
2026-07-23 01:57:34 -07:00
hanzo-dev 176f25ddcd Merge branch 'blue/console-research' into HEAD 2026-07-23 01:48:54 -07:00
hanzo-dev 4ed6cf6c7e Merge branch 'blue/console-research' into HEAD 2026-07-23 01:48:54 -07:00
hanzo-dev 2bdbdabe55 console/research: fix fmtValue honesty bug + verdict trim + rowKey collision (red)
- fmtValue: at abs>=100 toFixed(0) yields a dotless integer string; the naive
  trailing-zero trim ate its real zeros (150.4 -> '15', 1000.4 -> '1'). Guard
  the trim on a decimal point + group the rounded integer like the integer path
  (1000.4 -> '1,000'). Extracted to a pure research-fmt.ts (no JSX) so the
  honesty-critical formatter is unit-testable; 3 regression tests.
- verdict: trim before clamping so 'refuted ' still counts as refuted (was
  silently dropping from the Refuted KPI + refutations panel).
- rowKey: fall back to subject:task so id-less rows don't collide on key ''
  (expand-all bug).
- fmtDate: guard NaN date. Build + typecheck + 10 tests green.
2026-07-23 01:48:14 -07:00
hanzo-dev 646ad8435d console/research: fix fmtValue honesty bug + verdict trim + rowKey collision (red)
- fmtValue: at abs>=100 toFixed(0) yields a dotless integer string; the naive
  trailing-zero trim ate its real zeros (150.4 -> '15', 1000.4 -> '1'). Guard
  the trim on a decimal point + group the rounded integer like the integer path
  (1000.4 -> '1,000'). Extracted to a pure research-fmt.ts (no JSX) so the
  honesty-critical formatter is unit-testable; 3 regression tests.
- verdict: trim before clamping so 'refuted ' still counts as refuted (was
  silently dropping from the Refuted KPI + refutations panel).
- rowKey: fall back to subject:task so id-less rows don't collide on key ''
  (expand-all bug).
- fmtDate: guard NaN date. Build + typecheck + 10 tests green.
2026-07-23 01:48:14 -07:00
hanzo-dev 3245812ddb console: workspace switcher to the TOP of the sidebar, account stays at the bottom
Per the tenancy IA (Account → Workspace → Project → Environment): switching the
workspace changes everything beneath it, so it leads the sidebar. Split the old
bottom SidebarIdentity cluster into SidebarWorkspace (the OrgSwitcher, rendered at
the TOP just under SidebarBrand) + SidebarAccount (the user/account row, unchanged
at the bottom). A user no longer confuses 'who I am' (bottom) with 'which workspace
I'm acting in' (top). Applied across all sidebar layouts (persistent desktop, flyout,
mobile drawer); the collapsed icon-rail omits it (SidebarWorkspace returns null when
collapsed — workspace switching happens in the expanded flyout). No OrgSwitcher logic
change, only position. tsc clean; next build + build:embed green.
2026-07-23 00:32:37 -07:00
hanzo-dev 1a589b6d7e console: workspace switcher to the TOP of the sidebar, account stays at the bottom
Per the tenancy IA (Account → Workspace → Project → Environment): switching the
workspace changes everything beneath it, so it leads the sidebar. Split the old
bottom SidebarIdentity cluster into SidebarWorkspace (the OrgSwitcher, rendered at
the TOP just under SidebarBrand) + SidebarAccount (the user/account row, unchanged
at the bottom). A user no longer confuses 'who I am' (bottom) with 'which workspace
I'm acting in' (top). Applied across all sidebar layouts (persistent desktop, flyout,
mobile drawer); the collapsed icon-rail omits it (SidebarWorkspace returns null when
collapsed — workspace switching happens in the expanded flyout). No OrgSwitcher logic
change, only position. tsc clean; next build + build:embed green.
2026-07-23 00:32:37 -07:00
Hanzo AI 7188bdccf2 docs(llm): correct Block Storage — admin.hanzo.ai is the operator SPA, endpoint renamed 2026-07-22 23:59:27 -07:00
Hanzo AI bec350b24e docs(llm): correct Block Storage — admin.hanzo.ai is the operator SPA, endpoint renamed 2026-07-22 23:59:27 -07:00
Hanzo AI 2d05c108e5 refactor(admin): repoint Block Storage board to /v1/admin/block-storage (8.4.153)
Follows the cloud endpoint rename (/v1/admin/storage → /v1/admin/block-storage,
cloud 9a51bffbc) so /v1/admin/storage stays free for the operator's S3
object-buckets view. Client (storage-fleet.ts) + the ADMIN_AGGREGATE_HEADS /
ADMIN_V1_HEADS allow-lists + the e2e mock all move to the block-storage head; the
registry entry id was already block-storage. The real admin.hanzo.ai Block Storage
view lives in hanzoai/admin apps/operator (this console board is the super-admin
twin on console.hanzo.ai).

NOTE: main is RED from a PRE-EXISTING unrelated error — src/lib/event.ts:55 passes
`ingestKey` to createAnalytics but the installed @hanzo/event AnalyticsConfig has no
such field (another agent's 8.4.152 analytics work; needs a @hanzo/event bump).
This repoint is green on its own; the event.ts RED blocks the shared build:embed
gate until that lane fixes it.
2026-07-22 23:47:44 -07:00
Hanzo AI 0be9ea3657 refactor(admin): repoint Block Storage board to /v1/admin/block-storage (8.4.153)
Follows the cloud endpoint rename (/v1/admin/storage → /v1/admin/block-storage,
cloud 9a51bffbc) so /v1/admin/storage stays free for the operator's S3
object-buckets view. Client (storage-fleet.ts) + the ADMIN_AGGREGATE_HEADS /
ADMIN_V1_HEADS allow-lists + the e2e mock all move to the block-storage head; the
registry entry id was already block-storage. The real admin.hanzo.ai Block Storage
view lives in hanzoai/admin apps/operator (this console board is the super-admin
twin on console.hanzo.ai).

NOTE: main is RED from a PRE-EXISTING unrelated error — src/lib/event.ts:55 passes
`ingestKey` to createAnalytics but the installed @hanzo/event AnalyticsConfig has no
such field (another agent's 8.4.152 analytics work; needs a @hanzo/event bump).
This repoint is green on its own; the event.ts RED blocks the shared build:embed
gate until that lane fixes it.
2026-07-22 23:47:44 -07:00
hanzo-dev a2a9e5b719 Add Research evidence board to the cloud console
Surface the /v1/research R&D corpus (HIP-0512) in the console so the
experiments logged to the evidence plane are visible to platform admins.

- src/lib/api/research.ts: typed plain-REST client for the evidence plane
  (experiment ledger + totals/by-kind), read through the user-bearer BFF and
  org-scoped by the Bearer owner. Defensive normalizers parse the free-form
  meta frame (hypothesis/predict/verdict/because/log) and degrade a missing
  field to ''/0/[] rather than fabricate one. Reads via restGet because the
  endpoints speak bare JSON, not the casibase envelope.
- src/components/products/ResearchModule.tsx: the board — totals band, a
  per-kind facet, the verdict ledger (colored pills, expandable scientific
  frame), and a refutation highlight (a refutation is a first-class result).
  Renders OperatorAccessRequired for a non-super-admin client.
- src/lib/products/registry.tsx: one CatalogEntry (id research, Observe
  category, admin) mapping to ResearchModule.
- src/lib/server/proxy-allow.ts: admit the org-scoped `research` head on the
  /v1 user-bearer BFF so /v1/research/* reaches cloud.

Tests: client normalizers against the real Go/Rust wire shape; proxy-allow
admits the experiments/totals/projects sub-paths. typecheck + next build green.
2026-07-22 23:11:59 -07:00
hanzo-dev dabf1bb848 Add Research evidence board to the cloud console
Surface the /v1/research R&D corpus (HIP-0512) in the console so the
experiments logged to the evidence plane are visible to platform admins.

- src/lib/api/research.ts: typed plain-REST client for the evidence plane
  (experiment ledger + totals/by-kind), read through the user-bearer BFF and
  org-scoped by the Bearer owner. Defensive normalizers parse the free-form
  meta frame (hypothesis/predict/verdict/because/log) and degrade a missing
  field to ''/0/[] rather than fabricate one. Reads via restGet because the
  endpoints speak bare JSON, not the casibase envelope.
- src/components/products/ResearchModule.tsx: the board — totals band, a
  per-kind facet, the verdict ledger (colored pills, expandable scientific
  frame), and a refutation highlight (a refutation is a first-class result).
  Renders OperatorAccessRequired for a non-super-admin client.
- src/lib/products/registry.tsx: one CatalogEntry (id research, Observe
  category, admin) mapping to ResearchModule.
- src/lib/server/proxy-allow.ts: admit the org-scoped `research` head on the
  /v1 user-bearer BFF so /v1/research/* reaches cloud.

Tests: client normalizers against the real Go/Rust wire shape; proxy-allow
admits the experiments/totals/projects sub-paths. typecheck + next build green.
2026-07-22 23:11:59 -07:00
hanzo-dev 5a8de26c0c feat(console): telemetry on the canonical @hanzo/event 0.3.1 (/v1/event)
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.
2026-07-22 22:46:48 -07:00
hanzo-dev ceb9019095 feat(console): telemetry on the canonical @hanzo/event 0.3.1 (/v1/event)
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.
2026-07-22 22:46:48 -07:00
Hanzo AI 69d4b0a147 docs(llm): Block Storage board release note (v8.4.151) 2026-07-22 22:38:55 -07:00
Hanzo AI 82e141fd35 docs(llm): Block Storage board release note (v8.4.151) 2026-07-22 22:38:55 -07:00
Hanzo AI 4fbd373d5d feat(admin): Block Storage board — realtime DO fleet + datastore fill (8.4.151)
The admin.hanzo.ai realtime block-storage view, so we can watch the analytics
datastore fill and scale DO storage before it runs out. One read:
StorageFleetApi.snapshot() -> GET /v1/admin/storage (the global-admin-gated
aggregate; storage added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS).

- StorageFleetModule (Observe, admin:true): fleet KPIs (volumes / provisioned /
  used / monthly $), the analytics datastore highlighted with a green/amber/red
  fill bar + near-full badge, near-full alerts, and the full volume list.
- Honest by construction: DO gives capacity + attachment but NOT fill %, so a
  volume's used/pct render an em-dash "—", never a fabricated number; the
  datastore card shows only when a filesystem source (system.disks) reported fill.
- e2e (storage-fleet.spec.ts): renders the datastore (200 GiB), fleet KPIs
  (295 volumes / $1,309), a 91% near-full alert, and the honest "—" — passes.

Ships to admin.hanzo.ai via the next hanzoai/cloud release embedding console@main.
Pairs with cloud GET /v1/admin/storage (DO volume inventory + system.disks fill).
2026-07-22 22:32:37 -07:00
Hanzo AI 02228a642a feat(admin): Block Storage board — realtime DO fleet + datastore fill (8.4.151)
The admin.hanzo.ai realtime block-storage view, so we can watch the analytics
datastore fill and scale DO storage before it runs out. One read:
StorageFleetApi.snapshot() -> GET /v1/admin/storage (the global-admin-gated
aggregate; storage added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS).

- StorageFleetModule (Observe, admin:true): fleet KPIs (volumes / provisioned /
  used / monthly $), the analytics datastore highlighted with a green/amber/red
  fill bar + near-full badge, near-full alerts, and the full volume list.
- Honest by construction: DO gives capacity + attachment but NOT fill %, so a
  volume's used/pct render an em-dash "—", never a fabricated number; the
  datastore card shows only when a filesystem source (system.disks) reported fill.
- e2e (storage-fleet.spec.ts): renders the datastore (200 GiB), fleet KPIs
  (295 volumes / $1,309), a 91% near-full alert, and the honest "—" — passes.

Ships to admin.hanzo.ai via the next hanzoai/cloud release embedding console@main.
Pairs with cloud GET /v1/admin/storage (DO volume inventory + system.disks fill).
2026-07-22 22:32:37 -07:00
hanzo-dev 01119bce18 merge(main): integrate console main into company-captable UI 2026-07-22 22:22:08 -07:00
hanzo-dev 130b685e06 merge(main): integrate console main into company-captable UI 2026-07-22 22:22:08 -07:00
hanzo-dev 8dc71bbb67 feat(console): self-service Company formation + Cap Table modules
Company module: the 8-step formation wizard over /v1/company (the cloud
formation state machine). Renders the panel for the formation's CURRENT
stage — the backend is the source of truth — and advances through the
guarded transition door; KYC / e-sign / state-filing report an honest
"pending — manual review" while those providers are stubs, and a founder
is shown verified only when the backend records it.

Cap Table module: the ownership dashboard over /v1/captable — the computed
summary (fully-diluted totals + ownership donut + per-class issued/authorized),
stakeholders, issued shares, share classes, and fundraising (SAFEs + rounds),
with forms to add a stakeholder, issue shares, create a class, record a SAFE,
and open a round. The cap-table math is computed server-side (the summary
route), never in the client.

Both follow the existing product-module pattern: a thin /v1 API client with
pure, unit-tested logic + defensive normalizers, a *Module.tsx over the shared
UI primitives, and one registry entry each. company + captable are added to
CLOUD_HEADS (the /v1 bearer BFF) and to ALWAYS_ON_PRODUCTS so they appear for
every logged-in org. Adds one Field primitive (FieldOptionSelect) for
entity-reference pickers.

Tests: company.test.ts + captable.test.ts (stage mapping, cap-table view
derivation, validators, normalizers) + canonical-path assertions. tsc clean,
vitest 2933/2933, next build + build:embed green.
2026-07-22 22:06:30 -07:00
hanzo-dev 6817988ac8 feat(console): self-service Company formation + Cap Table modules
Company module: the 8-step formation wizard over /v1/company (the cloud
formation state machine). Renders the panel for the formation's CURRENT
stage — the backend is the source of truth — and advances through the
guarded transition door; KYC / e-sign / state-filing report an honest
"pending — manual review" while those providers are stubs, and a founder
is shown verified only when the backend records it.

Cap Table module: the ownership dashboard over /v1/captable — the computed
summary (fully-diluted totals + ownership donut + per-class issued/authorized),
stakeholders, issued shares, share classes, and fundraising (SAFEs + rounds),
with forms to add a stakeholder, issue shares, create a class, record a SAFE,
and open a round. The cap-table math is computed server-side (the summary
route), never in the client.

Both follow the existing product-module pattern: a thin /v1 API client with
pure, unit-tested logic + defensive normalizers, a *Module.tsx over the shared
UI primitives, and one registry entry each. company + captable are added to
CLOUD_HEADS (the /v1 bearer BFF) and to ALWAYS_ON_PRODUCTS so they appear for
every logged-in org. Adds one Field primitive (FieldOptionSelect) for
entity-reference pickers.

Tests: company.test.ts + captable.test.ts (stage mapping, cap-table view
derivation, validators, normalizers) + canonical-path assertions. tsc clean,
vitest 2933/2933, next build + build:embed green.
2026-07-22 22:06:30 -07:00
Hanzo AI 4232ee4826 e2e: admin-view audit — monochrome (dark+light) · no-crash · org-search
Renders every admin-only view as super-admin (primeSession owner:admin) against a
mocked local server and asserts: monochrome in dark AND forced-light (the hue-220
fix — no blue cast), org search reachable, and all 28 admin views render their
shell without an error-boundary/pageerror. Screenshot per view (e2e-shots/admin-audit).
All green locally.
2026-07-22 21:22:30 -07:00
Hanzo AI 648779e021 e2e: admin-view audit — monochrome (dark+light) · no-crash · org-search
Renders every admin-only view as super-admin (primeSession owner:admin) against a
mocked local server and asserts: monochrome in dark AND forced-light (the hue-220
fix — no blue cast), org search reachable, and all 28 admin views render their
shell without an error-boundary/pageerror. Screenshot per view (e2e-shots/admin-audit).
All green locally.
2026-07-22 21:22:30 -07:00
Hanzo AI 0afd7b76b3 theme: light theme is monochrome — kill the blue (hue-220) tinge
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.
2026-07-22 21:00:45 -07:00
Hanzo AI 3f017a3618 theme: light theme is monochrome — kill the blue (hue-220) tinge
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.
2026-07-22 21:00:45 -07:00
Hanzo AI 29bf133b9f fix(workbench): make the Developers footer dock unmistakable — tinted strip, boxed button, bordered command prompt, labeled Open (was a too-subtle hairline users couldn't spot) 2026-07-22 20:58:19 -07:00
Hanzo AI e4aa4f3549 fix(workbench): make the Developers footer dock unmistakable — tinted strip, boxed button, bordered command prompt, labeled Open (was a too-subtle hairline users couldn't spot) 2026-07-22 20:58:19 -07:00
Hanzo AI cf28ebd329 test(e2e): un-fixme gpus-connect + entitlement-sidebar — re-pinned to real behavior
- gpus-connect: BYO GB10 surfaces via /v1/fleet/workers (not /v1/machines, which
  excludes provider=byo); assert Connect+Deploy CTAs + the 'hanzo gpu connect' drawer.
- entitlement-sidebar: assert the real gating contract (enabled shown / non-entitled
  hidden / catalog affordance present+enabled); the AddProductPanel DetailPane is a
  separate concern. Both PASS locally.
2026-07-22 20:37:35 -07:00
Hanzo AI 0e569c24e5 test(e2e): un-fixme gpus-connect + entitlement-sidebar — re-pinned to real behavior
- gpus-connect: BYO GB10 surfaces via /v1/fleet/workers (not /v1/machines, which
  excludes provider=byo); assert Connect+Deploy CTAs + the 'hanzo gpu connect' drawer.
- entitlement-sidebar: assert the real gating contract (enabled shown / non-entitled
  hidden / catalog affordance present+enabled); the AddProductPanel DetailPane is a
  separate concern. Both PASS locally.
2026-07-22 20:37:35 -07:00
Hanzo AI 8b8492f47d feat(machines): GPU prepay = first hour, not a 24-hour minimum
GPU_MIN_HOURS 24 -> 1: launching a cloud GPU charges the first hour
upfront to the card instead of a 24-hour block. Card-required prepay
semantics are unchanged (commerce-enforced); only the console-owned
minimum changes. Copy + tests updated in lockstep.
2026-07-22 20:36:53 -07:00
Hanzo AI 3cb6ed21ad feat(machines): GPU prepay = first hour, not a 24-hour minimum
GPU_MIN_HOURS 24 -> 1: launching a cloud GPU charges the first hour
upfront to the card instead of a 24-hour block. Card-required prepay
semantics are unchanged (commerce-enforced); only the console-owned
minimum changes. Copy + tests updated in lockstep.
2026-07-22 20:36:53 -07:00
hanzo-dev 78d06ba89b merge(main): integrate parallel console main into entry-decomplect 2026-07-22 19:36:35 -07:00
hanzo-dev 62ad8a4379 merge(main): integrate parallel console main into entry-decomplect 2026-07-22 19:36:35 -07:00
hanzo-dev 8e144a25be fix(base): reach cloud's /v1/collections forward + attach the caller bearer
The Base product (Bases manager + Records) went dark in the go:embed console
(console.hanzo.ai): its client hit the retired /v1/superbase BFF prefix, which
build:embed prunes, so the call fell through to the SPA shell (or 404). Point the
Base data client at same-origin '/v1' so it calls /v1/collections/* — the path
cloud now serves (clients/base/collections.go, forwarded to the managed Base) and
which the embed's webui.go routes to the cloud router. And attach the caller's PKCE
Bearer + X-Org-Id in BaseDataApi (the embed has no BFF to inject a token
server-side; cloud validates the Bearer and forwards it to the Base, which scopes
per-user/per-collection). Add 'collections' to CLOUD_HEADS so the standalone /v1
BFF forwards it too. No IS_EMBED gating needed — collections is a normal cloud head
(unlike iam/paas), reached the same way from both the embed and standalone.

Verified: tsc clean (my files); proxy-allow 34/34; base-data/bases-logic 33/33.
2026-07-22 19:21:15 -07:00
hanzo-dev 21e8f967d8 fix(base): reach cloud's /v1/collections forward + attach the caller bearer
The Base product (Bases manager + Records) went dark in the go:embed console
(console.hanzo.ai): its client hit the retired /v1/superbase BFF prefix, which
build:embed prunes, so the call fell through to the SPA shell (or 404). Point the
Base data client at same-origin '/v1' so it calls /v1/collections/* — the path
cloud now serves (clients/base/collections.go, forwarded to the managed Base) and
which the embed's webui.go routes to the cloud router. And attach the caller's PKCE
Bearer + X-Org-Id in BaseDataApi (the embed has no BFF to inject a token
server-side; cloud validates the Bearer and forwards it to the Base, which scopes
per-user/per-collection). Add 'collections' to CLOUD_HEADS so the standalone /v1
BFF forwards it too. No IS_EMBED gating needed — collections is a normal cloud head
(unlike iam/paas), reached the same way from both the embed and standalone.

Verified: tsc clean (my files); proxy-allow 34/34; base-data/bases-logic 33/33.
2026-07-22 19:21:15 -07:00
hanzo-dev fc049e2270 fix(agents): Status/Logs/Metrics render the agents' OWN runs, not the empty subpage
/agents showed empty Status/Logs/Metrics despite agents + runs existing: the product
only owned the '' (Overview) route, so those base slugs fell to the shared subpage system
which reads o11y (not wired for agents) + the usage ledger (no product:agents-tagged spend)
→ honest-empty. But the data IS there in /v1/agents (invocations, health, activity).

Agents now OWNS Status/Logs/Metrics (registry subpages + :tab route, same pattern as
Inference). AgentsModule renders a focused slice per tab from its OWN derived data:
Metrics = counts + invocation trend + resource usage; Status = health donut + agents table;
Logs = the invocation activity feed; Overview = all. Settings stays the shared subpage.
tsc clean (my files); the AppLauncher/brands tsc errors are pre-existing main, not this.
2026-07-22 18:49:25 -07:00
hanzo-dev a9209b2566 fix(agents): Status/Logs/Metrics render the agents' OWN runs, not the empty subpage
/agents showed empty Status/Logs/Metrics despite agents + runs existing: the product
only owned the '' (Overview) route, so those base slugs fell to the shared subpage system
which reads o11y (not wired for agents) + the usage ledger (no product:agents-tagged spend)
→ honest-empty. But the data IS there in /v1/agents (invocations, health, activity).

Agents now OWNS Status/Logs/Metrics (registry subpages + :tab route, same pattern as
Inference). AgentsModule renders a focused slice per tab from its OWN derived data:
Metrics = counts + invocation trend + resource usage; Status = health donut + agents table;
Logs = the invocation activity feed; Overview = all. Settings stays the shared subpage.
tsc clean (my files); the AppLauncher/brands tsc errors are pre-existing main, not this.
2026-07-22 18:49:25 -07:00
hanzo-dev 507b7b04a0 chore(console): v8.4.150 — unified Code hub 2026-07-22 18:47:24 -07:00
hanzo-dev 09ce3041a5 chore(console): v8.4.150 — unified Code hub 2026-07-22 18:47:24 -07:00
hanzo-dev ba787ce0f8 feat(console): unified Code hub — Repositories · Search · Ask over native git
All our code in ONE place. The former Git (repo host, /v1/git) and Code
(intelligence, /v1/code HIP-0302) Dev products fold into a single "Code" hub —
one nav entry, DRY, brand-agnostic (org-scoped SERVER-SIDE, no cross-brand leak).

- Repositories face (git/RepoList): every repo the caller can see, GROUPED by org
  (header only when >1), a ReDoS-safe LITERAL filter, "Last synced"
  (repoView.updatedAt — advances on every mirror fast-forward, the honest freshness
  signal), default branch + size; rows open the repo browser. Honest empty/error.
- Search + Ask faces (code/IntelligenceFaces): hybrid cross-repo retrieval + cited
  answers over the EXISTING /v1/code engine (reuse, not a new /v1/git/search — one
  search engine, one way). A hit or a citation DEEP-LINKS into the file in the repo
  browser (the search->browse seam). Empty state states index-on-push truthfully.
- Repo browser (git/RepoBrowser + CodeView) rebased under /code/repos/:name, plus
  AGENTIC handoffs: repo-level Ask AI · Edit · Chat, file-level Ask AI — reusing the
  canonical cross-surface deep links (hanzo.app/dev?project= , hanzo.chat/?project=)
  and the built-in assistant (useFloatingChat().ask seeds the composer with repo/file
  context; never auto-sends).
- Registry: `code` -> hub (subpages repos/search/ask; routes ''/:tab/repos/:name);
  standalone `git` entry removed and aliased git->code (no legacy 404). GitModule
  deleted (folded).

Pure logic in code/hub-logic.ts (filter/group/deep-links/seed prompts) with tests.
tsc clean · vitest 2856 green · next build ok · build:embed ok (out/ + index.html).
2026-07-22 18:44:54 -07:00
hanzo-dev 9b8b3e6f2c feat(console): unified Code hub — Repositories · Search · Ask over native git
All our code in ONE place. The former Git (repo host, /v1/git) and Code
(intelligence, /v1/code HIP-0302) Dev products fold into a single "Code" hub —
one nav entry, DRY, brand-agnostic (org-scoped SERVER-SIDE, no cross-brand leak).

- Repositories face (git/RepoList): every repo the caller can see, GROUPED by org
  (header only when >1), a ReDoS-safe LITERAL filter, "Last synced"
  (repoView.updatedAt — advances on every mirror fast-forward, the honest freshness
  signal), default branch + size; rows open the repo browser. Honest empty/error.
- Search + Ask faces (code/IntelligenceFaces): hybrid cross-repo retrieval + cited
  answers over the EXISTING /v1/code engine (reuse, not a new /v1/git/search — one
  search engine, one way). A hit or a citation DEEP-LINKS into the file in the repo
  browser (the search->browse seam). Empty state states index-on-push truthfully.
- Repo browser (git/RepoBrowser + CodeView) rebased under /code/repos/:name, plus
  AGENTIC handoffs: repo-level Ask AI · Edit · Chat, file-level Ask AI — reusing the
  canonical cross-surface deep links (hanzo.app/dev?project= , hanzo.chat/?project=)
  and the built-in assistant (useFloatingChat().ask seeds the composer with repo/file
  context; never auto-sends).
- Registry: `code` -> hub (subpages repos/search/ask; routes ''/:tab/repos/:name);
  standalone `git` entry removed and aliased git->code (no legacy 404). GitModule
  deleted (folded).

Pure logic in code/hub-logic.ts (filter/group/deep-links/seed prompts) with tests.
tsc clean · vitest 2856 green · next build ok · build:embed ok (out/ + index.html).
2026-07-22 18:44:54 -07:00
d520665cff fix(console): go:embed org-switcher + Observe→Status — IAM-admin & PaaS use cloud-native /v1/* (8.4.149) (#167)
console.hanzo.ai/cloud.hanzo.ai serve the go:embed console inside the cloud binary,
whose webui.go serves the SPA index (HTTP 200 HTML) for any non-/v1/ path. build-embed
stashes the Next BFF route handlers, so the OrgSwitcher (/admin/iam) and Observe->Status
(/paas) client calls fell through to the SPA shell and threw 'Invalid response from
server (HTTP 200)' -> missing switcher + 'Could not reach the platform'.

Cloud already serves the equivalents natively at /v1/iam/* and /v1/paas/*, so in the
embed (IS_EMBED) the IAM-admin client uses client.ts iamList/iamOne/iamMutate and the
PaaS inventory uses cloudProxyV1Url('paas/...'). Standalone console2/admin.hanzo.ai are
UNCHANGED (their /v1 BFF deliberately excludes iam/* and paas/*). Scoping unchanged.

tsc clean (2 files); vitest 109 baseline + 3 new embed-path assertions.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-22 18:33:47 -07:00
06487f469e fix(console): go:embed org-switcher + Observe→Status — IAM-admin & PaaS use cloud-native /v1/* (8.4.149) (#167)
console.hanzo.ai/cloud.hanzo.ai serve the go:embed console inside the cloud binary,
whose webui.go serves the SPA index (HTTP 200 HTML) for any non-/v1/ path. build-embed
stashes the Next BFF route handlers, so the OrgSwitcher (/admin/iam) and Observe->Status
(/paas) client calls fell through to the SPA shell and threw 'Invalid response from
server (HTTP 200)' -> missing switcher + 'Could not reach the platform'.

Cloud already serves the equivalents natively at /v1/iam/* and /v1/paas/*, so in the
embed (IS_EMBED) the IAM-admin client uses client.ts iamList/iamOne/iamMutate and the
PaaS inventory uses cloudProxyV1Url('paas/...'). Standalone console2/admin.hanzo.ai are
UNCHANGED (their /v1 BFF deliberately excludes iam/* and paas/*). Scoping unchanged.

tsc clean (2 files); vitest 109 baseline + 3 new embed-path assertions.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-22 18:33:47 -07:00
hanzo-dev 6cd47e13a0 refactor(console): decomplect the entry gate — resolve(session)→stage, one switch, flat providers
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).
2026-07-22 18:10:22 -07:00
hanzo-dev b5f8a68a4b refactor(console): decomplect the entry gate — resolve(session)→stage, one switch, flat providers
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).
2026-07-22 18:10:22 -07:00
hanzo-dev 41d78c5b21 fix(console): scope the anon marketing landing to consumer hosts (not admin.hanzo.ai)
isAdminHost(host) gate: the operator cockpit keeps its silent-SSO bounce; only
consumer hosts (cloud/console/tenant) show the marketing landing at / for anon.
2026-07-22 15:47:44 -07:00
hanzo-dev e7052befbd fix(console): scope the anon marketing landing to consumer hosts (not admin.hanzo.ai)
isAdminHost(host) gate: the operator cockpit keeps its silent-SSO bounce; only
consumer hosts (cloud/console/tenant) show the marketing landing at / for anon.
2026-07-22 15:47:44 -07:00
hanzo-dev d7e04fd578 feat(console): public marketing landing for anon at / (one binary, one way)
Unauthenticated visitors at / get a marketing page (gather interest + explain the
product) instead of a bounce to /signin — served by the SAME one cloud binary that
serves the signed-in console (no separate marketing service). AuthGate gains a
'landing' surface: anon-at-/ → PublicLanding, signed-in-at-/ → console. Landing
content is derived from the real taxonomy (categoriesForBrand + CATEGORY_SUMMARY),
brand-scoped via getBrand, with the ONE sign-in CTA (/signin).
2026-07-22 15:43:47 -07:00
hanzo-dev 13a95428b1 feat(console): public marketing landing for anon at / (one binary, one way)
Unauthenticated visitors at / get a marketing page (gather interest + explain the
product) instead of a bounce to /signin — served by the SAME one cloud binary that
serves the signed-in console (no separate marketing service). AuthGate gains a
'landing' surface: anon-at-/ → PublicLanding, signed-in-at-/ → console. Landing
content is derived from the real taxonomy (categoriesForBrand + CATEGORY_SUMMARY),
brand-scoped via getBrand, with the ONE sign-in CTA (/signin).
2026-07-22 15:43:47 -07:00
hanzo-dev 98b4fd84c7 feat(console): Billing category (split from Observe) + brand-aware footer
Billing is its own top-level category — the Square-backed billing console — split
out of the Observe catch-all (finance, revenue, grants, usage, usage-caps-promo,
saas-metrics, business, fleet-customers). Observe tightened to LLM research/evals
+ o11y. Righted mis-files: vms/clusters/functions -> Compute, ai-accounts -> AI,
bots -> Apps, fleet-projects -> Platform. Adds a brand-aware ConsoleFooter
(docs/support/legal + copyright) on every page. tsc clean for touched files;
32/32 taxonomy tests pass.
2026-07-22 15:18:37 -07:00
hanzo-dev 294ed0debb feat(console): Billing category (split from Observe) + brand-aware footer
Billing is its own top-level category — the Square-backed billing console — split
out of the Observe catch-all (finance, revenue, grants, usage, usage-caps-promo,
saas-metrics, business, fleet-customers). Observe tightened to LLM research/evals
+ o11y. Righted mis-files: vms/clusters/functions -> Compute, ai-accounts -> AI,
bots -> Apps, fleet-projects -> Platform. Adds a brand-aware ConsoleFooter
(docs/support/legal + copyright) on every page. tsc clean for touched files;
32/32 taxonomy tests pass.
2026-07-22 15:18:37 -07:00
Hanzo AI c61b58d92d Add Subscription Plans admin editor (commerce plan SoT, increment 3a-console)
admin.hanzo.ai CMS editor for the platform subscription/DNS plan authority
(commerce models/plan — the SoT GET /v1/billing/plans + the internal-ledger
renewal charge read). Sibling of the Catalog editor over the same SuperAdmin
CRUD pattern (/v1/plans/entries), reusing its tested money + metadata logic.

- PlansCatalogModule: table (filter by category, monthly/annual price, custom/
  per-seat flags) + SlideOver create/edit form (name/price/annual/category/
  contactSales/popular/perSeat/trialDays + metadata) + delete confirm + seed.
  LIVE BILLING CONTROL: price edits change the real renewal charge (explicit
  warning); SLUG IMMUTABLE on edit (matches the commerce guard); contactSales =
  custom (null price), distinct from a free $0 tier.
- plans-admin.ts: /v1/plans/entries client (bare-JSON REST, defensive normalizers)
  via cloudProxyV1Url — works on the go:embed cloud console and standalone alike.
- app/v1/plans/[...path]: dedicated user-bearer proxy to commerce (sibling of
  /v1/catalog); allowPlansSurface least-privilege allow-list (entries CRUD + seed).
  commerce requireSuperAdmin (owner==admin) is the authoritative gate.
- pricing/MetadataEditor: extracted the shared key/value editor (DRY — catalog +
  plans now use one component). plans/logic reuses catalog/logic money helpers.
- registry: 'plan-catalog' admin entry (Observe, admin:true).

No 'published' field (Plan has none — not invented). Verify: tsc 0 errors;
vitest 2833 passed (+8: plans logic + proxy-allow); next build + build:embed
green (/v1/plans registered); commerce api/plan handler tests green; live
Playwright proof (5 plans render, edit pro $20->$25 -> PUT /v1/plans/entries/pro
price 2500, slug immutable, metadata type-exact -> table reflects $25.00/mo).
2026-07-22 13:54:38 -07:00
Hanzo AI 0a9055e745 Add Subscription Plans admin editor (commerce plan SoT, increment 3a-console)
admin.hanzo.ai CMS editor for the platform subscription/DNS plan authority
(commerce models/plan — the SoT GET /v1/billing/plans + the internal-ledger
renewal charge read). Sibling of the Catalog editor over the same SuperAdmin
CRUD pattern (/v1/plans/entries), reusing its tested money + metadata logic.

- PlansCatalogModule: table (filter by category, monthly/annual price, custom/
  per-seat flags) + SlideOver create/edit form (name/price/annual/category/
  contactSales/popular/perSeat/trialDays + metadata) + delete confirm + seed.
  LIVE BILLING CONTROL: price edits change the real renewal charge (explicit
  warning); SLUG IMMUTABLE on edit (matches the commerce guard); contactSales =
  custom (null price), distinct from a free $0 tier.
- plans-admin.ts: /v1/plans/entries client (bare-JSON REST, defensive normalizers)
  via cloudProxyV1Url — works on the go:embed cloud console and standalone alike.
- app/v1/plans/[...path]: dedicated user-bearer proxy to commerce (sibling of
  /v1/catalog); allowPlansSurface least-privilege allow-list (entries CRUD + seed).
  commerce requireSuperAdmin (owner==admin) is the authoritative gate.
- pricing/MetadataEditor: extracted the shared key/value editor (DRY — catalog +
  plans now use one component). plans/logic reuses catalog/logic money helpers.
- registry: 'plan-catalog' admin entry (Observe, admin:true).

No 'published' field (Plan has none — not invented). Verify: tsc 0 errors;
vitest 2833 passed (+8: plans logic + proxy-allow); next build + build:embed
green (/v1/plans registered); commerce api/plan handler tests green; live
Playwright proof (5 plans render, edit pro $20->$25 -> PUT /v1/plans/entries/pro
price 2500, slug immutable, metadata type-exact -> table reflects $25.00/mo).
2026-07-22 13:54:38 -07:00
Hanzo AI c3b4e4c8c6 chore(console): 8.4.148 — Developers workbench full tab set 2026-07-22 03:17:27 -07:00
Hanzo AI 37f0495ed7 chore(console): 8.4.148 — Developers workbench full tab set 2026-07-22 03:17:27 -07:00
Hanzo AI 10d7629e41 feat(workbench): full developer tab set in the Developers dock
Expand the bottom Developers dock from 3 tabs to the full developer surface,
each org-scoped, wired to REAL data or an honest empty/coming/runtime state,
each mounted only while active (lazy):

- Overview  enhanced: request volume + error rate + tokens/spend (charged
  ledger) + the account Cloud API key (KeysApi) + API v1 + dev-resource links.
- Logs      filterable + row -> JSON detail (real usage ledger).
- Events    platform-event stream projected from the same real ledger.
- Webhooks  event destinations: real rows when the endpoint API is live, else
  the honest create-first-destination state (forward-compatible).
- Health    Alerts/Errors/Insights from the o11y runtime (ApmApi + o11y rules;
  honest RuntimeNotice when o11y isn't routed for the org).
- Inspector fetch any object by id -> JSON + related ledger activity.
- Traces    the existing TracesModule embedded.
- Shell     enhanced: resource picker + show-code (curl/CLI).

Tab bodies live in tabs.tsx; Workbench.tsx is the dock shell owning the shared
usage fetch. Preserves the collapse/expand + Developers bar affordance and the
existing e2e contract (bar text, Overview/Logs/Shell labels, shell aria-label).
2026-07-22 03:17:24 -07:00
Hanzo AI a8a445ab0f feat(workbench): full developer tab set in the Developers dock
Expand the bottom Developers dock from 3 tabs to the full developer surface,
each org-scoped, wired to REAL data or an honest empty/coming/runtime state,
each mounted only while active (lazy):

- Overview  enhanced: request volume + error rate + tokens/spend (charged
  ledger) + the account Cloud API key (KeysApi) + API v1 + dev-resource links.
- Logs      filterable + row -> JSON detail (real usage ledger).
- Events    platform-event stream projected from the same real ledger.
- Webhooks  event destinations: real rows when the endpoint API is live, else
  the honest create-first-destination state (forward-compatible).
- Health    Alerts/Errors/Insights from the o11y runtime (ApmApi + o11y rules;
  honest RuntimeNotice when o11y isn't routed for the org).
- Inspector fetch any object by id -> JSON + related ledger activity.
- Traces    the existing TracesModule embedded.
- Shell     enhanced: resource picker + show-code (curl/CLI).

Tab bodies live in tabs.tsx; Workbench.tsx is the dock shell owning the shared
usage fetch. Preserves the collapse/expand + Developers bar affordance and the
existing e2e contract (bar text, Overview/Logs/Shell labels, shell aria-label).
2026-07-22 03:17:24 -07:00
Hanzo AI 9468431f19 feat(workbench): pure inspector router + show-code + events projection
Extend the workbench logic (node-testable, no React/gui imports):
- inspectorRoute(id): id-prefix (agent_/fn_/flow_/run_/prompt_/trace_, hk-/sk-/pk-)
  or raw resource/name path -> the same-origin /v1 GET; URL/traversal refused.
- curlFor/hanzoCli: the same read as curl (Bearer hk-) and the Hanzo CLI.
- eventsFrom(records): project the real usage ledger into a platform-event stream.
+8 tests.
2026-07-22 03:17:13 -07:00
Hanzo AI 6fe6e03ba2 feat(workbench): pure inspector router + show-code + events projection
Extend the workbench logic (node-testable, no React/gui imports):
- inspectorRoute(id): id-prefix (agent_/fn_/flow_/run_/prompt_/trace_, hk-/sk-/pk-)
  or raw resource/name path -> the same-origin /v1 GET; URL/traversal refused.
- curlFor/hanzoCli: the same read as curl (Bearer hk-) and the Hanzo CLI.
- eventsFrom(records): project the real usage ledger into a platform-event stream.
+8 tests.
2026-07-22 03:17:13 -07:00
Hanzo AI a5ca8e3f5c Add Catalog & Pricing admin editor (commerce catalog SoT, increment 2)
admin.hanzo.ai CMS editor for the platform product/pricing catalog — the 17
infra tiers increment 1 seeded (11 cloud + 3 gpu + 3 datastore) plus every
product surface. A filterable table + create/edit form over commerce's
SuperAdmin CRUD (/v1/catalog/entries); an edit flows to the live pricing pages
(the pricing service reads the same rows via GET /v1/commerce/catalog).

- CatalogModule: table (filter by category, price+spec+published) + SlideOver
  create/edit form (name/price/published/category/description + type-preserving
  metadata key/value editor + admin-only cost/margin) + delete confirm + seed.
- catalog/logic.ts: pure money (dollars<->cents) + metadata (JSON<->typed rows,
  type-exact round-trip) + category helpers, unit-tested (16 tests).
- catalog-admin.ts: /v1/catalog/entries client (bare-JSON REST, defensive
  normalizers) via cloudProxyV1Url — works on the go:embed cloud console and
  standalone alike.
- app/v1/catalog/[...path]: dedicated user-bearer proxy to commerce (mirrors the
  /v1/commerce store proxy); allowCatalogSurface least-privilege allow-list
  (entries CRUD + seed only). commerce requireSuperAdmin (owner==admin) is the
  authoritative gate.
- registry: 'catalog' admin entry (Observe, admin:true).

Verify: tsc 0 errors; vitest 2817 passed (+44: catalog logic + proxy-allow);
next build + build:embed green; commerce api/catalog handler tests pass; live
Playwright proof (17 tiers render, edit cloud-dev $15->$18 -> PUT
/v1/catalog/entries/cloud-dev priceCents 1800 -> table reflects $18.00).
2026-07-22 02:44:49 -07:00
Hanzo AI ec6b87ee37 Add Catalog & Pricing admin editor (commerce catalog SoT, increment 2)
admin.hanzo.ai CMS editor for the platform product/pricing catalog — the 17
infra tiers increment 1 seeded (11 cloud + 3 gpu + 3 datastore) plus every
product surface. A filterable table + create/edit form over commerce's
SuperAdmin CRUD (/v1/catalog/entries); an edit flows to the live pricing pages
(the pricing service reads the same rows via GET /v1/commerce/catalog).

- CatalogModule: table (filter by category, price+spec+published) + SlideOver
  create/edit form (name/price/published/category/description + type-preserving
  metadata key/value editor + admin-only cost/margin) + delete confirm + seed.
- catalog/logic.ts: pure money (dollars<->cents) + metadata (JSON<->typed rows,
  type-exact round-trip) + category helpers, unit-tested (16 tests).
- catalog-admin.ts: /v1/catalog/entries client (bare-JSON REST, defensive
  normalizers) via cloudProxyV1Url — works on the go:embed cloud console and
  standalone alike.
- app/v1/catalog/[...path]: dedicated user-bearer proxy to commerce (mirrors the
  /v1/commerce store proxy); allowCatalogSurface least-privilege allow-list
  (entries CRUD + seed only). commerce requireSuperAdmin (owner==admin) is the
  authoritative gate.
- registry: 'catalog' admin entry (Observe, admin:true).

Verify: tsc 0 errors; vitest 2817 passed (+44: catalog logic + proxy-allow);
next build + build:embed green; commerce api/catalog handler tests pass; live
Playwright proof (17 tiers render, edit cloud-dev $15->$18 -> PUT
/v1/catalog/entries/cloud-dev priceCents 1800 -> table reflects $18.00).
2026-07-22 02:44:49 -07:00
hanzo-dev 71f51822e3 fix(launcher): the Apps button + ⌘K show ALL apps — decouple discovery from entitlement
'Apps button / ⌘K doesn't show all apps.' ROOT: both browse-all surfaces were braided
with entitlement — AppLauncher passed the org's `enabled` set to visibleCatalogByCategory
and filterEntitled, and ⌘K passed it to searchDestinations, so a non-superadmin org only
saw its always-on + enabled subset (the launcher 'mirrored the sidebar scope').

DECOMPLECT: the Apps launcher and ⌘K are DISCOVERY surfaces — 'browse ALL apps' means the
WHOLE catalog. Entitlement is a property of USING a product (the sidebar = your workspace
nav, and the product page's honest 'enable for your org' state), NOT of SEEING it in the
directory. So both now render visibleCatalogByCategory(showAdmin, null) / searchDestinations
(query, showAdmin, null) — full catalog, admin-gated only (internal operator surfaces stay
behind showAdmin). The sidebar + product-use remain entitlement-scoped, unchanged.

tsc clean; 180 product/entitlement tests green (the filterEntitled/entitledSet functions are
untouched — still used by the sidebar; only the discovery callers stop scoping).
2026-07-22 01:37:31 -07:00
hanzo-dev 6ce8fe25ee fix(launcher): the Apps button + ⌘K show ALL apps — decouple discovery from entitlement
'Apps button / ⌘K doesn't show all apps.' ROOT: both browse-all surfaces were braided
with entitlement — AppLauncher passed the org's `enabled` set to visibleCatalogByCategory
and filterEntitled, and ⌘K passed it to searchDestinations, so a non-superadmin org only
saw its always-on + enabled subset (the launcher 'mirrored the sidebar scope').

DECOMPLECT: the Apps launcher and ⌘K are DISCOVERY surfaces — 'browse ALL apps' means the
WHOLE catalog. Entitlement is a property of USING a product (the sidebar = your workspace
nav, and the product page's honest 'enable for your org' state), NOT of SEEING it in the
directory. So both now render visibleCatalogByCategory(showAdmin, null) / searchDestinations
(query, showAdmin, null) — full catalog, admin-gated only (internal operator surfaces stay
behind showAdmin). The sidebar + product-use remain entitlement-scoped, unchanged.

tsc clean; 180 product/entitlement tests green (the filterEntitled/entitledSet functions are
untouched — still used by the sidebar; only the discovery callers stop scoping).
2026-07-22 01:37:31 -07:00
Hanzo AI 2d67180feb console: app launcher renders the full canonical surface set (adds bot/chat)
AppLauncher's cross-surface tiles now come from the ONE @hanzo/ui surfaces list
(otherSurfaces('console') = every surface but this one), each with a distinct
icon — replacing the hardcoded team+billing pair. Bumps @hanzo/ui to 8.0.6.
2026-07-21 23:57:34 -07:00
Hanzo AI 92e3ce3b52 console: app launcher renders the full canonical surface set (adds bot/chat)
AppLauncher's cross-surface tiles now come from the ONE @hanzo/ui surfaces list
(otherSurfaces('console') = every surface but this one), each with a distinct
icon — replacing the hardcoded team+billing pair. Bumps @hanzo/ui to 8.0.6.
2026-07-21 23:57:34 -07:00
hanzo-dev be8fef0245 ci: revert console-embed build to canonical hanzo-build pool
hanzo-build-linux-amd64 arc pool is repaired (recreated scale-set cleared the stuck
GitHub message session that was thrashing runners; restored the custom runner image
and the shared arc-github-secret). Drop the temporary hanzo-deploy override.
2026-07-21 23:31:56 -07:00
hanzo-dev 72c0e99b15 ci: revert console-embed build to canonical hanzo-build pool
hanzo-build-linux-amd64 arc pool is repaired (recreated scale-set cleared the stuck
GitHub message session that was thrashing runners; restored the custom runner image
and the shared arc-github-secret). Drop the temporary hanzo-deploy override.
2026-07-21 23:31:56 -07:00
hanzo-dev 195b62916a fix(auth): patch @hanzo/iam to derive token expiry from the JWT exp (kills the login loop)
console.hanzo.ai silent-SSO-looped: the hanzo-cloud IAM app issues tokens with no
configured lifetime, so the token response omits expires_in. @hanzo/iam@0.13.6
storeTokens() only writes hanzo_iam_expires_at WHEN expires_in is present (no else),
so isTokenExpired() returns true forever → getValidAccessToken() returns null → a
freshly-minted token reads as expired → endless authorize redirects (~128/min).

patch-package adds the missing else to all 6 SDK bundles: when expires_in is absent,
fall back to the access token's own JWT exp claim (RFC 7519) — authoritative for IAM's
JWTs, correct on the initial exchange AND every refresh. + patch-package postinstall so
CI applies it on npm ci. (SDK repo is badly diverged from the published 0.13.6, so a
republish would regress; patching the exact published bundle is the zero-regression fix.)
2026-07-21 22:46:19 -07:00
hanzo-dev 438b7d6576 fix(auth): patch @hanzo/iam to derive token expiry from the JWT exp (kills the login loop)
console.hanzo.ai silent-SSO-looped: the hanzo-cloud IAM app issues tokens with no
configured lifetime, so the token response omits expires_in. @hanzo/iam@0.13.6
storeTokens() only writes hanzo_iam_expires_at WHEN expires_in is present (no else),
so isTokenExpired() returns true forever → getValidAccessToken() returns null → a
freshly-minted token reads as expired → endless authorize redirects (~128/min).

patch-package adds the missing else to all 6 SDK bundles: when expires_in is absent,
fall back to the access token's own JWT exp claim (RFC 7519) — authoritative for IAM's
JWTs, correct on the initial exchange AND every refresh. + patch-package postinstall so
CI applies it on npm ci. (SDK repo is badly diverged from the published 0.13.6, so a
republish would regress; patching the exact published bundle is the zero-regression fix.)
2026-07-21 22:46:19 -07:00
hanzo-dev a9f62ece3c ci: route console-embed build to hanzo-deploy pool (hanzo-build arc wedged)
hanzo-build-linux-amd64 runners are stuck (empty-image drift on the live
AutoscalingRunnerSet + a duplicate listener; ephemeral runners churn Pending
without consuming the queued jobs), wedging every hanzoai build on that label.
Point the console CI/CD at the healthy hanzo-deploy-linux-amd64 pool so the
console-embed artifact (carrying the login-loop fix 975bb1729) rebuilds now.
Temporary — revert to the default pool once hanzo-build is repaired.
2026-07-21 22:38:27 -07:00
hanzo-dev 8ed6439abb ci: route console-embed build to hanzo-deploy pool (hanzo-build arc wedged)
hanzo-build-linux-amd64 runners are stuck (empty-image drift on the live
AutoscalingRunnerSet + a duplicate listener; ephemeral runners churn Pending
without consuming the queued jobs), wedging every hanzoai build on that label.
Point the console CI/CD at the healthy hanzo-deploy-linux-amd64 pool so the
console-embed artifact (carrying the login-loop fix e24869c3f) rebuilds now.
Temporary — revert to the default pool once hanzo-build is repaired.
2026-07-21 22:38:27 -07:00
hanzo-dev 975bb1729b fix(auth): keep a server-valid token when the SDK marks it expired — fixes login loop
The @hanzo/iam SDK's getValidAccessToken() returns null when the token-exchange
response carried no `expires_in` (the hanzo-cloud IAM app has no token lifetime set)
and no refresh token was issued — it flags the freshly-minted token expired even
though IAM accepts it (userinfo 200). That null dead-ended AccountApi.session() ->
account=null and looped /signin (~128 POST /login/oauth per minute). Fall back to the
raw stored token at the wrapper boundary; account.ts and the refresh timer are
unchanged (expiry still derived from the token's own exp).
2026-07-21 21:48:38 -07:00
hanzo-dev e24869c3fc fix(auth): keep a server-valid token when the SDK marks it expired — fixes login loop
The @hanzo/iam SDK's getValidAccessToken() returns null when the token-exchange
response carried no `expires_in` (the hanzo-cloud IAM app has no token lifetime set)
and no refresh token was issued — it flags the freshly-minted token expired even
though IAM accepts it (userinfo 200). That null dead-ended AccountApi.session() ->
account=null and looped /signin (~128 POST /login/oauth per minute). Fall back to the
raw stored token at the wrapper boundary; account.ts and the refresh timer are
unchanged (expiry still derived from the token's own exp).
2026-07-21 21:48:38 -07:00
Hanzo AI 46f4fe079c test(e2e): primeSession — one IAM-PKCE auth recipe for render specs; re-pin drifted mocks (v8.4.146) 2026-07-21 20:12:02 -07:00
Hanzo AI ab5aa0550d test(e2e): primeSession — one IAM-PKCE auth recipe for render specs; re-pin drifted mocks (v8.4.146) 2026-07-21 20:12:02 -07:00
cc4ac939e8 fix(auth): run the OAuth callback under the SPA fallback so console login completes (#165)
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>
2026-07-21 17:54:59 -07:00
1399fa825b fix(auth): run the OAuth callback under the SPA fallback so console login completes (#165)
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>
2026-07-21 17:54:59 -07:00
hanzo-dev b35b68c1e3 merge(feat/consume-hanzo-brand): consolidate onto main 2026-07-21 17:10:05 -07:00
hanzo-dev cc53ef6d64 merge(feat/consume-hanzo-brand): consolidate onto main 2026-07-21 17:10:05 -07:00
hanzo-dev 95164d73fd merge(feat/console-enso-surfaces): consolidate onto main 2026-07-21 17:09:54 -07:00
hanzo-dev 19daa14d3e merge(feat/console-enso-surfaces): consolidate onto main 2026-07-21 17:09:54 -07:00
hanzo-dev f42fa9db41 fix(auth): resolve session from access-token JWT claims (skip flaky userinfo)
iamSdk().getUserInfo() returns null on a 200 in the deployed SDK, so AccountApi.session()
dead-ended before accountFromClaims and looped /signin for everyone. Decode the account
from the access-token JWT directly (always carries >= sub); userinfo is now only a
fallback. Combined with the owner/config-org fallback, a valid token can't dead-end.
2026-07-21 17:05:29 -07:00
hanzo-dev bc59ea6b8b fix(auth): resolve session from access-token JWT claims (skip flaky userinfo)
iamSdk().getUserInfo() returns null on a 200 in the deployed SDK, so AccountApi.session()
dead-ended before accountFromClaims and looped /signin for everyone. Decode the account
from the access-token JWT directly (always carries >= sub); userinfo is now only a
fallback. Combined with the owner/config-org fallback, a valid token can't dead-end.
2026-07-21 17:05:29 -07:00
hanzo-dev a16801b9bf refactor(router): move router-config client to RESTful ZAP-native routes
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.
2026-07-21 16:50:08 -07:00
hanzo-dev 6802c5bb75 refactor(router): move router-config client to RESTful ZAP-native routes
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.
2026-07-21 16:50:08 -07:00
Hanzo AI dd8cabec52 feat(console): embed the full Studio app at /studio — same-site iframe, white-label gated (v8.4.145) 2026-07-21 16:49:51 -07:00
Hanzo AI 6ad804daa3 feat(console): embed the full Studio app at /studio — same-site iframe, white-label gated (v8.4.145) 2026-07-21 16:49:51 -07:00
hanzo-dev 4307f3e7c4 fix(auth): resolve account owner with config-org fallback — fixes login loop
The deployed OIDC /v1/iam/userinfo omits `owner` and `sub` is the user UUID, so
accountFromClaims returned null for every valid session and looped /signin. Resolve
owner: owner/organization claim -> sub-prefix -> config.iamOrgName. Also fall back
name to email. A valid IAM session can no longer dead-end.
2026-07-21 15:36:15 -07:00
hanzo-dev c06e27bc32 fix(auth): resolve account owner with config-org fallback — fixes login loop
The deployed OIDC /v1/iam/userinfo omits `owner` and `sub` is the user UUID, so
accountFromClaims returned null for every valid session and looped /signin. Resolve
owner: owner/organization claim -> sub-prefix -> config.iamOrgName. Also fall back
name to email. A valid IAM session can no longer dead-end.
2026-07-21 15:36:15 -07:00
hanzo-dev cf541c4cdb fix(auth): derive account owner from sub — fixes login loop
accountFromClaims required an `owner` claim, but OIDC /v1/iam/userinfo only
returns sub/preferred_username/name/email (no owner). Every login resolved to
account=null and looped back to /signin. `sub` is `owner/name`, so derive owner
from it exactly as `name` already is.
2026-07-21 15:30:10 -07:00
hanzo-dev 963e852e9d fix(auth): derive account owner from sub — fixes login loop
accountFromClaims required an `owner` claim, but OIDC /v1/iam/userinfo only
returns sub/preferred_username/name/email (no owner). Every login resolved to
account=null and looped back to /signin. `sub` is `owner/name`, so derive owner
from it exactly as `name` already is.
2026-07-21 15:30:10 -07:00
hanzo-dev 7ce975ea44 models(leaderboard): sync enso family + source measured-vs-reported toggle
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.

Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
2026-07-21 15:05:38 -07:00
hanzo-dev c5988b1100 models(leaderboard): sync enso family + source measured-vs-reported toggle
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.

Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
2026-07-21 15:05:38 -07:00
hanzo-dev b55eb201d6 WIP on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev e7b36af216 index on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev 9d6190a783 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:04:25 -07:00
hanzo-dev 78999d5531 models(leaderboard): sync enso family + source measured-vs-reported toggle
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.

Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
2026-07-21 14:27:03 -07:00
hanzo-dev 976eab1f24 models(leaderboard): sync enso family + source measured-vs-reported toggle
Re-run scripts/sync-benchmarks.mjs so the checked-in corpus fixture picks up the
enso family (enso-ultra/enso/enso-flash, hanzo-measured) and the provider-reported
relabel from priors/leaderboard.json.

Leaderboard gains a Source filter (All / Hanzo-measured / Vendor-reported) over a
pure sourceClass() classifier in the one reader, badges every row by class
(Enso / Hanzo-measured / reported), and shows the three Enso tiers side by side —
monotonic Ultra 92.9 > Pro 87.9 > Flash 75.8 GPQA, priced from the same corpus.
Honest by construction: enso ranks on merit (never floated to #1), tiers render
only when the corpus carries them, unscored rows stay omitted.
2026-07-21 14:27:03 -07:00
zandGitHub eba272e9f0 feat(gpus): per-GPU queue + live utilization in the console (v8.4.144)
Per-GPU job queue (visible + manageable) + live board utilization in the Hanzo console, over /v1/fleet/*. Includes adversarial-review fixes (confirm-gated cancel, surfaced errors, tab-gated polling, multi-running, in-flight guard, stale indicator, util de-dupe/idle-0%). 43 fleet unit tests; tsc + build:embed green.
2026-07-21 14:22:55 -07:00
zandGitHub f154c27374 feat(gpus): per-GPU queue + live utilization in the console (v8.4.144)
Per-GPU job queue (visible + manageable) + live board utilization in the Hanzo console, over /v1/fleet/*. Includes adversarial-review fixes (confirm-gated cancel, surfaced errors, tab-gated polling, multi-running, in-flight guard, stale indicator, util de-dupe/idle-0%). 43 fleet unit tests; tsc + build:embed green.
2026-07-21 14:22:55 -07:00
Hanzo AI cfbfedd395 docs(console): correct workbench wave test count 2026-07-21 13:01:00 -07:00
Hanzo AI 60779291e2 docs(console): correct workbench wave test count 2026-07-21 13:01:00 -07:00
Hanzo AI 66dd2ef302 feat(console): Workbench — persistent Developers dock (Overview · Logs · read-only /v1 Shell) (v8.4.143) 2026-07-21 13:00:48 -07:00
Hanzo AI c0c961bc51 feat(console): Workbench — persistent Developers dock (Overview · Logs · read-only /v1 Shell) (v8.4.143) 2026-07-21 13:00:48 -07:00
hanzo-dev 916d943b34 feat(console): per-org Router config panel — enabled-models allowlist + savings↔quality dial
Extend the Router > Policy editor (RouterPolicyEditor) with the two org-router
controls the auto-router (Enso) needs, round-tripped through the existing
GET/POST /v1/{get,update}-router-policy:

- Enabled models: a checklist populated from the policy's servable `available`
  set (shows name, submits id); Select all / Clear; empty selection = ALL models
  allowed (labelled clearly).
- Savings<->quality dial: a labeled 0..1 slider (step 0.05) via FieldSlider;
  0 = savings, 1 = quality, 0.5 = balanced; null/unset renders balanced.

Preserves prefer + costCeiling on save. RouterPolicy gains enabledModels /
qualityBias / available (RouterModel). The auto-routing ON/OFF toggle stays a
separate concern on the Smart-routing (ai-accounts) surface — not duplicated.

Playwright-verified (e2e/router-config.spec.ts, 3/3): panel renders, Select-all/
Clear re-count live, Save POSTs enabledModels + qualityBias (contract proof),
mobile no horizontal scroll. tsc clean; vitest 2751 pass; next build + build:embed green.
2026-07-21 11:36:26 -07:00
hanzo-dev 623e1941c1 feat(console): per-org Router config panel — enabled-models allowlist + savings↔quality dial
Extend the Router > Policy editor (RouterPolicyEditor) with the two org-router
controls the auto-router (Enso) needs, round-tripped through the existing
GET/POST /v1/{get,update}-router-policy:

- Enabled models: a checklist populated from the policy's servable `available`
  set (shows name, submits id); Select all / Clear; empty selection = ALL models
  allowed (labelled clearly).
- Savings<->quality dial: a labeled 0..1 slider (step 0.05) via FieldSlider;
  0 = savings, 1 = quality, 0.5 = balanced; null/unset renders balanced.

Preserves prefer + costCeiling on save. RouterPolicy gains enabledModels /
qualityBias / available (RouterModel). The auto-routing ON/OFF toggle stays a
separate concern on the Smart-routing (ai-accounts) surface — not duplicated.

Playwright-verified (e2e/router-config.spec.ts, 3/3): panel renders, Select-all/
Clear re-count live, Save POSTs enabledModels + qualityBias (contract proof),
mobile no horizontal scroll. tsc clean; vitest 2751 pass; next build + build:embed green.
2026-07-21 11:36:26 -07:00
hanzo-dev da701f2136 refactor(brand): source per-brand identity from @hanzo/brand
brands.ts stops duplicating the fleet brand data (lux/zoo/pars identity +
marks were re-typed here) — it now adapts @hanzo/brand/registry's
BrandIdentity onto the console Brand shape. Host resolution stays in ~/config
(SSR default + IAM app wiring untouched → zero auth-path change). Marks are
byte-identical (the registry carries the same console v1 currentColor marks),
so zero visual regression; getBrand() signature unchanged, all 8 consumers
unaffected. Drift-fix: Hanzo orgName 'Hanzo Industries Inc.' -> 'Hanzo AI Inc.'
(matches brand.json/legalEntity; not rendered in console).

typecheck (tsc --noEmit) green. Needs @hanzo/brand@^1.4.0 published + npm
install to update the lock (deploy-gate).
2026-07-21 09:31:31 -07:00
hanzo-dev 2b2a1ae5a1 refactor(brand): source per-brand identity from @hanzo/brand
brands.ts stops duplicating the fleet brand data (lux/zoo/pars identity +
marks were re-typed here) — it now adapts @hanzo/brand/registry's
BrandIdentity onto the console Brand shape. Host resolution stays in ~/config
(SSR default + IAM app wiring untouched → zero auth-path change). Marks are
byte-identical (the registry carries the same console v1 currentColor marks),
so zero visual regression; getBrand() signature unchanged, all 8 consumers
unaffected. Drift-fix: Hanzo orgName 'Hanzo Industries Inc.' -> 'Hanzo AI Inc.'
(matches brand.json/legalEntity; not rendered in console).

typecheck (tsc --noEmit) green. Needs @hanzo/brand@^1.4.0 published + npm
install to update the lock (deploy-gate).
2026-07-21 09:31:31 -07:00
Hanzo AI b546a1785d chore(console): migrate @hanzo/capture -> @hanzo/event
Swap analytics client to @hanzo/event@^0.2.0 (superset: identical
EVENTS/useAnalytics/AnalyticsProvider/usePageview API, adds
captureError/ErrorBoundary). Rewrite all 8 importers; update lockfile.
tsc --noEmit clean.
2026-07-20 23:28:35 -07:00
Hanzo AI 6313780dc6 chore(console): migrate @hanzo/capture -> @hanzo/event
Swap analytics client to @hanzo/event@^0.2.0 (superset: identical
EVENTS/useAnalytics/AnalyticsProvider/usePageview API, adds
captureError/ErrorBoundary). Rewrite all 8 importers; update lockfile.
tsc --noEmit clean.
2026-07-20 23:28:35 -07:00
hanzo-dev 778ad107d8 auth: @hanzo/iam single login flow (go-live)
# Conflicts:
#	package-lock.json
#	package.json
2026-07-20 16:38:23 -07:00
hanzo-dev 5d57702ec0 auth: @hanzo/iam single login flow (go-live)
# Conflicts:
#	package-lock.json
#	package.json
2026-07-20 16:38:23 -07:00
hanzo-dev 490c941819 Ground the model catalog in a checked-in openrouter fixture so it is always browsable
The catalog was driven entirely by the live gateway, so a full backend outage or an
unrouted pricing endpoint left the Models surface empty behind an error card rather
than a browsable list. This adds a versioned fixture, catalog.data.json, synced from
hanzoai/enso-bench priors/openrouter_models.json by scripts/sync-models.mjs and imported
at build time, as the guaranteed base of the one fetchCatalog. The reason it is a fixture
rather than an endpoint is the same reason the benchmark corpus is: it is a versioned
artefact that changes when a catalog sync lands, not per request, so an endpoint would
buy nothing and cost a loading state, a failure mode, and a fabrication risk on every
page view.

Each of the roughly three hundred forty rows carries the model id, vendor, context
window, both sides of the per-Mtok price, and the capability flags read from the prior's
own fields, so the Vision badge and the detail feature chips are derived, never guessed.
No description is emitted because the prior carries none, and an em-dash is more honest
than a fabricated blurb. Benchmark scores continue to come from the enso-bench leaderboard
corpus, which already excludes the degraded blank-heavy runs the summary marks, so a
model without a published score renders an em-dash and never a zero.

fetchCatalog now merges the fixture base with the live rich pricing catalog and the live
routing set in increasing order of authority, so live pricing always wins where it exists
and the fixture only fills what the live catalog omits. It no longer throws when the
gateway is unreachable; it degrades to the fixture with every model honestly marked
Catalog rather than Live. Vendor logos and blend semantics are unchanged.
2026-07-20 15:33:11 -07:00
hanzo-dev 089b7727c0 Ground the model catalog in a checked-in openrouter fixture so it is always browsable
The catalog was driven entirely by the live gateway, so a full backend outage or an
unrouted pricing endpoint left the Models surface empty behind an error card rather
than a browsable list. This adds a versioned fixture, catalog.data.json, synced from
hanzoai/enso-bench priors/openrouter_models.json by scripts/sync-models.mjs and imported
at build time, as the guaranteed base of the one fetchCatalog. The reason it is a fixture
rather than an endpoint is the same reason the benchmark corpus is: it is a versioned
artefact that changes when a catalog sync lands, not per request, so an endpoint would
buy nothing and cost a loading state, a failure mode, and a fabrication risk on every
page view.

Each of the roughly three hundred forty rows carries the model id, vendor, context
window, both sides of the per-Mtok price, and the capability flags read from the prior's
own fields, so the Vision badge and the detail feature chips are derived, never guessed.
No description is emitted because the prior carries none, and an em-dash is more honest
than a fabricated blurb. Benchmark scores continue to come from the enso-bench leaderboard
corpus, which already excludes the degraded blank-heavy runs the summary marks, so a
model without a published score renders an em-dash and never a zero.

fetchCatalog now merges the fixture base with the live rich pricing catalog and the live
routing set in increasing order of authority, so live pricing always wins where it exists
and the fixture only fills what the live catalog omits. It no longer throws when the
gateway is unreachable; it degrades to the fixture with every model honestly marked
Catalog rather than Live. Vendor logos and blend semantics are unchanged.
2026-07-20 15:33:11 -07:00
hanzo-dev c24268f88c auth: make @hanzo/iam the single login path
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.
2026-07-20 14:58:17 -07:00
hanzo-dev ee6e57ac10 auth: make @hanzo/iam the single login path
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.
2026-07-20 14:58:17 -07:00
Hanzo AI db393cff4b shell: hoisted @hanzo/ui@8 OrgSwitcher + canonical 7-path mark + cross-surface launcher tiles (v8.4.141)
- OrgSwitcher is now the hoisted @hanzo/ui/product switcher (ui#36 closed):
  a thin adapter wires ~/lib/org-scope (the same contract), the lazy
  IamAdminApi.organizations pager (super-admin only), the /v1/iam/onboard
  create hook, and the All-organizations picker row. One switcher, one home.
- Brand mark: HANZO logoContent + ui/HanzoMark now render @hanzo/logo
  MARK_PATHS (the canonical 7-path shaded H, currentColor) — the flat 5-path
  copies are gone; lux/zoo/pars white-label marks untouched.
- AppLauncher: Surfaces row (hanzo brand only, white-label safe) with the
  shared shell's hanzo.team tile + billing.hanzo.ai.
- deps: @hanzo/logo ^1.0.13, @hanzo/ui ^8.0.5 (transpiled — ships raw TS).
  Upstream unblocks published for the install: @hanzo/ui 8.0.5 (canvas peer
  floor >=0.1.0) + @hanzo/ui-shadcn 5.9.1 (framer-motion ^11 || ^12).
- main-green drive-bys: proxy-allow duplicate 'dns' head removed;
  billing-accounts test repointed to the shipped /v1/billing form;
  shell test follows the Social→Publish display rename.

tsc 0 errors; vitest 2757/2757; next build + build:embed green.
2026-07-20 13:44:47 -07:00
Hanzo AI 85e482c901 shell: hoisted @hanzo/ui@8 OrgSwitcher + canonical 7-path mark + cross-surface launcher tiles (v8.4.141)
- OrgSwitcher is now the hoisted @hanzo/ui/product switcher (ui#36 closed):
  a thin adapter wires ~/lib/org-scope (the same contract), the lazy
  IamAdminApi.organizations pager (super-admin only), the /v1/iam/onboard
  create hook, and the All-organizations picker row. One switcher, one home.
- Brand mark: HANZO logoContent + ui/HanzoMark now render @hanzo/logo
  MARK_PATHS (the canonical 7-path shaded H, currentColor) — the flat 5-path
  copies are gone; lux/zoo/pars white-label marks untouched.
- AppLauncher: Surfaces row (hanzo brand only, white-label safe) with the
  shared shell's hanzo.team tile + billing.hanzo.ai.
- deps: @hanzo/logo ^1.0.13, @hanzo/ui ^8.0.5 (transpiled — ships raw TS).
  Upstream unblocks published for the install: @hanzo/ui 8.0.5 (canvas peer
  floor >=0.1.0) + @hanzo/ui-shadcn 5.9.1 (framer-motion ^11 || ^12).
- main-green drive-bys: proxy-allow duplicate 'dns' head removed;
  billing-accounts test repointed to the shipped /v1/billing form;
  shell test follows the Social→Publish display rename.

tsc 0 errors; vitest 2757/2757; next build + build:embed green.
2026-07-20 13:44:47 -07:00
hanzo-dev 88f5a18fdd Add the models surfaces: catalog benchmarks, leaderboard, per-org Enso blend
Extends the existing models product with two tabs rather than adding a second
catalog. The Catalog tab gains a vision capability badge derived from the
catalog's own features, both sides of the per-Mtok price, and the published
benchmark headline. Leaderboard ranks the enso-bench prior corpus by any
benchmark it covers. Blend lets an org choose the models its router runs over
and shows the Enso flash, blend and ultra tiers re-forming as that set changes.

Benchmark scores are a checked-in fixture regenerated from hanzoai/enso-bench by
scripts/sync-benchmarks.mjs and imported at build time, because the corpus is a
versioned artefact rather than live state: it changes when a bench run lands, not
per request, so an endpoint would buy nothing and cost a loading state, a failure
mode and a fabrication risk on every page view. Every score keeps its source, our
own harness is badged, a model with no published score renders an em-dash rather
than a zero, and a model unscored on the selected benchmark is omitted from the
ranking rather than ranked last at zero.

The blend rules are a port of the reference semantics in enso-bench arms.py
resolve_blend, so the console means exactly what the router means by enabled.
Alongside the hand-written semantics tests there is a parity suite that executes
arms.py and diffs its real output; it caught a genuine divergence, since Python's
stable sort preserves catalog order for equally priced models and an id tie-break
in the port silently reordered them. That suite skips cleanly when the enso-bench
checkout is absent so CI never fails on a missing sibling repo.

Blend persistence rides the org's existing OrgSettings row rather than inventing
an endpoint. The gateway does not yet carry the three model columns, named in a
TODO on the client, so the write is attempted for real and then re-read to
confirm it survived; when it did not the board says so plainly instead of
confirming a write the backend discarded.

Vendor logos reuse the existing self-contained ProviderLogo marks with a monogram
fallback, with no external requests. The blend row's vendor label now resolves
identity-first through the same resolver the avatar uses, so a gateway-served
model reads as its true vendor rather than showing Zhipu or Moonshot artwork
beside the word Zen.

Verified with typecheck, the unit suite, both builds including the go:embed gate,
and a Playwright spec that drives all three surfaces in a browser.
2026-07-19 21:29:35 -07:00
hanzo-dev 039b018f80 Add the models surfaces: catalog benchmarks, leaderboard, per-org Enso blend
Extends the existing models product with two tabs rather than adding a second
catalog. The Catalog tab gains a vision capability badge derived from the
catalog's own features, both sides of the per-Mtok price, and the published
benchmark headline. Leaderboard ranks the enso-bench prior corpus by any
benchmark it covers. Blend lets an org choose the models its router runs over
and shows the Enso flash, blend and ultra tiers re-forming as that set changes.

Benchmark scores are a checked-in fixture regenerated from hanzoai/enso-bench by
scripts/sync-benchmarks.mjs and imported at build time, because the corpus is a
versioned artefact rather than live state: it changes when a bench run lands, not
per request, so an endpoint would buy nothing and cost a loading state, a failure
mode and a fabrication risk on every page view. Every score keeps its source, our
own harness is badged, a model with no published score renders an em-dash rather
than a zero, and a model unscored on the selected benchmark is omitted from the
ranking rather than ranked last at zero.

The blend rules are a port of the reference semantics in enso-bench arms.py
resolve_blend, so the console means exactly what the router means by enabled.
Alongside the hand-written semantics tests there is a parity suite that executes
arms.py and diffs its real output; it caught a genuine divergence, since Python's
stable sort preserves catalog order for equally priced models and an id tie-break
in the port silently reordered them. That suite skips cleanly when the enso-bench
checkout is absent so CI never fails on a missing sibling repo.

Blend persistence rides the org's existing OrgSettings row rather than inventing
an endpoint. The gateway does not yet carry the three model columns, named in a
TODO on the client, so the write is attempted for real and then re-read to
confirm it survived; when it did not the board says so plainly instead of
confirming a write the backend discarded.

Vendor logos reuse the existing self-contained ProviderLogo marks with a monogram
fallback, with no external requests. The blend row's vendor label now resolves
identity-first through the same resolver the avatar uses, so a gateway-served
model reads as its true vendor rather than showing Zhipu or Moonshot artwork
beside the word Zen.

Verified with typecheck, the unit suite, both builds including the go:embed gate,
and a Playwright spec that drives all three surfaces in a browser.
2026-07-19 21:29:35 -07:00
hanzo-dev 197b3217da merge: cloudflare support (feat/cloudflare-module) 2026-07-19 20:49:13 -07:00
hanzo-dev aa69e21d66 merge: cloudflare support (feat/cloudflare-module) 2026-07-19 20:49:13 -07:00
hanzo-dev 00b01f6960 console: point the CD module at /v1/deploy + mount the @hanzo/canvas fleet map
The GitOps module called /v1/gitops, which cloud never binds — the CD surface
was dead in the console. Repoint to the served /v1/deploy projection, remap the
DTO fields, admit deploy through the BFF proxy, and render the fleet on the
@hanzo/canvas Railway board with a node drawer.
2026-07-19 19:28:11 -07:00
hanzo-dev 61f3b40a4d console: point the CD module at /v1/deploy + mount the @hanzo/canvas fleet map
The GitOps module called /v1/gitops, which cloud never binds — the CD surface
was dead in the console. Repoint to the served /v1/deploy projection, remap the
DTO fields, admit deploy through the BFF proxy, and render the fleet on the
@hanzo/canvas Railway board with a node drawer.
2026-07-19 19:28:11 -07:00
hanzo-dev be1a96f05c feat(cd): Railway-grade fleet deploy MAP over /v1/deploy
Turn the console CD product into a mobile-first deployment map wired to the
native cloud CD projection (cd.hanzo.ai's surface).

- FIX the #1 blocker: repoint the client from the never-bound /v1/gitops to
  cloud's /v1/deploy, and REMAP the real clients/deploy DTOs into the console
  view-models (repository/version->image, runningVersion->liveTag,
  healthMessage->message, parentRefs[].ref->ownerRefs, object manifests->JSON,
  logs blob->lines). Add the deploy head to the proxy allow-list.
- RENDER the fleet as a @hanzo/canvas ProjectCanvas map: foldFleet folds each
  App CR into a service node (CD health status, git/image source, capability,
  deploy time), a deterministic grid layout (no invented edges), env switcher,
  search, and click-to-filter KPI tiles.
- ENRICH nodes best-effort in parallel: git repo+branch (GitApi.repos) and CI
  build time (BuildsApi); missing enrichment never drops a node.
- DRILL-IN drawer (ServiceDetailChar tabs): Resources = the owned-resource
  topology via treeToGraph, Deploys = the CI build timeline, Logs = live pod
  logs, Source = git repo/branch/commit + image; confirm-gated Sync + Rollback
  (rollback offers only real clean-semver git releases).
- MOBILE-first: full-screen drawer, touch pan/zoom, no horizontal body scroll,
  nav collapses to the hamburger.
- Mount the shared @hanzo/canvas primitive (the base @hanzo/gitops wraps);
  delete the dead parts.tsx + ui-contract.ts mount-seam.
- Tests: 52 (client DTO mapping + fold/grid/release-targets) + a responsive
  Playwright e2e (desktop 1440 + mobile 390, screenshots, no-overflow +
  nav-collapse asserts).
2026-07-19 12:18:24 -07:00
hanzo-dev b34dfca0a2 feat(cd): Railway-grade fleet deploy MAP over /v1/deploy
Turn the console CD product into a mobile-first deployment map wired to the
native cloud CD projection (cd.hanzo.ai's surface).

- FIX the #1 blocker: repoint the client from the never-bound /v1/gitops to
  cloud's /v1/deploy, and REMAP the real clients/deploy DTOs into the console
  view-models (repository/version->image, runningVersion->liveTag,
  healthMessage->message, parentRefs[].ref->ownerRefs, object manifests->JSON,
  logs blob->lines). Add the deploy head to the proxy allow-list.
- RENDER the fleet as a @hanzo/canvas ProjectCanvas map: foldFleet folds each
  App CR into a service node (CD health status, git/image source, capability,
  deploy time), a deterministic grid layout (no invented edges), env switcher,
  search, and click-to-filter KPI tiles.
- ENRICH nodes best-effort in parallel: git repo+branch (GitApi.repos) and CI
  build time (BuildsApi); missing enrichment never drops a node.
- DRILL-IN drawer (ServiceDetailChar tabs): Resources = the owned-resource
  topology via treeToGraph, Deploys = the CI build timeline, Logs = live pod
  logs, Source = git repo/branch/commit + image; confirm-gated Sync + Rollback
  (rollback offers only real clean-semver git releases).
- MOBILE-first: full-screen drawer, touch pan/zoom, no horizontal body scroll,
  nav collapses to the hamburger.
- Mount the shared @hanzo/canvas primitive (the base @hanzo/gitops wraps);
  delete the dead parts.tsx + ui-contract.ts mount-seam.
- Tests: 52 (client DTO mapping + fold/grid/release-targets) + a responsive
  Playwright e2e (desktop 1440 + mobile 390, screenshots, no-overflow +
  nav-collapse asserts).
2026-07-19 12:18:24 -07:00
zeekayandClaude Fable 5 d11a1b7bb4 feat(models): ONE unified family-grouped model selector across the console
Every model-selection surface now uses ONE selector fed from the live gateway
catalog — the hanzo.chat family-grouped picker (Enso, Zen, Anthropic, OpenAI
first, then alphabetical; family headers; premium chip; search; monochrome).

Console runs @hanzo/gui (Tamagui), NOT @hanzo/ui-shadcn, so per the one-way-per-
repo rule this is a faithful Tamagui twin of @hanzo/ui/models ModelSelector with
the SAME contract ({models,value,onChange,size,chatOnly}) and SAME family taxonomy
(~/lib/api/families groupModelsByFamily, mirroring @hanzo/ui/models). Drop-in swap
when the console migrates to the shadcn design system.

- ModelSelector.tsx: the ONE selector (family sections, premium PRO chip, search
  over the catalog, keyboard nav, monochrome marks, honest free-text fallback).
- useModelCatalog.ts: the ONE shared catalog-entries hook (fetchCatalog via the
  authed /ai proxy). Playground useModels now derives its ModelOption view from it
  (one fetch, exposes raw entries) — DRY.
- families.ts: add Enso as a first-party house family (own family, Hanzo mark);
  reorder to the contract (Enso, Zen, Anthropic, OpenAI, then alphabetical) as the
  ONE ordering shared by the selector AND the Models browser; add the contract-named
  groupModelsByFamily(catalog,{chatOnly}); groupByFamily stays its chat-only default.
- brand.ts / ProviderLogo.tsx: Enso resolves to the house brand; ProviderLogo gains
  a `mono` monochrome mode for the selector.
- Rewire: Composer + Evals (model under test + judge) onto ModelSelector; delete the
  two old pickers (products/ModelPicker, playground/ModelPicker) and the superseded
  provider-cascade grouping (playground/providers).
- Kill sunset zen4 literals in RouterPolicyEditor placeholders (catalog is truth).

Not unified (noted for later convergence): the modality playgrounds (image/video/
audio/embeddings) keep ModelSelect (a different domain — picking a non-chat model),
and agent-builder keeps its injected-loader ComboBox (a portable, decoupled builder).

Verify: tsc --noEmit clean for every touched file (54 pre-existing env-noise errors
from uninstalled @hanzo/canvas|capture|usage, down from 59 baseline — @hanzo/ui not
installed as expected); vitest families+brand+default-model+catalog 65/65 pass.

Ships to console.hanzo.ai only via the next hanzoai/cloud release embedding
console@main (CONSOLE_REF=main) — landing on console main does NOT deploy by itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:01:49 -07:00
zeekayandhanzo-dev 6eaf0d0688 feat(models): ONE unified family-grouped model selector across the console
Every model-selection surface now uses ONE selector fed from the live gateway
catalog — the hanzo.chat family-grouped picker (Enso, Zen, Anthropic, OpenAI
first, then alphabetical; family headers; premium chip; search; monochrome).

Console runs @hanzo/gui (Tamagui), NOT @hanzo/ui-shadcn, so per the one-way-per-
repo rule this is a faithful Tamagui twin of @hanzo/ui/models ModelSelector with
the SAME contract ({models,value,onChange,size,chatOnly}) and SAME family taxonomy
(~/lib/api/families groupModelsByFamily, mirroring @hanzo/ui/models). Drop-in swap
when the console migrates to the shadcn design system.

- ModelSelector.tsx: the ONE selector (family sections, premium PRO chip, search
  over the catalog, keyboard nav, monochrome marks, honest free-text fallback).
- useModelCatalog.ts: the ONE shared catalog-entries hook (fetchCatalog via the
  authed /ai proxy). Playground useModels now derives its ModelOption view from it
  (one fetch, exposes raw entries) — DRY.
- families.ts: add Enso as a first-party house family (own family, Hanzo mark);
  reorder to the contract (Enso, Zen, Anthropic, OpenAI, then alphabetical) as the
  ONE ordering shared by the selector AND the Models browser; add the contract-named
  groupModelsByFamily(catalog,{chatOnly}); groupByFamily stays its chat-only default.
- brand.ts / ProviderLogo.tsx: Enso resolves to the house brand; ProviderLogo gains
  a `mono` monochrome mode for the selector.
- Rewire: Composer + Evals (model under test + judge) onto ModelSelector; delete the
  two old pickers (products/ModelPicker, playground/ModelPicker) and the superseded
  provider-cascade grouping (playground/providers).
- Kill sunset zen4 literals in RouterPolicyEditor placeholders (catalog is truth).

Not unified (noted for later convergence): the modality playgrounds (image/video/
audio/embeddings) keep ModelSelect (a different domain — picking a non-chat model),
and agent-builder keeps its injected-loader ComboBox (a portable, decoupled builder).

Verify: tsc --noEmit clean for every touched file (54 pre-existing env-noise errors
from uninstalled @hanzo/canvas|capture|usage, down from 59 baseline — @hanzo/ui not
installed as expected); vitest families+brand+default-model+catalog 65/65 pass.

Ships to console.hanzo.ai only via the next hanzoai/cloud release embedding
console@main (CONSOLE_REF=main) — landing on console main does NOT deploy by itself.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 09:01:49 -07:00
hanzo-dev bdaa003fdf feat(cloudflare): console module over /v1/integrations/cloudflare
Pages and Workers are wired; R2/KV/D1 render as labelled Phase-2 tabs that
explain themselves instead of exposing controls that would 501.

- src/lib/api/cloudflare.ts: typed client over the asset plane. Relays
  Cloudflare API v4 `result` verbatim, so the normalizers map the real CF
  shapes (created_on, latest_stage, script id = script name) and tolerate the
  `{success:true}` object the backend returns for an empty result. Name and
  id validators mirror the backend's nameRE/idRE so a bad field is an inline
  error, not a 400.
- CloudflareModule: Pages (projects, deployments, custom domains) and Workers
  (scripts, workers.dev subdomain, zone routes). 503 -> connect-account
  affordance linking the existing integrations flow rather than a second
  connect surface; 403 explains the org-admin write gate; 501 -> Phase 2.
- BackendState: classify 501 as not-implemented, distinct from 404. The route
  exists and answers honestly; say that rather than "not available here".
- ConfirmDelete: lifted out of DnsModule so both modules share one destructive
  confirm.

Registered under Network beside DNS and Domains.
2026-07-19 02:51:46 -07:00
hanzo-dev ee3ac4d605 feat(cloudflare): console module over /v1/integrations/cloudflare
Pages and Workers are wired; R2/KV/D1 render as labelled Phase-2 tabs that
explain themselves instead of exposing controls that would 501.

- src/lib/api/cloudflare.ts: typed client over the asset plane. Relays
  Cloudflare API v4 `result` verbatim, so the normalizers map the real CF
  shapes (created_on, latest_stage, script id = script name) and tolerate the
  `{success:true}` object the backend returns for an empty result. Name and
  id validators mirror the backend's nameRE/idRE so a bad field is an inline
  error, not a 400.
- CloudflareModule: Pages (projects, deployments, custom domains) and Workers
  (scripts, workers.dev subdomain, zone routes). 503 -> connect-account
  affordance linking the existing integrations flow rather than a second
  connect surface; 403 explains the org-admin write gate; 501 -> Phase 2.
- BackendState: classify 501 as not-implemented, distinct from 404. The route
  exists and answers honestly; say that rather than "not available here".
- ConfirmDelete: lifted out of DnsModule so both modules share one destructive
  confirm.

Registered under Network beside DNS and Domains.
2026-07-19 02:51:46 -07:00
hanzo-dev adafd97b61 merge(feat/dns-proxy-head): land pending work on main 2026-07-19 02:25:17 -07:00
hanzo-dev 2b1dbf0ce4 merge(feat/dns-proxy-head): land pending work on main 2026-07-19 02:25:17 -07:00
hanzo-dev 680da6a452 fix(console): Usage Caps & Promo admin surface — panels no longer collapse
The Promo tab's Current-promo + Edit-promo panels (and the Caps tab's
Target-organization panel) are stacked in a vertical YStack but used Panel's
default grow=true (flex:1). Per Panel's own contract, flex-grown panels in a
column with no fixed height collapse onto each other — the Current-promo facts
overlapped the Edit-promo slider, making the flagship admin surface read as
broken. Pass grow={false} on the three column-stacked panels so each sizes to
its content and stacks cleanly (the documented fix).

Also folds in the surface's save/validation Note improvement: a success
confirmation renders a green check, reserving the red warning triangle for
errors. Verified locally (mocked global-admin render, before/after) — both tabs
stack cleanly desktop + mobile; tsc clean; admin/promo/budgets vitest 50/50.
2026-07-19 00:01:30 -07:00
hanzo-dev cda9f61c52 fix(console): Usage Caps & Promo admin surface — panels no longer collapse
The Promo tab's Current-promo + Edit-promo panels (and the Caps tab's
Target-organization panel) are stacked in a vertical YStack but used Panel's
default grow=true (flex:1). Per Panel's own contract, flex-grown panels in a
column with no fixed height collapse onto each other — the Current-promo facts
overlapped the Edit-promo slider, making the flagship admin surface read as
broken. Pass grow={false} on the three column-stacked panels so each sizes to
its content and stacks cleanly (the documented fix).

Also folds in the surface's save/validation Note improvement: a success
confirmation renders a green check, reserving the red warning triangle for
errors. Verified locally (mocked global-admin render, before/after) — both tabs
stack cleanly desktop + mobile; tsc clean; admin/promo/budgets vitest 50/50.
2026-07-19 00:01:30 -07:00
Hanzo AI fad2fb2073 console: rename Social → Publish (display only, plumbing unchanged)
The social product face is renamed to Publish everywhere it's user-visible — the
registry label + description, the SocialModule PageHeader title, and the single-
product shell wordmark (social.hanzo.ai header). The description now leads with the
user's framing: 'Queue and publish your content everywhere.'

Kept UNCHANGED (one seam, one name for the value — only the label moved): the
internal id 'social', the /v1/social + /v1/marketing cloud seam, config.socialOnly,
shell==='social', and the social.hanzo.ai host mapping. Renaming those would break
the folded cloud binding + the host→mode resolution.

tsc --noEmit clean (0 errors).
2026-07-18 23:32:42 -07:00
Hanzo AI 67826766bc console: rename Social → Publish (display only, plumbing unchanged)
The social product face is renamed to Publish everywhere it's user-visible — the
registry label + description, the SocialModule PageHeader title, and the single-
product shell wordmark (social.hanzo.ai header). The description now leads with the
user's framing: 'Queue and publish your content everywhere.'

Kept UNCHANGED (one seam, one name for the value — only the label moved): the
internal id 'social', the /v1/social + /v1/marketing cloud seam, config.socialOnly,
shell==='social', and the social.hanzo.ai host mapping. Renaming those would break
the folded cloud binding + the host→mode resolution.

tsc --noEmit clean (0 errors).
2026-07-18 23:32:42 -07:00
hanzo-dev 6752ae435c merge: DNS CRUD dashboard + Domains module (dns.hanzo.ai shell, dns proxy head) 2026-07-18 12:20:35 -07:00
hanzo-dev 4760bc2fe2 merge: DNS CRUD dashboard + Domains module (dns.hanzo.ai shell, dns proxy head) 2026-07-18 12:20:35 -07:00
hanzo-dev 5346d86df9 feat(dns): full CRUD DnsModule + Domains module + dns.hanzo.ai shell
Promote DnsModule to the full zones/records CRUD dashboard (typed /v1/dns
client in lib/api/dns.ts, Cloudflare Proxied toggle, DNSSEC/TTL/priority),
admit the org-scoped 'dns' cloud head through the console BFF, and add the
'dns' product shell so dns.hanzo.ai boots straight into the dashboard. Add the
Domains module (register/renew names via cloud clients/domain → name.com).

Integrates console feat/dns-crud-dashboard (c6f7bb54a) + feat/dns-proxy-head
onto main; the 'dns' proxy head is added exactly once. Domains work committed
from the working tree.

tsc --noEmit clean; vitest green (dns 24, proxy-allow 26, shell 11, config 41).

Assisted-by: neo:claude-opus-4-8
2026-07-18 12:19:33 -07:00
hanzo-dev 1c24d555de feat(dns): full CRUD DnsModule + Domains module + dns.hanzo.ai shell
Promote DnsModule to the full zones/records CRUD dashboard (typed /v1/dns
client in lib/api/dns.ts, Cloudflare Proxied toggle, DNSSEC/TTL/priority),
admit the org-scoped 'dns' cloud head through the console BFF, and add the
'dns' product shell so dns.hanzo.ai boots straight into the dashboard. Add the
Domains module (register/renew names via cloud clients/domain → name.com).

Integrates console feat/dns-crud-dashboard (da488eb2a) + feat/dns-proxy-head
onto main; the 'dns' proxy head is added exactly once. Domains work committed
from the working tree.

tsc --noEmit clean; vitest green (dns 24, proxy-allow 26, shell 11, config 41).
2026-07-18 12:19:33 -07:00
hanzo-dev e33cb74416 feat(console): SuperAdmin Usage Caps & Promo surface on admin.<brand>
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.
2026-07-18 10:23:47 -07:00
hanzo-dev eca1f0b526 feat(console): SuperAdmin Usage Caps & Promo surface on admin.<brand>
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.
2026-07-18 10:23:47 -07:00
zandGitHub e854502330 chore(console): merge dead-code decruft (green: tsc + next build) 2026-07-18 10:15:11 -07:00
zandGitHub aa1983fd32 chore(console): merge dead-code decruft (green: tsc + next build) 2026-07-18 10:15:11 -07:00
hanzo-dev a33299f7d3 chore(console): decruft dead code — remove unused file, redundant default exports, dead helpers
- delete unused HomeSummary.tsx (no importers)
- remove 44 redundant `export default X` lines from product Module components
  (registry imports every module by name; the default re-export was dead)
- remove provably-dead helpers/consts/types (zero references, not used internally):
  readPlainText, fetchBalance, findModule, husdToCents, MarginBasisTile, UrlRow,
  PhaseDot, metricTarget, GenericLogo, PERIOD_LABEL, addableCatalogByCategory,
  ActionsIcon, listingContext, templateImageMatch, turnstileEnabled, getSignupUrl,
  EMPTY_MODEL, METRICS_GROUP_BY, USAGE_RANGES, CustomModelMark, SLIDEOVER_LG,
  AccountStatus, PostStatus, StartupStage, SquareEnv
- drop imports orphaned by the above

tsc --noEmit and next build both green.
2026-07-18 10:14:48 -07:00
hanzo-dev e4f2652333 chore(console): decruft dead code — remove unused file, redundant default exports, dead helpers
- delete unused HomeSummary.tsx (no importers)
- remove 44 redundant `export default X` lines from product Module components
  (registry imports every module by name; the default re-export was dead)
- remove provably-dead helpers/consts/types (zero references, not used internally):
  readPlainText, fetchBalance, findModule, husdToCents, MarginBasisTile, UrlRow,
  PhaseDot, metricTarget, GenericLogo, PERIOD_LABEL, addableCatalogByCategory,
  ActionsIcon, listingContext, templateImageMatch, turnstileEnabled, getSignupUrl,
  EMPTY_MODEL, METRICS_GROUP_BY, USAGE_RANGES, CustomModelMark, SLIDEOVER_LG,
  AccountStatus, PostStatus, StartupStage, SquareEnv
- drop imports orphaned by the above

tsc --noEmit and next build both green.
2026-07-18 10:14:48 -07:00
hanzo-dev 8a36bf793a feat(dns): admit /v1/dns/* through the console BFF (CLOUD_HEADS)
Add the 'dns' head so the DNS control plane (hanzoai/dns at dns.hanzo.ai) is
reachable through the same-origin /v1 user-bearer proxy: the DnsModule can list/
manage authoritative + Cloudflare zones over /v1/dns/*, org-scoped by the JWT
owner claim server-side.
2026-07-18 10:08:41 -07:00
hanzo-dev 80dc0cf0fd feat(dns): admit /v1/dns/* through the console BFF (CLOUD_HEADS)
Add the 'dns' head so the DNS control plane (hanzoai/dns at dns.hanzo.ai) is
reachable through the same-origin /v1 user-bearer proxy: the DnsModule can list/
manage authoritative + Cloudflare zones over /v1/dns/*, org-scoped by the JWT
owner claim server-side.
2026-07-18 10:08:41 -07:00
zeekayandClaude Opus 4.8 bd8a816519 feat(console): per-project compute/platform resources in AppsModule
Detail rail now shows the project's REAL resources — Live URL, a Resources band
(Compute honest '—' edge-served, Storage from the live deployment bytes/files,
Domains count + bound-host list, Deployments count+status) — from /v1/projects/:slug
deployments + domains. Honest zeros; no faked project-scoped compute. Ships to
console.hanzo.ai on the next cloud rebuild (go:embed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 08:50:36 -07:00
zeekayandhanzo-dev 4f41cff5a7 feat(console): per-project compute/platform resources in AppsModule
Detail rail now shows the project's REAL resources — Live URL, a Resources band
(Compute honest '—' edge-served, Storage from the live deployment bytes/files,
Domains count + bound-host list, Deployments count+status) — from /v1/projects/:slug
deployments + domains. Honest zeros; no faked project-scoped compute. Ships to
console.hanzo.ai on the next cloud rebuild (go:embed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-18 08:50:36 -07:00
zeekayandClaude Opus 4.8 96721b78a1 fix(console): embed casibase session calls hit unprefixed /v1/* not /v1/iam/*
On the go:embed console.hanzo.ai (served by the hanzoai/cloud binary, no Next BFF),
the casibase COOKIE-session endpoints are served UNPREFIXED — /v1/get-account,
/v1/signin, /v1/signout, /v1/update-preferences. account.ts called them under
/v1/iam/* (the OIDC/bearer gate → 401), so a valid cloud_session_id resolved to
null and AuthGate rendered SignIn despite being logged in. Drop the 'iam/' prefix
on the four session calls; iam/keys + iam/onboard stay (cloud serves those at /v1/iam/*).

Ships to console.hanzo.ai via a hanzoai/cloud rebuild embedding console@main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:56:57 -07:00
zeekayandhanzo-dev 66299f8a51 fix(console): embed casibase session calls hit unprefixed /v1/* not /v1/iam/*
On the go:embed console.hanzo.ai (served by the hanzoai/cloud binary, no Next BFF),
the casibase COOKIE-session endpoints are served UNPREFIXED — /v1/get-account,
/v1/signin, /v1/signout, /v1/update-preferences. account.ts called them under
/v1/iam/* (the OIDC/bearer gate → 401), so a valid cloud_session_id resolved to
null and AuthGate rendered SignIn despite being logged in. Drop the 'iam/' prefix
on the four session calls; iam/keys + iam/onboard stay (cloud serves those at /v1/iam/*).

Ships to console.hanzo.ai via a hanzoai/cloud rebuild embedding console@main.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-18 00:56:57 -07:00
hanzo-dev 57e61c9ec0 chore(o11y): drop retired Langfuse name from AdminO11y user-facing copy
Langfuse retired (observability on SigNoz/o11y span plane). Removes the two
user-facing 'Langfuse' strings from the AI Metrics card. Applies the net
change from feat/ai-overview-page + chore/fork-hygiene-notice (identical
2-line debrand) directly, without dragging their stale 69-commit history.
2026-07-18 00:28:05 -07:00
hanzo-dev bf3340b7d2 chore(o11y): drop retired Langfuse name from AdminO11y user-facing copy
Langfuse retired (observability on SigNoz/o11y span plane). Removes the two
user-facing 'Langfuse' strings from the AI Metrics card. Applies the net
change from feat/ai-overview-page + chore/fork-hygiene-notice (identical
2-line debrand) directly, without dragging their stale 69-commit history.
2026-07-18 00:28:05 -07:00
hanzo-dev cccea4db87 merge: analytics instrumentation (@hanzo/capture) into main
feat/analytics-instrumentation: page/event instrumentation across sign-in,
onboarding, api-keys, plans, projects, create-app; dep rename
@hanzo/analytics -> @hanzo/capture. SignInForm conflict resolved keeping
main's live IAM provider list + Apple/GitLab/Wallet social specs (dropped
the branch's stale unused QrCode import). typecheck clean.
2026-07-18 00:27:09 -07:00
hanzo-dev dce424df72 merge: analytics instrumentation (@hanzo/capture) into main
feat/analytics-instrumentation: page/event instrumentation across sign-in,
onboarding, api-keys, plans, projects, create-app; dep rename
@hanzo/analytics -> @hanzo/capture. SignInForm conflict resolved keeping
main's live IAM provider list + Apple/GitLab/Wallet social specs (dropped
the branch's stale unused QrCode import). typecheck clean.
2026-07-18 00:27:09 -07:00
hanzo-dev f7ac350bf7 Merge feat/model-b: billing payer chain UI (attach account to org/project) 2026-07-17 21:57:14 -07:00
hanzo-dev 5d0532cc5d Merge feat/model-b: billing payer chain UI (attach account to org/project) 2026-07-17 21:57:14 -07:00
hanzo-dev aa6483ae91 ci: publish immutable image tags, mint the version as a receipt
The release workflow tagged the image :v<package.json version> on every push to
main, so a push that did not bump package.json re-published different bytes under
the same :v tag — anything pinned to it silently drifted (a deploy got an image
other than the one the tag was cut for).

Each build now pushes an immutable, content-addressable sha-<short-git-sha> tag
(the tag deploys should pin), then retags that proven image to the next free
v<X.Y.Z> = max(highest git tag, highest pushed container tag) + 1 and pushes the
git tag as a receipt. Order is build -> push image -> tag, so a failed build
leaves no tag and no version number is ever reused or overwritten. The workflow
owns the v* tags (the v* push trigger and the package.json tag-compute are gone);
the lane is serialized and never cancels mid-flight.
2026-07-17 18:41:28 -07:00
hanzo-dev 9a7931de0f ci: publish immutable image tags, mint the version as a receipt
The release workflow tagged the image :v<package.json version> on every push to
main, so a push that did not bump package.json re-published different bytes under
the same :v tag — anything pinned to it silently drifted (a deploy got an image
other than the one the tag was cut for).

Each build now pushes an immutable, content-addressable sha-<short-git-sha> tag
(the tag deploys should pin), then retags that proven image to the next free
v<X.Y.Z> = max(highest git tag, highest pushed container tag) + 1 and pushes the
git tag as a receipt. Order is build -> push image -> tag, so a failed build
leaves no tag and no version number is ever reused or overwritten. The workflow
owns the v* tags (the v* push trigger and the package.json tag-compute are gone);
the lane is serialized and never cancels mid-flight.
2026-07-17 18:41:28 -07:00
hanzo-dev f38c9a4567 console v8.4.140 — Langfuse-style Observability panel + native first-run tour
Ships two customer-facing features on the home + shell:
- Observability front-and-center on the overview (reuses ProductObservability).
- Native, CSP-safe first-run guided tour (Appcues alternative; zero external scripts).
Plus the comprehensive billing/usage/o11y E2E smoke (e2e/billing-usage-o11y.spec.ts).
tsc clean; tour vitest 10/10; the go:embed build:embed gate runs in CI.
2026-07-17 10:25:33 -07:00
hanzo-dev 059d17a01b console v8.4.140 — Langfuse-style Observability panel + native first-run tour
Ships two customer-facing features on the home + shell:
- Observability front-and-center on the overview (reuses ProductObservability).
- Native, CSP-safe first-run guided tour (Appcues alternative; zero external scripts).
Plus the comprehensive billing/usage/o11y E2E smoke (e2e/billing-usage-o11y.spec.ts).
tsc clean; tour vitest 10/10; the go:embed build:embed gate runs in CI.
2026-07-17 10:25:33 -07:00
hanzo-dev 7e0fe859c9 feat(console): native first-run guided tour — self-contained, CSP-safe (Appcues alternative)
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).
2026-07-17 10:25:20 -07:00
hanzo-dev b74b1ba07d feat(console): native first-run guided tour — self-contained, CSP-safe (Appcues alternative)
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).
2026-07-17 10:25:20 -07:00
hanzo-dev 108f025678 feat(console): Observability panel front-and-center on the home (Langfuse-style)
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.
2026-07-17 10:25:20 -07:00
hanzo-dev 1b21156c63 feat(console): Observability panel front-and-center on the home (Langfuse-style)
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.
2026-07-17 10:25:20 -07:00
hanzo-dev 465affa237 console v8.4.139 — release the paas proxy fix
build-image.yml publishes v<package.json version> on a main push, so the merge
that fixed the proxy republished v8.4.138 — the tag the fleet already ran. The
image changed underneath a name that did not, which is not a release.

8.4.139 gives the fix a name to be rolled to. crs/console.yaml moves to it once
CI publishes.

The fix: /paas/<x> forwarded to /v1/<x>, so every call 404'd (/paas/apps ->
/v1/apps). It aimed there because that is where the standalone Node platform
served apps; the plane moved into cloud under /v1/paas and the path never
followed.
2026-07-17 01:14:07 -07:00
hanzo-dev 27526baa20 console v8.4.139 — release the paas proxy fix
build-image.yml publishes v<package.json version> on a main push, so the merge
that fixed the proxy republished v8.4.138 — the tag the fleet already ran. The
image changed underneath a name that did not, which is not a release.

8.4.139 gives the fix a name to be rolled to. crs/console.yaml moves to it once
CI publishes.

The fix: /paas/<x> forwarded to /v1/<x>, so every call 404'd (/paas/apps ->
/v1/apps). It aimed there because that is where the standalone Node platform
served apps; the plane moved into cloud under /v1/paas and the path never
followed.
2026-07-17 01:14:07 -07:00
hanzo-dev 7225f4274a test(e2e): green the console suite — true no-tunnel invariants + fixture/creds gating
- Land the comprehensive billing/invoices/usage/o11y render smoke
  (billing-usage-o11y.spec.ts: every billing sub-page, invoice view/download/
  statement/reload, settings, usage/metrics/AI-metrics, the full o11y set, and a
  dead-card audit) — the E2E agent authored it but never committed before the
  session limit.
- console.spec + live-billing-admin: assert the TRUE "no data tunnel" invariant
  (an unauthenticated request never gets a 2xx carrying backend JSON; a SPA-HTML
  fallback and a >=401 gate both pass) instead of brittle exact status codes on
  renamed/pruned paths (superbase->base, the go:embed-pruned /admin/aggregate).
  theme-color #0a0a0a -> #000000 (live value).
- Shared fixture-server gate (_fixture.ts): the localhost:4000 render specs
  (ai-economics/budgets/gpus/provider-billing(A)/entitlement-sidebar/
  interactive-training/blank-audit) skip cleanly when that server is unreachable
  instead of ECONNREFUSED-failing against prod.
- probe-o11y skips without HANZO_PASSWORD instead of hard-throwing.
- playwright retries:2 in CI to absorb Tamagui/RNW SPA-hydration render flakiness
  (a real regression fails every attempt, so nothing is masked).

Full live run vs console.hanzo.ai: 15 passed / 299 skipped-cleanly / 0 failed.
Prod posture verified live: /v1/admin/* -> 403 JSON (fail-closed), no data tunnels
(superbase/keys/aggregate all SPA-HTML, never backend JSON).
2026-07-17 00:52:33 -07:00
hanzo-dev 54257b090f test(e2e): green the console suite — true no-tunnel invariants + fixture/creds gating
- Land the comprehensive billing/invoices/usage/o11y render smoke
  (billing-usage-o11y.spec.ts: every billing sub-page, invoice view/download/
  statement/reload, settings, usage/metrics/AI-metrics, the full o11y set, and a
  dead-card audit) — the E2E agent authored it but never committed before the
  session limit.
- console.spec + live-billing-admin: assert the TRUE "no data tunnel" invariant
  (an unauthenticated request never gets a 2xx carrying backend JSON; a SPA-HTML
  fallback and a >=401 gate both pass) instead of brittle exact status codes on
  renamed/pruned paths (superbase->base, the go:embed-pruned /admin/aggregate).
  theme-color #0a0a0a -> #000000 (live value).
- Shared fixture-server gate (_fixture.ts): the localhost:4000 render specs
  (ai-economics/budgets/gpus/provider-billing(A)/entitlement-sidebar/
  interactive-training/blank-audit) skip cleanly when that server is unreachable
  instead of ECONNREFUSED-failing against prod.
- probe-o11y skips without HANZO_PASSWORD instead of hard-throwing.
- playwright retries:2 in CI to absorb Tamagui/RNW SPA-hydration render flakiness
  (a real regression fails every attempt, so nothing is masked).

Full live run vs console.hanzo.ai: 15 passed / 299 skipped-cleanly / 0 failed.
Prod posture verified live: /v1/admin/* -> 403 JSON (fail-closed), no data tunnels
(superbase/keys/aggregate all SPA-HTML, never backend JSON).
2026-07-17 00:52:33 -07:00
hanzo-dev 98500bb012 Merge: paas proxy forwards to the plane it is named for
/paas/<x> built /v1/<x>, so every call 404'd: /paas/apps -> /v1/apps. It aimed
there because that is where the standalone Node platform served apps; the control
plane moved into cloud under /v1/paas and the path never followed.

Now /paas/<x> -> /v1/paas/<x>, name-preserving on both sides. apps is the proxy's
only consumer, so nothing else moves.
2026-07-17 00:41:49 -07:00
hanzo-dev cad2ea06d4 Merge: paas proxy forwards to the plane it is named for
/paas/<x> built /v1/<x>, so every call 404'd: /paas/apps -> /v1/apps. It aimed
there because that is where the standalone Node platform served apps; the control
plane moved into cloud under /v1/paas and the path never followed.

Now /paas/<x> -> /v1/paas/<x>, name-preserving on both sides. apps is the proxy's
only consumer, so nothing else moves.
2026-07-17 00:41:49 -07:00
hanzo-dev c3f0694b84 paas proxy: forward to the plane it is named for
/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.
2026-07-17 00:34:27 -07:00
hanzo-dev 43249fea52 paas proxy: forward to the plane it is named for
/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.
2026-07-17 00:34:27 -07:00
hanzo-dev 5f24718b83 feat(shell): cleaner sidebar/nav UX — expand-all, drill-in, rail+flyout, pin/unpin, dockable chat, mobile
Sidebar + product-nav overhaul for console.hanzo.ai (embedded in the cloud release):

- nav-accordion: SINGLE-OPEN → EXPAND-ALL-BY-DEFAULT. Every category renders expanded;
  an optional per-section chevron collapses one INDEPENDENTLY, persisted per-user
  (navCategoriesOpen), respected on every render; filtering force-opens. Tests rewritten.
- Category headers un-indented — flush-left with Overview/Docs (count + collapse chevron
  moved to the right), so the hierarchy reads clean.
- Whole-sidebar collapse to an icon RAIL (topbar toggle, persisted) with a HOVER flyout
  overlay that doesn't push content (classic rail+flyout); mobile keeps the left drawer.
- Drill-in / drill-back: clicking a product with sub-pages DRILLS the sidebar into its
  sub-nav (Overview · specifics · Settings/Status/Logs/Metrics) with a Back affordance;
  a single-page product navigates directly (replaces the inline sub-nav).
- Add-product panel: the broken "Enable" gate → pin/unpin (+ / −) to the sidebar; "In
  use" discovery from the real usage ledger; keeps "only pay for what you use".
- Bottom-left dedupe: ONE OrgSwitcher (org avatar + name; "All organizations" folded into
  its dropdown) — removed the redundant app-grid button. SidebarWallet = real live balance.
- Chat widget dockable as a PERMANENT right column (floating <-> docked, persisted); on
  phones it stays the floating bubble/sheet.
- Graceful 402 add-credits/top-up across the shared error primitives (States/BackendState).
- Mobile: overlay drawer + tap-scrim, >=44px targets, docked chat lg-only, no horizontal scroll.

Drive-by: align the stale projects.test.ts with the shipped /v1/iam routing (commit
1e17f0383 moved the code but not its test) — restores main to green.

Gate: tsc --noEmit 0 errors; vitest 2565/2565.
2026-07-16 20:30:33 -07:00
hanzo-dev 4fc98951c2 feat(shell): cleaner sidebar/nav UX — expand-all, drill-in, rail+flyout, pin/unpin, dockable chat, mobile
Sidebar + product-nav overhaul for console.hanzo.ai (embedded in the cloud release):

- nav-accordion: SINGLE-OPEN → EXPAND-ALL-BY-DEFAULT. Every category renders expanded;
  an optional per-section chevron collapses one INDEPENDENTLY, persisted per-user
  (navCategoriesOpen), respected on every render; filtering force-opens. Tests rewritten.
- Category headers un-indented — flush-left with Overview/Docs (count + collapse chevron
  moved to the right), so the hierarchy reads clean.
- Whole-sidebar collapse to an icon RAIL (topbar toggle, persisted) with a HOVER flyout
  overlay that doesn't push content (classic rail+flyout); mobile keeps the left drawer.
- Drill-in / drill-back: clicking a product with sub-pages DRILLS the sidebar into its
  sub-nav (Overview · specifics · Settings/Status/Logs/Metrics) with a Back affordance;
  a single-page product navigates directly (replaces the inline sub-nav).
- Add-product panel: the broken "Enable" gate → pin/unpin (+ / −) to the sidebar; "In
  use" discovery from the real usage ledger; keeps "only pay for what you use".
- Bottom-left dedupe: ONE OrgSwitcher (org avatar + name; "All organizations" folded into
  its dropdown) — removed the redundant app-grid button. SidebarWallet = real live balance.
- Chat widget dockable as a PERMANENT right column (floating <-> docked, persisted); on
  phones it stays the floating bubble/sheet.
- Graceful 402 add-credits/top-up across the shared error primitives (States/BackendState).
- Mobile: overlay drawer + tap-scrim, >=44px targets, docked chat lg-only, no horizontal scroll.

Drive-by: align the stale projects.test.ts with the shipped /v1/iam routing (commit
487a06a33 moved the code but not its test) — restores main to green.

Gate: tsc --noEmit 0 errors; vitest 2565/2565.
2026-07-16 20:30:33 -07:00
e760caf76a feat(embed): publish console static export as a versioned artifact image (#163)
Decomplect hanzoai/cloud's build: the console SPA static export (npm run
build:embed -> out/) is now a versioned immutable image (console-embed, /dist)
built by console's OWN CI, not re-run inside every cloud release. cloud will
FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console + COPY --from,
turning the ~15-min cache-busted npm+Next long pole into a registry pull.

- Dockerfile.embed: node build -> FROM scratch with /dist (fail-hard on a
  missing/placeholder bundle, same invariant cloud's console stage enforced;
  bakes the same public console.hanzo.ai analytics id).
- hanzo.yml: images: console-embed (hanzoai/ci builds + auto-mirrors to
  registry.hanzo.ai). Next.js server image stays in build-image.yml.
- cicd.yml: canonical hanzoai/ci caller.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:22:03 -07:00
69a6d8ec76 feat(embed): publish console static export as a versioned artifact image (#163)
Decomplect hanzoai/cloud's build: the console SPA static export (npm run
build:embed -> out/) is now a versioned immutable image (console-embed, /dist)
built by console's OWN CI, not re-run inside every cloud release. cloud will
FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console + COPY --from,
turning the ~15-min cache-busted npm+Next long pole into a registry pull.

- Dockerfile.embed: node build -> FROM scratch with /dist (fail-hard on a
  missing/placeholder bundle, same invariant cloud's console stage enforced;
  bakes the same public console.hanzo.ai analytics id).
- hanzo.yml: images: console-embed (hanzoai/ci builds + auto-mirrors to
  registry.hanzo.ai). Next.js server image stays in build-image.yml.
- cicd.yml: canonical hanzoai/ci caller.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:22:03 -07:00
hanzo-dev 1e17f0383b fix(console): route org IAM (projects, members) through /v1/iam
Projects + the member roster called the /org/iam BFF proxy, which the one-binary
(static-export) console cannot run — so on console.hanzo.ai they returned the SPA
shell and the Platform page showed "Request failed (HTTP 200)". Route them through
the main client at /v1/iam (the cloud IAM edge) instead, so ONE path serves both
the one-binary and split topologies, with CSRF + X-Org-Id + retry + envelope
unwrap for free. IAM's {status,msg,data,data2} IS our ApiResponse — no second
client (iamList/iamOne/iamMutate on client.ts).

tsc --noEmit clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-16 20:05:37 -07:00
hanzo-dev 487a06a33d fix(console): route org IAM (projects, members) through /v1/iam
Projects + the member roster called the /org/iam BFF proxy, which the one-binary
(static-export) console cannot run — so on console.hanzo.ai they returned the SPA
shell and the Platform page showed "Request failed (HTTP 200)". Route them through
the main client at /v1/iam (the cloud IAM edge) instead, so ONE path serves both
the one-binary and split topologies, with CSRF + X-Org-Id + retry + envelope
unwrap for free. IAM's {status,msg,data,data2} IS our ApiResponse — no second
client (iamList/iamOne/iamMutate on client.ts).

tsc --noEmit clean.
2026-07-16 20:05:37 -07:00
hanzo-dev 013d25b519 feat(console): built-in Contact page — support, sales, community, code, models
A responsive card grid (like the reference) so a user finds the right door without
leaving the console: Product Support (support@hanzo.ai), Contact Sales
(sales@hanzo.ai), X (@hanzoai), Discord (discord.gg/hanzo), LinkedIn (company/hanzoai),
GitHub (hanzoai), Hugging Face (hanzoai), More to Come. hanzo.ai is the hub; the AI
chat widget answers first, this is the human/channel fallback. Registered as the
`contact` product (Settings category). Pure presentational, mobile-stacked.
2026-07-16 19:50:38 -07:00
hanzo-dev c4d309e081 feat(console): built-in Contact page — support, sales, community, code, models
A responsive card grid (like the reference) so a user finds the right door without
leaving the console: Product Support (support@hanzo.ai), Contact Sales
(sales@hanzo.ai), X (@hanzoai), Discord (discord.gg/hanzo), LinkedIn (company/hanzoai),
GitHub (hanzoai), Hugging Face (hanzoai), More to Come. hanzo.ai is the hub; the AI
chat widget answers first, this is the human/channel fallback. Registered as the
`contact` product (Settings category). Pure presentational, mobile-stacked.
2026-07-16 19:50:38 -07:00
hanzo-dev 5fdd8b7ad8 test(e2e): credit flow authenticates as SuperAdmin via admin.hanzo.ai
Per the privilege-separation: z@ via console resolves to hanzo/z (non-admin), so
the credit gate refuses it. The funding spec now signs in through the admin.hanzo.ai
surface (admin-guard → owner==admin) and POSTs the credit + balance read there — the
only identity the gate admits. Robust login selectors for the hanzo.id portal form.
2026-07-16 19:30:52 -07:00
hanzo-dev 0c790c7812 test(e2e): credit flow authenticates as SuperAdmin via admin.hanzo.ai
Per the privilege-separation: z@ via console resolves to hanzo/z (non-admin), so
the credit gate refuses it. The funding spec now signs in through the admin.hanzo.ai
surface (admin-guard → owner==admin) and POSTs the credit + balance read there — the
only identity the gate admits. Robust login selectors for the hanzo.id portal form.
2026-07-16 19:30:52 -07:00
hanzo-dev cb153b3b9c test(e2e): repeatable proof — SuperAdmin funds the maxpower org
Live Playwright E2E: z@ signs in, credits the maxpower org via
/v1/admin/customers/:org/credit, and asserts the balance moves by exactly the
grant; a second spec verifies the funded member (davelorenzini) reaches /platform
with no dead "Could not load". Secrets come from env (HANZO_PASSWORD/DAVE_PASSWORD)
— never hardcoded; the credentialed specs skip without them, so CI stays green.

Surfaced a defect: unauthenticated credit returns 500, not 403 (core.Guard's
*zip.HTTPError 403 is re-wrapped as a generic api-error 500) — which is why the
console renders "Could not load" instead of an auth state. The fail-closed test
asserts rejection (no money moves) and flags the code for a follow-up fix.
2026-07-16 19:28:46 -07:00
hanzo-dev 9cec14c669 test(e2e): repeatable proof — SuperAdmin funds the maxpower org
Live Playwright E2E: z@ signs in, credits the maxpower org via
/v1/admin/customers/:org/credit, and asserts the balance moves by exactly the
grant; a second spec verifies the funded member (davelorenzini) reaches /platform
with no dead "Could not load". Secrets come from env (HANZO_PASSWORD/DAVE_PASSWORD)
— never hardcoded; the credentialed specs skip without them, so CI stays green.

Surfaced a defect: unauthenticated credit returns 500, not 403 (core.Guard's
*zip.HTTPError 403 is re-wrapped as a generic api-error 500) — which is why the
console renders "Could not load" instead of an auth state. The fail-closed test
asserts rejection (no money moves) and flags the code for a follow-up fix.
2026-07-16 19:28:46 -07:00
Hanzo AI 27dc1ff860 feat(console): Routing admin — config-as-Base auto-routing editor (v8.4.138)
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.
2026-07-16 17:53:23 -07:00
Hanzo AI c81ce07351 feat(console): Routing admin — config-as-Base auto-routing editor (v8.4.138)
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.
2026-07-16 17:53:23 -07:00
Hanzo AI f440bdc859 Add native Gitea Actions CI lane (.gitea/workflows/ci.yml) 2026-07-16 17:45:55 -07:00
Hanzo AI 56c698ed53 Add native Gitea Actions CI lane (.gitea/workflows/ci.yml) 2026-07-16 17:45:55 -07:00
b3e67226a8 feat(console): GPUs connected fleet with live heartbeat + Wallet top-up → pay.<brand> (v8.4.137) (#162)
GPUs — SEE your connected machines. The BYO connect fleet (`hanzo gpu connect`
boxes: home lab dbc/evo/spark) registers with a per-box heartbeat that ONLY
`GET /v1/fleet/workers` carries (`/v1/machines` folds them in without it,
`/v1/gpus` expands per-accelerator without it). New `FleetApi` (lib/api/fleet.ts)
reads it over the same-origin `/v1` bearer BFF (`fleet` allow-listed in
proxy-allow.ts; direct on the go:embed console), and a reused `ConnectedMachines`
section on the customer GPUs Overview + GPUs tab lists each box: name, accelerator
(arch), memory, online/offline (server-derived at a 90s heartbeat TTL), last
heartbeat, and a "Serving" badge when it runs hanzo-engine. Cloud GPU VMs
(`/v1/machines`, non-BYO) stay a separate list so a box is shown ONCE (DRY).
Honest states throughout; nothing fabricated.

Wallet — the sidebar chip shows the org balance (`/v1/billing/balance`, unchanged)
and "Top up" now LINKS to the brand's hosted payment page in a new tab. New
`config.payUrl` derives `pay.<brand>` from the brand billing host (white-label-safe:
a Lux console links to pay.lux.cloud, never pay.hanzo.ai). Display + link only — the
console hosts no card form and mints no credit.

Mobile — the section reuses the shared DataTable (scrolls inside its own overflow-x
box, never the page body) + flexWrap stat cards, the codebase's one responsive
mechanism. Verified live at 390x844 and 768x1024 (no horizontal body scroll).

Tests: +8 fleet (normalizer/helpers/route), +1 config payUrl (white-label), +3
responsive e2e (desktop render + phone/tablet no-overflow). Full suite 2544 green;
tsc clean; next build + build:embed (go:embed gate) green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 17:40:10 -07:00
9fa21511f9 feat(console): GPUs connected fleet with live heartbeat + Wallet top-up → pay.<brand> (v8.4.137) (#162)
GPUs — SEE your connected machines. The BYO connect fleet (`hanzo gpu connect`
boxes: home lab dbc/evo/spark) registers with a per-box heartbeat that ONLY
`GET /v1/fleet/workers` carries (`/v1/machines` folds them in without it,
`/v1/gpus` expands per-accelerator without it). New `FleetApi` (lib/api/fleet.ts)
reads it over the same-origin `/v1` bearer BFF (`fleet` allow-listed in
proxy-allow.ts; direct on the go:embed console), and a reused `ConnectedMachines`
section on the customer GPUs Overview + GPUs tab lists each box: name, accelerator
(arch), memory, online/offline (server-derived at a 90s heartbeat TTL), last
heartbeat, and a "Serving" badge when it runs hanzo-engine. Cloud GPU VMs
(`/v1/machines`, non-BYO) stay a separate list so a box is shown ONCE (DRY).
Honest states throughout; nothing fabricated.

Wallet — the sidebar chip shows the org balance (`/v1/billing/balance`, unchanged)
and "Top up" now LINKS to the brand's hosted payment page in a new tab. New
`config.payUrl` derives `pay.<brand>` from the brand billing host (white-label-safe:
a Lux console links to pay.lux.cloud, never pay.hanzo.ai). Display + link only — the
console hosts no card form and mints no credit.

Mobile — the section reuses the shared DataTable (scrolls inside its own overflow-x
box, never the page body) + flexWrap stat cards, the codebase's one responsive
mechanism. Verified live at 390x844 and 768x1024 (no horizontal body scroll).

Tests: +8 fleet (normalizer/helpers/route), +1 config payUrl (white-label), +3
responsive e2e (desktop render + phone/tablet no-overflow). Full suite 2544 green;
tsc clean; next build + build:embed (go:embed gate) green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 17:40:10 -07:00
4d8bec1154 feat(router): add Usage tab to the AI Usage & Training surface (reuse the one usage board) (#161)
Extend the existing `router` product (PR #160's org-user surface) so it covers ALL
org AI usage in addition to training + routing — without duplicating anything:

- New "Usage" tab in RouterModule (Overview · Usage · Policy) that renders the org's
  AI usage by REUSING the existing plumbing: native Hanzo usage (CloudUsageApi.overview
  → GET /v1/get-cloud-usages) beside imported connected-provider usage, via the same
  @hanzo/usage <UsagePanel>/<ConnectedUsage> the AI Metrics module already uses.
- Factor the shared usage-board body (fetch + honest async state for both planes) out
  of AiUsageModule into ONE component, src/components/products/usage/AiUsagePanels.tsx;
  AiUsageModule is now a thin adapter over it. One implementation, never a second copy.
- Relabel the `router` registry entry (id + route unchanged, still non-admin, category
  AI): label "AI Usage & Training", description mentions usage + training + routing; add
  a "Usage" subpage beside "Policy" in the level-2 sub-nav.
- Training status stays sourced from GET /v1/router/stats (Overview: retrain-gate line +
  quality proxy) and the opt-in training-contribution toggle stays in EXACTLY ONE place
  (RouterOverview) — not added to AiUsageModule or the shared body.

No admin/platform-operator surfaces touched. tsc clean; vitest 2535/2535; next build ✓;
build:embed ✓ (static export ready, 31 handlers restored).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 11:13:32 -07:00
9bd8b72aa0 feat(router): add Usage tab to the AI Usage & Training surface (reuse the one usage board) (#161)
Extend the existing `router` product (PR #160's org-user surface) so it covers ALL
org AI usage in addition to training + routing — without duplicating anything:

- New "Usage" tab in RouterModule (Overview · Usage · Policy) that renders the org's
  AI usage by REUSING the existing plumbing: native Hanzo usage (CloudUsageApi.overview
  → GET /v1/get-cloud-usages) beside imported connected-provider usage, via the same
  @hanzo/usage <UsagePanel>/<ConnectedUsage> the AI Metrics module already uses.
- Factor the shared usage-board body (fetch + honest async state for both planes) out
  of AiUsageModule into ONE component, src/components/products/usage/AiUsagePanels.tsx;
  AiUsageModule is now a thin adapter over it. One implementation, never a second copy.
- Relabel the `router` registry entry (id + route unchanged, still non-admin, category
  AI): label "AI Usage & Training", description mentions usage + training + routing; add
  a "Usage" subpage beside "Policy" in the level-2 sub-nav.
- Training status stays sourced from GET /v1/router/stats (Overview: retrain-gate line +
  quality proxy) and the opt-in training-contribution toggle stays in EXACTLY ONE place
  (RouterOverview) — not added to AiUsageModule or the shared body.

No admin/platform-operator surfaces touched. tsc clean; vitest 2535/2535; next build ✓;
build:embed ✓ (static export ready, 31 handlers restored).


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 11:13:32 -07:00
zandhanzo-dev 260deb6d79 console: render the limited-time plan promo on the pricing page
/v1/plans now carries promoPercent/promoUntil (commerce v1.48.6). The plan card
shows the effective (post-promo) price big, the list price struck through, and a
'50% off · limited time' badge — the discount is derived from the ONE plan source
via the pure, reusable lib/billing/promo (promoActive/effectiveMonthly/promoLabel),
no second discount source. Degrades cleanly to the list price when no promo is live.
2026-07-16 09:27:13 -07:00
zandhanzo-dev 42cc404307 console: render the limited-time plan promo on the pricing page
/v1/plans now carries promoPercent/promoUntil (commerce v1.48.6). The plan card
shows the effective (post-promo) price big, the list price struck through, and a
'50% off · limited time' badge — the discount is derived from the ONE plan source
via the pure, reusable lib/billing/promo (promoActive/effectiveMonthly/promoLabel),
no second discount source. Degrades cleanly to the list price when no promo is live.
2026-07-16 09:27:13 -07:00
zeekayandClaude Opus 4.8 87d3d84fc6 fix(session): post to the standard /v1/iam/oauth/token (drop the casdoor access_token alias)
iam2 serves ONE token endpoint — the RFC/discovery /oauth/token. No backwards-compat
access_token spelling; fix the client to the standard, not the backend to the client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:09:24 -07:00
zeekayandhanzo-dev d9398ed6c8 fix(session): post to the standard /v1/iam/oauth/token (drop the casdoor access_token alias)
iam2 serves ONE token endpoint — the RFC/discovery /oauth/token. No backwards-compat
access_token spelling; fix the client to the standard, not the backend to the client.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-16 09:09:24 -07:00
Hanzo AI 1597f34870 fix(git): null-guard useSearchParams in RepoBrowser — typecheck green
`useSearchParams()` is typed ReadonlyURLSearchParams | null, so the five raw
searchParams.get()/.toString() reads were `tsc --noEmit` errors (TS18047). CI only
runs `next build` (which tolerated them), so the standalone typecheck had drifted
red unnoticed. Fall back to empty params in one place; `npm run typecheck` is clean.
2026-07-16 01:06:25 -07:00
Hanzo AI e889982e28 fix(git): null-guard useSearchParams in RepoBrowser — typecheck green
`useSearchParams()` is typed ReadonlyURLSearchParams | null, so the five raw
searchParams.get()/.toString() reads were `tsc --noEmit` errors (TS18047). CI only
runs `next build` (which tolerated them), so the standalone typecheck had drifted
red unnoticed. Fall back to empty params in one place; `npm run typecheck` is clean.
2026-07-16 01:06:25 -07:00
Hanzo AI b64b4b6b7f fix(signup): drop dangling grantWelcomeCredit import — unbreak the v8.4.136 build
22ea663bf3 ("signup mints NO credit") deleted lib/server/billing-grant.ts and its
test but left app/auth/signup/route.ts still importing and awaiting grantWelcomeCredit,
so `next build` could not resolve '~/lib/server/billing-grant' and the v8.4.136 image
never shipped — the last two pushes to main (signup-no-credit and the #160 merge)
both failed to compile.

This finishes that commit's stated intent: the route no longer imports or calls the
grant, so a new account starts at $0 (credit comes only from an admin grant or the
user adding funds), and the header doc drops the now-false "$5 welcome grant" line.
2026-07-16 01:06:19 -07:00
Hanzo AI 70a354a3fd fix(signup): drop dangling grantWelcomeCredit import — unbreak the v8.4.136 build
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.
2026-07-16 01:06:19 -07:00
zandGitHub c9e72dd52a Merge pull request #160 from hanzoai/feat/router-admin-panel
feat(console): Router — routing observability dashboard over the reused policy editor
2026-07-16 00:30:35 -07:00
zandGitHub e40095f96e Merge pull request #160 from hanzoai/feat/router-admin-panel
feat(console): Router — routing observability dashboard over the reused policy editor
2026-07-16 00:30:35 -07:00
z 22ea663bf3 console: signup mints NO credit — $0 until granted/paid (kill auto welcome-grant)
Removed the server-side signup auto-grant: app/auth/signup/route.ts no longer
calls grantWelcomeCredit (→ commerce /v1/billing/grant-starter). Deleted the now-
dead lib/server/billing-grant.ts helper + its test (no residual). Together with
the session-bootstrap claimWelcomeGrantOnce removal (bd6e583cb), the console no
longer auto-grants any credit on signup or load. A new account starts at $0;
credit comes only from an admin grant (admin.hanzo.ai) or the user adding funds.
2026-07-16 00:28:38 -07:00
z 31ae930438 console: signup mints NO credit — $0 until granted/paid (kill auto welcome-grant)
Removed the server-side signup auto-grant: app/auth/signup/route.ts no longer
calls grantWelcomeCredit (→ commerce /v1/billing/grant-starter). Deleted the now-
dead lib/server/billing-grant.ts helper + its test (no residual). Together with
the session-bootstrap claimWelcomeGrantOnce removal (2fe3e197a), the console no
longer auto-grants any credit on signup or load. A new account starts at $0;
credit comes only from an admin grant (admin.hanzo.ai) or the user adding funds.
2026-07-16 00:28:38 -07:00
hanzo-dev 66abdb9d51 feat(console): Router — routing observability dashboard over the reused policy editor
Upgrade the `router` product from the single policy-editor route into a two-tab
Router dashboard: Overview (routing observability) + Policy (the reused λ/µ
editor). One editor, one place — no duplication.

Overview reads GET /v1/router/stats (org-scoped, RequirePrincipal) and renders:
(a) cost saved as a blended $/MTok PROXY — saved_pct + routed vs counterfactual
index + cumulative saved, honest "—" when priced_events==0; (b) quality proxy —
reward_rate + coverage, engine_share, avg_confidence, shadow_agreement only when
non-null; (c) per-task routed-model distribution (Donut) + by-model Donut +
throughput LineChart; (e) an opt-in training-contribution toggle wired to
GET/POST /v1/{get,update}-training-contribution (feature vectors only, optimistic
+ honest revert); (f) the last-retrain gate verdict line.

- New pure, node-tested logic (components/products/router/logic.ts, +15 tests):
  normalizeStats (partial/garbage → honest empty, cost stays null not $0),
  formatters (em-dash on absent), distributions by share, throughput UTC labels,
  retrainLine, range→hours. Reuses ui/Charts + ui/Metric + EmptyState/
  BackendStateCard — no chart dep, honest states throughout.
- Transport mirrors get-router-policy exactly: three heads added to
  next.config.mjs AI_V1_HEADS + app/ai/[...path] ALLOWED (v1/router/stats,
  v1/{get,update}-training-contribution) — the /ai user-bearer proxy; no new
  route handlers. go:embed hits cloud natively (honest BackendStateCard until the
  ai router-stats wave ships).
- Also completes the half-applied v8.4.137 rename that left origin/main
  non-building: RouterModule.tsx (the editor) → RouterPolicyEditor.tsx with its
  broken ~/lib/api/router-policy import + InferenceRouterModule export fixed; the
  registry entry repointed (id inference-router → router). No package.json bump.

tsc clean; vitest 2540/2540 (213 files, +15); next build ✓; build:embed ✓.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 00:28:20 -07:00
hanzo-dev 296961ab85 feat(console): Router — routing observability dashboard over the reused policy editor
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 ✓.
2026-07-16 00:28:20 -07:00
hanzo-dev 378ccbecd9 refactor(console): 'Inference Router' → 'Router' — drop the compound name (v8.4.137)
Product id 'router', label 'Router', RouterModule + lib/api/router.ts (renamed
from InferenceRouterModule + router-policy.ts). Same wire contract
(/v1/get-router-policy + /v1/update-router-policy). tsc clean, vitest green.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:02:25 -07:00
hanzo-dev 45d07c1235 refactor(console): 'Inference Router' → 'Router' — drop the compound name (v8.4.137)
Product id 'router', label 'Router', RouterModule + lib/api/router.ts (renamed
from InferenceRouterModule + router-policy.ts). Same wire contract
(/v1/get-router-policy + /v1/update-router-policy). tsc clean, vitest green.
2026-07-16 00:02:25 -07:00
hanzo-dev 7dfe32fa0e feat(console): Inference Router — per-org router policy editor (v8.4.136)
New AI-category product 'inference-router': org admins edit their own task →
model-pool prefer table + per-1k cost ceiling over the new hanzoai/ai
/v1/get-router-policy + /v1/update-router-policy (org-admin gated, self-scoped,
org > '*' > conf fold). v1-first transport: originGet/originPost + the two heads
added to AI_V1_HEADS dispatch and the /ai proxy ALLOWED set — no new route
handlers. tsc clean, vitest 2525/2525, next build + build:embed green.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-15 23:58:27 -07:00
hanzo-dev 85085d52e0 feat(console): Inference Router — per-org router policy editor (v8.4.136)
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.
2026-07-15 23:58:27 -07:00
z bd6e583cb6 console: boot session load times out (no infinite splash) + kill auto welcome-credit
Two fixes:
1) BOOT HANG (console.hanzo.ai splash): the session bootstrap awaited
   AccountApi.session() (→ /v1/iam/get-account) with NO timeout, so a degraded
   backend — the beego IAM proxy hop, a dead pruned route blocking 12s — left the
   splash pending forever (diagnosed live: get-account PENDING >25s, body empty).
   New reusable withTimeout() primitive caps the boot resolve at 8s → on timeout
   the visitor is anonymous and the sign-in card renders; a later reload/refresh
   resolves the real session. An app must never hand the browser to one request.
   (Root cause is the beego get-account proxy — fixed for real by the iam2 flip.)
2) AUTO-CREDIT: removed claimWelcomeGrantOnce() from the session bootstrap — the
   console auto-claimed the $5 welcome trial credit on every authenticated load.
   No more automatic credit; admin grants at admin.hanzo.ai only.

The 19 local tsc errors are a node_modules gap (@hanzo/capture ^0.1.0 not npm-installed
locally); CI resolves it. session.tsx + with-timeout.ts are clean.
2026-07-15 23:40:38 -07:00
z 2fe3e197a0 console: boot session load times out (no infinite splash) + kill auto welcome-credit
Two fixes:
1) BOOT HANG (console.hanzo.ai splash): the session bootstrap awaited
   AccountApi.session() (→ /v1/iam/get-account) with NO timeout, so a degraded
   backend — the beego IAM proxy hop, a dead pruned route blocking 12s — left the
   splash pending forever (diagnosed live: get-account PENDING >25s, body empty).
   New reusable withTimeout() primitive caps the boot resolve at 8s → on timeout
   the visitor is anonymous and the sign-in card renders; a later reload/refresh
   resolves the real session. An app must never hand the browser to one request.
   (Root cause is the beego get-account proxy — fixed for real by the iam2 flip.)
2) AUTO-CREDIT: removed claimWelcomeGrantOnce() from the session bootstrap — the
   console auto-claimed the $5 welcome trial credit on every authenticated load.
   No more automatic credit; admin grants at admin.hanzo.ai only.

The 19 local tsc errors are a node_modules gap (@hanzo/capture ^0.1.0 not npm-installed
locally); CI resolves it. session.tsx + with-timeout.ts are clean.
2026-07-15 23:40:38 -07:00
hanzo-dev fbe44a937f feat(console): native Git repo-browser over /v1/git (list → tree/blob → commits)
Upgrade the git product from an external git.hanzo.ai link-out to a native,
in-console gitea-parity READ surface over the real per-org /v1/git subsystem
(cloud clients/git), org-scoped SERVER-SIDE (no org param leaves the browser):

  /git        → repos list (name · description · default branch · size · clone)
  /git/:name  → repo browser: tree/blob (line numbers + image preview + README
                auto-render), branch/tag selector, commit history, clone URLs;
                ref/path/view/tab live in the URL query (shareable deep links).

- src/lib/api/git.ts — typed client + defensive normalizers over the documented
  contract: GET /v1/git/repos, /repos/:name, /repos/:name/{refs,tree,blob,
  commits,readme} (ref+path ride as QUERY params — unambiguous for slashed
  branches, matching the backend's own ?ref= convention). repoView list/detail
  are LIVE; browse endpoints degrade to honest 'not available' until served.
- src/components/products/git/ — RepoList, RepoBrowser, CodeView, CommitsView,
  parts, pure logic + GitModule; honest loading/empty/BackendStateCard states,
  never a fabricated repo/tree/commit.
- src/lib/server/proxy-allow.ts — 'git' added to CLOUD_HEADS (the /v1 user-bearer
  BFF admits the git head, org from the token owner; cookie-only call 403s).
- registry.tsx — git entry upgraded external→module (brand-agnostic native surface).

Tests: git/logic.test.ts (view logic), api/git.test.ts (normalizers + query URL),
git/routes.test.ts (/git + /git/:name routing). tsc --noEmit 0 new errors vs
baseline; vitest 68/68 green (incl. proxy-allow).
2026-07-15 22:08:39 -07:00
hanzo-dev 1a12c55a1e feat(console): native Git repo-browser over /v1/git (list → tree/blob → commits)
Upgrade the git product from an external git.hanzo.ai link-out to a native,
in-console gitea-parity READ surface over the real per-org /v1/git subsystem
(cloud clients/git), org-scoped SERVER-SIDE (no org param leaves the browser):

  /git        → repos list (name · description · default branch · size · clone)
  /git/:name  → repo browser: tree/blob (line numbers + image preview + README
                auto-render), branch/tag selector, commit history, clone URLs;
                ref/path/view/tab live in the URL query (shareable deep links).

- src/lib/api/git.ts — typed client + defensive normalizers over the documented
  contract: GET /v1/git/repos, /repos/:name, /repos/:name/{refs,tree,blob,
  commits,readme} (ref+path ride as QUERY params — unambiguous for slashed
  branches, matching the backend's own ?ref= convention). repoView list/detail
  are LIVE; browse endpoints degrade to honest 'not available' until served.
- src/components/products/git/ — RepoList, RepoBrowser, CodeView, CommitsView,
  parts, pure logic + GitModule; honest loading/empty/BackendStateCard states,
  never a fabricated repo/tree/commit.
- src/lib/server/proxy-allow.ts — 'git' added to CLOUD_HEADS (the /v1 user-bearer
  BFF admits the git head, org from the token owner; cookie-only call 403s).
- registry.tsx — git entry upgraded external→module (brand-agnostic native surface).

Tests: git/logic.test.ts (view logic), api/git.test.ts (normalizers + query URL),
git/routes.test.ts (/git + /git/:name routing). tsc --noEmit 0 new errors vs
baseline; vitest 68/68 green (incl. proxy-allow).
2026-07-15 22:08:39 -07:00
a c742e107e5 test(e2e): provider-billing fixture owner 'hanzo' -> 'admin' — super-admin gate is isSuperAdminOwner now (spec-only, no app change) 2026-07-15 22:02:00 -07:00
a 4c4dd5d88e test(e2e): provider-billing fixture owner 'hanzo' -> 'admin' — super-admin gate is isSuperAdminOwner now (spec-only, no app change) 2026-07-15 22:02:00 -07:00
Hanzo AI dd2c96fe14 feat(console): AI Economics admin board — model mix, margin, eval→training loop
New admin.hanzo.ai module (`admin: true`, category AI) answering the KEY
question "how many requests hit each model" plus unit economics and the
eval→training flywheel. Composes the existing admin reads — never forks them:

- Model mix: requests / share% / tokens / cost per (provider, model), folded
  from /v1/admin/usage/funding over a 24h/7d/30d window (share donut + table +
  totals row).
- Profitability: upstream cost vs revenue vs gross margin + runway
  (/v1/admin/finance) and per-provider credit (/v1/admin/providers/credit).
- Training data: the HONEST collection card — the metering ledger (datastore)
  holds no prompt/completion content and nothing harvests traffic; the only
  training data is the user-curated eval dataset registry (live counts).
- Evals: recent LLM-as-judge runs (dataset, evaluator, score, when).
- Router loop: how eval scores fold into the enso router (offline ridge +
  online LinUCB), with the honest "per-request reward not yet persisted".

Pure rollups in src/lib/api/ai-economics.ts (foldModelMix / topModelShares /
datasetStats / marginTone …) with 19 vitest cases; one route-mocked Playwright
spec (fable-5 75% mix, 62% margin, honest training card, fail-closed gate).

v8.4.135
2026-07-15 21:48:58 -07:00
Hanzo AI 5a2a6267e2 feat(console): AI Economics admin board — model mix, margin, eval→training loop
New admin.hanzo.ai module (`admin: true`, category AI) answering the KEY
question "how many requests hit each model" plus unit economics and the
eval→training flywheel. Composes the existing admin reads — never forks them:

- Model mix: requests / share% / tokens / cost per (provider, model), folded
  from /v1/admin/usage/funding over a 24h/7d/30d window (share donut + table +
  totals row).
- Profitability: upstream cost vs revenue vs gross margin + runway
  (/v1/admin/finance) and per-provider credit (/v1/admin/providers/credit).
- Training data: the HONEST collection card — the metering ledger (datastore)
  holds no prompt/completion content and nothing harvests traffic; the only
  training data is the user-curated eval dataset registry (live counts).
- Evals: recent LLM-as-judge runs (dataset, evaluator, score, when).
- Router loop: how eval scores fold into the enso router (offline ridge +
  online LinUCB), with the honest "per-request reward not yet persisted".

Pure rollups in src/lib/api/ai-economics.ts (foldModelMix / topModelShares /
datasetStats / marginTone …) with 19 vitest cases; one route-mocked Playwright
spec (fable-5 75% mix, 62% margin, honest training card, fail-closed gate).

v8.4.135
2026-07-15 21:48:58 -07:00
zeekayandClaude Opus 4.8 2fdb6b87c6 fix(console): white-label the browser-tab title client-side (BrandTitle)
The console ships into hanzoai/cloud as a Next.js STATIC EXPORT: generateMetadata
runs at build time with the default host, so the exported <title> is baked to
"Hanzo Cloud Console" for every host. The cloud serving layer rewrites it per
Host on first paint, but Next re-applies the baked metadata title on hydration,
reverting a Lux/Zoo tab to "Hanzo Cloud Console" — a white-label violation
(the visible shell was already client-branded; only the tab title leaked).

BrandTitle is a client net (mirrors ChunkGuard) that sets document.title from
window.location via the existing `branding.name` and re-affirms it through a head
MutationObserver, defeating the baked-metadata re-application. On the dynamic
standalone app the SSR title is already host-correct, so it is a no-op there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:52:10 -07:00
zeekayandhanzo-dev d19eba0f21 fix(console): white-label the browser-tab title client-side (BrandTitle)
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>
2026-07-15 18:52:10 -07:00
Hanzo AI b97e2ba263 feat(console): interactive training surface (Tinker-style engine plane)
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.
2026-07-15 18:18:34 -07:00
Hanzo AI 45748ea753 feat(console): interactive training surface (Tinker-style engine plane)
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.
2026-07-15 18:18:34 -07:00
Hanzo AI b9f570db74 feat(console): enforce /v1-first API paths across every same-origin namespace
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.
2026-07-15 17:07:17 -07:00
Hanzo AI d5e4eb829b feat(console): enforce /v1-first API paths across every same-origin namespace
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.
2026-07-15 17:07:17 -07:00
hanzo-dev b8fb513a25 feat(billing): payer chain UI — attach an account to an org or project, reorder it
Billing Center gains an Accounts tab: which account pays, and in what order.
Attach a billing account to the ORGANIZATION or to ONE PROJECT, see the ordered
chain, reorder it, detach it.

The chain is READ from commerce's own resolver, never recomputed here. Commerce
resolves the payer at charge time and is the one source of truth for who pays, so
the page writes a PRIORITY and re-reads the chain — it never predicts the order.
A local re-sort would be a second, divergent answer to the one question that must
have exactly one, and would start lying the moment commerce's ordering rule moves.

Attach and reorder are the SAME call: a binding's row id is deterministic in
(holderKind, holderId, accountId), so re-asserting a pair at a new priority
updates that one row. Reordering is not a second verb.

Priorities are anchored on the anchor's fixed 0, so a link promoted above the
derived subject gets a negative priority — how an explicit binding preempts the
anchor. An attach always lands at the END, never silently taking over today's
payer.

The holder is derived, never asserted. Whose chain an account attaches to is a
payer decision, so the browser names a holder KIND and the /billing proxy derives
holderId from the session — the same pin scopedBillingSearch already applies to
the billing subject, extended to the one write body that names a holder. A body
with no holderKind is untouched, so a spend-alert (whose scope IS a project name)
passes through unchanged.

The project switcher additionally sends X-Act-As-Project: an intent and an
assertion must not share a name. The intent is a request a validating boundary
checks against the caller's scope set before minting the authoritative
X-Project-Id; the existing X-Project-Id stamp is unchanged and stays advisory
(the gateway strips it, the /v1 /vm /commerce proxies drop it per the RED MED-1
eval-isolation invariant). The intent is consumed by a boundary and never
forwarded to a backend.

Reuses @hanzo/gui + the shared BackendState/PageHeader/EmptyState primitives; no
component forked, no brand literal (white-label safe). Routes are /v1 only.

Tests: 2499 green (208 files), tsc clean, next build compiles. Each guard proven
non-vacuous by neutering it and watching it fail — including one order test that
passed under neuter until its fixture was fixed to be priority-unsorted.
2026-07-15 13:13:56 -07:00
hanzo-dev 4767e432c5 feat(billing): payer chain UI — attach an account to an org or project, reorder it
Billing Center gains an Accounts tab: which account pays, and in what order.
Attach a billing account to the ORGANIZATION or to ONE PROJECT, see the ordered
chain, reorder it, detach it.

The chain is READ from commerce's own resolver, never recomputed here. Commerce
resolves the payer at charge time and is the one source of truth for who pays, so
the page writes a PRIORITY and re-reads the chain — it never predicts the order.
A local re-sort would be a second, divergent answer to the one question that must
have exactly one, and would start lying the moment commerce's ordering rule moves.

Attach and reorder are the SAME call: a binding's row id is deterministic in
(holderKind, holderId, accountId), so re-asserting a pair at a new priority
updates that one row. Reordering is not a second verb.

Priorities are anchored on the anchor's fixed 0, so a link promoted above the
derived subject gets a negative priority — how an explicit binding preempts the
anchor. An attach always lands at the END, never silently taking over today's
payer.

The holder is derived, never asserted. Whose chain an account attaches to is a
payer decision, so the browser names a holder KIND and the /billing proxy derives
holderId from the session — the same pin scopedBillingSearch already applies to
the billing subject, extended to the one write body that names a holder. A body
with no holderKind is untouched, so a spend-alert (whose scope IS a project name)
passes through unchanged.

The project switcher additionally sends X-Act-As-Project: an intent and an
assertion must not share a name. The intent is a request a validating boundary
checks against the caller's scope set before minting the authoritative
X-Project-Id; the existing X-Project-Id stamp is unchanged and stays advisory
(the gateway strips it, the /v1 /vm /commerce proxies drop it per the RED MED-1
eval-isolation invariant). The intent is consumed by a boundary and never
forwarded to a backend.

Reuses @hanzo/gui + the shared BackendState/PageHeader/EmptyState primitives; no
component forked, no brand literal (white-label safe). Routes are /v1 only.

Tests: 2499 green (208 files), tsc clean, next build compiles. Each guard proven
non-vacuous by neutering it and watching it fail — including one order test that
passed under neuter until its fixture was fixed to be priority-unsorted.
2026-07-15 13:13:56 -07:00
Hanzo AIandhanzo-dev ae3a9418dc feat(admin): Launch Control — waitlist services board + pending-users queue
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).
2026-07-15 11:33:53 -07:00
Hanzo AIandhanzo-dev 184d999ca3 feat(admin): Launch Control — waitlist services board + pending-users queue
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).
2026-07-15 11:33:53 -07:00
hanzo-dev 06d563b32e feat(console): GPUs page — Connect (BYO) vs Deploy (cloud) + BYO/Cloud badges
The customer GPUs page now makes both paths to add a GPU explicit and equal:
- "Connect GPU" opens a drawer with the ready-to-copy `hanzo login && hanzo gpu
  connect` (+ `--serve-engine` to also serve models) and the Desktop toggle note.
- "Deploy GPU" is the existing Visor/DOKS launch flow (relabeled from "Launch GPU").
Machine rows gain a monochrome Source badge distinguishing BYO (provider=byo) from
Cloud accelerators; the empty state offers both actions.

- gpus/ConnectGpuDrawer.tsx: the BYO connect drawer (copy-to-run commands).
- gpus/CustomerGpus.tsx: Connect+Deploy header actions, ProviderBadge, Source column.
- e2e/gpus-connect.spec.ts: mocked-network screenshot proof (BYO GB10 + cloud H100).
2026-07-15 11:25:21 -07:00
hanzo-dev 6adee31279 feat(console): GPUs page — Connect (BYO) vs Deploy (cloud) + BYO/Cloud badges
The customer GPUs page now makes both paths to add a GPU explicit and equal:
- "Connect GPU" opens a drawer with the ready-to-copy `hanzo login && hanzo gpu
  connect` (+ `--serve-engine` to also serve models) and the Desktop toggle note.
- "Deploy GPU" is the existing Visor/DOKS launch flow (relabeled from "Launch GPU").
Machine rows gain a monochrome Source badge distinguishing BYO (provider=byo) from
Cloud accelerators; the empty state offers both actions.

- gpus/ConnectGpuDrawer.tsx: the BYO connect drawer (copy-to-run commands).
- gpus/CustomerGpus.tsx: Connect+Deploy header actions, ProviderBadge, Source column.
- e2e/gpus-connect.spec.ts: mocked-network screenshot proof (BYO GB10 + cloud H100).
2026-07-15 11:25:21 -07:00
Hanzo AI 0468e5d5f3 feat(console): Connections page + import connected usage into AI Metrics
- 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).
2026-07-15 10:39:23 -07:00
Hanzo AI 420ea5ee36 feat(console): Connections page + import connected usage into AI Metrics
- 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).
2026-07-15 10:39:23 -07:00
hanzo-dev 84bea92dfa refactor(signin): social buttons are dynamic-only — kill the hardcoded fallback
ONE source of truth: render the social row purely from IAM get-app-login (the
app real provider list). Drop FALLBACK_PROVIDERS — a hardcoded set drifts from
IAM and can show a button IAM cannot honor. Empty list (loading or IAM
unreachable) => no social buttons; email/password stays. signInProvidersOf now
returns a plain array (one type, no null branch).
2026-07-15 09:51:51 -07:00
hanzo-dev 1ace60647f refactor(signin): social buttons are dynamic-only — kill the hardcoded fallback
ONE source of truth: render the social row purely from IAM get-app-login (the
app real provider list). Drop FALLBACK_PROVIDERS — a hardcoded set drifts from
IAM and can show a button IAM cannot honor. Empty list (loading or IAM
unreachable) => no social buttons; email/password stays. signInProvidersOf now
returns a plain array (one type, no null branch).
2026-07-15 09:51:51 -07:00
Hanzo AI 467605c2fd fix(overview): tenant landing Overview reads get-cloud-usages, never "Access required"
The landing `overview` living board's PRIMARY source is the super-admin
`/v1/admin/overview` god-view (gated to the reserved `admin` org), which 403s a
non-super-admin — even an org's OWN admin (e.g. `hanzo/z`, Admin). It is meant to
fall back to the tenant usage ledger so it is never blank, but the fallback read
`UsageApi.overview` -> the `/billing/usage` Next BFF proxy, whose route handler is
PRUNED from the go:embed console that serves console.hanzo.ai. In the embed that
fallback failed and surfaced the honest-but-wrong "Access required" card instead of
the org's real spend.

Repoint the fallback at cloud's NATIVE `GET /v1/get-cloud-usages` (via the existing
`CloudUsageApi`) -- the SAME source the AI Metrics board (`AiUsageModule`) already
reads. It is org-scoped server-side and cookie-authed, so a non-super-admin gets a
200 for THEIR org and the board renders real usage; being cloud-native it also works
in the embed. A 403 on the admin aggregate still falls through here silently (the
isSuperAdmin gate already skips the aggregate for tenants).

DRY: the ONE `fromCloudUsage` adapter now accepts the canonical `@hanzo/usage`
`CloudUsageOverview` (what get-cloud-usages returns), reading the console-only
`byStatus` slice as an optional extension -- so BOTH usage sources (the cloud
aggregate and the `/billing` ledger rollup) flow through it unchanged.

tsc clean; vitest 2435/2435; build:embed green.
2026-07-15 09:48:02 -07:00
Hanzo AI cdd807aced fix(overview): tenant landing Overview reads get-cloud-usages, never "Access required"
The landing `overview` living board's PRIMARY source is the super-admin
`/v1/admin/overview` god-view (gated to the reserved `admin` org), which 403s a
non-super-admin — even an org's OWN admin (e.g. `hanzo/z`, Admin). It is meant to
fall back to the tenant usage ledger so it is never blank, but the fallback read
`UsageApi.overview` -> the `/billing/usage` Next BFF proxy, whose route handler is
PRUNED from the go:embed console that serves console.hanzo.ai. In the embed that
fallback failed and surfaced the honest-but-wrong "Access required" card instead of
the org's real spend.

Repoint the fallback at cloud's NATIVE `GET /v1/get-cloud-usages` (via the existing
`CloudUsageApi`) -- the SAME source the AI Metrics board (`AiUsageModule`) already
reads. It is org-scoped server-side and cookie-authed, so a non-super-admin gets a
200 for THEIR org and the board renders real usage; being cloud-native it also works
in the embed. A 403 on the admin aggregate still falls through here silently (the
isSuperAdmin gate already skips the aggregate for tenants).

DRY: the ONE `fromCloudUsage` adapter now accepts the canonical `@hanzo/usage`
`CloudUsageOverview` (what get-cloud-usages returns), reading the console-only
`byStatus` slice as an optional extension -- so BOTH usage sources (the cloud
aggregate and the `/billing` ledger rollup) flow through it unchanged.

tsc clean; vitest 2435/2435; build:embed green.
2026-07-15 09:48:02 -07:00
zandGitHub a132fd0c66 feat(analytics): instrument console with @hanzo/capture (v8.4.133)
Shared @hanzo/capture analytics client wired through AnalyticsProvider + AnalyticsBridge; sign-in/signup funnel, api-keys, plans, projects, paas create events. Rebased onto main (post QR-drop, post link-manager); version bumped to v8.4.133 so the main-push image build publishes a fresh semver tag. tsc --noEmit clean; all touched components are client components.
2026-07-15 09:44:53 -07:00
zandGitHub 18dea7a39c feat(analytics): instrument console with @hanzo/capture (v8.4.133)
Shared @hanzo/capture analytics client wired through AnalyticsProvider + AnalyticsBridge; sign-in/signup funnel, api-keys, plans, projects, paas create events. Rebased onto main (post QR-drop, post link-manager); version bumped to v8.4.133 so the main-push image build publishes a fresh semver tag. tsc --noEmit clean; all touched components are client components.
2026-07-15 09:44:53 -07:00
hanzo-dev cce2646d30 Merge feat/link-manager: unified AI login manager — Machines tab over /v1/links (v8.4.132) 2026-07-15 03:33:15 -07:00
hanzo-dev c3b9018c42 Merge feat/link-manager: unified AI login manager — Machines tab over /v1/links (v8.4.132) 2026-07-15 03:33:15 -07:00
hanzo-dev 6f5bced5b9 feat(ai-accounts): unified AI login manager — Machines tab over /v1/links (v8.4.132)
A new Machines tab on the AI Accounts product: every provider account signed into
Claude Code / Codex / the CLI across your machines, grouped by device, with each
account's live usage (session/weekly rate limits, tokens, spend), how it BILLS (a
subscription bills your plan; an api key bills credits), the device's active
sessions, and a per-account / per-device LOG OUT that revokes the account and stops
its running sessions — plus the redundancy route plan (subscriptions first, then the
metered API backstop) across your accounts.

- lib/api/links.ts — the /v1/links client (cloudProxyV1Url; defensive normalizers);
  'links' added to CLOUD_HEADS.
- ai-accounts/links-logic.ts — pure labels/tones/formatting + the KPI roll-up.
- ai-accounts/MachinesTab.tsx — the dashboard (honest loading/error/empty states,
  @hanzo/gui v5 shorthands, mobile-responsive flexWrap rows).
- AIAccountsModule + registry: the Machines tab + subpage.

Every number is real from /v1/links or an honest '—'; nothing fabricated. tsc clean;
vitest 2435/2435 (+13); build:embed green. Reachability is the post-deploy gate
(same /v1 BFF / go:embed contract as agents).
2026-07-15 03:30:42 -07:00
hanzo-dev 5b482c48f7 feat(ai-accounts): unified AI login manager — Machines tab over /v1/links (v8.4.132)
A new Machines tab on the AI Accounts product: every provider account signed into
agent / Codex / the CLI across your machines, grouped by device, with each
account's live usage (session/weekly rate limits, tokens, spend), how it BILLS (a
subscription bills your plan; an api key bills credits), the device's active
sessions, and a per-account / per-device LOG OUT that revokes the account and stops
its running sessions — plus the redundancy route plan (subscriptions first, then the
metered API backstop) across your accounts.

- lib/api/links.ts — the /v1/links client (cloudProxyV1Url; defensive normalizers);
  'links' added to CLOUD_HEADS.
- ai-accounts/links-logic.ts — pure labels/tones/formatting + the KPI roll-up.
- ai-accounts/MachinesTab.tsx — the dashboard (honest loading/error/empty states,
  @hanzo/gui v5 shorthands, mobile-responsive flexWrap rows).
- AIAccountsModule + registry: the Machines tab + subpage.

Every number is real from /v1/links or an honest '—'; nothing fabricated. tsc clean;
vitest 2435/2435 (+13); build:embed green. Reachability is the post-deploy gate
(same /v1 BFF / go:embed contract as agents).
2026-07-15 03:30:42 -07:00
hanzo-dev d8e898f0a6 Merge feat/github-app-sync: GitHub App repositories view
Connect the Hanzo GitHub App (existing connect flow) -> a connected GitHub card
opens a repositories view listing the org's granted repos with per-repo import +
live sync status (Not imported / Importing / Synced / Conflict) and import-all.
GitHubApi over the same-origin /v1/integrations head.
2026-07-15 02:20:45 -07:00
hanzo-dev 9a9c190905 Merge feat/github-app-sync: GitHub App repositories view
Connect the Hanzo GitHub App (existing connect flow) -> a connected GitHub card
opens a repositories view listing the org's granted repos with per-repo import +
live sync status (Not imported / Importing / Synced / Conflict) and import-all.
GitHubApi over the same-origin /v1/integrations head.
2026-07-15 02:20:45 -07:00
hanzo-dev fa88ea4c64 Merge feat/mission-control: mobile-first swipeable mission-control cockpit
A new AI product (Mission Control) over the live agent-session plane
(/v1/agents/sessions): swipe one live terminal per agent session, drive it
(pause/resume/stop/message), and roster the run-targets (#48) with which
sessions run where. Server-side org isolation; pure logic unit-tested; the
catch-all renders it. Ships to console.hanzo.ai via the cloud embed of console@main.
2026-07-15 02:08:52 -07:00
hanzo-dev ef196f2c81 Merge feat/mission-control: mobile-first swipeable mission-control cockpit
A new AI product (Mission Control) over the live agent-session plane
(/v1/agents/sessions): swipe one live terminal per agent session, drive it
(pause/resume/stop/message), and roster the run-targets (#48) with which
sessions run where. Server-side org isolation; pure logic unit-tested; the
catch-all renders it. Ships to console.hanzo.ai via the cloud embed of console@main.
2026-07-15 02:08:52 -07:00
hanzo-dev 62cb43944d mission-control: mobile-first swipeable terminal-per-agent + devices
A new AI-category product over the live agent-session plane
(/v1/agents/sessions): one swipe card per session — a live terminal (the
event stream tailed over SSE, poll backstop), a status pill, the machine/
repo/agent it runs on, and the plane's control ops (pause/resume/stop/
message). A Devices view rosters the run-targets (#48) unioned with the
hosts live sessions report, showing which sessions run where, with a
link-a-computer form. Org isolation is server-side (bearer owner).

MissionControlApi over the same-origin /v1 (agents head already allow-listed);
pure logic unit-tested (deviceRoster union/no-double-count, eventLine,
mergeEvents, normalizers).
2026-07-15 02:07:59 -07:00
hanzo-dev b85fa49ae2 mission-control: mobile-first swipeable terminal-per-agent + devices
A new AI-category product over the live agent-session plane
(/v1/agents/sessions): one swipe card per session — a live terminal (the
event stream tailed over SSE, poll backstop), a status pill, the machine/
repo/agent it runs on, and the plane's control ops (pause/resume/stop/
message). A Devices view rosters the run-targets (#48) unioned with the
hosts live sessions report, showing which sessions run where, with a
link-a-computer form. Org isolation is server-side (bearer owner).

MissionControlApi over the same-origin /v1 (agents head already allow-listed);
pure logic unit-tested (deviceRoster union/no-double-count, eventLine,
mergeEvents, normalizers).
2026-07-15 02:07:59 -07:00
hanzo-dev 63c45abda0 GitHub App repos: connect -> import repositories into git.hanzo.ai + sync status
OrgIntegrationsModule: a connected GitHub card opens a repositories view
(GitHubReposView) listing the org's granted repos with per-repo import + live
sync status (Not imported / Importing / Synced / Conflict) and an import-all
action. GitHubApi (listRepos/importRepos) over the same-origin /v1/integrations
head; pure logic + normalizers unit-tested; StatusTag learns synced/conflict/importing.
2026-07-15 02:04:37 -07:00
hanzo-dev 4145a92ae1 GitHub App repos: connect -> import repositories into git.hanzo.ai + sync status
OrgIntegrationsModule: a connected GitHub card opens a repositories view
(GitHubReposView) listing the org's granted repos with per-repo import + live
sync status (Not imported / Importing / Synced / Conflict) and an import-all
action. GitHubApi (listRepos/importRepos) over the same-origin /v1/integrations
head; pure logic + normalizers unit-tested; StatusTag learns synced/conflict/importing.
2026-07-15 02:04:37 -07:00
Hanzo AI dceeecbafb console: drop QR sign-in (RFC 8628 device flow) — didn't work on the consumer host
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.
2026-07-15 01:57:24 -07:00
Hanzo AI 2b2ec016bb console: drop QR sign-in (RFC 8628 device flow) — didn't work on the consumer host
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.
2026-07-15 01:57:24 -07:00
hanzo-dev a571f9f4ae docs(LLM): TODO — embed Hanzo Social dashboard via @hanzo/ui@8.0.2 product/social (follow-up) 2026-07-15 00:05:40 -07:00
hanzo-dev 08d207a26d docs(LLM): TODO — embed Hanzo Social dashboard via @hanzo/ui@8.0.2 product/social (follow-up) 2026-07-15 00:05:40 -07:00
hanzo-dev 34c4161048 fix(signin): social buttons render from IAM's live provider list — kill the GitLab dead-end
The embedded login hardcoded its social buttons, which drifted from IAM:
'Continue with GitLab' hinted provider-gitlab, a provider the hanzo-cloud app
does not have — IAM could not auto-advance and stranded the user on the
hanzo.id login page with no GitLab option (the reported 'redirects to hanzo.id
and doesn't have social options' dead-end). GitHub/Google were verified live to
auto-advance clean; GitLab reproduced the strand headless.

ONE source of truth: SignInForm now renders its social row from
get-app-login's real provider list (CORS-open to the console origin, verified),
mapped per provider TYPE (GitHub/Google/GitLab/Apple/Web3) — an unknown type
renders nothing, and a provider IAM can't honor never gets a button. Fallback
while loading / on a failed read = the set proven live (GitHub+Google+Wallet),
never GitLab. Apple (live on the app) now shows. +4 tests pin the normalizer
and the no-GitLab fallback.

Claude-Session: https://claude.ai/code/session_01XptqW83ZLpqyGBENc1wAQz
2026-07-14 22:49:49 -07:00
hanzo-dev b2ef8e54c1 fix(signin): social buttons render from IAM's live provider list — kill the GitLab dead-end
The embedded login hardcoded its social buttons, which drifted from IAM:
'Continue with GitLab' hinted provider-gitlab, a provider the hanzo-cloud app
does not have — IAM could not auto-advance and stranded the user on the
hanzo.id login page with no GitLab option (the reported 'redirects to hanzo.id
and doesn't have social options' dead-end). GitHub/Google were verified live to
auto-advance clean; GitLab reproduced the strand headless.

ONE source of truth: SignInForm now renders its social row from
get-app-login's real provider list (CORS-open to the console origin, verified),
mapped per provider TYPE (GitHub/Google/GitLab/Apple/Web3) — an unknown type
renders nothing, and a provider IAM can't honor never gets a button. Fallback
while loading / on a failed read = the set proven live (GitHub+Google+Wallet),
never GitLab. Apple (live on the app) now shows. +4 tests pin the normalizer
and the no-GitLab fallback.
2026-07-14 22:49:49 -07:00
hanzo-dev 166f927c66 Merge affiliates console: rewards chart + referral links + leaderboard + set-rate
Extend the affiliate dashboard onto cloud's new /v1/affiliates surface: per-period
share LineChart + per-referral aggregate, referral-link manager with click/signup/
conversion stats, privacy-preserving leaderboard (opt-in handle + your own rank,
never an org identity), and a SuperAdmin set-rate action. tsc clean, 2394 vitest
pass, next build green.
2026-07-14 22:32:32 -07:00
hanzo-dev d5455fa3fa Merge affiliates console: rewards chart + referral links + leaderboard + set-rate
Extend the affiliate dashboard onto cloud's new /v1/affiliates surface: per-period
share LineChart + per-referral aggregate, referral-link manager with click/signup/
conversion stats, privacy-preserving leaderboard (opt-in handle + your own rank,
never an org identity), and a SuperAdmin set-rate action. tsc clean, 2394 vitest
pass, next build green.
2026-07-14 22:32:32 -07:00
hanzo-dev 848f7e2f90 affiliates console: rewards chart + referral links + leaderboard + admin set-rate
Extend the affiliate dashboard onto the new cloud /v1/affiliates surface:
- Rewards panel: per-period share LineChart + per-direct-referral aggregate
  contribution + the profit-share basis (your rate of Hanzo's margin).
- Referral links panel: list links with click/signup/conversion stats, copy,
  and create (label + auto-minted code), respecting the per-affiliate cap.
- Leaderboard panel: opt-in handle + your own rank (always visible) + aggregate
  only; never another org's identity.
- Admin: a Set-rate action (percent -> bps, capped at 93%) beside approve/payout.
- Wire a best-effort click ping into the ?aff capture (once per code/session).

API: AffiliatesApi.{earnings,links,createLink,setHandle,leaderboard,click} +
marginBps/handle on the overview; AdminAffiliatesApi.setRate. logic.{monthLabel,
percentToBps}. Defensive normalizers throughout.

Tests: normalizer + exact-path transport tests for every new call; monthLabel +
percentToBps units. tsc clean, 2394 vitest pass, next build green.
2026-07-14 22:30:28 -07:00
hanzo-dev 775d312796 affiliates console: rewards chart + referral links + leaderboard + admin set-rate
Extend the affiliate dashboard onto the new cloud /v1/affiliates surface:
- Rewards panel: per-period share LineChart + per-direct-referral aggregate
  contribution + the profit-share basis (your rate of Hanzo's margin).
- Referral links panel: list links with click/signup/conversion stats, copy,
  and create (label + auto-minted code), respecting the per-affiliate cap.
- Leaderboard panel: opt-in handle + your own rank (always visible) + aggregate
  only; never another org's identity.
- Admin: a Set-rate action (percent -> bps, capped at 93%) beside approve/payout.
- Wire a best-effort click ping into the ?aff capture (once per code/session).

API: AffiliatesApi.{earnings,links,createLink,setHandle,leaderboard,click} +
marginBps/handle on the overview; AdminAffiliatesApi.setRate. logic.{monthLabel,
percentToBps}. Defensive normalizers throughout.

Tests: normalizer + exact-path transport tests for every new call; monthLabel +
percentToBps units. tsc clean, 2394 vitest pass, next build green.
2026-07-14 22:30:28 -07:00
hanzo-dev 8e39b62167 merge: GitLab + Connect Wallet sign-in on the console login card
Adds Continue-with-GitLab and Connect-Wallet buttons alongside GitHub/Google,
handing off to the hanzo.id hosted login via provider_hint. tsc clean; 2382
vitest tests pass.
2026-07-14 20:14:00 -07:00
hanzo-dev 8e21bcf41c merge: GitLab + Connect Wallet sign-in on the console login card
Adds Continue-with-GitLab and Connect-Wallet buttons alongside GitHub/Google,
handing off to the hanzo.id hosted login via provider_hint. tsc clean; 2382
vitest tests pass.
2026-07-14 20:14:00 -07:00
hanzo-dev f21cb0a5df feat(console): GitLab + Connect Wallet sign-in on the login card
Add "Continue with GitLab" (signInWith provider-gitlab) and "Connect Wallet"
(signInWith provider-web3) buttons alongside GitHub/Google. Both hand off to the
hanzo.id hosted login via provider_hint like the existing social buttons; the
wallet button lands on the login page where the native multi-chain SIWx flow
runs and returns an authorization code to /auth/callback.
2026-07-14 20:12:20 -07:00
hanzo-dev 7b411fbf45 feat(console): GitLab + Connect Wallet sign-in on the login card
Add "Continue with GitLab" (signInWith provider-gitlab) and "Connect Wallet"
(signInWith provider-web3) buttons alongside GitHub/Google. Both hand off to the
hanzo.id hosted login via provider_hint like the existing social buttons; the
wallet button lands on the login page where the native multi-chain SIWx flow
runs and returns an authorization code to /auth/callback.
2026-07-14 20:12:20 -07:00
hanzo-dev 5baeb5b114 Merge branch 'feat/deploys-dashboard'
# Conflicts:
#	package.json
2026-07-14 16:09:11 -07:00
hanzo-dev 04697c6ba4 Merge branch 'feat/deploys-dashboard'
# Conflicts:
#	package.json
2026-07-14 16:09:11 -07:00
hanzo-dev 7e5e065d93 feat(gitops): console /gitops route mounting @hanzo/ui/gitops seam
Thin console surface for the native ArgoCD replacement: a GitOps product
(admin: true, Platform category) that reads the services.hanzo.ai operator CRs
through cloud's /v1/gitops/* — the console holds no cluster creds, cloud enforces
SuperAdmin. Applications board (name/version/health/sync + rollback/sync), an
application detail (header, actions, owned-resource inventory, rollout history,
logs), health/sync pills. The heavy topology/diff/log UI is deferred to the
parallel @hanzo/ui/gitops export via a documented MOUNT SEAM (ui-contract.ts) —
the pure treeToGraph adapter already maps into its @hanzo/canvas model. Adds the
gitops head to proxy-allow CLOUD_HEADS. tsc clean, 2353 tests green.
2026-07-14 16:00:31 -07:00
hanzo-dev 844d93ec76 feat(gitops): console /gitops route mounting @hanzo/ui/gitops seam
Thin console surface for the native ArgoCD replacement: a GitOps product
(admin: true, Platform category) that reads the services.hanzo.ai operator CRs
through cloud's /v1/gitops/* — the console holds no cluster creds, cloud enforces
SuperAdmin. Applications board (name/version/health/sync + rollback/sync), an
application detail (header, actions, owned-resource inventory, rollout history,
logs), health/sync pills. The heavy topology/diff/log UI is deferred to the
parallel @hanzo/ui/gitops export via a documented MOUNT SEAM (ui-contract.ts) —
the pure treeToGraph adapter already maps into its @hanzo/canvas model. Adds the
gitops head to proxy-allow CLOUD_HEADS. tsc clean, 2353 tests green.
2026-07-14 16:00:31 -07:00
hanzo-dev 6a401479ad feat(gitops): repoint client to /v1/gitops contract (applications + sync)
Rename the deploy-plane client to the GitOps namespace the cloud agent owns:
GET /v1/gitops/applications, /{name}/tree, /{name}/resource/{ref}, /{name}/logs,
POST /{name}/rollback, POST /{name}/sync (sync replaces restart). Domain nouns
follow ArgoCD: Application/HealthStatus/SyncStatus. Pure folds + tree adapter
unchanged (feed the interim board and @hanzo/ui/gitops when it lands). 33 tests.
2026-07-14 15:50:22 -07:00
hanzo-dev 5cbed19714 feat(gitops): repoint client to /v1/gitops contract (applications + sync)
Rename the deploy-plane client to the GitOps namespace the cloud agent owns:
GET /v1/gitops/applications, /{name}/tree, /{name}/resource/{ref}, /{name}/logs,
POST /{name}/rollback, POST /{name}/sync (sync replaces restart). Domain nouns
follow ArgoCD: Application/HealthStatus/SyncStatus. Pure folds + tree adapter
unchanged (feed the interim board and @hanzo/ui/gitops when it lands). 33 tests.
2026-07-14 15:50:22 -07:00
hanzo-dev 267d512875 feat(deploys): typed deploy-plane client + pure health/sync folds
Consumes the cloud-owned /v1/deploys contract (list/tree/resource/logs/rollback)
that reads the services.hanzo.ai operator CRs — the native ArgoCD-free deploy
plane. Pure logic folds CR .status (phase + ready replicas) into ArgoCD-style
health, desired-vs-live image tag into sync, and a CR's owned-resource tree into
the @hanzo/canvas node/edge model. 31 unit tests green.
2026-07-14 15:43:15 -07:00
hanzo-dev bbdf4739dc feat(deploys): typed deploy-plane client + pure health/sync folds
Consumes the cloud-owned /v1/deploys contract (list/tree/resource/logs/rollback)
that reads the services.hanzo.ai operator CRs — the native ArgoCD-free deploy
plane. Pure logic folds CR .status (phase + ready replicas) into ArgoCD-style
health, desired-vs-live image tag into sync, and a CR's owned-resource tree into
the @hanzo/canvas node/edge model. 31 unit tests green.
2026-07-14 15:43:15 -07:00
d34ff8eb1c fix(console): direct /signin load resolves to the form under the SPA fallback (#159)
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>
2026-07-14 11:21:09 -07:00
2afa592128 fix(console): direct /signin load resolves to the form under the SPA fallback (#159)
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>
2026-07-14 11:21:09 -07:00
cf30e199b9 feat(guide): Business AI Guide journey UI (/v1/guide) (#158)
Org-level Guide module over the real cloud clients/guide surface: an
interactive launch checklist the Business AI can complete for you.

- GuideModule.tsx — launch-progress bar (Complete/Total/Next tiles), a
  focused current-step card (why · how-on-Hanzo · done-when + Mark done /
  Skip / a primary Do-it-for-me), and the full step list with a state chip,
  a blocked/lock hint, and inline per-row actions on non-terminal steps.
- Do-it-for-me streams the agent's plan → draft → action → result → state
  events live (SSE via streamDo), aborts on unmount/close, and falls back
  to the non-streaming JSON do when the backend can't stream. An error
  event stays an error — success is never fabricated; states are loading /
  BackendStateCard / empty throughout.
- Consumes the pre-written guide API client (GuideApi + streamDo, defensive
  normalizers) + pure view logic (stateLabel/currentStep/clampPercent/…),
  both unit-tested (17 tests). Fix: putCurriculum now takes a parsed object
  (the transport JSON-encodes once) instead of a pre-serialized string,
  which the restPut body would double-encode.
- Wiring: `guide` added to proxy-allow CLOUD_HEADS (the /v1 bearer BFF
  forwards /v1/guide/*), one catalog entry + import in registry.tsx
  (Apps, routes '' and ':tab'), and `guide` added to ALWAYS_ON_PRODUCTS so
  every org sees the foundational onboarding surface (like 'platform').

Ships to console.hanzo.ai via the next hanzoai/cloud release embedding
console@main; build:embed stays green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:12:36 -07:00
bedc5dfb99 feat(guide): Business AI Guide journey UI (/v1/guide) (#158)
Org-level Guide module over the real cloud clients/guide surface: an
interactive launch checklist the Business AI can complete for you.

- GuideModule.tsx — launch-progress bar (Complete/Total/Next tiles), a
  focused current-step card (why · how-on-Hanzo · done-when + Mark done /
  Skip / a primary Do-it-for-me), and the full step list with a state chip,
  a blocked/lock hint, and inline per-row actions on non-terminal steps.
- Do-it-for-me streams the agent's plan → draft → action → result → state
  events live (SSE via streamDo), aborts on unmount/close, and falls back
  to the non-streaming JSON do when the backend can't stream. An error
  event stays an error — success is never fabricated; states are loading /
  BackendStateCard / empty throughout.
- Consumes the pre-written guide API client (GuideApi + streamDo, defensive
  normalizers) + pure view logic (stateLabel/currentStep/clampPercent/…),
  both unit-tested (17 tests). Fix: putCurriculum now takes a parsed object
  (the transport JSON-encodes once) instead of a pre-serialized string,
  which the restPut body would double-encode.
- Wiring: `guide` added to proxy-allow CLOUD_HEADS (the /v1 bearer BFF
  forwards /v1/guide/*), one catalog entry + import in registry.tsx
  (Apps, routes '' and ':tab'), and `guide` added to ALWAYS_ON_PRODUCTS so
  every org sees the foundational onboarding surface (like 'platform').

Ships to console.hanzo.ai via the next hanzoai/cloud release embedding
console@main; build:embed stays green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:12:36 -07:00
8604da5eac feat(knowledge): force-directed KB graph + vault import module (#157)
Reads GET /v1/kb/graph (kb-page/kb-memory/kb-source nodes; parent,
wikilink, provenance edges) and renders a deterministic Fruchterman-
Reingold force graph on a CSP-safe canvas, click-to-inspect. Import
panel POSTs an Obsidian/Notion/Roam/Evernote export to /v1/kb/import.

Both ride the same-origin /v1 user-bearer BFF; 'kb'/'knowledge' heads
allow-listed in proxy-allow. Registered in the product registry under
AI as 'Knowledge'. Pure graph-logic (normalize/layout/hitTest) is unit
tested (8 vitest cases); proxy-allow head list stays green (25 cases).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:04:39 -07:00
7f7439b6e9 feat(knowledge): force-directed KB graph + vault import module (#157)
Reads GET /v1/kb/graph (kb-page/kb-memory/kb-source nodes; parent,
wikilink, provenance edges) and renders a deterministic Fruchterman-
Reingold force graph on a CSP-safe canvas, click-to-inspect. Import
panel POSTs an Obsidian/Notion/Roam/Evernote export to /v1/kb/import.

Both ride the same-origin /v1 user-bearer BFF; 'kb'/'knowledge' heads
allow-listed in proxy-allow. Registered in the product registry under
AI as 'Knowledge'. Pure graph-logic (normalize/layout/hitTest) is unit
tested (8 vitest cases); proxy-allow head list stays green (25 cases).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:04:39 -07:00
hanzo-dev 8d13556517 analytics: rename dep @hanzo/analytics -> @hanzo/capture
The shared client publishes as @hanzo/capture (the @hanzo/analytics name is a
pre-existing, incompatible hanzoai npm package). No behavior change.
2026-07-14 11:04:07 -07:00
hanzo-dev a23822d873 analytics: rename dep @hanzo/analytics -> @hanzo/capture
The shared client publishes as @hanzo/capture (the @hanzo/analytics name is a
pre-existing, incompatible hanzoai npm package). No behavior change.
2026-07-14 11:04:07 -07:00
hanzo-dev c69b67835c feat(analytics): instrument console with the shared @hanzo/analytics client
Mount AnalyticsProvider (product: console, cookie/same-origin — the tenant is
stamped server-side, no getToken) inside the session so identify can bind the
signed-in actor. AnalyticsBridge fires route-change pageviews via
usePageview(usePathname()) and identifies the person by the stable owner/name
actor id (never email; anonymous sessions skipped).

Capture EVENTS at the real surfaces:
- signup funnel (SignInForm): SIGNUP_VIEWED on the create-account view,
  SIGNUP_SUBMITTED on submit, SIGNUP_COMPLETED on account creation (self-serve
  signup has no separate verify step).
- first action (onboarding LaunchStep): FIRST_ACTION on the first product tile.
- upgrade intent (PlansModule): PRICING_VIEWED on view, PLAN_CLICKED +
  CHECKOUT_STARTED on a plan CTA.
- feature usage: API_KEY_CREATED (ApiKeysModule), PROJECT_CREATED / APP_CREATED
  / DEPLOY_STARTED (PaaS CreateAppForm), PROJECT_CREATED (ProjectsModule).

No secrets/PII captured — only user/org ids and non-sensitive props.
2026-07-14 10:56:09 -07:00
hanzo-dev 63291dc620 feat(analytics): instrument console with the shared @hanzo/analytics client
Mount AnalyticsProvider (product: console, cookie/same-origin — the tenant is
stamped server-side, no getToken) inside the session so identify can bind the
signed-in actor. AnalyticsBridge fires route-change pageviews via
usePageview(usePathname()) and identifies the person by the stable owner/name
actor id (never email; anonymous sessions skipped).

Capture EVENTS at the real surfaces:
- signup funnel (SignInForm): SIGNUP_VIEWED on the create-account view,
  SIGNUP_SUBMITTED on submit, SIGNUP_COMPLETED on account creation (self-serve
  signup has no separate verify step).
- first action (onboarding LaunchStep): FIRST_ACTION on the first product tile.
- upgrade intent (PlansModule): PRICING_VIEWED on view, PLAN_CLICKED +
  CHECKOUT_STARTED on a plan CTA.
- feature usage: API_KEY_CREATED (ApiKeysModule), PROJECT_CREATED / APP_CREATED
  / DEPLOY_STARTED (PaaS CreateAppForm), PROJECT_CREATED (ProjectsModule).

No secrets/PII captured — only user/org ids and non-sensitive props.
2026-07-14 10:56:09 -07:00
Hanzo AI 6f3ff57ce8 fix(console): <UsagePanel> import → @hanzo/usage/panel (0.1.4)
@hanzo/usage 0.1.4 ships the gui/Tamagui <UsagePanel> at its own ./panel
source subpath (./react is the DOM <UsageDashboard>). Point AiUsageModule at
./panel and bump the dep. typecheck green.
2026-07-14 09:37:34 -07:00
Hanzo AI 8382468328 fix(console): <UsagePanel> import → @hanzo/usage/panel (0.1.4)
@hanzo/usage 0.1.4 ships the gui/Tamagui <UsagePanel> at its own ./panel
source subpath (./react is the DOM <UsageDashboard>). Point AiUsageModule at
./panel and bump the dep. typecheck green.
2026-07-14 09:37:34 -07:00
Hanzo AI c1c9df9a05 feat(console): AI Metrics renders canonical <UsagePanel> over get-cloud-usages
Replace the ai-metrics LivingOverview client-side re-derivation with the ONE
<UsagePanel> (@hanzo/usage/react) reading GET /v1/get-cloud-usages via a new
CloudUsageApi (same-origin /v1 proxy, cookie auth). Transpile @hanzo/usage so
its source panel compiles in the client bundle, and drop its now-unnecessary
serverExternalPackages entry (its headless entry carries no node built-ins).
2026-07-14 07:11:40 -07:00
Hanzo AI e173a5d1d6 feat(console): AI Metrics renders canonical <UsagePanel> over get-cloud-usages
Replace the ai-metrics LivingOverview client-side re-derivation with the ONE
<UsagePanel> (@hanzo/usage/react) reading GET /v1/get-cloud-usages via a new
CloudUsageApi (same-origin /v1 proxy, cookie auth). Transpile @hanzo/usage so
its source panel compiles in the client bundle, and drop its now-unnecessary
serverExternalPackages entry (its headless entry carries no node built-ins).
2026-07-14 07:11:40 -07:00
Hanzo AI acb72dd6be chore(release): v8.4.129 — restore QR device login (RFC 8628) + Code/Treasury/Authors
These 6 commits (QR device login, Code dashboard, Treasury, OSS Authors) were
never on origin main; publish them forward. QR device login is the console side
of the device-flow login loop — /auth/device BFF starts+polls IAM with client
hanzo-cloud, which now carries the device_code grant (universe ed55bdaa). Bump
128→129 so a clean image builds (origin already tagged 128 without QR).
2026-07-14 05:31:30 -07:00
Hanzo AI 42cfd4be0e chore(release): v8.4.129 — restore QR device login (RFC 8628) + Code/Treasury/Authors
These 6 commits (QR device login, Code dashboard, Treasury, OSS Authors) were
never on origin main; publish them forward. QR device login is the console side
of the device-flow login loop — /auth/device BFF starts+polls IAM with client
hanzo-cloud, which now carries the device_code grant (universe ed55bdaa). Bump
128→129 so a clean image builds (origin already tagged 128 without QR).
2026-07-14 05:31:30 -07:00
hanzo-dev 5c7e476f61 merge(treasury): integrate feat/console-treasury — dashboard already shipped on main
The Treasury admin dashboard (reserve fund + revenue-share + backed payouts +
Hanzo L1 anchor, v8.4.112) and the OSS Authors royalty product (v8.4.111) that
this branch introduced are already present in main, merged via PR #122 and #120.
The core files (TreasuryAdminModule, admin-treasury{,.test}, AuthorsAdminModule,
admin-authors) are byte-identical; main additionally carries the /cloud->/v1
bearer-proxy migration on the two authors files, so main is strictly newer.

Record the branch as integrated with -s ours (keep main's tree unchanged) so the
stale duplicate is not re-applied and the branch can be retired cleanly.
2026-07-13 12:45:49 -07:00
hanzo-dev 164ec7f420 merge(treasury): integrate feat/console-treasury — dashboard already shipped on main
The Treasury admin dashboard (reserve fund + revenue-share + backed payouts +
Hanzo L1 anchor, v8.4.112) and the OSS Authors royalty product (v8.4.111) that
this branch introduced are already present in main, merged via PR #122 and #120.
The core files (TreasuryAdminModule, admin-treasury{,.test}, AuthorsAdminModule,
admin-authors) are byte-identical; main additionally carries the /cloud->/v1
bearer-proxy migration on the two authors files, so main is strictly newer.

Record the branch as integrated with -s ours (keep main's tree unchanged) so the
stale duplicate is not re-applied and the branch can be retired cleanly.
2026-07-13 12:45:49 -07:00
Hanzo AI 8d5766062b Merge remote-tracking branch 'origin/feat/console-treasury'
# Conflicts:
#	LLM.md
#	next.config.mjs
#	package.json
#	src/components/products/AuthorsModule.tsx
#	src/lib/api/authors.test.ts
#	src/lib/api/authors.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-13 11:05:35 -07:00
Hanzo AI 4dd93c5056 Merge remote-tracking branch 'origin/feat/console-treasury'
# Conflicts:
#	LLM.md
#	next.config.mjs
#	package.json
#	src/components/products/AuthorsModule.tsx
#	src/lib/api/authors.test.ts
#	src/lib/api/authors.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-13 11:05:35 -07:00
Hanzo AI 09c481e0ac merge: QR device login (RFC 8628) + Code dashboard 2026-07-12 22:58:19 -07:00
Hanzo AI a2fee80f5a merge: QR device login (RFC 8628) + Code dashboard 2026-07-12 22:58:19 -07:00
Hanzo AI 3923cf2fe0 feat(console): QR device login (RFC 8628) — scan to sign in on any machine
Open console.<brand> on ANY machine, scan the QR with your phone, sign in +
approve at the brand IAM, and this tab's session starts — no password typed
on the machine at hand.

- session.ts: deviceCodeGrant(deviceCode, clientId) — public-client device
  poll; maps authorization_pending/slow_down → pending, expired_token →
  expired, success seals the session (reuses sealSession). SessionError now
  carries the OAuth error code so the poll tells the states apart.
- app/auth/device/route.ts BFF: action=start proxies IAM's device endpoint at
  the public issuer (so verification_uri is scannable), action=poll redeems and
  sets the exact sealed cookies /auth/session uses.
- iam-device.ts: thin same-origin client wire over that route.
- QrSignIn.tsx: renders the QR of verification_uri_complete + the user code,
  polls at the IAM cadence, and on approval reloads the session → '/'. Reached
  via a "Sign in with QR code" affordance on SignInForm, so console.<brand> on
  any device offers it.
- dep: qrcode.react 4.2.0 (pinned).

Tested: deviceCodeGrant states (session.test.ts, fetch-mocked); typecheck +
build green.
2026-07-12 22:50:31 -07:00
Hanzo AI b1552a9cf9 feat(console): QR device login (RFC 8628) — scan to sign in on any machine
Open console.<brand> on ANY machine, scan the QR with your phone, sign in +
approve at the brand IAM, and this tab's session starts — no password typed
on the machine at hand.

- session.ts: deviceCodeGrant(deviceCode, clientId) — public-client device
  poll; maps authorization_pending/slow_down → pending, expired_token →
  expired, success seals the session (reuses sealSession). SessionError now
  carries the OAuth error code so the poll tells the states apart.
- app/auth/device/route.ts BFF: action=start proxies IAM's device endpoint at
  the public issuer (so verification_uri is scannable), action=poll redeems and
  sets the exact sealed cookies /auth/session uses.
- iam-device.ts: thin same-origin client wire over that route.
- QrSignIn.tsx: renders the QR of verification_uri_complete + the user code,
  polls at the IAM cadence, and on approval reloads the session → '/'. Reached
  via a "Sign in with QR code" affordance on SignInForm, so console.<brand> on
  any device offers it.
- dep: qrcode.react 4.2.0 (pinned).

Tested: deviceCodeGrant states (session.test.ts, fetch-mocked); typecheck +
build green.
2026-07-12 22:50:31 -07:00
hanzo-dev f5b24fe295 merge: session accessClaims derives isSuperAdmin owner-canonically (owner==admin, zero boolean-claim reads) 2026-07-11 23:47:13 -07:00
hanzo-dev 56ee9c5c2a merge: session accessClaims derives isSuperAdmin owner-canonically (owner==admin, zero boolean-claim reads) 2026-07-11 23:47:13 -07:00
hanzo-dev cf653a87b2 auth: derive isSuperAdmin owner-canonically in accessClaims (drop last boolean-claim read)
The console predicate was already owner=='admin' (isSuperAdminOwner / gateAllows /
accountOf); accessClaims still projected a JWT isSuperAdmin boolean claim that
accountOf overrode. The gateway now drops the isSuperAdmin/isGlobalAdmin boolean
entirely, so this makes accessClaims owner-canonical too: SuperAdmin has ONE signal
(owner==admin org), no boolean claim read anywhere. Org-scoped isAdmin untouched.
2026-07-11 22:48:34 -07:00
hanzo-dev 0878f12d5d auth: derive isSuperAdmin owner-canonically in accessClaims (drop last boolean-claim read)
The console predicate was already owner=='admin' (isSuperAdminOwner / gateAllows /
accountOf); accessClaims still projected a JWT isSuperAdmin boolean claim that
accountOf overrode. The gateway now drops the isSuperAdmin/isGlobalAdmin boolean
entirely, so this makes accessClaims owner-canonical too: SuperAdmin has ONE signal
(owner==admin org), no boolean claim read anywhere. Org-scoped isAdmin untouched.
2026-07-11 22:48:34 -07:00
hanzo-dev e1e19059b7 refactor(auth): ONE SuperAdmin predicate — isSuperAdminOwner(owner), no second signal
SuperAdmin ⟺ the principal's IAM org (owner) IS the reserved 'admin' org — the same
equality IAM's User.IsSuperAdmin() uses (user.Owner == conf.AdminOrg). IAM DERIVES its
isSuperAdmin claim from that equality, so reading the claim as well was two signals for
one fact.

- config: export isSuperAdminOwner(owner) — THE predicate, one place.
- session.accountOf + auth/admin.isSuperAdminAccount: use it; drop the redundant
   read entirely. No claim, no fallback, no compat.
- test: strengthened — a claim on a NON-admin-org account can NEVER confer SuperAdmin
  (a second signal would be forgeable); admin-org membership alone decides.
tsc 0 errors; auth/session/identity/admin-policy suites 55/55 green.
2026-07-11 22:33:47 -07:00
hanzo-dev ffe5067963 refactor(auth): ONE SuperAdmin predicate — isSuperAdminOwner(owner), no second signal
SuperAdmin ⟺ the principal's IAM org (owner) IS the reserved 'admin' org — the same
equality IAM's User.IsSuperAdmin() uses (user.Owner == conf.AdminOrg). IAM DERIVES its
isSuperAdmin claim from that equality, so reading the claim as well was two signals for
one fact.

- config: export isSuperAdminOwner(owner) — THE predicate, one place.
- session.accountOf + auth/admin.isSuperAdminAccount: use it; drop the redundant
   read entirely. No claim, no fallback, no compat.
- test: strengthened — a claim on a NON-admin-org account can NEVER confer SuperAdmin
  (a second signal would be forgeable); admin-org membership alone decides.
tsc 0 errors; auth/session/identity/admin-policy suites 55/55 green.
2026-07-11 22:33:47 -07:00
hanzo-dev 52102a9abb refactor(admin): drop legacy isGlobalAdmin claim compat — isSuperAdmin claim OR owner=='admin', one way 2026-07-11 19:10:38 -07:00
hanzo-dev 58e7a89ed2 refactor(admin): drop legacy isGlobalAdmin claim compat — isSuperAdmin claim OR owner=='admin', one way 2026-07-11 19:10:38 -07:00
hanzo-dev 414792c55e refactor(admin): isGlobalAdmin -> isSuperAdmin server-side; owner=='admin' canonical, legacy IAM claim read-only fallback 2026-07-11 18:44:46 -07:00
hanzo-dev f477075b4f refactor(admin): isGlobalAdmin -> isSuperAdmin server-side; owner=='admin' canonical, legacy IAM claim read-only fallback 2026-07-11 18:44:46 -07:00
hanzo-dev df0ce10be3 admin: Provider Billing board — per-provider credit + credit-vs-paid funding split
New global-admin (admin:true) board beside the AI-Providers routing board, over the
already-gated /v1/admin/{providers/credit,usage/funding} reads (no proxy change).

- provider-billing.ts: tolerant restGet client (bare-array OR envelope), defensive
  normalizers, pure creditSummary/foldFunding roll-ups, FUNDING_META, runway/usd/
  compact formatting, fundingWindow(range)->RFC3339. +25 unit tests.
- ProvidersBillingModule.tsx: per-provider credit cards (balance/burn/runway_days +
  has-credit/paid-only badge, tabular-nums) + credit-vs-paid split (RangeTabs + KPI
  band + donut + table). Honest empty/one-provider/403 states.
- registry: one admin:true entry next to provider-admin (category AI).
- e2e/provider-billing.spec.ts: mocked-session fixture render (DO $26k + glm-5.2
  split) desktop+mobile, plus staged live fail-closed + real-DO render.

tsc clean; vitest 2320/2320; build:embed green.
2026-07-11 17:47:16 -07:00
hanzo-dev 038b180192 admin: Provider Billing board — per-provider credit + credit-vs-paid funding split
New global-admin (admin:true) board beside the AI-Providers routing board, over the
already-gated /v1/admin/{providers/credit,usage/funding} reads (no proxy change).

- provider-billing.ts: tolerant restGet client (bare-array OR envelope), defensive
  normalizers, pure creditSummary/foldFunding roll-ups, FUNDING_META, runway/usd/
  compact formatting, fundingWindow(range)->RFC3339. +25 unit tests.
- ProvidersBillingModule.tsx: per-provider credit cards (balance/burn/runway_days +
  has-credit/paid-only badge, tabular-nums) + credit-vs-paid split (RangeTabs + KPI
  band + donut + table). Honest empty/one-provider/403 states.
- registry: one admin:true entry next to provider-admin (category AI).
- e2e/provider-billing.spec.ts: mocked-session fixture render (DO $26k + glm-5.2
  split) desktop+mobile, plus staged live fail-closed + real-DO render.

tsc clean; vitest 2320/2320; build:embed green.
2026-07-11 17:47:16 -07:00
hanzo-dev 2dc4169a7f feat(admin): send stable Idempotency-Key on credit grants (dedupe operator retries) 2026-07-11 17:06:12 -07:00
hanzo-dev a81074016b feat(admin): send stable Idempotency-Key on credit grants (dedupe operator retries) 2026-07-11 17:06:12 -07:00
hanzo-dev 855a290c69 feat(bots): list + stop in the console bots view 2026-07-11 15:07:29 -07:00
hanzo-dev fdf6e2f0f7 feat(bots): list + stop in the console bots view 2026-07-11 15:07:29 -07:00
hanzo-dev ed34d24c47 merge(sentry): Hanzo Sentry dashboard + decomplect all 5 product faces onto ONE shell model
Merges feat/sentry-dashboard into main. Sentry lands as a host-branded product FACE
(sentry.hanzo.ai = the hanzo brand wearing a Sentry error/log/trace shell over /v1/sentry).

RECONCILIATION — the social-mode lane (marketing/ads/social) had added more per-mode booleans
(config.{marketingOnly,adsOnly,socialOnly} + is{Marketing,Ads,Social}Host + {MARKETING,ADS,
SOCIAL}_ID + N per-mode branches in visibleCatalog/page). This branch generalized the billing
special-case into ONE `shell`/`ShellId` model. Resolved by DECOMPLECTING all FIVE faces into
that ONE model (a name is a value in one namespace, not N parallel booleans):

- config: ShellId = console|billing|marketing|ads|social|sentry; ONE `shellFromHost` resolver
  (host prefix / NEXT_PUBLIC_*_ONLY / NEXT_PUBLIC_PRODUCT_SHELL) drives everything. The four
  {billing,marketing,ads,social}Only booleans are now DERIVED aliases (shell === '<x>'), kept
  for existing call sites + tests. Orthogonal to brand — a face never crosses a brand.
- shell.ts: ONE descriptor (rootId/wordmark/home/indexLabel) per face — the single source of
  each face's scope. Replaces the {BILLING,MARKETING,ADS,SOCIAL}_ID consts (removed).
- registry: visibleCatalog + visibleCatalogByCategory collapse the 4 per-mode branches into
  ONE isProductShell(config.shell) path (shellFor().rootId); e.shell hides only face-scoped
  entries (sentry) from the console — marketing/ads/social stay normal Apps products.
- page: ONE shellHome redirect for every face. DashboardShell: ONE isProductShell face-nav
  branch (billing/sentry = root sub-pages; single-screen marketing/ads/social = a lone
  Overview; product wordmark beside the Hanzo mark — billing keeps its legacy mark-only look).

ZERO regression — all FIVE faces resolve, filter the catalog, and route home (unit-asserted).
package.json 8.4.128. tsc clean; vitest 2295/2295 (194 files); next build ✓; all 5 face roots
+ sentry sub-routes serve 200 at runtime.
2026-07-11 12:10:02 -07:00
hanzo-dev 4b563afdee merge(sentry): Hanzo Sentry dashboard + decomplect all 5 product faces onto ONE shell model
Merges feat/sentry-dashboard into main. Sentry lands as a host-branded product FACE
(sentry.hanzo.ai = the hanzo brand wearing a Sentry error/log/trace shell over /v1/sentry).

RECONCILIATION — the social-mode lane (marketing/ads/social) had added more per-mode booleans
(config.{marketingOnly,adsOnly,socialOnly} + is{Marketing,Ads,Social}Host + {MARKETING,ADS,
SOCIAL}_ID + N per-mode branches in visibleCatalog/page). This branch generalized the billing
special-case into ONE `shell`/`ShellId` model. Resolved by DECOMPLECTING all FIVE faces into
that ONE model (a name is a value in one namespace, not N parallel booleans):

- config: ShellId = console|billing|marketing|ads|social|sentry; ONE `shellFromHost` resolver
  (host prefix / NEXT_PUBLIC_*_ONLY / NEXT_PUBLIC_PRODUCT_SHELL) drives everything. The four
  {billing,marketing,ads,social}Only booleans are now DERIVED aliases (shell === '<x>'), kept
  for existing call sites + tests. Orthogonal to brand — a face never crosses a brand.
- shell.ts: ONE descriptor (rootId/wordmark/home/indexLabel) per face — the single source of
  each face's scope. Replaces the {BILLING,MARKETING,ADS,SOCIAL}_ID consts (removed).
- registry: visibleCatalog + visibleCatalogByCategory collapse the 4 per-mode branches into
  ONE isProductShell(config.shell) path (shellFor().rootId); e.shell hides only face-scoped
  entries (sentry) from the console — marketing/ads/social stay normal Apps products.
- page: ONE shellHome redirect for every face. DashboardShell: ONE isProductShell face-nav
  branch (billing/sentry = root sub-pages; single-screen marketing/ads/social = a lone
  Overview; product wordmark beside the Hanzo mark — billing keeps its legacy mark-only look).

ZERO regression — all FIVE faces resolve, filter the catalog, and route home (unit-asserted).
package.json 8.4.128. tsc clean; vitest 2295/2295 (194 files); next build ✓; all 5 face roots
+ sentry sub-routes serve 200 at runtime.
2026-07-11 12:10:02 -07:00
hanzo-dev 354c5b3f41 release: console v8.4.128 — Hanzo Sentry dashboard (host-branded error/log/trace product) 2026-07-11 11:52:12 -07:00
hanzo-dev a2ea8f2a00 release: console v8.4.128 — Hanzo Sentry dashboard (host-branded error/log/trace product) 2026-07-11 11:52:12 -07:00
hanzo-dev 793a4f31d1 feat(sentry): Hanzo Sentry — full error/log/trace dashboard as a host-branded product face
sentry.hanzo.ai is the SAME console app, host-branded into a Sentry PRODUCT shell —
Hanzo IAM + @hanzo/gui identity, product-labelled "Sentry" (no upstream Sentry look).
Extends the brand-by-host machinery with an orthogonal product-SHELL concept and adds
the full /v1/sentry dashboard. Reuses the existing primitives; composition, not greenfield.

- config: ShellId + shellFromHost (console/billing/sentry faces), orthogonal to brand —
  a shell never crosses a brand; billingOnly is now the derived `shell === 'billing'`.
- lib/products/shell.ts: the pure per-face descriptor (root module, wordmark, home) — the
  ONE source the nav + home redirect + catalog gate share. + shell.test.ts.
- lib/api/sentry.ts: the /v1/sentry client — projects (+ DSN/key rotate), issues (list/get/
  update/events), discover, events, logs, traces (+ detail), stats. Version-less same-origin
  BFF (originV1Url, session cookie only), org server-enforced. Defensive normalizers + tests.
- components/products/sentry/*: Issues (search/status/sort/period/project + KPIs + sparklines),
  IssueDetail (stack trace + source context, breadcrumbs, tags, linked trace, resolve/ignore/
  reopen, occurrence timeline), Discover (filter/agg/group-by builder + table + chart), Logs
  (level filter + detail rail), Traces (list + span waterfall), Monitor (event/error timeseries),
  Projects (DSN + SDK snippet — CLEAN path, NO /api/), Members (composes Hanzo IAM TeamApi).
- SentryModule: ONE module routed by :tab (+ :tab/:id detail); the shell nav branch is
  generalized (billing + sentry) from the descriptor, the home redirect too. Sub-pages derive
  from SENTRY_TABS (one source). `logs` base-slug precedence proven in sentry-routing.test.ts.
- registry: sentry entry (shell-scoped → hidden from the full console); proxy-allow `sentry` head.

Verify: tsc clean; vitest 2267/2267 (193 files, +52 new); next build ✓; all 10 /sentry routes
serve 200 at runtime. Panels are contract-wired — the live /v1/sentry backend + the
authenticated Playwright proof against sentry.hanzo.ai are the post-deploy gate.
2026-07-11 11:47:25 -07:00
hanzo-dev 7089fd292e feat(sentry): Hanzo Sentry — full error/log/trace dashboard as a host-branded product face
sentry.hanzo.ai is the SAME console app, host-branded into a Sentry PRODUCT shell —
Hanzo IAM + @hanzo/gui identity, product-labelled "Sentry" (no upstream Sentry look).
Extends the brand-by-host machinery with an orthogonal product-SHELL concept and adds
the full /v1/sentry dashboard. Reuses the existing primitives; composition, not greenfield.

- config: ShellId + shellFromHost (console/billing/sentry faces), orthogonal to brand —
  a shell never crosses a brand; billingOnly is now the derived `shell === 'billing'`.
- lib/products/shell.ts: the pure per-face descriptor (root module, wordmark, home) — the
  ONE source the nav + home redirect + catalog gate share. + shell.test.ts.
- lib/api/sentry.ts: the /v1/sentry client — projects (+ DSN/key rotate), issues (list/get/
  update/events), discover, events, logs, traces (+ detail), stats. Version-less same-origin
  BFF (originV1Url, session cookie only), org server-enforced. Defensive normalizers + tests.
- components/products/sentry/*: Issues (search/status/sort/period/project + KPIs + sparklines),
  IssueDetail (stack trace + source context, breadcrumbs, tags, linked trace, resolve/ignore/
  reopen, occurrence timeline), Discover (filter/agg/group-by builder + table + chart), Logs
  (level filter + detail rail), Traces (list + span waterfall), Monitor (event/error timeseries),
  Projects (DSN + SDK snippet — CLEAN path, NO /api/), Members (composes Hanzo IAM TeamApi).
- SentryModule: ONE module routed by :tab (+ :tab/:id detail); the shell nav branch is
  generalized (billing + sentry) from the descriptor, the home redirect too. Sub-pages derive
  from SENTRY_TABS (one source). `logs` base-slug precedence proven in sentry-routing.test.ts.
- registry: sentry entry (shell-scoped → hidden from the full console); proxy-allow `sentry` head.

Verify: tsc clean; vitest 2267/2267 (193 files, +52 new); next build ✓; all 10 /sentry routes
serve 200 at runtime. Panels are contract-wired — the live /v1/sentry backend + the
authenticated Playwright proof against sentry.hanzo.ai are the post-deploy gate.
2026-07-11 11:47:25 -07:00
Hanzo AI 456e417589 merge(social-mode): publish action, calendar/list, live connect readiness (parity) 2026-07-11 11:37:05 -07:00
Hanzo AI eff93e6e94 merge(social-mode): publish action, calendar/list, live connect readiness (parity) 2026-07-11 11:37:05 -07:00
Hanzo AI 0624f8055a feat(social-mode): parity console — publish action, calendar/list, live connect readiness
Flesh the SocialModule to real parity with the live social-frontend over the extended
native /v1/social surface:

- Publish action: a post detail drawer with a real Publish now button
  (POST /v1/social/posts/:id/publish) that surfaces the honest outcome — external id on
  success, or the exact missing-credentials 503 on the fail-closed provider seam.
- Compose: draft / schedule / publish-now modes; when the target network isn't
  configured to publish, an inline honest warning naming the missing OAuth-app creds
  (from GET /v1/social/providers) — never a fabricated success.
- Calendar/list: a view toggle — the existing list table plus a calendar (agenda)
  view grouping timed posts by day.
- Connect flow: the connect panel shows LIVE per-network publish-readiness
  (configured / needs X_API_KEY, ...) beside the account add.

lib/api/social.ts gains providers() + posts.publish() + the server-managed post result
fields (accountId/externalId/error), all defensively normalized. social.test.ts (8 tests)
pins the same-origin /v1/social paths (providers, publish) + the normalizers. tsc --noEmit
clean; vitest green.
2026-07-11 11:06:23 -07:00
Hanzo AI 9451f5ad3a feat(social-mode): parity console — publish action, calendar/list, live connect readiness
Flesh the SocialModule to real parity with the live social-frontend over the extended
native /v1/social surface:

- Publish action: a post detail drawer with a real Publish now button
  (POST /v1/social/posts/:id/publish) that surfaces the honest outcome — external id on
  success, or the exact missing-credentials 503 on the fail-closed provider seam.
- Compose: draft / schedule / publish-now modes; when the target network isn't
  configured to publish, an inline honest warning naming the missing OAuth-app creds
  (from GET /v1/social/providers) — never a fabricated success.
- Calendar/list: a view toggle — the existing list table plus a calendar (agenda)
  view grouping timed posts by day.
- Connect flow: the connect panel shows LIVE per-network publish-readiness
  (configured / needs X_API_KEY, ...) beside the account add.

lib/api/social.ts gains providers() + posts.publish() + the server-managed post result
fields (accountId/externalId/error), all defensively normalized. social.test.ts (8 tests)
pins the same-origin /v1/social paths (providers, publish) + the normalizers. tsc --noEmit
clean; vitest green.
2026-07-11 11:06:23 -07:00
Hanzo AI c40e33e5c0 merge(social): social.hanzo.ai host-mode + Social product (ship-dormant)
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/config/index.test.ts
#	src/config/index.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-10 22:01:38 -07:00
Hanzo AI 3cf236bab1 merge(social): social.hanzo.ai host-mode + Social product (ship-dormant)
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/config/index.test.ts
#	src/config/index.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-10 22:01:38 -07:00
Hanzo AI 5eb511b896 merge(ads): ads.hanzo.ai host-mode + Ads product
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/config/index.test.ts
#	src/config/index.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-10 21:56:03 -07:00
Hanzo AI 4f5717814b merge(ads): ads.hanzo.ai host-mode + Ads product
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/config/index.test.ts
#	src/config/index.ts
#	src/lib/products/registry.tsx
#	src/lib/server/proxy-allow.ts
2026-07-10 21:56:03 -07:00
Hanzo AI 09c2ab7ce9 merge(marketing): marketing.hanzo.ai host-mode + Marketing product 2026-07-10 21:47:59 -07:00
Hanzo AI 913b3b7e04 merge(marketing): marketing.hanzo.ai host-mode + Marketing product 2026-07-10 21:47:59 -07:00
Hanzo AI b3c4376834 feat(ads): ads.hanzo.ai host-mode + Ads product over /v1/ads
Console half of the new /v1/ads domain seam (one console, host-resolved modes,
orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:

- config: adsOnly field + isAdsHost/isAds (ads.<brand> prefix, NEXT_PUBLIC_ADS_ONLY
  override) — the host->mode twin of billingOnly.
- registry: a real Ads CatalogEntry (id 'ads', Apps) rendering AdsModule;
  visibleCatalog/visibleCatalogByCategory filter to it when adsOnly, same shape as billing.
- page: home route redirects to /ads in ads-only mode.
- AdsApi + AdsModule: thin, honest per-org view over the REAL cloud /v1/ads surface
  (summary + campaign CRUD), same client path as CRM; 'ads' head allow-listed in
  proxy-allow CLOUD_HEADS.

tsc --noEmit green; config test +5 ads cases (26 pass).
2026-07-10 18:50:58 -07:00
Hanzo AI a864f1c430 feat(ads): ads.hanzo.ai host-mode + Ads product over /v1/ads
Console half of the new /v1/ads domain seam (one console, host-resolved modes,
orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:

- config: adsOnly field + isAdsHost/isAds (ads.<brand> prefix, NEXT_PUBLIC_ADS_ONLY
  override) — the host->mode twin of billingOnly.
- registry: a real Ads CatalogEntry (id 'ads', Apps) rendering AdsModule;
  visibleCatalog/visibleCatalogByCategory filter to it when adsOnly, same shape as billing.
- page: home route redirects to /ads in ads-only mode.
- AdsApi + AdsModule: thin, honest per-org view over the REAL cloud /v1/ads surface
  (summary + campaign CRUD), same client path as CRM; 'ads' head allow-listed in
  proxy-allow CLOUD_HEADS.

tsc --noEmit green; config test +5 ads cases (26 pass).
2026-07-10 18:50:58 -07:00
hanzo-dev f8415b116b feat(social): social.hanzo.ai host-mode + Social product over /v1/social
The console half of the new /v1/social domain seam — the host→mode twin of
Billing/Marketing. social.<brand> (config.socialOnly, isSocialHost, or
NEXT_PUBLIC_SOCIAL_ONLY=1) boots the SAME console image into the ONE Social
product; the catalog is filtered to it and home redirects to /social.

- config: socialOnly on ConsoleConfig + isSocialHost/isSocial, resolved in
  resolveConfig (mirrors billingOnly 1:1). 5 new host tests (26/26 pass).
- SocialModule: per-org Posts + Accounts CRUD + real summary over cloud
  /v1/social, through the /v1 user-bearer BFF. Honest loading/error/empty
  states, never fabricated rows.
- lib/api/social.ts: thin REST client (accounts + posts + summary),
  defensive normalizers — twin of crm.ts/marketing.ts.
- registry: Social CatalogEntry + SOCIAL_ID + visibleCatalog(ByCategory)
  social-only branch. proxy-allow: 'social' CLOUD_HEAD.

Typecheck: tsc --noEmit GREEN (0 errors).
2026-07-10 18:49:37 -07:00
hanzo-dev 683119843e feat(social): social.hanzo.ai host-mode + Social product over /v1/social
The console half of the new /v1/social domain seam — the host→mode twin of
Billing/Marketing. social.<brand> (config.socialOnly, isSocialHost, or
NEXT_PUBLIC_SOCIAL_ONLY=1) boots the SAME console image into the ONE Social
product; the catalog is filtered to it and home redirects to /social.

- config: socialOnly on ConsoleConfig + isSocialHost/isSocial, resolved in
  resolveConfig (mirrors billingOnly 1:1). 5 new host tests (26/26 pass).
- SocialModule: per-org Posts + Accounts CRUD + real summary over cloud
  /v1/social, through the /v1 user-bearer BFF. Honest loading/error/empty
  states, never fabricated rows.
- lib/api/social.ts: thin REST client (accounts + posts + summary),
  defensive normalizers — twin of crm.ts/marketing.ts.
- registry: Social CatalogEntry + SOCIAL_ID + visibleCatalog(ByCategory)
  social-only branch. proxy-allow: 'social' CLOUD_HEAD.

Typecheck: tsc --noEmit GREEN (0 errors).
2026-07-10 18:49:37 -07:00
Hanzo AI 1127f85100 feat(marketing): marketing.hanzo.ai host-mode + Marketing product over /v1/marketing
Console half of the new /v1/marketing domain seam (one console, host-resolved
modes, orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:

- config: marketingOnly field + isMarketingHost/isMarketing (marketing.<brand>
  prefix, NEXT_PUBLIC_MARKETING_ONLY override) — the host->mode twin of billingOnly.
- registry: a real Marketing CatalogEntry (id 'marketing', Apps) rendering
  MarketingModule; visibleCatalog/visibleCatalogByCategory filter to it when
  marketingOnly, same shape as billing.
- page: home route redirects to /marketing in marketing-only mode.
- MarketingApi + MarketingModule: thin, honest per-org view over the REAL cloud
  /v1/marketing surface (summary + campaign CRUD), same client path as CRM;
  'marketing' head allow-listed in proxy-allow CLOUD_HEADS.

tsc --noEmit green; config test +5 marketing cases (26 pass).
2026-07-10 18:08:21 -07:00
Hanzo AI ec6ce8c4ea feat(marketing): marketing.hanzo.ai host-mode + Marketing product over /v1/marketing
Console half of the new /v1/marketing domain seam (one console, host-resolved
modes, orthogonal /v1 domains). Mirrors the billing-only shell EXACTLY:

- config: marketingOnly field + isMarketingHost/isMarketing (marketing.<brand>
  prefix, NEXT_PUBLIC_MARKETING_ONLY override) — the host->mode twin of billingOnly.
- registry: a real Marketing CatalogEntry (id 'marketing', Apps) rendering
  MarketingModule; visibleCatalog/visibleCatalogByCategory filter to it when
  marketingOnly, same shape as billing.
- page: home route redirects to /marketing in marketing-only mode.
- MarketingApi + MarketingModule: thin, honest per-org view over the REAL cloud
  /v1/marketing surface (summary + campaign CRUD), same client path as CRM;
  'marketing' head allow-listed in proxy-allow CLOUD_HEADS.

tsc --noEmit green; config test +5 marketing cases (26 pass).
2026-07-10 18:08:21 -07:00
hanzo-dev ede309ed2f feat(bots): launch + watch a computer-using bot from the console
New customer surface at /bot/run (BotsConsole) over cloud POST /v1/bots/run
(BotsApi.run): boot a desktop/terminal computer, run a computer-using bot on it
against a task, and attach live over the returned VNC session — org-scoped +
metered a flat per-run 'bot' fee (402 → add-funds nudge). Registered as the
'run' route on the customer-facing 'bot' product (distinct from BotModule's
gateway status and the admin BotsModule fleet-spend analytics).

Launch + watch today; a persistent runs list + stop need the cloud endpoints the
launch-only bots surface deliberately lacks (GET /v1/bots + stop, proxying the
bot-gateway's live nodes) — session history is client-side until then. VNC embed
needs the bot gateway to allow this console origin as a frame-ancestor; the
open-in-new-tab fallback works regardless.
2026-07-10 16:03:50 -07:00
hanzo-dev db305a61d4 feat(bots): launch + watch a computer-using bot from the console
New customer surface at /bot/run (BotsConsole) over cloud POST /v1/bots/run
(BotsApi.run): boot a desktop/terminal computer, run a computer-using bot on it
against a task, and attach live over the returned VNC session — org-scoped +
metered a flat per-run 'bot' fee (402 → add-funds nudge). Registered as the
'run' route on the customer-facing 'bot' product (distinct from BotModule's
gateway status and the admin BotsModule fleet-spend analytics).

Launch + watch today; a persistent runs list + stop need the cloud endpoints the
launch-only bots surface deliberately lacks (GET /v1/bots + stop, proxying the
bot-gateway's live nodes) — session history is client-side until then. VNC embed
needs the bot gateway to allow this console origin as a frame-ancestor; the
open-in-new-tab fallback works regardless.
2026-07-10 16:03:50 -07:00
hanzo-dev 1e1c6b37d8 Merge feat/errors-tab: Sentry-class error-tracking Errors tab on /v1/o11y 2026-07-10 15:00:02 -07:00
hanzo-dev af2ba34b43 Merge feat/errors-tab: Sentry-class error-tracking Errors tab on /v1/o11y 2026-07-10 15:00:02 -07:00
hanzo-dev 1515df9802 Merge feat/observe-traces-on-o11y: flip gen_ai Observe traces to /v1/o11y 2026-07-10 14:59:52 -07:00
hanzo-dev cad05037f8 Merge feat/observe-traces-on-o11y: flip gen_ai Observe traces to /v1/o11y 2026-07-10 14:59:52 -07:00
hanzo-dev 0806575a96 feat(observe): read traces/observations/sessions from the o11y gen_ai span plane
Flip the Observe read plane (O11yApi.traces/observations/sessions + their trace/
session detail) from the /v1/evals cloud_usage projection to the NATIVE o11y span
plane (/v1/o11y), the declared observation-of-record. Step 3 of the unified AI-
observability collapse; the CTO call flagged open since v8.4.124.

- traces/observations/sessions read /v1/o11y/{traces,observations,sessions} via the
  /v1 user-bearer BFF; unwrap o11y's { status, data } over { items, offset, limit };
  map the native camelCase view-models (view.go) with honest guards (non-finite
  cost/latency/tokens -> null em dash, never fabricated).
- trace(id)/session(id) DETAIL composed from the list views filtered by traceId/
  sessionId (o11y exposes no detail endpoint). trace(id) header roll-ups come from the
  SERVER-aggregated trace row (grouped over ALL spans) so a >200-span trace never
  undercounts; the waterfall list is bounded to 200. Inline scores stay EVAL scores for
  that trace (EvalsApi.listScoresTyped by traceId) — nothing lost.
- SCORES + score-configs + datasets/evaluators/runs STAY on /v1/evals. One way per
  domain. o11y llmobs span views are org-scoped fail-closed on gen_ai.hanzo.org_id
  (C1), server-set from the validated X-Org-Id — tenant-safe.

Red review (SHIP): LOW-1 trace-header undercount fixed via the server-aggregated row;
INFO-1 the 5 eval-domain trace/observation/session readers kept as a documented
RETAINED SDK surface (not re-wired into O11yApi) — evals.ts scope doc corrected.

Tests: o11y.test.ts (23) pure adapters + honesty guards + the flip contract
(traces/observations/sessions -> /v1/o11y, scores/score-configs -> /v1/evals, LOW-1
server-aggregated header + fallback). vitest 40/40 (o11y+evals) green; tsc clean for
changed files.
2026-07-10 14:38:17 -07:00
hanzo-dev e5e3964664 feat(observe): read traces/observations/sessions from the o11y gen_ai span plane
Flip the Observe read plane (O11yApi.traces/observations/sessions + their trace/
session detail) from the /v1/evals cloud_usage projection to the NATIVE o11y span
plane (/v1/o11y), the declared observation-of-record. Step 3 of the unified AI-
observability collapse; the CTO call flagged open since v8.4.124.

- traces/observations/sessions read /v1/o11y/{traces,observations,sessions} via the
  /v1 user-bearer BFF; unwrap o11y's { status, data } over { items, offset, limit };
  map the native camelCase view-models (view.go) with honest guards (non-finite
  cost/latency/tokens -> null em dash, never fabricated).
- trace(id)/session(id) DETAIL composed from the list views filtered by traceId/
  sessionId (o11y exposes no detail endpoint). trace(id) header roll-ups come from the
  SERVER-aggregated trace row (grouped over ALL spans) so a >200-span trace never
  undercounts; the waterfall list is bounded to 200. Inline scores stay EVAL scores for
  that trace (EvalsApi.listScoresTyped by traceId) — nothing lost.
- SCORES + score-configs + datasets/evaluators/runs STAY on /v1/evals. One way per
  domain. o11y llmobs span views are org-scoped fail-closed on gen_ai.hanzo.org_id
  (C1), server-set from the validated X-Org-Id — tenant-safe.

Red review (SHIP): LOW-1 trace-header undercount fixed via the server-aggregated row;
INFO-1 the 5 eval-domain trace/observation/session readers kept as a documented
RETAINED SDK surface (not re-wired into O11yApi) — evals.ts scope doc corrected.

Tests: o11y.test.ts (23) pure adapters + honesty guards + the flip contract
(traces/observations/sessions -> /v1/o11y, scores/score-configs -> /v1/evals, LOW-1
server-aggregated header + fallback). vitest 40/40 (o11y+evals) green; tsc clean for
changed files.
2026-07-10 14:38:17 -07:00
zeekayandClaude Opus 4.8 d4526fb854 feat(shell): top-left brand logomark + consolidated bottom-left org/user/wallet cluster
Match the unified Hanzo app-shell (hanzo.app + hanzo.chat): the top-left
is now the white-label brand logomark ALONE (host-derived BrandMark — Hanzo H /
Lux / Zoo / Pars per host, never hardcoded, no wordmark/product-name/letter-H
text) with a right-click brand context menu (Settings · Brand · Docs · About),
and the org switcher + user/account menu + wallet consolidate into ONE bottom-left
cluster (was split user-on-top / wallet-below). Applied across the expanded rail,
the collapsed icon rail, the billing-only shell, and the mobile drawer (all mount
SidebarNav). Reuses the existing BrandMark/getBrand white-label resolver, OrgSwitcher,
SidebarWallet, and Popover menu primitive — no new systems, @hanzo/gui engine unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:16:55 -07:00
zeekayandhanzo-dev c075f9a0dd feat(shell): top-left brand logomark + consolidated bottom-left org/user/wallet cluster
Match the unified Hanzo app-shell (hanzo.app + hanzo.chat): the top-left
is now the white-label brand logomark ALONE (host-derived BrandMark — Hanzo H /
Lux / Zoo / Pars per host, never hardcoded, no wordmark/product-name/letter-H
text) with a right-click brand context menu (Settings · Brand · Docs · About),
and the org switcher + user/account menu + wallet consolidate into ONE bottom-left
cluster (was split user-on-top / wallet-below). Applied across the expanded rail,
the collapsed icon rail, the billing-only shell, and the mobile drawer (all mount
SidebarNav). Reuses the existing BrandMark/getBrand white-label resolver, OrgSwitcher,
SidebarWallet, and Popover menu primitive — no new systems, @hanzo/gui engine unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 14:16:55 -07:00
zeekayandClaude Opus 4.8 7bbe95038c fix(o11y): flat version-less public paths — drop nested /api/vN (#71)
The o11y public contract is FLAT and version-less (one /v1/, no nested
/api/vN). The upstream SigNoz engine version is an internal impl detail
resolved SERVER-SIDE in cloud (clients/o11y), never leaked into a route.

- telemetry.ts: SuperAdmin VM proxy  o11y/vm/api/v1/{query,query_range}
  -> o11y/vm/{query,query_range}  (cloud vmproxy.go calls VM api/v1/* inside).
- apm.ts: composite builder query  COMPOSITE_QUERY_RANGE 'api/v3/query_range'
  -> 'query_range'  (cloud query.go resolves the flat path to the v3 engine).
- proxy-allow.ts: the single `o11y` head already admits every o11y sub-path;
  drop the dead `allowTelemetrySurface`/`TELEMETRY_READ` allowlist and the
  now-dead `/telemetry` Next route (stripped by static export; replaced by
  the SuperAdmin VM proxy — one and one way).

SuperAdmin gate + {up,sum(up),count(up)} allowlist preserved (cloud-side).
No `api/v1/query` / `api/v3/query_range` strings remain in console source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:03 -07:00
zeekayandhanzo-dev 28d125ae50 fix(o11y): flat version-less public paths — drop nested /api/vN (#71)
The o11y public contract is FLAT and version-less (one /v1/, no nested
/api/vN). The upstream SigNoz engine version is an internal impl detail
resolved SERVER-SIDE in cloud (clients/o11y), never leaked into a route.

- telemetry.ts: SuperAdmin VM proxy  o11y/vm/api/v1/{query,query_range}
  -> o11y/vm/{query,query_range}  (cloud vmproxy.go calls VM api/v1/* inside).
- apm.ts: composite builder query  COMPOSITE_QUERY_RANGE 'api/v3/query_range'
  -> 'query_range'  (cloud query.go resolves the flat path to the v3 engine).
- proxy-allow.ts: the single `o11y` head already admits every o11y sub-path;
  drop the dead `allowTelemetrySurface`/`TELEMETRY_READ` allowlist and the
  now-dead `/telemetry` Next route (stripped by static export; replaced by
  the SuperAdmin VM proxy — one and one way).

SuperAdmin gate + {up,sum(up),count(up)} allowlist preserved (cloud-side).
No `api/v1/query` / `api/v3/query_range` strings remain in console source.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 13:01:03 -07:00
zeekayandClaude Opus 4.8 643ad2e8c8 fix(status): mark Status product admin-only (#71)
StatusModule reads the whole platform's VictoriaMetrics up{} inventory through
the new SuperAdmin-gated cloud VM proxy (/v1/o11y/vm/*), which 403s a non-super
caller. Without gating the ROUTE, a customer landing on /status hits that proxy
and gets a 403 console error + an error card.

Mark the `status` registry entry `admin: true` so ProductRoute renders the
graceful AdminManagedNotice for non-super callers (no proxy call, no console
error) and only a SuperAdmin renders StatusModule + the live board — matching
MetricsModule's PlatformInfraHealth gating. Customers never hit it (role-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:26:24 -07:00
zeekayandhanzo-dev 6f5c2aa3d4 fix(status): mark Status product admin-only (#71)
StatusModule reads the whole platform's VictoriaMetrics up{} inventory through
the new SuperAdmin-gated cloud VM proxy (/v1/o11y/vm/*), which 403s a non-super
caller. Without gating the ROUTE, a customer landing on /status hits that proxy
and gets a 403 console error + an error card.

Mark the `status` registry entry `admin: true` so ProductRoute renders the
graceful AdminManagedNotice for non-super callers (no proxy call, no console
error) and only a SuperAdmin renders StatusModule + the live board — matching
MetricsModule's PlatformInfraHealth gating. Customers never hit it (role-gated).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 12:26:24 -07:00
Hanzo AI ac2faaaa82 feat(paas): env/secrets editor, live build-log tail, one create flow, domains in drawer
- Variables tab: real add/edit/delete env + secret editor (write-only secrets:
  Keep/Replace, never revealed) writing through setEnv (PUT .../env). Kept sealed
  secrets submit an empty value (preserve-on-empty) so an edit never wipes KMS.
- Logs tab: live-tail the latest deployment's logs while a build/deploy is in
  progress (poll — endpoint is a snapshot, no SSE — reuse railway phase, auto-scroll,
  stop at terminal).
- One create/connect-repo flow: extract shared CreateAppForm; the canvas 'New
  service' now uses it (was a CLI-text stub), same path as the Applications board.
- Domains tab on the canvas drawer reuses DomainsPanel (add/remove/verify), not
  verify-only.
- Pure env-editor helpers + unit tests (toEnvDrafts/draftsToEnv/validateEnvDrafts).
2026-07-10 11:55:08 -07:00
Hanzo AI fa64ac1dbb feat(paas): env/secrets editor, live build-log tail, one create flow, domains in drawer
- Variables tab: real add/edit/delete env + secret editor (write-only secrets:
  Keep/Replace, never revealed) writing through setEnv (PUT .../env). Kept sealed
  secrets submit an empty value (preserve-on-empty) so an edit never wipes KMS.
- Logs tab: live-tail the latest deployment's logs while a build/deploy is in
  progress (poll — endpoint is a snapshot, no SSE — reuse railway phase, auto-scroll,
  stop at terminal).
- One create/connect-repo flow: extract shared CreateAppForm; the canvas 'New
  service' now uses it (was a CLI-text stub), same path as the Applications board.
- Domains tab on the canvas drawer reuses DomainsPanel (add/remove/verify), not
  verify-only.
- Pure env-editor helpers + unit tests (toEnvDrafts/draftsToEnv/validateEnvDrafts).
2026-07-10 11:55:08 -07:00
zeekayandClaude Opus 4.8 1beb6fca9b fix(telemetry): route infra-health board through SuperAdmin cloud VM proxy (#71)
The /metrics and /status SuperAdmin infra-health board called the console's
Next.js `/telemetry/[...path]` server route, which the static-export embed
(cloud go:embeds console as output:'export') STRIPS — so a browser call to
`/telemetry/api/v1/query` 404s (the last console error).

Repoint TelemetryApi's transport from `/telemetry/*` to the same-origin,
versionless cloud proxy `/v1/o11y/vm/api/v1/{query,query_range}`
(cloudProxyV1Url → clients/o11y/vmproxy.go). Cloud gates it to platform
SuperAdmins, allowlists the query to exactly {up, sum(up), count(up)}, and
returns VM's native Prometheus envelope verbatim — so parseInstant/parseRange
are unchanged. One shared line fixes both MetricsModule and StatusModule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:44:28 -07:00
zeekayandhanzo-dev 187bf91c75 fix(telemetry): route infra-health board through SuperAdmin cloud VM proxy (#71)
The /metrics and /status SuperAdmin infra-health board called the console's
Next.js `/telemetry/[...path]` server route, which the static-export embed
(cloud go:embeds console as output:'export') STRIPS — so a browser call to
`/telemetry/api/v1/query` 404s (the last console error).

Repoint TelemetryApi's transport from `/telemetry/*` to the same-origin,
versionless cloud proxy `/v1/o11y/vm/api/v1/{query,query_range}`
(cloudProxyV1Url → clients/o11y/vmproxy.go). Cloud gates it to platform
SuperAdmins, allowlists the query to exactly {up, sum(up), count(up)}, and
returns VM's native Prometheus envelope verbatim — so parseInstant/parseRange
are unchanged. One shared line fixes both MetricsModule and StatusModule.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 11:44:28 -07:00
d750e83aca feat(onboarding): real provider-login OAuth connect in AiAccessStep (#113) (#154)
The AiAccessStep "Connect a provider login" card was a disabled "coming soon"
stub. The backend OAuth is now live (ai#85: GET /v1/ai/connections/:provider/
authorize + callback, KMS-sealed), so flip it to a real connect button.

- `AiConnectionsApi.authorizeUrl(provider)` — fetch-then-redirect: GETs
  `…/connections/<provider>/authorize?format=json` → `{ authorizeUrl }` (tolerates
  authorize_url / url), then the step redirects the browser to the provider consent
  screen; the backend seals the token on its callback. Records the `connect` choice.
- `/ai` proxy allow-list: add the `v1/ai/connections/<provider>/authorize` sub-path
  (a narrow regex, not a general tunnel). The callback is backend↔provider, never
  through this proxy.
- Honest states: a provider whose OAuth app creds aren't provisioned returns 503 →
  "not available on this deployment yet" for that provider (provisioning is a
  separate ops step); other errors show a retry message. Providers are probed
  lazily on click (no eager authorize calls that would mint dangling OAuth state).

Tests: +6 (ai-connections.test.ts) — authorize path/format, camel+snake+bare URL
normalization, throw-on-missing, 503 propagation, provider list. tsc + next build
clean; full vitest green except a pre-existing unrelated apm-service-scope failure
on main.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:29:58 -07:00
38c829d885 feat(onboarding): real provider-login OAuth connect in AiAccessStep (#113) (#154)
The AiAccessStep "Connect a provider login" card was a disabled "coming soon"
stub. The backend OAuth is now live (ai#85: GET /v1/ai/connections/:provider/
authorize + callback, KMS-sealed), so flip it to a real connect button.

- `AiConnectionsApi.authorizeUrl(provider)` — fetch-then-redirect: GETs
  `…/connections/<provider>/authorize?format=json` → `{ authorizeUrl }` (tolerates
  authorize_url / url), then the step redirects the browser to the provider consent
  screen; the backend seals the token on its callback. Records the `connect` choice.
- `/ai` proxy allow-list: add the `v1/ai/connections/<provider>/authorize` sub-path
  (a narrow regex, not a general tunnel). The callback is backend↔provider, never
  through this proxy.
- Honest states: a provider whose OAuth app creds aren't provisioned returns 503 →
  "not available on this deployment yet" for that provider (provisioning is a
  separate ops step); other errors show a retry message. Providers are probed
  lazily on click (no eager authorize calls that would mint dangling OAuth state).

Tests: +6 (ai-connections.test.ts) — authorize path/format, camel+snake+bare URL
normalization, throw-on-missing, 503 propagation, provider list. tsc + next build
clean; full vitest green except a pre-existing unrelated apm-service-scope failure
on main.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 11:29:58 -07:00
zeekayandClaude Opus 4.8 db1648dff4 fix(o11y): add required selectColumns to traces list query
The v3 traces list builder HARD-fails with 'select columns cannot be empty for
panelType list' (500) when a noop list query carries no selectColumns — the recent-
traces widget on 6 product pages 500'd. Add the display columns normalizeTraceSpan
reads (name, duration_nano, response_status_code) as materialized static trace
columns. Logs list stays empty (its noop path returns a default row set — verified
200). Validated live: traces+selectColumns -> 200 with real span data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:38:23 -07:00
zeekayandhanzo-dev b78e07c195 fix(o11y): add required selectColumns to traces list query
The v3 traces list builder HARD-fails with 'select columns cannot be empty for
panelType list' (500) when a noop list query carries no selectColumns — the recent-
traces widget on 6 product pages 500'd. Add the display columns normalizeTraceSpan
reads (name, duration_nano, response_status_code) as materialized static trace
columns. Logs list stays empty (its noop path returns a default row set — verified
200). Validated live: traces+selectColumns -> 200 with real span data.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 10:38:23 -07:00
hanzo-dev ecf06ae606 feat(errors): console Errors tab over the o11y errortracking module
Adds an Errors (Issues) product under Observe, reading the o11y errortracking
module over the SAME version-less, IAM-scoped /v1/o11y/* BFF as Service Map/Logs:

- lib/api/apm.ts: Issue/Occurrence/IssueDetail types + defensive normalizers
  (unwrap the {status,data} envelope, tolerate garbage) + ErrorTrackingApi
  (listIssues/getIssue/updateIssue). Org scope is server-enforced.
- components/products/ErrorsModule.tsx: status tabs, KPI band (issues/unresolved/
  regressed/events), by-level Donut, issues table, and a detail SlideOver with the
  latest occurrence, stack trace, and resolve/ignore/reopen actions. Dependency-free
  inline-SVG charts (CSP-safe); honest RuntimeNotice/empty states, never fabricated.
- registry.tsx: one Observe catalog entry ('errors', routes ''|':id').
- errortracking.test.ts: 8 normalizer tests (envelope unwrap, defaults, garbage).

tsc clean (my files); vitest 8/8. Lights up when cloud bumps its embedded o11y
dep to the errortracking build (same gating as the llmobs org-scope fix).
2026-07-10 10:34:19 -07:00
hanzo-dev 6a621dbcbe feat(errors): console Errors tab over the o11y errortracking module
Adds an Errors (Issues) product under Observe, reading the o11y errortracking
module over the SAME version-less, IAM-scoped /v1/o11y/* BFF as Service Map/Logs:

- lib/api/apm.ts: Issue/Occurrence/IssueDetail types + defensive normalizers
  (unwrap the {status,data} envelope, tolerate garbage) + ErrorTrackingApi
  (listIssues/getIssue/updateIssue). Org scope is server-enforced.
- components/products/ErrorsModule.tsx: status tabs, KPI band (issues/unresolved/
  regressed/events), by-level Donut, issues table, and a detail SlideOver with the
  latest occurrence, stack trace, and resolve/ignore/reopen actions. Dependency-free
  inline-SVG charts (CSP-safe); honest RuntimeNotice/empty states, never fabricated.
- registry.tsx: one Observe catalog entry ('errors', routes ''|':id').
- errortracking.test.ts: 8 normalizer tests (envelope unwrap, defaults, garbage).

tsc clean (my files); vitest 8/8. Lights up when cloud bumps its embedded o11y
dep to the errortracking build (same gating as the llmobs org-scope fix).
2026-07-10 10:34:19 -07:00
5486885b69 feat(console): Code dashboard — /v1/code hybrid search + cited ask (Dev) (#153)
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.

- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
  user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
  defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
  (query + hybrid|text|symbol|semantic mode) → clickable file:line results
  table (row → span detail pane), and an ASK panel rendering answer +
  citations[] as file:line refs (click reveals the cited span via search).
  Honest states throughout (inert-until-queried, "not connected" on a 404
  route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
  data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
  allow-listed the `code` head in proxy-allow.ts (+ test).

tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-10 10:11:04 -07:00
e5ce877221 feat(console): Code dashboard — /v1/code hybrid search + cited ask (Dev) (#153)
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.

- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
  user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
  defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
  (query + hybrid|text|symbol|semantic mode) → clickable file:line results
  table (row → span detail pane), and an ASK panel rendering answer +
  citations[] as file:line refs (click reveals the cited span via search).
  Honest states throughout (inert-until-queried, "not connected" on a 404
  route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
  data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
  allow-listed the `code` head in proxy-allow.ts (+ test).

tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-10 10:11:04 -07:00
Hanzo AI 8f10ccb5ad feat(console): Code dashboard — /v1/code hybrid search + cited ask (Dev)
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.

- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
  user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
  defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
  (query + hybrid|text|symbol|semantic mode) → clickable file:line results
  table (row → span detail pane), and an ASK panel rendering answer +
  citations[] as file:line refs (click reveals the cited span via search).
  Honest states throughout (inert-until-queried, "not connected" on a 404
  route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
  data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
  allow-listed the `code` head in proxy-allow.ts (+ test).

tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.
2026-07-10 10:09:33 -07:00
Hanzo AI 8bf3e69d3a feat(console): Code dashboard — /v1/code hybrid search + cited ask (Dev)
Native Code module surfacing the LIVE per-org /v1/code code-intelligence
engine, mirroring the Agents module's structure/idiom exactly.

- lib/api/code.ts — CodeApi (search/ask/context) over the same-origin /v1
  user-bearer proxy, org-scoped SERVER-SIDE (never a client-side org param);
  defensive normalizers + pure derivers/formatters. +21 unit tests.
- components/products/CodeModule.tsx + code/parts.tsx — a hybrid SEARCH box
  (query + hybrid|text|symbol|semantic mode) → clickable file:line results
  table (row → span detail pane), and an ASK panel rendering answer +
  citations[] as file:line refs (click reveals the cited span via search).
  Honest states throughout (inert-until-queried, "not connected" on a 404
  route, BackendStateCard on 403/5xx, degraded banner) — never fabricated
  data, exactly like Agents.
- Registered `code` in the products registry (Dev, Code2 icon) and
  allow-listed the `code` head in proxy-allow.ts (+ test).

tsc clean; vitest +48 new pass (the lone suite failure is a pre-existing
origin/main apm-service-scope test, untouched here); next build green.
2026-07-10 10:09:33 -07:00
ff2aa1eb87 feat(platform-canvas): wire real per-service o11y metrics + observed dependency edges (#152)
Fills the two stubbed seams in the App Platform canvas (@hanzo/canvas):

1. Per-service metrics — the card sparkline + drawer Metrics tab now show REAL
   per-service RED metrics from cloud's o11y surface (GET /v1/o11y/metrics?product=
   <slug>, clients/o11y): requests, error rate, and p95 latency time-series, org-
   scoped server-side. New `lib/api/o11y-metrics.ts` client (honest states: 200
   honest-empty for a service with no telemetry; connected:false on 503/404/401/403;
   400 = bad slug → honest-empty for that one app — never throws, never a fabricated
   chart). `platform-apps/metrics.ts` folds the requests series into the card's
   ServiceMetric (undefined = no sparkline, the exact prior honest state) and fetches
   the visible apps' metrics concurrency-capped. The drawer Metrics tab renders the
   full requests/errors/latency set with a 1h/6h/24h window toggle. Per-service
   CPU/memory are NOT exposed by this RED (trace-derived) read, so they are labeled
   honestly as not-exposed rather than estimated.

2. Dependency edges — the platform store declares no service bindings (verified in
   cloud clients/platform: no dependency/link/binding model), so instead of an
   always-empty declared-deps endpoint we overlay the REAL OBSERVED runtime
   dependency graph from o11y (ApmApi.dependencies → /v1/o11y/dependency_graph). A
   solid `dependency` edge is drawn ONLY where both endpoints resolve to apps in the
   canvas (matched by OTel service.name), and an observed dependency supersedes the
   env-var-derived `reference` guess for the same pair. Env-var references remain the
   declared-intent hint when no telemetry links two apps.

`buildProjectCanvas` gains an optional `extras` arg (metricByApp + serviceDeps) so
the fold stays pure; the module fetches the live o11y signals separately. No
@hanzo/canvas change needed — MetricSparkline/ServiceMetric already accept the data.

Tests: +24 (canvas metric injection + dependency-edge supersession/scoping,
o11y-metrics normalizer + honest transport states, metrics folds). Full suite
184 files / 2149 pass; tsc clean; next build ✓ (23/23).

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 09:35:01 -07:00
95a04663bf feat(platform-canvas): wire real per-service o11y metrics + observed dependency edges (#152)
Fills the two stubbed seams in the App Platform canvas (@hanzo/canvas):

1. Per-service metrics — the card sparkline + drawer Metrics tab now show REAL
   per-service RED metrics from cloud's o11y surface (GET /v1/o11y/metrics?product=
   <slug>, clients/o11y): requests, error rate, and p95 latency time-series, org-
   scoped server-side. New `lib/api/o11y-metrics.ts` client (honest states: 200
   honest-empty for a service with no telemetry; connected:false on 503/404/401/403;
   400 = bad slug → honest-empty for that one app — never throws, never a fabricated
   chart). `platform-apps/metrics.ts` folds the requests series into the card's
   ServiceMetric (undefined = no sparkline, the exact prior honest state) and fetches
   the visible apps' metrics concurrency-capped. The drawer Metrics tab renders the
   full requests/errors/latency set with a 1h/6h/24h window toggle. Per-service
   CPU/memory are NOT exposed by this RED (trace-derived) read, so they are labeled
   honestly as not-exposed rather than estimated.

2. Dependency edges — the platform store declares no service bindings (verified in
   cloud clients/platform: no dependency/link/binding model), so instead of an
   always-empty declared-deps endpoint we overlay the REAL OBSERVED runtime
   dependency graph from o11y (ApmApi.dependencies → /v1/o11y/dependency_graph). A
   solid `dependency` edge is drawn ONLY where both endpoints resolve to apps in the
   canvas (matched by OTel service.name), and an observed dependency supersedes the
   env-var-derived `reference` guess for the same pair. Env-var references remain the
   declared-intent hint when no telemetry links two apps.

`buildProjectCanvas` gains an optional `extras` arg (metricByApp + serviceDeps) so
the fold stays pure; the module fetches the live o11y signals separately. No
@hanzo/canvas change needed — MetricSparkline/ServiceMetric already accept the data.

Tests: +24 (canvas metric injection + dependency-edge supersession/scoping,
o11y-metrics normalizer + honest transport states, metrics folds). Full suite
184 files / 2149 pass; tsc clean; next build ✓ (23/23).


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 09:35:01 -07:00
zeekayandClaude Opus 4.8 a2265dd34e fix(o11y): pin composite query_range to explicit v3 endpoint
The console's logs/traces list widget builds the v3 composite payload
(compositeQuery.{queryType,builderQueries}) with a matching v3 response parser
(parseListRows over data.result[].list). It posted to the version-less
/v1/o11y/query_range alias, which the embedded o11y resolves to the HIGHEST
version (v5) — whose composite query accepts only {queries:[…]} and 400s the v3
shape (unknown field "queryType"). This broke the overview-metrics widget on 6
product pages (studio/gateway/cli/registry/desktop/console).

Pin both composite calls to the explicit /v1/o11y/api/v3/query_range (verified
200 against live embed with the exact payload). Request+response stay a matched
v3 pair; the v3 handler is registered and live. No backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:29:12 -07:00
zeekayandhanzo-dev 2a2ea26d09 fix(o11y): pin composite query_range to explicit v3 endpoint
The console's logs/traces list widget builds the v3 composite payload
(compositeQuery.{queryType,builderQueries}) with a matching v3 response parser
(parseListRows over data.result[].list). It posted to the version-less
/v1/o11y/query_range alias, which the embedded o11y resolves to the HIGHEST
version (v5) — whose composite query accepts only {queries:[…]} and 400s the v3
shape (unknown field "queryType"). This broke the overview-metrics widget on 6
product pages (studio/gateway/cli/registry/desktop/console).

Pin both composite calls to the explicit /v1/o11y/api/v3/query_range (verified
200 against live embed with the exact payload). Request+response stay a matched
v3 pair; the v3 handler is registered and live. No backend change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 09:29:12 -07:00
0db1e9db64 console: call /v1/iam/{keys,onboard}, /v1/csrf, /v1/commerce/topup/wallet (kill /v1/console/*) (#150)
Follows hanzoai/cloud removing the /v1/console/* API namespace — "console" is just
our FE name, so every route moves to its REAL domain. Forwards-only, no dual-path
fallback:

  keys.ts     originV1Url('console/keys')      → originV1Url('iam/keys')
  wallet.ts   v1Url('console/topup/wallet')     → v1Url('commerce/topup/wallet')
  OrgOnboarding + OrgSwitcher  v1Url('console/onboard') → v1Url('iam/onboard')
  csrf.ts     GET /v1/console/csrf              → GET /v1/csrf

CSRF_WRITE_PREFIXES becomes SPECIFIC — ['/v1/iam/keys','/v1/iam/onboard','/v1/billing/',
'/v1/commerce/'] — NOT a broad '/v1/iam/', so the SPA's IAM login/signin writes still do
NOT trigger a spurious pre-auth CSRF mint (csrf.test.ts asserts /v1/iam/login|signin stay
false while the four gated surfaces stay true). Each call keeps its exact mechanism
(same-origin originV1Url for keys, cross-origin v1Url for onboard/topup) — only the path
segment changes.

Verified: npm run typecheck (0 errors), npm test (2125/2125, incl. csrf), npm run build
(Compiled successfully). Zero '/v1/console' / 'clients/console' references remain.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 03:24:39 -07:00
f1639838e7 console: call /v1/iam/{keys,onboard}, /v1/csrf, /v1/commerce/topup/wallet (kill /v1/console/*) (#150)
Follows hanzoai/cloud removing the /v1/console/* API namespace — "console" is just
our FE name, so every route moves to its REAL domain. Forwards-only, no dual-path
fallback:

  keys.ts     originV1Url('console/keys')      → originV1Url('iam/keys')
  wallet.ts   v1Url('console/topup/wallet')     → v1Url('commerce/topup/wallet')
  OrgOnboarding + OrgSwitcher  v1Url('console/onboard') → v1Url('iam/onboard')
  csrf.ts     GET /v1/console/csrf              → GET /v1/csrf

CSRF_WRITE_PREFIXES becomes SPECIFIC — ['/v1/iam/keys','/v1/iam/onboard','/v1/billing/',
'/v1/commerce/'] — NOT a broad '/v1/iam/', so the SPA's IAM login/signin writes still do
NOT trigger a spurious pre-auth CSRF mint (csrf.test.ts asserts /v1/iam/login|signin stay
false while the four gated surfaces stay true). Each call keeps its exact mechanism
(same-origin originV1Url for keys, cross-origin v1Url for onboard/topup) — only the path
segment changes.

Verified: npm run typecheck (0 errors), npm test (2125/2125, incl. csrf), npm run build
(Compiled successfully). Zero '/v1/console' / 'clients/console' references remain.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 03:24:39 -07:00
d25ddcc5e2 feat(platform): Railway-grade PaaS project canvas via @hanzo/canvas (#151)
App Platform (and the org-wide Map) render as a Railway-style project canvas
over the live /v1/platform + /v1/<kind> data, using the reusable @hanzo/canvas
components (one canvas implementation, reused across both surfaces).

- PlatformAppsModule: upgraded from a table into the project canvas — env +
  project switchers, ProjectCanvas of the org's apps + their domains + the
  managed data they reference (honest env-derived edges), rich ServiceDetailDrawer
  (Overview/Deployments/Variables/Metrics/Logs/Domains/SBOM — reuses the existing
  SBOM/logs/domains rendering as tab content, not duplicated), "+ New service"
  affordance (honest CLI/API paths), StatCards, and honest loading/empty/error.
- MapModule: migrated onto @hanzo/canvas (ProjectCanvas + ServiceDetailDrawer),
  keeping the pure buildGraph fold; the bespoke MapCanvas/nodes/presentation are
  removed (one node card, one canvas — DRY).
- platform-apps/canvas.ts: pure fold PlatformApp[] + resources -> the generic
  node/edge model, honest edges only (unit-tested, 7 tests). subsystems.ts: the
  curated /v1/<svc> capability catalog so a node shows its Hanzo capability.
- Metrics per app are an HONEST empty state (no fabricated chart) — documented
  seam to wire o11y/usage. Dependency edges are env-derived — documented seam to
  wire real dependency data.

tsc clean; vitest 2132/2132; next build green.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 03:06:23 -07:00
d579686034 feat(platform): Railway-grade PaaS project canvas via @hanzo/canvas (#151)
App Platform (and the org-wide Map) render as a Railway-style project canvas
over the live /v1/platform + /v1/<kind> data, using the reusable @hanzo/canvas
components (one canvas implementation, reused across both surfaces).

- PlatformAppsModule: upgraded from a table into the project canvas — env +
  project switchers, ProjectCanvas of the org's apps + their domains + the
  managed data they reference (honest env-derived edges), rich ServiceDetailDrawer
  (Overview/Deployments/Variables/Metrics/Logs/Domains/SBOM — reuses the existing
  SBOM/logs/domains rendering as tab content, not duplicated), "+ New service"
  affordance (honest CLI/API paths), StatCards, and honest loading/empty/error.
- MapModule: migrated onto @hanzo/canvas (ProjectCanvas + ServiceDetailDrawer),
  keeping the pure buildGraph fold; the bespoke MapCanvas/nodes/presentation are
  removed (one node card, one canvas — DRY).
- platform-apps/canvas.ts: pure fold PlatformApp[] + resources -> the generic
  node/edge model, honest edges only (unit-tested, 7 tests). subsystems.ts: the
  curated /v1/<svc> capability catalog so a node shows its Hanzo capability.
- Metrics per app are an HONEST empty state (no fabricated chart) — documented
  seam to wire o11y/usage. Dependency edges are env-derived — documented seam to
  wire real dependency data.

tsc clean; vitest 2132/2132; next build green.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 03:06:23 -07:00
zeekayandClaude Opus 4.8 fdb45e6dc5 feat(nav): native Tracker is always-on — first-class in every org's sidebar
The native @hanzo/gui Tracker (task #58, replaced Huly/hanzo.team) is a first-class
Hanzo Cloud work surface, peer of the project HUB. Add it to ALWAYS_ON_PRODUCTS so it
shows in the sidebar + ⌘K palette + launcher for every org (it was entitlement-gated,
so it rendered only via a direct /tracker URL and never appeared in nav).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:46:26 -07:00
zeekayandhanzo-dev ed6fe7ebe5 feat(nav): native Tracker is always-on — first-class in every org's sidebar
The native @hanzo/gui Tracker (task #58, replaced Huly/hanzo.team) is a first-class
Hanzo Cloud work surface, peer of the project HUB. Add it to ALWAYS_ON_PRODUCTS so it
shows in the sidebar + ⌘K palette + launcher for every org (it was entitlement-gated,
so it rendered only via a direct /tracker URL and never appeared in nav).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 00:46:26 -07:00
zeekayandClaude Opus 4.8 b96db9c320 fix(resource): String()-coerce createdAt before localeCompare — Vector module crash
The vector kind's createdAt is a numeric epoch; `?? ''` only guards null/undefined,
so `(number).localeCompare` threw 'localeCompare is not a function' and crashed the
whole /vector module render (surfaced once deep-link module rendering was fixed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:43:29 -07:00
zeekayandhanzo-dev 03015f22de fix(resource): String()-coerce createdAt before localeCompare — Vector module crash
The vector kind's createdAt is a numeric epoch; `?? ''` only guards null/undefined,
so `(number).localeCompare` threw 'localeCompare is not a function' and crashed the
whole /vector module render (surfaced once deep-link module rendering was fixed).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 23:43:29 -07:00
zeekayandClaude Opus 4.8 eebb3b9560 fix(embed): resolve deep links client-side so modules render (not just Overview)
The one-binary cloud embed is a Next output:'export' static build; cloud serves
the ROOT (dashboard)/page.tsx index.html for EVERY deep link (a static export
can't pre-generate arbitrary product slugs). So /models, /chat, /tracker … —
direct load AND client nav that hard-falls-back — rendered the home Overview,
never the module. No product module surfaced in the embed.

Fix: the home page resolves the LIVE path via usePathname() and hands any real
product route to the shared ProductRoute renderer (extracted from the [...slug]
catch-all — one definition, both entry points). mounted-gated so the first
client render matches the exported home ('/') — no hydration mismatch. On a real
Next server the home only renders for '/', so behavior there is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:24:23 -07:00
zeekayandhanzo-dev 8dc5a2b8e1 fix(embed): resolve deep links client-side so modules render (not just Overview)
The one-binary cloud embed is a Next output:'export' static build; cloud serves
the ROOT (dashboard)/page.tsx index.html for EVERY deep link (a static export
can't pre-generate arbitrary product slugs). So /models, /chat, /tracker … —
direct load AND client nav that hard-falls-back — rendered the home Overview,
never the module. No product module surfaced in the embed.

Fix: the home page resolves the LIVE path via usePathname() and hands any real
product route to the shared ProductRoute renderer (extracted from the [...slug]
catch-all — one definition, both entry points). mounted-gated so the first
client render matches the exported home ('/') — no hydration mismatch. On a real
Next server the home only renders for '/', so behavior there is unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 22:24:23 -07:00
adc0e9a55b fix(admin): admin.<brand> operator cockpit is never behind the consumer waitlist (v8.4.127) (#149)
admin.hanzo.ai served the console SPA correctly (gated by admin-guard, on the
new console — NOT legacy console2) and /v1/admin/* returned real data, but the
(dashboard) shell wraps EVERY authenticated surface in <WaitlistGate>, which has
no admin bypass. So the operator superuser (z@hanzo.ai, owner=admin) saw the
consumer product waitlist panel instead of the operator cockpit on admin.hanzo.ai.

The operator cockpit is a distinct concern from consumer product rollout, so an
operator is never held behind the line:
- server (the access authority): waitlistAccess() short-circuits hasAccess=true on
  an admin host (isAdminHost) before consulting the waitlist plugin.
- client (WaitlistGate): mirrors it — an admin host OR a super (platform) admin
  disables the gate, so the cockpit never even flashes the waitlist panel.

Real authorization to admin.<brand> + /v1/admin/* is unchanged: admin-guard
ForwardAuth (PKCE via hanzo.id, org=admin) + the cloud global-admin gate still
enforce access. This only lifts the consumer waitlist UX off the operator surface.

Tests: +3 server waitlistAccess admin-host-bypass cases; tsc clean; full vitest
2082+ green; next build ✓.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 18:55:57 -07:00
bbbac329a9 fix(admin): admin.<brand> operator cockpit is never behind the consumer waitlist (v8.4.127) (#149)
admin.hanzo.ai served the console SPA correctly (gated by admin-guard, on the
new console — NOT legacy console2) and /v1/admin/* returned real data, but the
(dashboard) shell wraps EVERY authenticated surface in <WaitlistGate>, which has
no admin bypass. So the operator superuser (z@hanzo.ai, owner=admin) saw the
consumer product waitlist panel instead of the operator cockpit on admin.hanzo.ai.

The operator cockpit is a distinct concern from consumer product rollout, so an
operator is never held behind the line:
- server (the access authority): waitlistAccess() short-circuits hasAccess=true on
  an admin host (isAdminHost) before consulting the waitlist plugin.
- client (WaitlistGate): mirrors it — an admin host OR a super (platform) admin
  disables the gate, so the cockpit never even flashes the waitlist panel.

Real authorization to admin.<brand> + /v1/admin/* is unchanged: admin-guard
ForwardAuth (PKCE via hanzo.id, org=admin) + the cloud global-admin gate still
enforce access. This only lifts the consumer waitlist UX off the operator surface.

Tests: +3 server waitlistAccess admin-host-bypass cases; tsc clean; full vitest
2082+ green; next build ✓.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 18:55:57 -07:00
zeekayandClaude Opus 4.8 f7880f1831 docs(deploy): console.hanzo.ai serves the go:embed'd console in the cloud binary, not the standalone CR — HUB goes live on the next cloud release (CONSOLE_REF=main)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:22:34 -07:00
zeekayandhanzo-dev f4d5d00bfa docs(deploy): console.hanzo.ai serves the go:embed'd console in the cloud binary, not the standalone CR — HUB goes live on the next cloud release (CONSOLE_REF=main)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 17:22:34 -07:00
zeekayandClaude Opus 4.8 a3f194de5f fix(platform): make the project HUB always-on — shows in sidebar + Apps map for every org (v8.4.126)
Live verify of v8.4.125 (Dave/maxpower org admin) found platform absent from the
entitlement-gated customer sidebar/Apps map. The project HUB is a first-class core
capability, so add 'platform' to ALWAYS_ON_PRODUCTS — visible for every org.

tsc clean; vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:59:36 -07:00
zeekayandhanzo-dev 85a6141347 fix(platform): make the project HUB always-on — shows in sidebar + Apps map for every org (v8.4.126)
Live verify of v8.4.125 (Dave/maxpower org admin) found platform absent from the
entitlement-gated customer sidebar/Apps map. The project HUB is a first-class core
capability, so add 'platform' to ALWAYS_ON_PRODUCTS — visible for every org.

tsc clean; vitest green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 16:59:36 -07:00
zeekayandClaude Opus 4.8 becdc3c597 feat(platform): console.hanzo.ai is the project HUB — deploy IAM-native projects + cross-surface deep links (v8.4.125)
New first-class `Platform` product (registry id `platform`, Platform category,
routes '' | ':name') → sidebar + Apps map + launcher + shared subpages. It's the
project HUB: create an IAM-native project (ProjectApi, name slugified so
name === deploy slug === the ?project= key), drag-drop a .zip/.tar.gz (or a
client-packed folder) to deploy over the embedded PaaS static engine
(/v1/platform/sites/*), view deployments with status/logs, bind custom domains,
and edit config. Cross-surface deep links on the ONE shared IAM project id —
Edit → hanzo.app/dev?project=<id>, Chat → hanzo.chat/?project=<id> — plus an
inbound ?project= handler that scopes + opens the hub.

- lib/api/platform-sites.ts (+ contract test): the /v1/platform/sites client.
- bearer-proxy: forward a NON-JSON body VERBATIM (bytes + Content-Type), never
  text-decode/re-stamp application/json — unblocks binary artifact upload for the
  ONE shared proxy; client.restPostRaw posts the artifact (keeps 401-refresh).
- lib/deploy/{archive,drop}.ts: pure ustar tar builder + native gzip + folder walk.
- lib/products/cross-surface.ts: ?project= links + slug helper (config.chatUrl added).
- ProjectApi.create gains optional displayName (additive). `projects` stays the
  scope picker (no duplicate); models/billing-band/single-level-nav untouched.

tsc clean; vitest 2122/2122 (+48); next build ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:42:46 -07:00
zeekayandhanzo-dev 762c524647 feat(platform): console.hanzo.ai is the project HUB — deploy IAM-native projects + cross-surface deep links (v8.4.125)
New first-class `Platform` product (registry id `platform`, Platform category,
routes '' | ':name') → sidebar + Apps map + launcher + shared subpages. It's the
project HUB: create an IAM-native project (ProjectApi, name slugified so
name === deploy slug === the ?project= key), drag-drop a .zip/.tar.gz (or a
client-packed folder) to deploy over the embedded PaaS static engine
(/v1/platform/sites/*), view deployments with status/logs, bind custom domains,
and edit config. Cross-surface deep links on the ONE shared IAM project id —
Edit → hanzo.app/dev?project=<id>, Chat → hanzo.chat/?project=<id> — plus an
inbound ?project= handler that scopes + opens the hub.

- lib/api/platform-sites.ts (+ contract test): the /v1/platform/sites client.
- bearer-proxy: forward a NON-JSON body VERBATIM (bytes + Content-Type), never
  text-decode/re-stamp application/json — unblocks binary artifact upload for the
  ONE shared proxy; client.restPostRaw posts the artifact (keeps 401-refresh).
- lib/deploy/{archive,drop}.ts: pure ustar tar builder + native gzip + folder walk.
- lib/products/cross-surface.ts: ?project= links + slug helper (config.chatUrl added).
- ProjectApi.create gains optional displayName (additive). `projects` stays the
  scope picker (no duplicate); models/billing-band/single-level-nav untouched.

tsc clean; vitest 2122/2122 (+48); next build ✓.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 16:42:46 -07:00
6518808537 feat(console): first-run onboarding — 2FA, consent, team, trial credits, AI access (connect/BYO/router) (#148)
Guided post-signup wizard shown once (resumable, skippable) via a new
OnboardingGate in the dashboard layout. Reuses existing real surfaces:
- 2FA: MfaApi -> /console/mfa -> IAM mfa/setup
- consent: account preference (data-sharing default OFF) + local guard
- team: TeamApi confirm/rename org
- trial credits: Square hosted element + BillingApi.createPaymentMethod + welcome + balance
- AI access: BYO keys -> real KMS-sealed /v1/ai/connections (new AiConnectionsApi,
  allow-listed in the /ai proxy); Hanzo router via AiAccountsApi.saveSettings;
  provider-login OAuth is an honest coming-soon (backend gap)
- first action: deep-link CTAs

tsc clean; vitest 2074/2074 (+11); next build green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:55:10 -07:00
abac96d601 feat(console): first-run onboarding — 2FA, consent, team, trial credits, AI access (connect/BYO/router) (#148)
Guided post-signup wizard shown once (resumable, skippable) via a new
OnboardingGate in the dashboard layout. Reuses existing real surfaces:
- 2FA: MfaApi -> /console/mfa -> IAM mfa/setup
- consent: account preference (data-sharing default OFF) + local guard
- team: TeamApi confirm/rename org
- trial credits: Square hosted element + BillingApi.createPaymentMethod + welcome + balance
- AI access: BYO keys -> real KMS-sealed /v1/ai/connections (new AiConnectionsApi,
  allow-listed in the /ai proxy); Hanzo router via AiAccountsApi.saveSettings;
  provider-login OAuth is an honest coming-soon (backend gap)
- first action: deep-link CTAs

tsc clean; vitest 2074/2074 (+11); next build green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:55:10 -07:00
0a26473b52 feat(console): Vercel-style lazy-loading org/team switcher (#147)
Upgrade the topbar OrgSwitcher from a load-everything dropdown into a
Vercel-style, truly lazy switcher that scales to thousands of orgs.

- Lazy-load / paginated search: new useOrgList hook fetches ONE page of
  get-organizations at a time (orgQuery/ORG_PAGE_SIZE), appends via
  mergeOrgs, and loads more on demand (infinite-scroll + Load more).
  Search is debounced (250ms) and pushed to the server (field/value name
  LIKE) so it narrows at the source; orgRows ALSO client-filters loaded
  rows over name+displayName so what renders is correct even if the
  backend ignores the server filter. hasMore derives from pageIsFull (no
  reliance on a backend total).
- Vercel UI: Find organization search, avatar + name rows, honest
  present-only plan/tier badge (tierOf — Hobby/Pro/Enterprise, omitted
  when absent, never fabricated), checkmark on the current org, empty
  state, and a Create organization footer wired to the existing /onboard
  flow. Keyboard nav (up/down/enter/esc) mirrors CommandPalette.
- Masquerade preserved: super admin sees all orgs (lazy, paged); a
  regular user sees only their own org (synthesized, unchanged, never
  another tenant's). Switching still switchOrg (persist X-Org-Id +
  reload). Projects stay lazy via useScope (untouched).
- Pure decisions in src/lib/org-list.ts (+16 vitest); reuses org-picker
  logic (orgTitle/initialsOf) and org-scope filterOrgs — DRY.

tsc clean; vitest 2079 pass; next build ok.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:45:34 -07:00
cf7c12af87 feat(console): Vercel-style lazy-loading org/team switcher (#147)
Upgrade the topbar OrgSwitcher from a load-everything dropdown into a
Vercel-style, truly lazy switcher that scales to thousands of orgs.

- Lazy-load / paginated search: new useOrgList hook fetches ONE page of
  get-organizations at a time (orgQuery/ORG_PAGE_SIZE), appends via
  mergeOrgs, and loads more on demand (infinite-scroll + Load more).
  Search is debounced (250ms) and pushed to the server (field/value name
  LIKE) so it narrows at the source; orgRows ALSO client-filters loaded
  rows over name+displayName so what renders is correct even if the
  backend ignores the server filter. hasMore derives from pageIsFull (no
  reliance on a backend total).
- Vercel UI: Find organization search, avatar + name rows, honest
  present-only plan/tier badge (tierOf — Hobby/Pro/Enterprise, omitted
  when absent, never fabricated), checkmark on the current org, empty
  state, and a Create organization footer wired to the existing /onboard
  flow. Keyboard nav (up/down/enter/esc) mirrors CommandPalette.
- Masquerade preserved: super admin sees all orgs (lazy, paged); a
  regular user sees only their own org (synthesized, unchanged, never
  another tenant's). Switching still switchOrg (persist X-Org-Id +
  reload). Projects stay lazy via useScope (untouched).
- Pure decisions in src/lib/org-list.ts (+16 vitest); reuses org-picker
  logic (orgTitle/initialsOf) and org-scope filterOrgs — DRY.

tsc clean; vitest 2079 pass; next build ok.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 15:45:34 -07:00
hanzo-devandGitHub a98db5eb2e insights(o11y): repoint to version-less /v1/o11y/<resource> via the /cloud bearer proxy (v8.4.114) (#146)
The rebooted o11y backend (cloud embedded o11y v1.5.4) serves the canonical
VERSION-LESS surface /v1/o11y/<resource> (no nested v1/v3, no /api). Proven live:
GET /v1/o11y/health -> 200 {"service":"o11y","status":"ok"}; the reads
(/services, /query_range, /rules) -> 403 "no validated principal" (IAM-gated).

The Insights modules were still calling the nested-version SigNoz forms
(/v1/o11y/v1/*, /v1/o11y/v3/query_range) over a bare /v1/o11y/* (originV1Url),
which on the live ingress reaches the gateway with no minted bearer -> 403.

- apm.ts (ApmApi): version-less paths (services, dependency_graph,
  service/top_operations, hosts/pods/nodes/list, listErrors, dashboards,
  query_range), addressed via cloudProxyV1Url (the /cloud user-bearer proxy).
- AlertsModule -> o11y/rules; o11y.ts annotation-queues/users -> /cloud;
  added O11yApi.health() (o11y/health).
- proxy-allow/ServiceMap/Logs/ProductLogs docstrings updated to the version-less
  + bearer-proxy contract.
- Tests: canonical-paths pins ApmApi.dashboards -> /cloud/v1/o11y/dashboards;
  apm-service-scope pins /cloud/v1/o11y/query_range. tsc clean, vitest 1900/1900.
- e2e/insights-o11y.spec.ts: (A) unauthenticated gate proof (PASSES LIVE), (B)
  authenticated render proof on admin.hanzo.ai (staged: needs the admin-org
  SuperAdmin password + a browser env that renders the RNW SPA).
- Traces/Observations left on /v1/evals (LLM-trace domain w/ cost/tokens/scores);
  repointing to /v1/o11y/traces is a data-domain change flagged for the CTO.
2026-07-09 15:41:43 -07:00
hanzo-devandGitHub 3cae19a170 insights(o11y): repoint to version-less /v1/o11y/<resource> via the /cloud bearer proxy (v8.4.114) (#146)
The rebooted o11y backend (cloud embedded o11y v1.5.4) serves the canonical
VERSION-LESS surface /v1/o11y/<resource> (no nested v1/v3, no /api). Proven live:
GET /v1/o11y/health -> 200 {"service":"o11y","status":"ok"}; the reads
(/services, /query_range, /rules) -> 403 "no validated principal" (IAM-gated).

The Insights modules were still calling the nested-version SigNoz forms
(/v1/o11y/v1/*, /v1/o11y/v3/query_range) over a bare /v1/o11y/* (originV1Url),
which on the live ingress reaches the gateway with no minted bearer -> 403.

- apm.ts (ApmApi): version-less paths (services, dependency_graph,
  service/top_operations, hosts/pods/nodes/list, listErrors, dashboards,
  query_range), addressed via cloudProxyV1Url (the /cloud user-bearer proxy).
- AlertsModule -> o11y/rules; o11y.ts annotation-queues/users -> /cloud;
  added O11yApi.health() (o11y/health).
- proxy-allow/ServiceMap/Logs/ProductLogs docstrings updated to the version-less
  + bearer-proxy contract.
- Tests: canonical-paths pins ApmApi.dashboards -> /cloud/v1/o11y/dashboards;
  apm-service-scope pins /cloud/v1/o11y/query_range. tsc clean, vitest 1900/1900.
- e2e/insights-o11y.spec.ts: (A) unauthenticated gate proof (PASSES LIVE), (B)
  authenticated render proof on admin.hanzo.ai (staged: needs the admin-org
  SuperAdmin password + a browser env that renders the RNW SPA).
- Traces/Observations left on /v1/evals (LLM-trace domain w/ cost/tokens/scores);
  repointing to /v1/o11y/traces is a data-domain change flagged for the CTO.
2026-07-09 15:41:43 -07:00
hanzo-devandGitHub 972a3e70b1 feat(status): global system-status badge in the topbar (#143)
A compact health indicator in the lg+ topbar reflecting the OVERALL health of
the Hanzo cloud, pulled from the brand's Gatus status page (status.hanzo.ai).

How it pulls status: the badge fetches a same-origin /system-status BFF route
(app/system-status/route.ts) which server-side fetches
status.<brand>/api/v1/endpoints/statuses and returns a small JSON summary. This
sidesteps the status API's missing CORS header (a browser fetch cross-origin is
blocked) and matches the console's established BFF pattern — the badge renders
NATIVELY from the JSON summary (no iframe, no third-party script).

- src/lib/status/summary.ts — PURE summarizeStatuses(): collapses the Gatus feed
  to { overall, total, up, down[] }; defensive (garbage → 'unknown', never throws).
- src/lib/status/summary.test.ts — vitest: operational/degraded/down/unknown +
  last-result-wins + garbage input (7 tests).
- app/system-status/route.ts — GET BFF; bounded fetchWithTimeout(4s); fail-soft
  → overall:'unknown' at HTTP 200 (never 500); Cache-Control max-age=30.
- src/config/index.ts — per-brand statusUrl (status.<brand-domain>) +
  NEXT_PUBLIC_STATUS_URL override, mirroring docsUrl.
- src/components/ui/SystemStatusBadge.tsx — compact dot+label pill (theme-aware
  $green10/$yellow10/$red10 tokens), a Popover status panel listing any down
  components + a "View full status" link; polls 60s, pauses when tab hidden,
  non-blocking ("Checking…" until first response).
- src/components/DashboardShell.tsx — mounts <SystemStatusBadge/> as the first
  topbar control.

Verification: vitest src/lib/status 7/7 green; tsc --noEmit clean for all changed
files (the only tsc errors in the tree are pre-existing @hanzo/usage module
resolution in unrelated ai-accounts/* files — a dep present in package.json but
not in this worktree's reused node_modules; my files import none of it). Not
deployed; no package.json version bump (release agent owns that).
2026-07-09 15:20:13 -07:00
hanzo-devandGitHub 35a1cd683a feat(status): global system-status badge in the topbar (#143)
A compact health indicator in the lg+ topbar reflecting the OVERALL health of
the Hanzo cloud, pulled from the brand's Gatus status page (status.hanzo.ai).

How it pulls status: the badge fetches a same-origin /system-status BFF route
(app/system-status/route.ts) which server-side fetches
status.<brand>/api/v1/endpoints/statuses and returns a small JSON summary. This
sidesteps the status API's missing CORS header (a browser fetch cross-origin is
blocked) and matches the console's established BFF pattern — the badge renders
NATIVELY from the JSON summary (no iframe, no third-party script).

- src/lib/status/summary.ts — PURE summarizeStatuses(): collapses the Gatus feed
  to { overall, total, up, down[] }; defensive (garbage → 'unknown', never throws).
- src/lib/status/summary.test.ts — vitest: operational/degraded/down/unknown +
  last-result-wins + garbage input (7 tests).
- app/system-status/route.ts — GET BFF; bounded fetchWithTimeout(4s); fail-soft
  → overall:'unknown' at HTTP 200 (never 500); Cache-Control max-age=30.
- src/config/index.ts — per-brand statusUrl (status.<brand-domain>) +
  NEXT_PUBLIC_STATUS_URL override, mirroring docsUrl.
- src/components/ui/SystemStatusBadge.tsx — compact dot+label pill (theme-aware
  $green10/$yellow10/$red10 tokens), a Popover status panel listing any down
  components + a "View full status" link; polls 60s, pauses when tab hidden,
  non-blocking ("Checking…" until first response).
- src/components/DashboardShell.tsx — mounts <SystemStatusBadge/> as the first
  topbar control.

Verification: vitest src/lib/status 7/7 green; tsc --noEmit clean for all changed
files (the only tsc errors in the tree are pre-existing @hanzo/usage module
resolution in unrelated ai-accounts/* files — a dep present in package.json but
not in this worktree's reused node_modules; my files import none of it). Not
deployed; no package.json version bump (release agent owns that).
2026-07-09 15:20:13 -07:00
hanzo-devandGitHub c15fdc48f4 debrand: SigNoz -> O11y across branding surfaces (#144)
Rebrand all SigNoz/signoz branding in comments, docstrings, tests, SDK
identifiers, and LLM.md to the o11y product name (case-correct):
signoz->o11y, SigNoz->O11y, Signoz->O11y.

- rename type SignozDataSource -> O11yDataSource (apm.ts + index.ts re-export)
- fix stale comment ref O11ySignozApi.logs -> ApmApi.logs

Kept (NOT branding):
- proxy-allow.ts attribution reworded to explicit 'forked from SigNoz'
- admin-o11y.ts ClickHouse table names signoz_traces/signoz_logs left intact
  (real upstream SigNoz schema owned by hanzoai/datastore, not this repo)

This repo does not import the collector; no dependency added.
2026-07-09 15:20:09 -07:00
hanzo-devandGitHub 45a2e8849b debrand: SigNoz -> O11y across branding surfaces (#144)
Rebrand all SigNoz/signoz branding in comments, docstrings, tests, SDK
identifiers, and LLM.md to the o11y product name (case-correct):
signoz->o11y, SigNoz->O11y, Signoz->O11y.

- rename type SignozDataSource -> O11yDataSource (apm.ts + index.ts re-export)
- fix stale comment ref O11ySignozApi.logs -> ApmApi.logs

Kept (NOT branding):
- proxy-allow.ts attribution reworded to explicit 'forked from SigNoz'
- admin-o11y.ts ClickHouse table names signoz_traces/signoz_logs left intact
  (real upstream SigNoz schema owned by hanzoai/datastore, not this repo)

This repo does not import the collector; no dependency added.
2026-07-09 15:20:09 -07:00
aaecae8c8d feat(console): SBOM panel on platform deployments (#145)
Add a read-only "Bill of Materials (SBOM)" section to the App Platform
deployment detail (the app-detail SlideOver), wired strictly to the backend
wire contract GET /v1/sbom/{ref}.

- data layer (lib/api/platform-apps.ts): new exported types SbomComponent +
  Sbom (SbomView), and PlatformAppsApi.sbom(imageRef) — same-origin /v1
  user-bearer proxy via cloudProxyV1Url, matching the file's restGet idiom;
  returns null on 404 (no SBOM recorded — expected, not an error), throws on
  any other non-200.
- UI (PlatformAppsModule.tsx): fetches sbom(appImageRef(app)) on SlideOver
  open; reuses the shared Spinner (loading), DataTable (overflow-x scrollable
  Name/Version/Type/License table, componentCount in the header) and the
  existing muted-Text patterns for the "No SBOM recorded" empty state and the
  "SBOM datastore unavailable" (503) note. Read-only, no mutations.
- appImageRef (platform-apps/logic.ts): one source for the image ref, shared
  by the Image fact and the SBOM lookup (+ unit tests).
- proxy-allow.ts: allow-list the `sbom` cloud head so the /v1 BFF forwards it.

tsc --noEmit clean; vitest 2056/2056 (incl. +2 appImageRef, proxy-allow head).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 14:28:25 -07:00
9eafc4c406 feat(console): SBOM panel on platform deployments (#145)
Add a read-only "Bill of Materials (SBOM)" section to the App Platform
deployment detail (the app-detail SlideOver), wired strictly to the backend
wire contract GET /v1/sbom/{ref}.

- data layer (lib/api/platform-apps.ts): new exported types SbomComponent +
  Sbom (SbomView), and PlatformAppsApi.sbom(imageRef) — same-origin /v1
  user-bearer proxy via cloudProxyV1Url, matching the file's restGet idiom;
  returns null on 404 (no SBOM recorded — expected, not an error), throws on
  any other non-200.
- UI (PlatformAppsModule.tsx): fetches sbom(appImageRef(app)) on SlideOver
  open; reuses the shared Spinner (loading), DataTable (overflow-x scrollable
  Name/Version/Type/License table, componentCount in the header) and the
  existing muted-Text patterns for the "No SBOM recorded" empty state and the
  "SBOM datastore unavailable" (503) note. Read-only, no mutations.
- appImageRef (platform-apps/logic.ts): one source for the image ref, shared
  by the Image fact and the SBOM lookup (+ unit tests).
- proxy-allow.ts: allow-list the `sbom` cloud head so the /v1 BFF forwards it.

tsc --noEmit clean; vitest 2056/2056 (incl. +2 appImageRef, proxy-allow head).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 14:28:25 -07:00
zeekayandClaude Opus 4.8 821cb266ad fix(mobile+shell): safe-area insets, de-dup sidebar footer identity (v8.4.123)
Systematic responsive/cross-platform pass. The v8.4.112 work already handles the big
items (no horizontal overflow on /, /models, /chat, /gpus, /billing at 390/768px;
tables scroll inside their own container; tab rows wrap; chat composer docks; coarse-
pointer tap targets are 44px; light-theme parity is intact). Concrete fixes:

- Sidebar footer no longer repeats the account identity — the new top switcher owns
  it. SidebarWallet is now purely the wallet (balance → Cost, Top up, Sign out), so
  the user's name/avatar isn't shown twice.
- Safe-area insets for notched devices: viewport-fit=cover exposes the insets; the
  chat composer dock pads the home indicator (bottom); the SlideOver drawers inset
  top (notch) + bottom (home indicator). Zero effect on devices without a cutout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:39:55 -07:00
zeekayandhanzo-dev 5d4c308d58 fix(mobile+shell): safe-area insets, de-dup sidebar footer identity (v8.4.123)
Systematic responsive/cross-platform pass. The v8.4.112 work already handles the big
items (no horizontal overflow on /, /models, /chat, /gpus, /billing at 390/768px;
tables scroll inside their own container; tab rows wrap; chat composer docks; coarse-
pointer tap targets are 44px; light-theme parity is intact). Concrete fixes:

- Sidebar footer no longer repeats the account identity — the new top switcher owns
  it. SidebarWallet is now purely the wallet (balance → Cost, Top up, Sign out), so
  the user's name/avatar isn't shown twice.
- Safe-area insets for notched devices: viewport-fit=cover exposes the insets; the
  chat composer dock pads the home indicator (bottom); the SlideOver drawers inset
  top (notch) + bottom (home indicator). Zero effect on devices without a cutout.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 13:39:55 -07:00
zeekayandClaude Opus 4.8 9d3fa34cb2 fix(csrf): scope the embed CSRF mint to cloud's requireCSRF write surfaces — kill the pre-auth 403
authedFetch fetched GET /v1/console/csrf for EVERY mutating request in the embed, so a
mutating request that fires BEFORE a session cookie exists (the pre-login session POST)
minted a token with no principal → 403 — the SPA's ONE remaining browser console error
(self-healing, but a logged error). Cloud only gates POST/DELETE /v1/console/{keys,
onboard,topup/wallet}, POST /v1/billing/*, and mutating /v1/commerce/* (clients/console/
console.go). Scope csrfRequired(method,url) to exactly those prefixes: money-writes still
get the token; login/session/control-plane writes no longer trigger the spurious mint.
Tests: +scoping case; 8 csrf + 12 client-retry pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:18:13 -07:00
zeekayandhanzo-dev e19a7251f2 fix(csrf): scope the embed CSRF mint to cloud's requireCSRF write surfaces — kill the pre-auth 403
authedFetch fetched GET /v1/console/csrf for EVERY mutating request in the embed, so a
mutating request that fires BEFORE a session cookie exists (the pre-login session POST)
minted a token with no principal → 403 — the SPA's ONE remaining browser console error
(self-healing, but a logged error). Cloud only gates POST/DELETE /v1/console/{keys,
onboard,topup/wallet}, POST /v1/billing/*, and mutating /v1/commerce/* (clients/console/
console.go). Scope csrfRequired(method,url) to exactly those prefixes: money-writes still
get the token; login/session/control-plane writes no longer trigger the spurious mint.
Tests: +scoping case; 8 csrf + 12 client-retry pass; typecheck clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-09 13:18:13 -07:00
hanzo-dev c7241900a4 Merge branch 'feat/unified-design' of github.com:hanzoai/console into HEAD 2026-07-08 17:16:15 -07:00
hanzo-dev e93f975381 Merge branch 'feat/unified-design' of github.com:hanzoai/console into HEAD 2026-07-08 17:16:15 -07:00
zandGitHub d3d1690d87 Merge pull request #142 from hanzoai/feat/saas-dashboard
feat(saas): SaaS Metrics admin board (commerce-backed) + LLM-obs panel
2026-07-08 17:13:47 -07:00
zandGitHub 50fc887e96 Merge pull request #142 from hanzoai/feat/saas-dashboard
feat(saas): SaaS Metrics admin board (commerce-backed) + LLM-obs panel
2026-07-08 17:13:47 -07:00
hanzo-dev f122b5bdc9 design: converge console tokens to canonical black-monochrome (de-tint neutrals, Geist Sans) 2026-07-08 17:09:30 -07:00
hanzo-dev a638c0dbe9 design: converge console tokens to canonical black-monochrome (de-tint neutrals, Geist Sans) 2026-07-08 17:09:30 -07:00
hanzo-dev 26b4a02921 feat(saas): SaaS Metrics admin board (commerce-backed) + LLM-obs panel
admin.hanzo.ai 'SaaS Metrics' board (global-admin only): MRR/ARR, MRR by plan
category, subscription mix (per-plan/trials/seats + recent create/cancel feed),
metered pay-as-you-go revenue, and top customers by revenue — all rendered from
commerce's /v1/commerce/metrics/saas aggregate (the money SOT), never
re-aggregated client-side, no client Stripe.

- app/admin/saas: global-admin-gated (getAdminGate) commerce proxy forwarding
  COMMERCE_SERVICE_TOKEN — the pattern commerce's api/costs gate documents;
  fixed path, allow-listed window/limit params, honest 501/502 states.
- next.config: route /v1/admin/saas to the commerce proxy (NOT the cloud
  aggregate); saas deliberately not in ADMIN_V1_HEADS.
- saas.ts: typed client + defensive normalizers (null upgrades/downgrades
  preserved; snake/camel tolerant).
- SaasModule: the board; the AI/LLM panel COMPOSES the SAME fleet o11y
  aggregate (AdminO11yApi /v1/admin/o11y) — per-model spend + fleet latency/
  error — so per-model obs is shared, never forked. Honest not-instrumented
  notes surfaced from the backend gaps[].

Proofs: tsc clean; vitest 929 passed (incl saas normalizer); next build OK
(/admin/saas registered).
2026-07-08 17:08:14 -07:00
hanzo-dev 9ec866de4b feat(saas): SaaS Metrics admin board (commerce-backed) + LLM-obs panel
admin.hanzo.ai 'SaaS Metrics' board (global-admin only): MRR/ARR, MRR by plan
category, subscription mix (per-plan/trials/seats + recent create/cancel feed),
metered pay-as-you-go revenue, and top customers by revenue — all rendered from
commerce's /v1/commerce/metrics/saas aggregate (the money SOT), never
re-aggregated client-side, no client Stripe.

- app/admin/saas: global-admin-gated (getAdminGate) commerce proxy forwarding
  COMMERCE_SERVICE_TOKEN — the pattern commerce's api/costs gate documents;
  fixed path, allow-listed window/limit params, honest 501/502 states.
- next.config: route /v1/admin/saas to the commerce proxy (NOT the cloud
  aggregate); saas deliberately not in ADMIN_V1_HEADS.
- saas.ts: typed client + defensive normalizers (null upgrades/downgrades
  preserved; snake/camel tolerant).
- SaasModule: the board; the AI/LLM panel COMPOSES the SAME fleet o11y
  aggregate (AdminO11yApi /v1/admin/o11y) — per-model spend + fleet latency/
  error — so per-model obs is shared, never forked. Honest not-instrumented
  notes surfaced from the backend gaps[].

Proofs: tsc clean; vitest 929 passed (incl saas normalizer); next build OK
(/admin/saas registered).
2026-07-08 17:08:14 -07:00
Hanzo AI 5d031fb55f feat(csrf): SPA echoes X-CSRF-Token on embed ambient-cookie money writes
The embed session-bridge money path (v8.4.122: billing/commerce/keys at bare
/v1 same-origin, caller resolved from the first-party IAM cookie) is guarded by
the cloud binary's requireCSRF (clients/console/csrf.go) — POST/DELETE
/v1/console/{keys,onboard,topup/wallet}, POST /v1/billing/*, POST|PUT|PATCH|DELETE
/v1/commerce/*. An ambient-cookie write with no X-CSRF-Token is refused (403).

Wire the SPA to satisfy it, DRY, one module (src/lib/api/csrf.ts):
- csrfToken() mints from GET /v1/console/csrf (SOP hides its body from a
  cross-site page), caches until ~1min pre-expiry, shares one in-flight fetch.
- applyCsrfToInit() stamps X-CSRF-Token on mutating requests in authedFetch (the
  ONE fetch — covers billing/commerce/wallet via restRequest); keys.ts (raw
  fetch) echoes it too. Both re-mint once on a 403 (server key resets on restart
  when CONSOLE_CSRF_KEY is unset) — blue's re-fetch-on-403 contract.
- Gated on IS_EMBED + a mutating verb: a non-embed host writes through the
  user-bearer BFF (Authorization ⇒ CSRF-immune), so this is a strict no-op there.

tsc clean; vitest 2048/2048 (+8 csrf: verb gate, cache, in-flight share, 403
re-mint, fail-secure null, header-shape stamping, non-embed no-op); next build ok.
2026-07-08 16:06:34 -07:00
Hanzo AI 82b97eec1c feat(csrf): SPA echoes X-CSRF-Token on embed ambient-cookie money writes
The embed session-bridge money path (v8.4.122: billing/commerce/keys at bare
/v1 same-origin, caller resolved from the first-party IAM cookie) is guarded by
the cloud binary's requireCSRF (clients/console/csrf.go) — POST/DELETE
/v1/console/{keys,onboard,topup/wallet}, POST /v1/billing/*, POST|PUT|PATCH|DELETE
/v1/commerce/*. An ambient-cookie write with no X-CSRF-Token is refused (403).

Wire the SPA to satisfy it, DRY, one module (src/lib/api/csrf.ts):
- csrfToken() mints from GET /v1/console/csrf (SOP hides its body from a
  cross-site page), caches until ~1min pre-expiry, shares one in-flight fetch.
- applyCsrfToInit() stamps X-CSRF-Token on mutating requests in authedFetch (the
  ONE fetch — covers billing/commerce/wallet via restRequest); keys.ts (raw
  fetch) echoes it too. Both re-mint once on a 403 (server key resets on restart
  when CONSOLE_CSRF_KEY is unset) — blue's re-fetch-on-403 contract.
- Gated on IS_EMBED + a mutating verb: a non-embed host writes through the
  user-bearer BFF (Authorization ⇒ CSRF-immune), so this is a strict no-op there.

tsc clean; vitest 2048/2048 (+8 csrf: verb gate, cache, in-flight share, 403
re-mint, fail-secure null, header-shape stamping, non-embed no-op); next build ok.
2026-07-08 16:06:34 -07:00
afeed80c01 feat: super-admin panel + entitlement-gated sidebar; rename isGlobalAdmin→isSuperAdmin (#141)
Out-of-box each customer org assembles its own backend: the sidebar/launcher/
palette show ONLY the products the org has enabled (always-on essentials + its
entitled set), with an "Add product" flow to enable more. Super admins bypass
gating (see everything) and get a per-org Entitlements editor to manage any org's
enabled products after masquerading in.

- src/lib/entitlements.ts: the ONE /v1/orgs/{org}/entitlements client + pure
  helpers (ALWAYS_ON_PRODUCTS, entitledSet, filterEntitled, nextEnabled). Swapping
  the mock for the real backend is this file alone; until it lands the GET 404s and
  the set is treated as null=UNGATED (show everything) → zero pre-launch regression.
- src/lib/entitlements-context.tsx: EntitlementsProvider/useEntitlements — ONE
  shared fetch of the active org's set, wired inside SessionProvider.
- registry: visibleCatalog(showAdmin, enabled?) + visibleCatalogByCategory +
  addableCatalogByCategory gate through the one filterEntitled predicate; threaded
  into DashboardShell, AppLauncher, CommandPalette, CategoryOverview + search.
- AddProductPanel: the customer enable flow; EntitlementsAdminModule + registry
  entry 'entitlements' (admin:true): the super-admin per-org editor.
- proxy-allow: 'orgs' head admits the org-scoped entitlements surface through /v1.
- Rename isGlobalAdmin→isSuperAdmin across the client (useIsSuperAdmin,
  isSuperAdminAccount, PickerContext/LoadContext field, 'Super admin' label).
  TRANSITIONAL: isSuperAdminAccount reads account.isSuperAdmin ?? account.isGlobalAdmin
  so it works before/after the IAM field rename. Server projected-claim untouched.

Tests: vitest 2040 pass (incl. entitlements 16, entitlements/logic 6, admin
back-compat 6, org-picker rename 25); tsc clean; next build ✓; playwright
entitlement-sidebar spec ✓ (gated nav shows Agents, hides GPUs, Add product lists
Enable GPUs).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-08 15:12:44 -07:00
8ca06f29ba feat: super-admin panel + entitlement-gated sidebar; rename isGlobalAdmin→isSuperAdmin (#141)
Out-of-box each customer org assembles its own backend: the sidebar/launcher/
palette show ONLY the products the org has enabled (always-on essentials + its
entitled set), with an "Add product" flow to enable more. Super admins bypass
gating (see everything) and get a per-org Entitlements editor to manage any org's
enabled products after masquerading in.

- src/lib/entitlements.ts: the ONE /v1/orgs/{org}/entitlements client + pure
  helpers (ALWAYS_ON_PRODUCTS, entitledSet, filterEntitled, nextEnabled). Swapping
  the mock for the real backend is this file alone; until it lands the GET 404s and
  the set is treated as null=UNGATED (show everything) → zero pre-launch regression.
- src/lib/entitlements-context.tsx: EntitlementsProvider/useEntitlements — ONE
  shared fetch of the active org's set, wired inside SessionProvider.
- registry: visibleCatalog(showAdmin, enabled?) + visibleCatalogByCategory +
  addableCatalogByCategory gate through the one filterEntitled predicate; threaded
  into DashboardShell, AppLauncher, CommandPalette, CategoryOverview + search.
- AddProductPanel: the customer enable flow; EntitlementsAdminModule + registry
  entry 'entitlements' (admin:true): the super-admin per-org editor.
- proxy-allow: 'orgs' head admits the org-scoped entitlements surface through /v1.
- Rename isGlobalAdmin→isSuperAdmin across the client (useIsSuperAdmin,
  isSuperAdminAccount, PickerContext/LoadContext field, 'Super admin' label).
  TRANSITIONAL: isSuperAdminAccount reads account.isSuperAdmin ?? account.isGlobalAdmin
  so it works before/after the IAM field rename. Server projected-claim untouched.

Tests: vitest 2040 pass (incl. entitlements 16, entitlements/logic 6, admin
back-compat 6, org-picker rename 25); tsc clean; next build ✓; playwright
entitlement-sidebar spec ✓ (gated nav shows Agents, hides GPUs, Add product lists
Enable GPUs).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-08 15:12:44 -07:00
Hanzo AI 86aafd1fb5 api(embed): address per-tenant billing/commerce + hk-keys at bare /v1 (v8.4.122)
In IS_EMBED (the static bundle the cloud binary go:embeds) there is NO Next server,
so the service-token BFF route handlers the money clients rely on — app/billing/v1,
app/commerce/[...path], app/keys/route.ts — are stripped by the static export; a
request to them falls through to the SPA shell (HTML, not JSON), so billing showed
'not available', the API-key CTA dead-ended, and the commerce store read as empty.
The embed is same-origin with the cloud binary, which serves the SAME heads at the
CANONICAL bare /v1/* (caller resolved from the first-party IAM session cookie →
validated principal, cloud middleware_identity.go), so in embed mode:

  billingProxyV1Url  → <origin>/v1/billing/<path>    (was /billing/v1/<path>)
  commerceProxyV1Url → <origin>/v1/commerce/<path>   (was /commerce/v1/<path>)
  keysUrl (keys.ts)  → <origin>/v1/console/keys       (was /keys)

Guarded by IS_EMBED, so every non-embed console (console2/admin/brand hosts, whose
gateway-fronted ingress 403s a cookie-only bare /v1) is UNCHANGED — only the cloud
embed build sets NEXT_PUBLIC_CONSOLE_EMBED=1. cloudProxyV1Url already equals
originV1Url on main (bare /v1), so cloud heads (framework/s3/gpus/functions/…) already
resolve correctly in the embed; billing/commerce/keys were the remaining BFF paths.
+embed-paths.test.ts pins the embed contract; canonical-paths.test.ts unchanged/green.
2026-07-08 09:45:26 -07:00
Hanzo AI d1321e998a api(embed): address per-tenant billing/commerce + hk-keys at bare /v1 (v8.4.122)
In IS_EMBED (the static bundle the cloud binary go:embeds) there is NO Next server,
so the service-token BFF route handlers the money clients rely on — app/billing/v1,
app/commerce/[...path], app/keys/route.ts — are stripped by the static export; a
request to them falls through to the SPA shell (HTML, not JSON), so billing showed
'not available', the API-key CTA dead-ended, and the commerce store read as empty.
The embed is same-origin with the cloud binary, which serves the SAME heads at the
CANONICAL bare /v1/* (caller resolved from the first-party IAM session cookie →
validated principal, cloud middleware_identity.go), so in embed mode:

  billingProxyV1Url  → <origin>/v1/billing/<path>    (was /billing/v1/<path>)
  commerceProxyV1Url → <origin>/v1/commerce/<path>   (was /commerce/v1/<path>)
  keysUrl (keys.ts)  → <origin>/v1/console/keys       (was /keys)

Guarded by IS_EMBED, so every non-embed console (console2/admin/brand hosts, whose
gateway-fronted ingress 403s a cookie-only bare /v1) is UNCHANGED — only the cloud
embed build sets NEXT_PUBLIC_CONSOLE_EMBED=1. cloudProxyV1Url already equals
originV1Url on main (bare /v1), so cloud heads (framework/s3/gpus/functions/…) already
resolve correctly in the embed; billing/commerce/keys were the remaining BFF paths.
+embed-paths.test.ts pins the embed contract; canonical-paths.test.ts unchanged/green.
2026-07-08 09:45:26 -07:00
9da0dbe492 feat(signup): public open signup + waitlisted product access (referral + run-hanzod move-up) (#140)
* test(console): guard proxy allow-lists against internal-infra + privileged heads

Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.

Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.

* feat(signup): public open signup + waitlisted product access (referral + run-hanzod move-up)

Signup is now PUBLIC (open, no invite required) and PROTECTED; product access is
WAITLISTED with two server-attested move-up paths. One coherent system over the
waitlist Base plugin (/v1/waitlist/*), shared by console/chat/app.

Public + protected signup (/auth/signup):
- Turnstile bot wall (verifyTurnstile; config-gated on TURNSTILE_SECRET_KEY)
- per-IP sliding-window rate limit (signupLimiter, default 5/IP/hr)
- disposable-email block (isDisposableEmail)
- same-origin CSRF gate (unchanged); new signups are self-service customer orgs
  (owner=personal slug, never the reserved admin org) — unchanged
- on success, best-effort join to the brand waitlist honoring a ?ref= referrer

Waitlisted product access:
- /auth/waitlist BFF resolves the signed-in email -> plugin status -> hasAccess
- WaitlistGate wraps the shell (AuthGate > WaitlistGate > OrgGate): renders the
  product only at the front of the line, else the waitlist panel (position +
  run-a-node + invite move-up). FAIL-OPEN: a waitlist blip never locks a user out.
- re-gatable via plugin knobs (WAITLIST_OPEN / WAITLIST_ACCESS_CAPACITY); the
  console-side switch is WAITLIST_URL (unset => gate off).

SignInForm: Turnstile widget (signup mode) + ?ref= capture. Tests: rate limiter,
disposable guard, waitlist client fail-open (14 tests). typecheck + next build green.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:02:54 -07:00
522c5160d1 feat(signup): public open signup + waitlisted product access (referral + run-hanzod move-up) (#140)
* test(console): guard proxy allow-lists against internal-infra + privileged heads

Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.

Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.

* feat(signup): public open signup + waitlisted product access (referral + run-hanzod move-up)

Signup is now PUBLIC (open, no invite required) and PROTECTED; product access is
WAITLISTED with two server-attested move-up paths. One coherent system over the
waitlist Base plugin (/v1/waitlist/*), shared by console/chat/app.

Public + protected signup (/auth/signup):
- Turnstile bot wall (verifyTurnstile; config-gated on TURNSTILE_SECRET_KEY)
- per-IP sliding-window rate limit (signupLimiter, default 5/IP/hr)
- disposable-email block (isDisposableEmail)
- same-origin CSRF gate (unchanged); new signups are self-service customer orgs
  (owner=personal slug, never the reserved admin org) — unchanged
- on success, best-effort join to the brand waitlist honoring a ?ref= referrer

Waitlisted product access:
- /auth/waitlist BFF resolves the signed-in email -> plugin status -> hasAccess
- WaitlistGate wraps the shell (AuthGate > WaitlistGate > OrgGate): renders the
  product only at the front of the line, else the waitlist panel (position +
  run-a-node + invite move-up). FAIL-OPEN: a waitlist blip never locks a user out.
- re-gatable via plugin knobs (WAITLIST_OPEN / WAITLIST_ACCESS_CAPACITY); the
  console-side switch is WAITLIST_URL (unset => gate off).

SignInForm: Turnstile widget (signup mode) + ?ref= capture. Tests: rate limiter,
disposable guard, waitlist client fail-open (14 tests). typecheck + next build green.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:02:54 -07:00
6174ea4d73 fix(network): mainnet chainID 36900 -> genesis-canonical 36963 (#139)
The mainnet default was the placeholder 36900; genesis
(lux/genesis/configs/hanzo-mainnet), the CLI, and the hanzo-evm comment
all say 36963. Align it so the console network model matches the CLI
exactly (same networkID/chainID/rpc per network):

  mainnet  36900 -> 36963   (rpc.hanzo.network)
  testnet  36962            (rpc.testnet.hanzo.network)  [unchanged]
  devnet   36964            (rpc.devnet.hanzo.network)   [unchanged]
  local    1337             (localhost:9630)             [unchanged]

Sovereign L1: networkID == evmChainID. All values stay env-overridable.
Fixes the hanzo-evm hex example (0x9024 -> 0x9063) + doc comments.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:01:12 -07:00
5e3206fb57 fix(network): mainnet chainID 36900 -> genesis-canonical 36963 (#139)
The mainnet default was the placeholder 36900; genesis
(lux/genesis/configs/hanzo-mainnet), the CLI, and the hanzo-evm comment
all say 36963. Align it so the console network model matches the CLI
exactly (same networkID/chainID/rpc per network):

  mainnet  36900 -> 36963   (rpc.hanzo.network)
  testnet  36962            (rpc.testnet.hanzo.network)  [unchanged]
  devnet   36964            (rpc.devnet.hanzo.network)   [unchanged]
  local    1337             (localhost:9630)             [unchanged]

Sovereign L1: networkID == evmChainID. All values stay env-overridable.
Fixes the hanzo-evm hex example (0x9024 -> 0x9063) + doc comments.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:01:12 -07:00
066baf1977 test(console): guard proxy allow-lists against internal-infra + privileged heads (#138)
Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.

Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 08:45:44 -07:00
cbd411ec26 test(console): guard proxy allow-lists against internal-infra + privileged heads (#138)
Pin the same-origin /v1 proxy boundary to the canonical capability manifest
(hanzoai/openapi CAPABILITIES.md): assert CLOUD_HEADS + COMMERCE_HEADS never
admit an internal-infra name (principal/goja/mpc/controlplane) nor a privileged
head (iam/admin/kms), and that every head is a clean, unique, lowercase segment.

Enforces the mandate rule that internal infra never appears as a public
capability, at the console boundary — non-breaking, no allow-list widening.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 08:45:44 -07:00
ee980c8fba feat(console): network switcher — local + custom networkID/EVM chainID, wire hanzo.network (v8.4.121) (#137)
The network switcher now expresses ONE model — (label, networkID, evmChainID,
rpcEndpoint, apiEndpoint) — for every selectable network, in a new `lib/network.ts`.

- Stock networks wired to REAL Hanzo endpoints (env-overridable), honoring
  networkID == evmChainID for the Hanzo sovereign L1:
    mainnet  36900  rpc.hanzo.network            (matches the deployed wallet RPC)
    testnet  36962  rpc.testnet.hanzo.network     (canonical genesis id)
    devnet   36964  rpc.devnet.hanzo.network      (canonical genesis id)
  API stays same-origin for the stock tiers (they differ by X-Environment, not host,
  keeping the session cookie first-party).
- Local: networkID/chainID 1337 (localnet), RPC localhost, API same-origin — a home
  user running the cloud binary sees "Local" and the console talks to their binary.
- Custom: user enters networkID + EVM chainID (defaults to networkID) + RPC + optional
  API endpoint; validated, persisted in localStorage, removable.

Selecting a network is ONE move: the active network's id IS the X-Environment string,
so it re-scopes every cloud call AND retargets chain/RPC/API — no parallel state, no
special-casing. `activeApiBase()` points the direct cloud client at the selected
deployment (`apiEndpoint` override, else same-origin). Existing mainnet/testnet/devnet
scoping is unchanged; the wallet's HANZO_MAINNET is now derived from the network model
(one source of truth).

Tests: 20 new (registry, resolution, validation, persistence). Full suite 1991 green,
typecheck + build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 08:27:42 -07:00
9f4263d8be feat(console): network switcher — local + custom networkID/EVM chainID, wire hanzo.network (v8.4.121) (#137)
The network switcher now expresses ONE model — (label, networkID, evmChainID,
rpcEndpoint, apiEndpoint) — for every selectable network, in a new `lib/network.ts`.

- Stock networks wired to REAL Hanzo endpoints (env-overridable), honoring
  networkID == evmChainID for the Hanzo sovereign L1:
    mainnet  36900  rpc.hanzo.network            (matches the deployed wallet RPC)
    testnet  36962  rpc.testnet.hanzo.network     (canonical genesis id)
    devnet   36964  rpc.devnet.hanzo.network      (canonical genesis id)
  API stays same-origin for the stock tiers (they differ by X-Environment, not host,
  keeping the session cookie first-party).
- Local: networkID/chainID 1337 (localnet), RPC localhost, API same-origin — a home
  user running the cloud binary sees "Local" and the console talks to their binary.
- Custom: user enters networkID + EVM chainID (defaults to networkID) + RPC + optional
  API endpoint; validated, persisted in localStorage, removable.

Selecting a network is ONE move: the active network's id IS the X-Environment string,
so it re-scopes every cloud call AND retargets chain/RPC/API — no parallel state, no
special-casing. `activeApiBase()` points the direct cloud client at the selected
deployment (`apiEndpoint` override, else same-origin). Existing mainnet/testnet/devnet
scoping is unchanged; the wallet's HANZO_MAINNET is now derived from the network model
(one source of truth).

Tests: 20 new (registry, resolution, validation, persistence). Full suite 1991 green,
typecheck + build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 08:27:42 -07:00
effcc7fc13 fix(console): root ALL cloud API paths at /v1/ — kill the /cloud/ prefix (v8.4.120) (#136)
CTO contract: ZERO prefix before /v1/ on any cloud API call. The console
rewrote /v1/<cloudhead> -> /cloud/v1/<cloudhead> (next.config.mjs) and
cloudProxyV1Url built /cloud/v1/... directly, so the /cloud/ prefix leaked to
clients and 404'd Automations (/v1/automations/* -> /cloud/v1/automations/*).

The user-bearer BFF (mints a short-lived IAM token from the session cookie;
cookie never reaches cloud-api; org server-authoritative from the Bearer owner;
same-origin CSRF guard on mutations; least-privilege allowCloudSurface allow-list)
moves from app/cloud/[...path] to app/v1/[...path]. It re-prepends the v1/ root,
so the allow-list and upstream URL still see v1/<head>. Every guard in
forwardWithUserBearer is preserved — a PATH change, not a security change.

- Removed the CLOUD_V1_HEADS / CLOUD_INFRA_V1_HEADS / CLOUD_PRODUCT_V1_HEADS
  -> /cloud/v1 rewrites in next.config.mjs; cloud heads now fall through to the
  /v1 catch-all (no rewrite). cloudProxyBase deleted; cloudProxyV1Url === originV1Url.
- Kept beforeFiles dispatch (wins over the catch-all): AI heads -> /ai, admin
  aggregate /v1/admin/* -> /admin/aggregate, visor catalog -> /vm, /v1/billing/*
  -> /billing/v1, /v1/commerce/* -> /commerce/v1 (server-internal; client only
  ever builds /v1/...).
- Scrubbed every /cloud/v1 and /cloud proxy reference across routes, rewrites,
  client calls, tests, comments, and LLM.md; dead CLOUD_V1_HEADS comment refs fixed.

Acceptance (built server): GET /v1/automations/connectors -> 401 JSON (reaches
the cloud BFF, not 404, not the SPA shell); /v1/agents, /v1/platform/projects ->
401 JSON (regression OK); /v1/billing/balance -> 401 "Sign in to view billing"
(billing dispatch still wins); /v1/bogushead -> 404 JSON (allow-list intact).
git grep /cloud/v1 = ZERO. tsc + next build green; 1965/1965 unit tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 07:16:48 -07:00
334c62f9f5 fix(console): root ALL cloud API paths at /v1/ — kill the /cloud/ prefix (v8.4.120) (#136)
CTO contract: ZERO prefix before /v1/ on any cloud API call. The console
rewrote /v1/<cloudhead> -> /cloud/v1/<cloudhead> (next.config.mjs) and
cloudProxyV1Url built /cloud/v1/... directly, so the /cloud/ prefix leaked to
clients and 404'd Automations (/v1/automations/* -> /cloud/v1/automations/*).

The user-bearer BFF (mints a short-lived IAM token from the session cookie;
cookie never reaches cloud-api; org server-authoritative from the Bearer owner;
same-origin CSRF guard on mutations; least-privilege allowCloudSurface allow-list)
moves from app/cloud/[...path] to app/v1/[...path]. It re-prepends the v1/ root,
so the allow-list and upstream URL still see v1/<head>. Every guard in
forwardWithUserBearer is preserved — a PATH change, not a security change.

- Removed the CLOUD_V1_HEADS / CLOUD_INFRA_V1_HEADS / CLOUD_PRODUCT_V1_HEADS
  -> /cloud/v1 rewrites in next.config.mjs; cloud heads now fall through to the
  /v1 catch-all (no rewrite). cloudProxyBase deleted; cloudProxyV1Url === originV1Url.
- Kept beforeFiles dispatch (wins over the catch-all): AI heads -> /ai, admin
  aggregate /v1/admin/* -> /admin/aggregate, visor catalog -> /vm, /v1/billing/*
  -> /billing/v1, /v1/commerce/* -> /commerce/v1 (server-internal; client only
  ever builds /v1/...).
- Scrubbed every /cloud/v1 and /cloud proxy reference across routes, rewrites,
  client calls, tests, comments, and LLM.md; dead CLOUD_V1_HEADS comment refs fixed.

Acceptance (built server): GET /v1/automations/connectors -> 401 JSON (reaches
the cloud BFF, not 404, not the SPA shell); /v1/agents, /v1/platform/projects ->
401 JSON (regression OK); /v1/billing/balance -> 401 "Sign in to view billing"
(billing dispatch still wins); /v1/bogushead -> 404 JSON (allow-list intact).
git grep /cloud/v1 = ZERO. tsc + next build green; 1965/1965 unit tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 07:16:48 -07:00
zeekay af33afa321 Revert "fix(embed): billing reads via bare /v1/billing in the static embed (fix overview usage + wallet)"
This reverts commit ee2638c777.
2026-07-08 05:26:31 -07:00
zeekay 6b87e99122 Revert "fix(embed): billing reads via bare /v1/billing in the static embed (fix overview usage + wallet)"
This reverts commit 2331023c14.
2026-07-08 05:26:31 -07:00
322ca05e40 chore(release): console v8.4.119 — publish the enterprise usage view + query depth (#135)
console#132 (unified Usage view + audit/logs/billing query depth) and #133 merged
at version 8.4.118 without a bump, so CI re-pushed the mutable v8.4.118 tag over the
prior build. Bump to v8.4.119 so the next build publishes a clean, distinct tag and
the git version again identifies a unique image (tag truth). The live deploy is
digest-pinned (universe#445); this fixes forward drift.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-08 04:43:16 -07:00
b839030912 chore(release): console v8.4.119 — publish the enterprise usage view + query depth (#135)
console#132 (unified Usage view + audit/logs/billing query depth) and #133 merged
at version 8.4.118 without a bump, so CI re-pushed the mutable v8.4.118 tag over the
prior build. Bump to v8.4.119 so the next build publishes a clean, distinct tag and
the git version again identifies a unique image (tag truth). The live deploy is
digest-pinned (universe#445); this fixes forward drift.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-08 04:43:16 -07:00
zeekayandClaude Opus 4.8 ee2638c777 fix(embed): billing reads via bare /v1/billing in the static embed (fix overview usage + wallet)
The go:embed static console has NO Next BFF, so billingProxyBase()'s /billing/v1/*
route-handler is absent → SPA fallback (200 HTML) → the overview 'Real-time usage'
tile (UsageApi.overview→fetchUsageRecords) + wallet throw 'Invalid response (HTTP
200)'. In the embed, /v1/* is served same-origin by cloud (validates the session,
resolves org from the owner claim) — proven by the working bare-/v1 heads (agents/
tracker/analytics). So IS_EMBED addresses cloud's /v1/billing/* directly. The
Next-server console (console2.hanzo.ai) keeps the /billing BFF (IS_EMBED=false) —
no regression there; embed billing was already broken so this can only improve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 03:43:45 -07:00
zeekayandhanzo-dev 2331023c14 fix(embed): billing reads via bare /v1/billing in the static embed (fix overview usage + wallet)
The go:embed static console has NO Next BFF, so billingProxyBase()'s /billing/v1/*
route-handler is absent → SPA fallback (200 HTML) → the overview 'Real-time usage'
tile (UsageApi.overview→fetchUsageRecords) + wallet throw 'Invalid response (HTTP
200)'. In the embed, /v1/* is served same-origin by cloud (validates the session,
resolves org from the owner claim) — proven by the working bare-/v1 heads (agents/
tracker/analytics). So IS_EMBED addresses cloud's /v1/billing/* directly. The
Next-server console (console2.hanzo.ai) keeps the /billing BFF (IS_EMBED=false) —
no regression there; embed billing was already broken so this can only improve.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 03:43:45 -07:00
hanzo-devandGitHub 24260390c4 feat(automations): native in-console Automations module — kill the auto.hanzo.ai link-out (#134)
Decomplect the TWO automation surfaces into ONE native one. The console had
two external tiles both pointing at the standalone auto.hanzo.ai engine
(`auto` → auto.hanzo.ai, `automations` → auto.hanzo.ai/automations). Collapse
them into ONE native module backed by the go-forward `/v1/automations` engine
(706-piece catalogue, flows/runs durable on the shared Tasks engine) — like the
App Platform module is native on /v1/platform. No link-out, one nav tile.

- registry: remove the `auto` external entry; convert `automations` from
  kind:external → a native module (routes  + :tab; subpages Connectors/Runs).
- AutomationsModule: Flows (create/enable/disable/run/delete) · Connectors (the
  706-piece catalogue, search + category filter) · Runs — over the /cloud
  user-bearer proxy (org from the Bearer owner; honest loading/empty/error).
- lib/api/automations.ts: AutomationsApi + defensive normalizers (mirrors
  paas.ts transport: cloudProxyV1Url → /cloud/v1/automations).
- match-core: `/auto` + `/automation` alias → `automations` (was → external
  `auto`); ONE product, ONE surface, aliases preserved. The external kind stays
  for the Lux/Zoo chain-app tiles (test fixture repointed to a chain app).
- proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS: add `automations`.

tsc --noEmit clean (0 errors), vitest 1970/1970 (+25 automations, match-core
updated), next build ✓.
2026-07-07 23:11:47 -07:00
hanzo-devandGitHub 33d4b1d322 feat(automations): native in-console Automations module — kill the auto.hanzo.ai link-out (#134)
Decomplect the TWO automation surfaces into ONE native one. The console had
two external tiles both pointing at the standalone auto.hanzo.ai engine
(`auto` → auto.hanzo.ai, `automations` → auto.hanzo.ai/automations). Collapse
them into ONE native module backed by the go-forward `/v1/automations` engine
(706-piece catalogue, flows/runs durable on the shared Tasks engine) — like the
App Platform module is native on /v1/platform. No link-out, one nav tile.

- registry: remove the `auto` external entry; convert `automations` from
  kind:external → a native module (routes  + :tab; subpages Connectors/Runs).
- AutomationsModule: Flows (create/enable/disable/run/delete) · Connectors (the
  706-piece catalogue, search + category filter) · Runs — over the /cloud
  user-bearer proxy (org from the Bearer owner; honest loading/empty/error).
- lib/api/automations.ts: AutomationsApi + defensive normalizers (mirrors
  paas.ts transport: cloudProxyV1Url → /cloud/v1/automations).
- match-core: `/auto` + `/automation` alias → `automations` (was → external
  `auto`); ONE product, ONE surface, aliases preserved. The external kind stays
  for the Lux/Zoo chain-app tiles (test fixture repointed to a chain app).
- proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS: add `automations`.

tsc --noEmit clean (0 errors), vitest 1970/1970 (+25 automations, match-core
updated), next build ✓.
2026-07-07 23:11:47 -07:00
6275707e57 feat(console): Startups pipeline board (Startup Program) (#133)
New Startups module rendering the cloud /v1/crm/applications pipeline as a
@hanzo/data board (lanes by stage: applied→screened→qualified→credits-offered→
onboarded→rejected). Cards show company, AI score, tier-1 flag, suggested credits;
card opens a SlideOver drawer with all submitted data, the AI screen (score/tier1/
credits/summary + copy-able draft reply), stage timeline, stage-advance buttons
(PATCH via the server stage machine; drag also advances), and a grant-credits deep
link into billing. StartupsApi mirrors CrmApi (originV1Url → /cloud bearer proxy).
Registry: one import + one Apps entry.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-07 22:11:04 -07:00
84b5fa6f0f feat(console): Startups pipeline board (Startup Program) (#133)
New Startups module rendering the cloud /v1/crm/applications pipeline as a
@hanzo/data board (lanes by stage: applied→screened→qualified→credits-offered→
onboarded→rejected). Cards show company, AI score, tier-1 flag, suggested credits;
card opens a SlideOver drawer with all submitted data, the AI screen (score/tier1/
credits/summary + copy-able draft reply), stage timeline, stage-advance buttons
(PATCH via the server stage machine; drag also advances), and a grant-credits deep
link into billing. StartupsApi mirrors CrmApi (originV1Url → /cloud bearer proxy).
Registry: one import + one Apps entry.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-07 22:11:04 -07:00
893f9a846e feat(usage,audit,logs,billing): enterprise usage view + query depth (#132)
- Usage (new product, Observe): the org's unified footprint on one screen —
  KPI band (spend/MTD/balance/LLM tokens+spend/machines/GPUs), spend-over-time +
  spend-by-category charts, a CSV-exportable cost breakdown, and per-source
  connected/not-connected badges. Backed by GET /v1/usage/summary (+ visor
  inventory), org-scoped via the /cloud bearer proxy.

- Audit (upgraded to enterprise grade): filters (time/actor/action/resource+id/
  result), real server pagination, a per-event detail drawer with the hash-chain
  linkage (immutability evidence), and CSV export. Now backed by the org-scoped
  cloud audit trail (GET /v1/audit) instead of the IAM record list.

- Logs: a query builder on the application-logs lens (severity/service/
  contains-text over the o11y time-range query) + localStorage saved views +
  CSV export of visible rows; contains-text + CSV added to the request lens too.

- Billing Reports: CSV export of the full filtered usage breakdown.

- Shared: src/lib/csv.ts (RFC-4180 serializer + browser download, one place);
  api clients usage-summary.ts + audit.ts; register usage/audit as /cloud proxy
  heads (next.config CLOUD_V1_HEADS + proxy-allow CLOUD_HEADS).

tsc 0, vitest 1679 green, next build ok.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-07 22:09:01 -07:00
de79129d3a feat(usage,audit,logs,billing): enterprise usage view + query depth (#132)
- Usage (new product, Observe): the org's unified footprint on one screen —
  KPI band (spend/MTD/balance/LLM tokens+spend/machines/GPUs), spend-over-time +
  spend-by-category charts, a CSV-exportable cost breakdown, and per-source
  connected/not-connected badges. Backed by GET /v1/usage/summary (+ visor
  inventory), org-scoped via the /cloud bearer proxy.

- Audit (upgraded to enterprise grade): filters (time/actor/action/resource+id/
  result), real server pagination, a per-event detail drawer with the hash-chain
  linkage (immutability evidence), and CSV export. Now backed by the org-scoped
  cloud audit trail (GET /v1/audit) instead of the IAM record list.

- Logs: a query builder on the application-logs lens (severity/service/
  contains-text over the o11y time-range query) + localStorage saved views +
  CSV export of visible rows; contains-text + CSV added to the request lens too.

- Billing Reports: CSV export of the full filtered usage breakdown.

- Shared: src/lib/csv.ts (RFC-4180 serializer + browser download, one place);
  api clients usage-summary.ts + audit.ts; register usage/audit as /cloud proxy
  heads (next.config CLOUD_V1_HEADS + proxy-allow CLOUD_HEADS).

tsc 0, vitest 1679 green, next build ok.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-07 22:09:01 -07:00
zeekayandClaude Opus 4.8 5ea2efc881 fix(embed): skip BFF-only session probes in the static embed (kill 405 console noise)
The hanzoai/cloud go:embed serves console2 as a static SPA with NO Node BFF
runtime, so the server-side session routes (/auth/refresh, /auth/session) and the
/billing/v1/me/welcome proxy don't exist — their client POSTs fall through to the
GET-only SPA fallback and 405 on every load (3 console errors/load, observed via
live playwright E2E as z@hanzo.ai). The console already runs on the casibase
session there (login→org→modules→real data all work while these 405'd), so the
probes are pure noise + a doomed session-rotation attempt.

Gate them behind IS_EMBED (NEXT_PUBLIC_CONSOLE_EMBED=1, set by build-embed.mjs):
in the embed, refreshSession()/consoleGet()/establishSession()/signout-DELETE and
the welcome-grant self-heal skip the fetch and fall back to the casibase session —
identical behavior, zero 405s. The Next-server console (console2.hanzo.ai,
admin.hanzo.ai) leaves IS_EMBED false and keeps the full durable-session BFF.

Tests: refresh.test.ts adds an embed case (skips fetch, resolves false); all 5
refresh + 22 canonical-path tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:29:46 -07:00
zeekayandhanzo-dev 84b55f90fe fix(embed): skip BFF-only session probes in the static embed (kill 405 console noise)
The hanzoai/cloud go:embed serves console2 as a static SPA with NO Node BFF
runtime, so the server-side session routes (/auth/refresh, /auth/session) and the
/billing/v1/me/welcome proxy don't exist — their client POSTs fall through to the
GET-only SPA fallback and 405 on every load (3 console errors/load, observed via
live playwright E2E as z@hanzo.ai). The console already runs on the casibase
session there (login→org→modules→real data all work while these 405'd), so the
probes are pure noise + a doomed session-rotation attempt.

Gate them behind IS_EMBED (NEXT_PUBLIC_CONSOLE_EMBED=1, set by build-embed.mjs):
in the embed, refreshSession()/consoleGet()/establishSession()/signout-DELETE and
the welcome-grant self-heal skip the fetch and fall back to the casibase session —
identical behavior, zero 405s. The Next-server console (console2.hanzo.ai,
admin.hanzo.ai) leaves IS_EMBED false and keeps the full durable-session BFF.

Tests: refresh.test.ts adds an embed case (skips fetch, resolves false); all 5
refresh + 22 canonical-path tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 21:29:46 -07:00
10030b9001 feat(ai-accounts): honor server-driven org routing defaults in the Routing tab (#131)
The Smart Routing toggle was a purely-local sealed-cookie preference. Ops can now
set a per-org default (auto_routing_active + default_session_routing) from
admin.hanzo.ai, exposed by cloud-api `GET /v1/get-routing-defaults`.

- New READ-ONLY proxy `app/ai-accounts/v1/routing-defaults` forwards the caller's
  minted user bearer to cloud-api (org = token owner), same auth pattern as `/cloud`.
  Does NOT touch the org-settings write path (confused-deputy escalation — reads only).
- The cookie preference becomes a tri-state user OVERRIDE (true/false/null); absent
  cookie = null = follow the org default.
- One pure `resolveRouting(pref, org)` (src/lib/products/ai-accounts.ts): explicit
  override wins, else org default, else off; an org that disabled routing disables the
  toggle with honest copy.
- Fail-soft everywhere: `routingDefaults()` returns null on 404 (older cloud-api) /
  error, so the tab works unchanged with the preference alone.
- RoutingTab shows "Organization default: On/Off — set by your admin".

Tests: +4 resolveRouting, normalizeSettings updated for tri-state. 1929 green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:40:42 -07:00
b0b4f4d11e feat(ai-accounts): honor server-driven org routing defaults in the Routing tab (#131)
The Smart Routing toggle was a purely-local sealed-cookie preference. Ops can now
set a per-org default (auto_routing_active + default_session_routing) from
admin.hanzo.ai, exposed by cloud-api `GET /v1/get-routing-defaults`.

- New READ-ONLY proxy `app/ai-accounts/v1/routing-defaults` forwards the caller's
  minted user bearer to cloud-api (org = token owner), same auth pattern as `/cloud`.
  Does NOT touch the org-settings write path (confused-deputy escalation — reads only).
- The cookie preference becomes a tri-state user OVERRIDE (true/false/null); absent
  cookie = null = follow the org default.
- One pure `resolveRouting(pref, org)` (src/lib/products/ai-accounts.ts): explicit
  override wins, else org default, else off; an org that disabled routing disables the
  toggle with honest copy.
- Fail-soft everywhere: `routingDefaults()` returns null on 404 (older cloud-api) /
  error, so the tab works unchanged with the preference alone.
- RoutingTab shows "Organization default: On/Off — set by your admin".

Tests: +4 resolveRouting, normalizeSettings updated for tri-state. 1929 green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:40:42 -07:00
2c564cf698 feat(ai-accounts): badge In-app tracked vs Connect-only from @hanzo/usage (#130)
Wire the AccountsTab provider badge off trackedProviderIds (derived export new
in @hanzo/usage 0.1.2), bump the dep, so each provider row shows whether it has
a live in-app usage pipeline or is connect-only. typecheck + 1925 vitest green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:15:18 -07:00
a2b97f25dc feat(ai-accounts): badge In-app tracked vs Connect-only from @hanzo/usage (#130)
Wire the AccountsTab provider badge off trackedProviderIds (derived export new
in @hanzo/usage 0.1.2), bump the dep, so each provider row shows whether it has
a live in-app usage pipeline or is connect-only. typecheck + 1925 vitest green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:15:18 -07:00
9a1f0ba32f docs(ai-accounts): document why Smart Routing stays cookie-only (#129)
cloud-api now enforces per-org auto-routing via OrgSettings.AutoRouting
(hanzoai/ai), toggled through the global-admin-gated, non-gateway-exposed
POST /v1/update-org-settings. The Routing tab is a customer surface whose
minted hanzo-console bearer is not global-admin, and the only admin proxy
(/admin/aggregate) fail-closed-403s a non-global-admin — so there is no clean
authenticated path to write cloud-side OrgSettings, and forging one would be a
confused-deputy escalation. Keep the sealed-cookie org preference and document
the exact unlock condition; no auth bodge, no behavior change.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:07:05 -07:00
04a0fb0c6e docs(ai-accounts): document why Smart Routing stays cookie-only (#129)
cloud-api now enforces per-org auto-routing via OrgSettings.AutoRouting
(hanzoai/ai), toggled through the global-admin-gated, non-gateway-exposed
POST /v1/update-org-settings. The Routing tab is a customer surface whose
minted hanzo-console bearer is not global-admin, and the only admin proxy
(/admin/aggregate) fail-closed-403s a non-global-admin — so there is no clean
authenticated path to write cloud-side OrgSettings, and forging one would be a
confused-deputy escalation. Keep the sealed-cookie org preference and document
the exact unlock condition; no auth bodge, no behavior change.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:07:05 -07:00
70b38c0b93 feat(ai-accounts): surface Smart Routing as a first-class, enable-able option (#128)
Add a Routing tab to the AI Accounts module explaining model:"auto" (local
zen -> cheap tier -> frontier, billed as the X-Routed-Model that served it, up
to 90% lower spend), with docs + blog links and a copyable curl for API users.

Persist the org/user routingEnabled preference server-side via the SAME sealed-
cookie store the credential store uses (extended with a non-secret settings
blob) behind a new static /ai-accounts/v1/settings route (GET/PUT, session-gated,
CSRF-guarded). The toggle is honest about scope: a preference Hanzo surfaces read;
API callers opt in per request with model:"auto".

- lib/server/ai-accounts.ts: AI_SETTINGS_COOKIE + normalizeSettings/readSettings/
  settingsCookie (fail-closed to routing OFF)
- app/ai-accounts/v1/settings/route.ts: GET/PUT preference route
- lib/api/ai-accounts.ts: AiAccountsSettings + settings()/saveSettings()
- components/products/ai-accounts/RoutingTab.tsx: value-prop card + toggle + curl
- AIAccountsModule.tsx: third Routing tab; registry.tsx: routing subpage

Validate: tsc --noEmit clean; vitest 1925/1925 (+2 normalizeSettings).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 17:43:10 -07:00
0d19583383 feat(ai-accounts): surface Smart Routing as a first-class, enable-able option (#128)
Add a Routing tab to the AI Accounts module explaining model:"auto" (local
zen -> cheap tier -> frontier, billed as the X-Routed-Model that served it, up
to 90% lower spend), with docs + blog links and a copyable curl for API users.

Persist the org/user routingEnabled preference server-side via the SAME sealed-
cookie store the credential store uses (extended with a non-secret settings
blob) behind a new static /ai-accounts/v1/settings route (GET/PUT, session-gated,
CSRF-guarded). The toggle is honest about scope: a preference Hanzo surfaces read;
API callers opt in per request with model:"auto".

- lib/server/ai-accounts.ts: AI_SETTINGS_COOKIE + normalizeSettings/readSettings/
  settingsCookie (fail-closed to routing OFF)
- app/ai-accounts/v1/settings/route.ts: GET/PUT preference route
- lib/api/ai-accounts.ts: AiAccountsSettings + settings()/saveSettings()
- components/products/ai-accounts/RoutingTab.tsx: value-prop card + toggle + curl
- AIAccountsModule.tsx: third Routing tab; registry.tsx: routing subpage

Validate: tsc --noEmit clean; vitest 1925/1925 (+2 normalizeSettings).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 17:43:10 -07:00
c5ca7fb1f2 fix(embed): resolve @hanzo/usage from registry so the static export builds in CI (#127)
build:embed (the hanzoai/cloud go:embed source) failed in any isolated clone:
@hanzo/usage was pinned to file:../usage/packages/core — a sibling repo absent
from the Docker console-stage clone — so it resolved to a dangling symlink and
`next build` died with "Cannot find module '@hanzo/usage'". This is why the
cloud image silently shipped the placeholder shell.

Pin @hanzo/usage to the published 0.1.0 (registry.npmjs.org; exports the
UsageSnapshot type the console imports). Also drop the accidentally-committed
`node_modules` symlink (-> /Users/z/work/hanzo/console2/node_modules, a dead
macOS dev path to the old repo name) and tighten .gitignore to a bare
`node_modules` so it can't recur.

Verified: npm install + npm run build:embed from this branch emit a real static
export (7.7M out/, ~360KB index.html + 4.4M _next/) that hanzoai/cloud embeds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 15:52:54 -07:00
dffc97e376 fix(embed): resolve @hanzo/usage from registry so the static export builds in CI (#127)
build:embed (the hanzoai/cloud go:embed source) failed in any isolated clone:
@hanzo/usage was pinned to file:../usage/packages/core — a sibling repo absent
from the Docker console-stage clone — so it resolved to a dangling symlink and
`next build` died with "Cannot find module '@hanzo/usage'". This is why the
cloud image silently shipped the placeholder shell.

Pin @hanzo/usage to the published 0.1.0 (registry.npmjs.org; exports the
UsageSnapshot type the console imports). Also drop the accidentally-committed
`node_modules` symlink (-> /Users/z/work/hanzo/console2/node_modules, a dead
macOS dev path to the old repo name) and tighten .gitignore to a bare
`node_modules` so it can't recur.

Verified: npm install + npm run build:embed from this branch emit a real static
export (7.7M out/, ~360KB index.html + 4.4M _next/) that hanzoai/cloud embeds.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 15:52:54 -07:00
Hanzo AI 8f96a115a9 feat(ai-accounts): AI Accounts product — connect accounts + unified usage
New Observe module 'ai-accounts' (label AI Accounts) with Overview + Accounts
tabs, over the headless @hanzo/usage engine plus the org's Hanzo commerce lane.

- Registry: one CatalogEntry (kind module, routes ''+:tab, subpage 'accounts')
  so /ai-accounts and /ai-accounts/accounts route with no new page files.
- Accounts tab: connect Hanzo (native), OpenAI/Codex, Anthropic/Claude by
  pasting an API key / OAuth token / cookie header; non-AI groups are honest
  catalog-driven 'coming soon' rows. Secret POSTed to the new server route.
- Overview tab: server route runs runPipeline (nodeHost) per connected provider
  and merges the real commerce CloudUsageOverview for the Hanzo lane; empty
  state has a 'Connect your AI accounts' CTA (1% routed-usage fee copy).
- Credentials: sealed (AES-256-GCM, reusing session.ts) in an httpOnly cookie
  scoped to /ai-accounts, per user; never localStorage, never logged, masked on
  read-back. TODO(KMS) to move at-rest storage to kms.hanzo.ai.
- Dep: @hanzo/usage file:../usage/packages/core; serverExternalPackages entry.

typecheck clean; vitest 1923/1923.
2026-07-07 15:14:58 -07:00
Hanzo AI e580334e04 feat(ai-accounts): AI Accounts product — connect accounts + unified usage
New Observe module 'ai-accounts' (label AI Accounts) with Overview + Accounts
tabs, over the headless @hanzo/usage engine plus the org's Hanzo commerce lane.

- Registry: one CatalogEntry (kind module, routes ''+:tab, subpage 'accounts')
  so /ai-accounts and /ai-accounts/accounts route with no new page files.
- Accounts tab: connect Hanzo (native), OpenAI/Codex, Anthropic/Claude by
  pasting an API key / OAuth token / cookie header; non-AI groups are honest
  catalog-driven 'coming soon' rows. Secret POSTed to the new server route.
- Overview tab: server route runs runPipeline (nodeHost) per connected provider
  and merges the real commerce CloudUsageOverview for the Hanzo lane; empty
  state has a 'Connect your AI accounts' CTA (1% routed-usage fee copy).
- Credentials: sealed (AES-256-GCM, reusing session.ts) in an httpOnly cookie
  scoped to /ai-accounts, per user; never localStorage, never logged, masked on
  read-back. TODO(KMS) to move at-rest storage to kms.hanzo.ai.
- Dep: @hanzo/usage file:../usage/packages/core; serverExternalPackages entry.

typecheck clean; vitest 1923/1923.
2026-07-07 15:14:58 -07:00
Hanzo AI fc4c48bc32 feat(console): two-level org model + org-picker landing (v8.4.118)
Every login now lands org-LESS on a full-page org picker (the "Home" org
list) instead of auto-scoping into the brand org. Clicking an org card scopes
the whole console to it (X-Org-Id); a sidebar Home affordance de-scopes back
to the picker. A one-org user still sees a one-card list and clicks in; a
global admin (z@hanzo.ai) sees every live org (masquerade).

- org-scope: add the SELECTION concern orthogonal to the org VALUE —
  hasSelectedOrg()/enterOrg()/leaveOrg(). currentOrg()/setCurrentOrg()/
  isScopedAway()/switchOrg()/filterOrgs() unchanged; switchOrg keeps the
  selection set. Pure + tested (+4 tests).
- OrgPicker: the full-page landing — responsive card grid (logo or monogram,
  role, honest quick facts), filter (reuses filterOrgs), client "Show more"
  pagination (PAGE_SIZE 24), a Create-organization CTA (→ OrgOnboarding), and
  honest loading/empty/no-match/error states (own-org fallback, never
  fabricated). Global admin lists all orgs via /admin/iam; a tenant sees its
  own org synthesized from the session.
- org-picker/logic: the pure decision core (sort/filter/paginate + the card
  view-model, role, facts). 25 vitest cases, incl. a literal-substring
  (no-regex) guard.
- OrgGate: routes 0 orgs → OrgOnboarding · has-orgs+none-selected → OrgPicker
  · selected → the scoped shell. Drops the auto-scope-seed/reload effect
  (explicit enter replaces it). Selection read on mount to avoid a flash.
- DashboardShell: the sidebar top-left keeps the OrgSwitcher (active org +
  quick-switch) and gains a Home affordance (leaveOrg → picker) in the
  expanded rail, the collapsed rail, and the mobile drawer.
- Drive-by (unblocks `next build` — pre-existing on this base): null-guard
  useSearchParams()/usePathname() in app/accept + SearchModule (Next 15
  stricter types).

typecheck clean (0), vitest 1923/1923 (+36 across the two new/updated files),
next build green (18/18). @hanzo/gui v5 shorthands only.
2026-07-07 12:46:02 -07:00
Hanzo AI b26c559e88 feat(console): two-level org model + org-picker landing (v8.4.118)
Every login now lands org-LESS on a full-page org picker (the "Home" org
list) instead of auto-scoping into the brand org. Clicking an org card scopes
the whole console to it (X-Org-Id); a sidebar Home affordance de-scopes back
to the picker. A one-org user still sees a one-card list and clicks in; a
global admin (z@hanzo.ai) sees every live org (masquerade).

- org-scope: add the SELECTION concern orthogonal to the org VALUE —
  hasSelectedOrg()/enterOrg()/leaveOrg(). currentOrg()/setCurrentOrg()/
  isScopedAway()/switchOrg()/filterOrgs() unchanged; switchOrg keeps the
  selection set. Pure + tested (+4 tests).
- OrgPicker: the full-page landing — responsive card grid (logo or monogram,
  role, honest quick facts), filter (reuses filterOrgs), client "Show more"
  pagination (PAGE_SIZE 24), a Create-organization CTA (→ OrgOnboarding), and
  honest loading/empty/no-match/error states (own-org fallback, never
  fabricated). Global admin lists all orgs via /admin/iam; a tenant sees its
  own org synthesized from the session.
- org-picker/logic: the pure decision core (sort/filter/paginate + the card
  view-model, role, facts). 25 vitest cases, incl. a literal-substring
  (no-regex) guard.
- OrgGate: routes 0 orgs → OrgOnboarding · has-orgs+none-selected → OrgPicker
  · selected → the scoped shell. Drops the auto-scope-seed/reload effect
  (explicit enter replaces it). Selection read on mount to avoid a flash.
- DashboardShell: the sidebar top-left keeps the OrgSwitcher (active org +
  quick-switch) and gains a Home affordance (leaveOrg → picker) in the
  expanded rail, the collapsed rail, and the mobile drawer.
- Drive-by (unblocks `next build` — pre-existing on this base): null-guard
  useSearchParams()/usePathname() in app/accept + SearchModule (Next 15
  stricter types).

typecheck clean (0), vitest 1923/1923 (+36 across the two new/updated files),
next build green (18/18). @hanzo/gui v5 shorthands only.
2026-07-07 12:46:02 -07:00
Hanzo AI b8a088d797 chore: release v8.4.117 — single-open sidebar accordion 2026-07-07 12:08:43 -07:00
Hanzo AI b087123658 chore: release v8.4.117 — single-open sidebar accordion 2026-07-07 12:08:43 -07:00
Hanzo AI 9f916dd52e sidebar: single-open category accordion (only ONE menu expanded at a time)
The level-1 product nav let multiple category sections stay expanded at once
(toggleCategory flipped each category independently), cluttering the sidebar.
Make it a true single-open accordion: opening a category collapses whatever was
open, and with no explicit choice the active route's category is the one open
section. Keeps the filtering=all-open and active-route-visible behaviors.

- nav-accordion.ts: openChoice() enforces the single-open invariant on read;
  categoryIsOpen honors exactly one choice (else the active category);
  toggleCategory opens only the clicked category (or clears on re-click).
  Exported signatures unchanged — DashboardShell binding untouched.
- test: assert the single-open invariant (opening one collapses others; never
  two expanded). 11/11 green.
2026-07-07 12:05:17 -07:00
Hanzo AI e2939ee830 sidebar: single-open category accordion (only ONE menu expanded at a time)
The level-1 product nav let multiple category sections stay expanded at once
(toggleCategory flipped each category independently), cluttering the sidebar.
Make it a true single-open accordion: opening a category collapses whatever was
open, and with no explicit choice the active route's category is the one open
section. Keeps the filtering=all-open and active-route-visible behaviors.

- nav-accordion.ts: openChoice() enforces the single-open invariant on read;
  categoryIsOpen honors exactly one choice (else the active category);
  toggleCategory opens only the clicked category (or clears on re-click).
  Exported signatures unchanged — DashboardShell binding untouched.
- test: assert the single-open invariant (opening one collapses others; never
  two expanded). 11/11 green.
2026-07-07 12:05:17 -07:00
zeekayandClaude Opus 4.8 de14e749b2 fix(models+shell): render all gateway vendors on /models + GCP-style sidebar (v8.4.116)
/models regression — groupByFamily dropped every do-ai model (OpenAI, Claude,
DeepSeek, Llama, …) because their provider "do-ai" matched no curated family, so
only Zen showed. Families are now derived from the ONE brand resolver
(brandForModel, by model id), so every gateway vendor surfaces (Zen first, then
OpenAI/Anthropic/Google/Meta/DeepSeek/Qwen/…); an unknown vendor falls to an honest
"Other models" catch-all — a chat model is never silently dropped again. Adds
OpenAI o-series id resolution + curated GLM/MiniMax marks; embedding/video ids are
typed correctly so they stay out of the chat browser.

/auth/refresh 502 — tokenRequest retries once on a TRANSIENT upstream failure
(network error or a non-JSON/HTML body, e.g. IAM mid-roll on its Recreate strategy),
self-healing a momentary blip; a definitive OAuth error envelope is still not retried
(invalid_grant → 401, else 502). The /models page also renders independently of the
session (pricing/plans failures are already caught).

Overview cleanup — removed the redundant Billing/Usage/Metrics summary band from
every product overview (the dedicated Billing/Usage/Metrics pages own those figures);
deleted the now-dead ProductQuickLinks component + quick-links helpers.

Sidebar redesign (Google-Cloud-Console style) — the top switcher is the USER
(account menu: profile · theme · sign out) with the ORG switcher directly below it;
removed the org-accent green left strip; the two-level slide is replaced by a single
always-visible grouped nav where the active product's sub-pages expand INLINE, so any
product or sub-page routes directly with no "back". The org switcher moved out of the
topbar (project scope stays). Category overview gains prev/next paging through the
fixed category order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:02:05 -07:00
zeekayandhanzo-dev da4726e6f9 fix(models+shell): render all gateway vendors on /models + GCP-style sidebar (v8.4.116)
/models regression — groupByFamily dropped every do-ai model (OpenAI, Claude,
DeepSeek, Llama, …) because their provider "do-ai" matched no curated family, so
only Zen showed. Families are now derived from the ONE brand resolver
(brandForModel, by model id), so every gateway vendor surfaces (Zen first, then
OpenAI/Anthropic/Google/Meta/DeepSeek/Qwen/…); an unknown vendor falls to an honest
"Other models" catch-all — a chat model is never silently dropped again. Adds
OpenAI o-series id resolution + curated GLM/MiniMax marks; embedding/video ids are
typed correctly so they stay out of the chat browser.

/auth/refresh 502 — tokenRequest retries once on a TRANSIENT upstream failure
(network error or a non-JSON/HTML body, e.g. IAM mid-roll on its Recreate strategy),
self-healing a momentary blip; a definitive OAuth error envelope is still not retried
(invalid_grant → 401, else 502). The /models page also renders independently of the
session (pricing/plans failures are already caught).

Overview cleanup — removed the redundant Billing/Usage/Metrics summary band from
every product overview (the dedicated Billing/Usage/Metrics pages own those figures);
deleted the now-dead ProductQuickLinks component + quick-links helpers.

Sidebar redesign (Google-Cloud-Console style) — the top switcher is the USER
(account menu: profile · theme · sign out) with the ORG switcher directly below it;
removed the org-accent green left strip; the two-level slide is replaced by a single
always-visible grouped nav where the active product's sub-pages expand INLINE, so any
product or sub-page routes directly with no "back". The org switcher moved out of the
topbar (project scope stays). Category overview gains prev/next paging through the
fixed category order.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 21:02:05 -07:00
b25b0005c1 feat(oss): universal per-product upstream credit across every module (#125)
v8.4.114 rendered the upstream attribution only on NativeOverview products
(gateway) — resource cards and bespoke admin modules bypassed it. Add ONE
shared surface instead: `ProductUpstreamNote` mounted once in DashboardShell
under the product content column, resolving the active entry's `upstream` from
the catalog (the single source of truth). Now every fork module — native,
resource, or bespoke — shows "Built on open source — forked from <name>
(<license>)" linking upstream. Original Hanzo products render nothing.

Drop the now-redundant per-overview "Forked from" button in NativeOverview
(the shared note supersedes it — one link, one way); the informational
"Upstream" key-fact stays.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:10:53 -07:00
944c4d0b11 feat(oss): universal per-product upstream credit across every module (#125)
v8.4.114 rendered the upstream attribution only on NativeOverview products
(gateway) — resource cards and bespoke admin modules bypassed it. Add ONE
shared surface instead: `ProductUpstreamNote` mounted once in DashboardShell
under the product content column, resolving the active entry's `upstream` from
the catalog (the single source of truth). Now every fork module — native,
resource, or bespoke — shows "Built on open source — forked from <name>
(<license>)" linking upstream. Original Hanzo products render nothing.

Drop the now-redundant per-overview "Forked from" button in NativeOverview
(the shared note supersedes it — one link, one way); the informational
"Upstream" key-fact stays.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 19:10:53 -07:00
4fb1c08fc1 feat(oss): per-product upstream attribution in the product catalog (#124)
Add an optional `upstream {name,url,license}` field to CatalogBase and set it
on the 12 verified fork modules (upstream verified against each repo's git
upstream remote / LICENSE / tree fingerprint):

  base, records → PocketBase (MIT)     iam    → Casdoor (Apache-2.0)
  chat          → LibreChat (MIT)      kv     → Valkey (BSD-3-Clause)
  docdb         → FerretDB (Apache-2.0) s3    → SeaweedFS (Apache-2.0)
  search        → Meilisearch (MIT)    vector → Qdrant (Apache-2.0)
  studio        → ComfyUI (GPL-3.0)    auto   → Activepieces (MIT)
  gateway       → KrakenD (Apache-2.0)

Render it in the module about surfaces (reusing existing plumbing, no new
components): NativeOverview gains a "Forked from <name>" link button beside
Source, resolveSpec appends an "Upstream" key-fact (name + SPDX license), and
the ProductInterstitial open-source card notes the fork. Original Hanzo
products (no upstream) are unchanged. Distinct from the Zen model brand policy —
this credits product forks, never model provenance.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:55:34 -07:00
03052677a3 feat(oss): per-product upstream attribution in the product catalog (#124)
Add an optional `upstream {name,url,license}` field to CatalogBase and set it
on the 12 verified fork modules (upstream verified against each repo's git
upstream remote / LICENSE / tree fingerprint):

  base, records → PocketBase (MIT)     iam    → Casdoor (Apache-2.0)
  chat          → LibreChat (MIT)      kv     → Valkey (BSD-3-Clause)
  docdb         → FerretDB (Apache-2.0) s3    → SeaweedFS (Apache-2.0)
  search        → Meilisearch (MIT)    vector → Qdrant (Apache-2.0)
  studio        → ComfyUI (GPL-3.0)    auto   → Activepieces (MIT)
  gateway       → KrakenD (Apache-2.0)

Render it in the module about surfaces (reusing existing plumbing, no new
components): NativeOverview gains a "Forked from <name>" link button beside
Source, resolveSpec appends an "Upstream" key-fact (name + SPDX license), and
the ProductInterstitial open-source card notes the fork. Original Hanzo
products (no upstream) are unchanged. Distinct from the Zen model brand policy —
this credits product forks, never model provenance.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 18:55:34 -07:00
f92fa2cb57 feat(console): per-org Finance module over /v1/finance/* (shared @hanzo/finance-ui) (#123)
Adds a customer-facing Finance surface (id finance-center, label Finance, Observe)
rendering the SHARED @hanzo/finance-ui FinanceDashboard — the SAME board
finance.hanzo.ai renders — over the unified finance ledger (/v1/finance/*), so a
spend/usage/credits card is identical across both surfaces (the shared-reuse point).

- finance-ledger.ts: console transport (the /cloud user-bearer proxy resolves the
  org from the token owner; cookie-only bare /v1/finance/* 403s on the live ingress),
  envelope-unwrap, wired to httpFinanceClient. +4 tests.
- FinanceModule.tsx: console chrome (PageHeader) + honest states (BackendStateCard)
  around the shared board. Nothing reimplemented, nothing fabricated.
- next.config.mjs: 'finance' -> CLOUD_V1_HEADS (rewrite /v1/finance/* -> /cloud);
  @hanzo/finance-ui -> transpilePackages.
- proxy-allow.ts: 'finance' -> CLOUD_HEADS (defense-in-depth allow-list).
- registry.tsx: catalog entry (distinct id from the admin 'finance' FinOps board and
  from 'billing'/commerce).

Distinct from the shared package (@hanzo/finance-ui, published) which both this
module and finance.hanzo.ai consume. tsc clean; vitest 1891/1891; next build green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 13:59:00 -07:00
15d3901d2b feat(console): per-org Finance module over /v1/finance/* (shared @hanzo/finance-ui) (#123)
Adds a customer-facing Finance surface (id finance-center, label Finance, Observe)
rendering the SHARED @hanzo/finance-ui FinanceDashboard — the SAME board
finance.hanzo.ai renders — over the unified finance ledger (/v1/finance/*), so a
spend/usage/credits card is identical across both surfaces (the shared-reuse point).

- finance-ledger.ts: console transport (the /cloud user-bearer proxy resolves the
  org from the token owner; cookie-only bare /v1/finance/* 403s on the live ingress),
  envelope-unwrap, wired to httpFinanceClient. +4 tests.
- FinanceModule.tsx: console chrome (PageHeader) + honest states (BackendStateCard)
  around the shared board. Nothing reimplemented, nothing fabricated.
- next.config.mjs: 'finance' -> CLOUD_V1_HEADS (rewrite /v1/finance/* -> /cloud);
  @hanzo/finance-ui -> transpilePackages.
- proxy-allow.ts: 'finance' -> CLOUD_HEADS (defense-in-depth allow-list).
- registry.tsx: catalog entry (distinct id from the admin 'finance' FinOps board and
  from 'billing'/commerce).

Distinct from the shared package (@hanzo/finance-ui, published) which both this
module and finance.hanzo.ai consume. tsc clean; vitest 1891/1891; next build green.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 13:59:00 -07:00
eacffd81f6 feat(treasury): admin.hanzo.ai Treasury dashboard — reserve fund + revenue-share + backed payouts + Hanzo L1 anchor (v8.4.112) (#122)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 13:38:32 -07:00
aace79a1fd feat(treasury): admin.hanzo.ai Treasury dashboard — reserve fund + revenue-share + backed payouts + Hanzo L1 anchor (v8.4.112) (#122)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 13:38:32 -07:00
hanzo-dev 6eed04b972 feat(treasury): admin.hanzo.ai Treasury dashboard — reserve fund + revenue-share + backed payouts + Hanzo L1 anchor (v8.4.112) 2026-07-05 13:33:03 -07:00
hanzo-dev 004e305cf4 feat(treasury): admin.hanzo.ai Treasury dashboard — reserve fund + revenue-share + backed payouts + Hanzo L1 anchor (v8.4.112) 2026-07-05 13:33:03 -07:00
ace1bf12db fix(a11y+responsive): touch tap-targets, focus-visible ring, overflow-x guard, FAB clearance (v8.4.112) (#121)
State-of-the-art visual/interaction QA pass (as Dave/maxpower, live) found the
console already excellent across mobile/tablet/laptop/desktop — distinct per-family
model icons, honest GPU prepay-card vs Machines credit gate, working ⌘K palette,
org switcher, quick-links band navigation, light+dark contrast, and NO horizontal
body overflow at any viewport. Four small CSS/markup polish defects were fixed:

- **Touch tap targets < 44px.** The top-bar controls (hamburger/Apps/account at
  size="$3" = 36px; ThemeToggle/Help/Notifications at size="$2" = ~28px) were under
  the 44×44 minimum on a coarse pointer. Added a `hz-topbar` class on the top-bar
  XStack (DashboardShell) + a `@media (pointer: coarse){ .hz-topbar button {min-height/
  min-width:44px} }` rule — touch devices meet WCAG 2.5.5; desktop mouse density is
  deliberately unchanged (the rule is coarse-pointer-scoped).
- **No global keyboard focus ring.** Added a `:focus-visible` outline floor
  (theme-token colour, adapts light/dark) + `:focus:not(:focus-visible){outline:none}`
  so every control is focus-visible on tab-through without touching pointer presses.
- **Horizontal-scroll guard.** `html, body { overflow-x: clip }` — a stray fixed/
  off-screen drawer can never scroll the whole document sideways (`clip` keeps sticky/
  fixed descendants working). No overflow was observed live; this is the permanent floor.
- **Floating chat FAB overlapped bottom-right content** (Live/Catalog pills, table
  badges). Reserved an 80px bottom gutter on the content column (split the content
  `py` into responsive `pt` + fixed `pb={80}`) so the last row always clears the FAB.

e2e: e2e/polish-qa.spec.ts locks all of it — a PUBLIC block (runs in CI, no creds)
for the overflow-x guard + :focus-visible ring + no-sideways-scroll at 390/768/1440,
and an authenticated block (gates on HANZO_PASSWORD, repo convention) for overview
real-data, quick-links navigation, the GPU prepay-card gate, distinct model icons,
mobile sidebar→drawer collapse, and the ≥44px touch tap-targets.

Verification: tsc --noEmit clean; next build ✓ (BUILD_EXIT:0). Rebased on origin/main
(v8.4.111) → v8.4.112. Live re-verify + the authenticated e2e block are the post-deploy
gate (the (dashboard) group is behind AuthGate).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-05 12:20:23 -07:00
9913a9842c fix(a11y+responsive): touch tap-targets, focus-visible ring, overflow-x guard, FAB clearance (v8.4.112) (#121)
State-of-the-art visual/interaction QA pass (as Dave/maxpower, live) found the
console already excellent across mobile/tablet/laptop/desktop — distinct per-family
model icons, honest GPU prepay-card vs Machines credit gate, working ⌘K palette,
org switcher, quick-links band navigation, light+dark contrast, and NO horizontal
body overflow at any viewport. Four small CSS/markup polish defects were fixed:

- **Touch tap targets < 44px.** The top-bar controls (hamburger/Apps/account at
  size="$3" = 36px; ThemeToggle/Help/Notifications at size="$2" = ~28px) were under
  the 44×44 minimum on a coarse pointer. Added a `hz-topbar` class on the top-bar
  XStack (DashboardShell) + a `@media (pointer: coarse){ .hz-topbar button {min-height/
  min-width:44px} }` rule — touch devices meet WCAG 2.5.5; desktop mouse density is
  deliberately unchanged (the rule is coarse-pointer-scoped).
- **No global keyboard focus ring.** Added a `:focus-visible` outline floor
  (theme-token colour, adapts light/dark) + `:focus:not(:focus-visible){outline:none}`
  so every control is focus-visible on tab-through without touching pointer presses.
- **Horizontal-scroll guard.** `html, body { overflow-x: clip }` — a stray fixed/
  off-screen drawer can never scroll the whole document sideways (`clip` keeps sticky/
  fixed descendants working). No overflow was observed live; this is the permanent floor.
- **Floating chat FAB overlapped bottom-right content** (Live/Catalog pills, table
  badges). Reserved an 80px bottom gutter on the content column (split the content
  `py` into responsive `pt` + fixed `pb={80}`) so the last row always clears the FAB.

e2e: e2e/polish-qa.spec.ts locks all of it — a PUBLIC block (runs in CI, no creds)
for the overflow-x guard + :focus-visible ring + no-sideways-scroll at 390/768/1440,
and an authenticated block (gates on HANZO_PASSWORD, repo convention) for overview
real-data, quick-links navigation, the GPU prepay-card gate, distinct model icons,
mobile sidebar→drawer collapse, and the ≥44px touch tap-targets.

Verification: tsc --noEmit clean; next build ✓ (BUILD_EXIT:0). Rebased on origin/main
(v8.4.111) → v8.4.112. Live re-verify + the authenticated e2e block are the post-deploy
gate (the (dashboard) group is behind AuthGate).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 12:20:23 -07:00
fdce8b3253 feat(authors): OSS Author royalty product — connect GitHub, verify repos, earn on deploys (v8.4.111) (#120)
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.

- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
  explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
  panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
  snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
  author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
  server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
  ({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
  next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
  admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
  based — no ?xxx= link) and no app/ route (the catch-all resolves it).

Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 06:12:02 -07:00
26194f7701 feat(authors): OSS Author royalty product — connect GitHub, verify repos, earn on deploys (v8.4.111) (#120)
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.

- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
  explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
  panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
  snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
  author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
  server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
  ({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
  next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
  admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
  based — no ?xxx= link) and no app/ route (the catch-all resolves it).

Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 06:12:02 -07:00
hanzo-dev 67e0bde60f feat(authors): OSS Author royalty product — connect GitHub, verify repos, earn on deploys (v8.4.111)
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.

- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
  explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
  panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
  snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
  author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
  server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
  ({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
  next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
  admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
  based — no ?xxx= link) and no app/ route (the catch-all resolves it).

Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).
2026-07-05 06:09:32 -07:00
hanzo-dev f916fec4cd feat(authors): OSS Author royalty product — connect GitHub, verify repos, earn on deploys (v8.4.111)
Mirrors the Affiliates + Referrals pattern for the new OSS Author program over the
real cloud /v1/authors surface (native-Go clients/authors). An author connects GitHub,
proves they own a repo (GitHub OAuth admin-check OR a hanzo.json verify-code file), and
earns a royalty when any org deploys their open-source project on Hanzo.

- AuthorsModule (customer, id:authors, Web3): not-enrolled Connect-GitHub card + 3-step
  explainer; enrolled dashboard — status/verified/share, 4 MetricCards, a repositories
  panel (verify + per-repo Copy-badge markdown), a verify-by-file recipe (hanzo.json
  snippet), deploys-of-your-work, and payout history. Honest empty states throughout.
- AuthorsAdminModule (id:authors-admin, admin:true, Observe): Run-sweep + summary tiles +
  author directory with status-gated Approve(+share override)/Reactivate/Payout/Suspend;
  server-gated via /admin/aggregate; honest access/empty/error states.
- Clients: lib/api/authors.ts (BARE JSON via cloudProxyV1Url) + lib/api/admin-authors.ts
  ({status,msg,data} envelope via originGet/originPost); pure products/authors/logic.ts.
- Registration: 2 registry.tsx catalog entries (BookOpen icon); 'authors' added to
  next.config.mjs CLOUD_V1_HEADS + ADMIN_V1_HEADS, proxy-allow.ts CLOUD_HEADS, and
  admin-aggregate.ts ADMIN_AGGREGATE_HEADS. No claim.ts/session wiring (connect/verify
  based — no ?xxx= link) and no app/ route (the catch-all resolves it).

Gates green: tsc --noEmit 0 errors; vitest 1864 → 1887 (+23: 7 authors + 8 admin + 8 logic);
next build ✓ (Compiled successfully, 18/18 pages).
2026-07-05 06:09:32 -07:00
08d3d233e6 feat(affiliates): partner-commission product + admin board + ?aff capture (v8.4.110) (#119)
Mirrors the just-merged Referrals product (#118) for the OTHER growth loop:
partners earn an ONGOING commission on the metered spend of the customers they
refer (vs referrals' one-time both-sides credit). All over the real cloud
clients/affiliates /v1 surface, org-scoped server-side, honest states, no fakes.

- lib/api/affiliates.ts — customer client (apply/overview/attribute) through the
  /cloud user-bearer proxy (cloudProxyV1Url); defensive normalizers.
- lib/api/admin-affiliates.ts — global-admin client (list/approve/suspend/payout/
  sweep) through the admin-aggregate proxy (originGet/originPost).
- AffiliatesModule — apply form (not enrolled) / dashboard (code + link + copy,
  rate, referred/accrued/pending/paid tiles, payout history).
- AffiliatesAdminModule (admin:true) — applications -> approve/suspend, accrual
  summary, inline record-payout (credits vs cash) + run-sweep, honest empty states.
- lib/affiliates/claim.ts — ?aff=<code> capture + attribute-once, wired into
  session.tsx (orthogonal to the ?ref referral capture).
- Wiring: registry (affiliates Web3 + affiliates-admin Observe), proxy-allow
  CLOUD_HEADS, admin-aggregate + next.config heads (affiliates).
- affiliates/logic.ts pure helpers (usd/ratePct/status/date/method/dollarsToCents).

Gates: tsc --noEmit clean; vitest +26 (4 files: api normalizers+paths, admin
normalizers+paths, logic, ?aff capture) all green; next build ok (the /[...slug]
catch-all renders both modules). Authenticated visual e2e is post-deploy.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 05:34:07 -07:00
a13505e4de feat(affiliates): partner-commission product + admin board + ?aff capture (v8.4.110) (#119)
Mirrors the just-merged Referrals product (#118) for the OTHER growth loop:
partners earn an ONGOING commission on the metered spend of the customers they
refer (vs referrals' one-time both-sides credit). All over the real cloud
clients/affiliates /v1 surface, org-scoped server-side, honest states, no fakes.

- lib/api/affiliates.ts — customer client (apply/overview/attribute) through the
  /cloud user-bearer proxy (cloudProxyV1Url); defensive normalizers.
- lib/api/admin-affiliates.ts — global-admin client (list/approve/suspend/payout/
  sweep) through the admin-aggregate proxy (originGet/originPost).
- AffiliatesModule — apply form (not enrolled) / dashboard (code + link + copy,
  rate, referred/accrued/pending/paid tiles, payout history).
- AffiliatesAdminModule (admin:true) — applications -> approve/suspend, accrual
  summary, inline record-payout (credits vs cash) + run-sweep, honest empty states.
- lib/affiliates/claim.ts — ?aff=<code> capture + attribute-once, wired into
  session.tsx (orthogonal to the ?ref referral capture).
- Wiring: registry (affiliates Web3 + affiliates-admin Observe), proxy-allow
  CLOUD_HEADS, admin-aggregate + next.config heads (affiliates).
- affiliates/logic.ts pure helpers (usd/ratePct/status/date/method/dollarsToCents).

Gates: tsc --noEmit clean; vitest +26 (4 files: api normalizers+paths, admin
normalizers+paths, logic, ?aff capture) all green; next build ok (the /[...slug]
catch-all renders both modules). Authenticated visual e2e is post-deploy.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 05:34:07 -07:00
zeekayandClaude Opus 4.8 659a0231aa fix(console): mobile-usable PageHeader actions, SlideOver form, empty state (v8.4.109)
Pixel QA at 360/390 phone widths surfaced three mobile defects, all now fixed
in the shared primitives (verified in a real browser at 390 + 834):

- PageHeader: a header with many actions (Tracker's Projects/Refresh/List/Board/
  New issue/Delete) ran off-screen and CLIPPED the last buttons — "New issue" and
  "Delete project" were unreachable on a phone. Actions now take a full line below
  the title and WRAP (< $md); inline right-aligned at $md+. TrackerModule's action
  row is full-width on phones so it wraps rather than overflows.
- Field (SlideOver create/edit form): the fixed 180px label + 240px control forced
  the row wider than a phone, clipping every label. FieldRow now STACKS (label above
  a full-width control) below $md and keeps the two-column layout at $md+.
- DataTable empty state: the "Nothing here yet." message sat inside the min-width
  (horizontally scrolling) table area and was clipped off the right on a phone. It
  now renders outside the scroll area and centers/wraps within the visible width.

No change at tablet/laptop/desktop (>= $md) — the two-column form and inline actions
are preserved. typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 05:23:00 -07:00
zeekayandhanzo-dev ed80b42c26 fix(console): mobile-usable PageHeader actions, SlideOver form, empty state (v8.4.109)
Pixel QA at 360/390 phone widths surfaced three mobile defects, all now fixed
in the shared primitives (verified in a real browser at 390 + 834):

- PageHeader: a header with many actions (Tracker's Projects/Refresh/List/Board/
  New issue/Delete) ran off-screen and CLIPPED the last buttons — "New issue" and
  "Delete project" were unreachable on a phone. Actions now take a full line below
  the title and WRAP (< $md); inline right-aligned at $md+. TrackerModule's action
  row is full-width on phones so it wraps rather than overflows.
- Field (SlideOver create/edit form): the fixed 180px label + 240px control forced
  the row wider than a phone, clipping every label. FieldRow now STACKS (label above
  a full-width control) below $md and keeps the two-column layout at $md+.
- DataTable empty state: the "Nothing here yet." message sat inside the min-width
  (horizontally scrolling) table area and was clipped off the right on a phone. It
  now renders outside the scroll area and centers/wraps within the visible width.

No change at tablet/laptop/desktop (>= $md) — the two-column form and inline actions
are preserved. typecheck clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 05:23:00 -07:00
b2bd4f5152 feat(referrals): real Referrals product + admin board + signup capture (v8.4.108) (#118)
Turns the placeholder referrals entry (a ConsoleFeatureModule shim that 404'd —
no backend existed) into a real product over the new cloud /v1/referrals surface.

- src/lib/api/referrals.ts — customer client over the /cloud user-bearer proxy
  (cloudProxyV1Url, live-ingress-safe for a new head): overview + claim, defensive
  normalizers.
- ReferralsModule — one clean screen: link + Copy, 'give $5 get $10' explainer,
  three real stat tiles (invites/credited/credit earned), referrals list with live
  status; loading/BackendStateCard/empty states, no fabricated rows.
- Admin board (admin: true, hidden from customers): src/lib/api/admin-referrals.ts
  via originGet/originPost -> the global-admin-gated app/admin/aggregate proxy;
  ReferralsAdminModule = summary tiles + directory + a 'Run sweep' action.
- Signup capture: src/lib/referrals/claim.ts (stashReferralCode reads ?ref into
  localStorage; claimReferralOnce POSTs /v1/referrals/claim once per session per
  org after first login), wired into session.tsx beside the welcome grant.
- Wiring: 'referrals' added to CLOUD_V1_HEADS + CLOUD_HEADS (customer proxy) and
  ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS (admin proxy). Registry repointed to the
  real module + an admin entry; dropped the dead docs deep link.

Gates: tsc --noEmit clean; vitest 1838/1838 (+15 referrals api/logic/claim); next
build ok. Version 8.4.107 -> 8.4.108.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 05:02:02 -07:00
87a399b9b5 feat(referrals): real Referrals product + admin board + signup capture (v8.4.108) (#118)
Turns the placeholder referrals entry (a ConsoleFeatureModule shim that 404'd —
no backend existed) into a real product over the new cloud /v1/referrals surface.

- src/lib/api/referrals.ts — customer client over the /cloud user-bearer proxy
  (cloudProxyV1Url, live-ingress-safe for a new head): overview + claim, defensive
  normalizers.
- ReferralsModule — one clean screen: link + Copy, 'give $5 get $10' explainer,
  three real stat tiles (invites/credited/credit earned), referrals list with live
  status; loading/BackendStateCard/empty states, no fabricated rows.
- Admin board (admin: true, hidden from customers): src/lib/api/admin-referrals.ts
  via originGet/originPost -> the global-admin-gated app/admin/aggregate proxy;
  ReferralsAdminModule = summary tiles + directory + a 'Run sweep' action.
- Signup capture: src/lib/referrals/claim.ts (stashReferralCode reads ?ref into
  localStorage; claimReferralOnce POSTs /v1/referrals/claim once per session per
  org after first login), wired into session.tsx beside the welcome grant.
- Wiring: 'referrals' added to CLOUD_V1_HEADS + CLOUD_HEADS (customer proxy) and
  ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS (admin proxy). Registry repointed to the
  real module + an admin entry; dropped the dead docs deep link.

Gates: tsc --noEmit clean; vitest 1838/1838 (+15 referrals api/logic/claim); next
build ok. Version 8.4.107 -> 8.4.108.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 05:02:02 -07:00
zandGitHub 70a4053749 Merge pull request #117 from hanzoai/release/v8.4.107
release(console): v8.4.107 — reconcile feat/team-invite-mfa (2FA-enroll + team-invite) into main (Pro-fix + budgets + chat + models)
2026-07-05 02:17:46 -07:00
zandGitHub 2699dafdb1 Merge pull request #117 from hanzoai/release/v8.4.107
release(console): v8.4.107 — reconcile feat/team-invite-mfa (2FA-enroll + team-invite) into main (Pro-fix + budgets + chat + models)
2026-07-05 02:17:46 -07:00
hanzo-dev 7fcedda84b Merge remote-tracking branch 'origin/feat/team-invite-mfa' into release/v8.4.107
# Conflicts:
#	package.json
2026-07-05 02:14:33 -07:00
hanzo-dev 742181bb22 Merge remote-tracking branch 'origin/feat/team-invite-mfa' into release/v8.4.107
# Conflicts:
#	package.json
2026-07-05 02:14:33 -07:00
2d777c7cd6 fix(plans): render Pro $49/mo tiers + live subscribe CTA from /billing/v1/plans (v8.4.104) (#116)
The /plans "Upgrade to Pro" page showed a bare "Not authorized": PlansApi.pricing() hit GET /v1/pricing, but on the live console ingress /v1/* is routed straight to the gateway-fronted cloud binary (cookie-only, no bearer) and 401s, so no tiers rendered and nobody could subscribe to Pro.

Fix: read the money-truth catalog through the per-tenant billing proxy (GET /billing/v1/plans -> commerce api/billing.ListPlans), the SAME credentialed BFF path every working billing call uses (balance/usage/subscriptions). Map the commerce staticPlan wire shape (bare array, CENTS, slug) to a display Plan (whole dollars), filter to the cloud account tiers (personal/team/enterprise), order the grid, and mark Pro popular. Each card CTA opens the brand billing portal checkout (config.billingUrl#pricing, Square) — a live, non-dead subscribe path matching the portal own CTA.

plans.ts rewritten (endpoint + shape + Plan/PlanLimits types); PlansModule.tsx consumes the new shape with honest loading/empty/error (BackendStateCard); index.ts exports Plan/PlanLimits; plans.test.ts (6) pins endpoint + cents->dollars + Pro popular + filter + order; canonical-paths.test.ts pins PlansApi.plans -> /billing/v1/plans.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 01:12:31 -07:00
7ad5a8db40 fix(plans): render Pro $49/mo tiers + live subscribe CTA from /billing/v1/plans (v8.4.104) (#116)
The /plans "Upgrade to Pro" page showed a bare "Not authorized": PlansApi.pricing() hit GET /v1/pricing, but on the live console ingress /v1/* is routed straight to the gateway-fronted cloud binary (cookie-only, no bearer) and 401s, so no tiers rendered and nobody could subscribe to Pro.

Fix: read the money-truth catalog through the per-tenant billing proxy (GET /billing/v1/plans -> commerce api/billing.ListPlans), the SAME credentialed BFF path every working billing call uses (balance/usage/subscriptions). Map the commerce staticPlan wire shape (bare array, CENTS, slug) to a display Plan (whole dollars), filter to the cloud account tiers (personal/team/enterprise), order the grid, and mark Pro popular. Each card CTA opens the brand billing portal checkout (config.billingUrl#pricing, Square) — a live, non-dead subscribe path matching the portal own CTA.

plans.ts rewritten (endpoint + shape + Plan/PlanLimits types); PlansModule.tsx consumes the new shape with honest loading/empty/error (BackendStateCard); index.ts exports Plan/PlanLimits; plans.test.ts (6) pins endpoint + cents->dollars + Pro popular + filter + order; canonical-paths.test.ts pins PlansApi.plans -> /billing/v1/plans.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 01:12:31 -07:00
ef3a2d087c fix(models): default to non-premium zen5-flash, not premium zen5-mini (#115)
The Models page marked zen5-mini as the "Default" model, but zen5-mini is
PREMIUM — it 402s for a trial / $5-welcome balance, so a new user's first
call to the "default" model fails. The user-facing default must be a model
that always works on the trial tier.

zen5-flash is the non-premium Zen flagship (per the live catalog `premium`
flag; the Playground's default-model.test.ts documents the ground truth:
"zen5 / zen5-mini / zen5-max premium; zen5-flash / zen5-coder not").

- families.ts: DEFAULT_MODEL 'zen5-mini' -> 'zen5-flash' (the ONE constant
  the Models page "Default" pill and the Zen-family sort read).
- ChatConversation.tsx: preselect DEFAULT_MODEL (case-insensitive) before
  falling back to first-Zen, so Chat agrees with the Models page and never
  seeds a premium default for a trial user.
- families.test.ts: retitle + pin DEFAULT_MODEL === 'zen5-flash'.

Playground already picked the non-premium Zen flagship at runtime
(defaultModelId), so Chat, Playground, and the Models page now all agree on
the same trial-safe default. tsc clean; vitest 1808/1808.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 00:57:50 -07:00
7d174768bd fix(models): default to non-premium zen5-flash, not premium zen5-mini (#115)
The Models page marked zen5-mini as the "Default" model, but zen5-mini is
PREMIUM — it 402s for a trial / $5-welcome balance, so a new user's first
call to the "default" model fails. The user-facing default must be a model
that always works on the trial tier.

zen5-flash is the non-premium Zen flagship (per the live catalog `premium`
flag; the Playground's default-model.test.ts documents the ground truth:
"zen5 / zen5-mini / zen5-max premium; zen5-flash / zen5-coder not").

- families.ts: DEFAULT_MODEL 'zen5-mini' -> 'zen5-flash' (the ONE constant
  the Models page "Default" pill and the Zen-family sort read).
- ChatConversation.tsx: preselect DEFAULT_MODEL (case-insensitive) before
  falling back to first-Zen, so Chat agrees with the Models page and never
  seeds a premium default for a trial user.
- families.test.ts: retitle + pin DEFAULT_MODEL === 'zen5-flash'.

Playground already picked the non-premium Zen flagship at runtime
(defaultModelId), so Chat, Playground, and the Models page now all agree on
the same trial-safe default. tsc clean; vitest 1808/1808.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 00:57:50 -07:00
18269d908b fix(chat+grants): surface gateway error envelopes instead of "(empty response)"; grant banner shows real amount (v8.4.103) (#114)
Pre-launch QA sweep found two honest-state defects on the LIVE console.

1) Chat "(empty response)" — the gateway can answer a 200 whose BODY is a plain
   casibase error envelope (NOT SSE): `{status:"error", msg:"the token count:
   [4198] exceeds the model: [minimax-m2.5]'s maximum token count: [4096]"}`.
   That body carries no `data:` events, so `readChatStream` yielded no content and
   resolved to '' -> the chat rendered a silent "(empty response)" bubble that hid
   the real reason. New pure `streamErrorMessage` (stream.ts) detects a non-SSE
   JSON error envelope (casibase `{status,msg}` AND OpenAI `{error:{message}}`),
   and `readChatStream` throws it when the stream produced no content -> honest
   error card. A real completion object (`choices`) is never treated as an error,
   and a genuinely-empty successful stream still resolves to '' (no false error).
   (The premium-model 402 path was already handled by the non-ok branch.)

2) Grants "$0.00" banner — the `POST /v1/admin/grants` create response does not
   echo the requested amount/source, so the success banner read "Granted $0.00"
   even though the ledger recorded the real amount. `AdminGrantsApi.create` now
   backfills the REQUESTED amount/source/org when the response omits them, so the
   returned row (which the banner renders) is self-consistent whatever the shape.

Tests: +stream.test.ts (6), +admin-grants.test.ts (3), +4 ai.test.ts regression
cases (200-envelope casibase + OpenAI, blank-stream no-false-error).
Gates: tsc --noEmit clean; vitest 1808/1808; next build OK.

NOTE (separate backend release): the ROOT cause of the chat failure is a GATEWAY
model-config bug — every routed model reports max_tokens=4096 (zen-agent->
minimax-m2.5, zen5->deepseek-v4-pro), far below their real context windows, so the
console's grounded system prompt (~4190 tok) exceeds it. This console fix makes the
error HONEST; the gateway 4096 cap must be raised on api.hanzo.ai.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 00:12:35 -07:00
7b1a3896eb fix(chat+grants): surface gateway error envelopes instead of "(empty response)"; grant banner shows real amount (v8.4.103) (#114)
Pre-launch QA sweep found two honest-state defects on the LIVE console.

1) Chat "(empty response)" — the gateway can answer a 200 whose BODY is a plain
   casibase error envelope (NOT SSE): `{status:"error", msg:"the token count:
   [4198] exceeds the model: [minimax-m2.5]'s maximum token count: [4096]"}`.
   That body carries no `data:` events, so `readChatStream` yielded no content and
   resolved to '' -> the chat rendered a silent "(empty response)" bubble that hid
   the real reason. New pure `streamErrorMessage` (stream.ts) detects a non-SSE
   JSON error envelope (casibase `{status,msg}` AND OpenAI `{error:{message}}`),
   and `readChatStream` throws it when the stream produced no content -> honest
   error card. A real completion object (`choices`) is never treated as an error,
   and a genuinely-empty successful stream still resolves to '' (no false error).
   (The premium-model 402 path was already handled by the non-ok branch.)

2) Grants "$0.00" banner — the `POST /v1/admin/grants` create response does not
   echo the requested amount/source, so the success banner read "Granted $0.00"
   even though the ledger recorded the real amount. `AdminGrantsApi.create` now
   backfills the REQUESTED amount/source/org when the response omits them, so the
   returned row (which the banner renders) is self-consistent whatever the shape.

Tests: +stream.test.ts (6), +admin-grants.test.ts (3), +4 ai.test.ts regression
cases (200-envelope casibase + OpenAI, blank-stream no-false-error).
Gates: tsc --noEmit clean; vitest 1808/1808; next build OK.

NOTE (separate backend release): the ROOT cause of the chat failure is a GATEWAY
model-config bug — every routed model reports max_tokens=4096 (zen-agent->
minimax-m2.5, zen5->deepseek-v4-pro), far below their real context windows, so the
console's grounded system prompt (~4190 tok) exceeds it. This console fix makes the
error HONEST; the gateway 4096 cap must be raised on api.hanzo.ai.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-05 00:12:35 -07:00
2df1ebd052 Budgets & limits: spend caps + rate limits on the existing Budgets page (Hanzo Cloud #70) (#113)
* console2: extend Budgets page into Budgets & limits (spend caps + rate limits)

Extend the existing /billing/budgets tab (BillingBudgets.tsx) over the SAME real
commerce spend-alerts API (/v1/billing/spend-alerts) from soft alerts into full
per-scope spend caps + rate limits, per the CTO re-anchor (drops the separate
/v1/commerce/limits system).

- lib/api/billing.ts: SpendAlert + normalizer gain project/service/enforce/softPct/
  rateLimitRpm + read-only periodSpentCents/over/warn (forward-compatible: a legacy
  soft alert renders as an org-wide alert, meter/enforce/rate-limit light up when the
  backend emits the fields). createSpendAlert takes the new fields; add updateSpendAlert
  (PATCH) + deleteSpendAlert (DELETE).
- app/billing/v1/[...path]/route.ts: add PATCH verb (forwardBilling already forwards
  the method + CSRF-guards mutations + pins the billing subject server-side).
- billing/budgets-logic.ts (pure, tested): capVerdict/spendPct/scopeLabel/summary +
  boundary parse/validate.
- BillingBudgets.tsx: per-scope cards with a usage-vs-cap meter (WARN/OVER, cents->$),
  scope selector (Org-wide/Project/Service), Enforce (hard cap) toggle, rate-limit
  field; create=POST, edit=PATCH, delete=DELETE. @hanzo/ui, mobile-responsive.
- e2e/budgets-responsive.spec.ts: mocked-contract render + no-horizontal-scroll proof
  at 1440px and 390px + inline edit-form open.

tsc clean; vitest 1796/1796; next build ok; budgets-responsive e2e green.

* docs: fix budgets e2e screenshot names in LLM.md note

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 22:41:00 -07:00
96f3df6a6d Budgets & limits: spend caps + rate limits on the existing Budgets page (Hanzo Cloud #70) (#113)
* console2: extend Budgets page into Budgets & limits (spend caps + rate limits)

Extend the existing /billing/budgets tab (BillingBudgets.tsx) over the SAME real
commerce spend-alerts API (/v1/billing/spend-alerts) from soft alerts into full
per-scope spend caps + rate limits, per the CTO re-anchor (drops the separate
/v1/commerce/limits system).

- lib/api/billing.ts: SpendAlert + normalizer gain project/service/enforce/softPct/
  rateLimitRpm + read-only periodSpentCents/over/warn (forward-compatible: a legacy
  soft alert renders as an org-wide alert, meter/enforce/rate-limit light up when the
  backend emits the fields). createSpendAlert takes the new fields; add updateSpendAlert
  (PATCH) + deleteSpendAlert (DELETE).
- app/billing/v1/[...path]/route.ts: add PATCH verb (forwardBilling already forwards
  the method + CSRF-guards mutations + pins the billing subject server-side).
- billing/budgets-logic.ts (pure, tested): capVerdict/spendPct/scopeLabel/summary +
  boundary parse/validate.
- BillingBudgets.tsx: per-scope cards with a usage-vs-cap meter (WARN/OVER, cents->$),
  scope selector (Org-wide/Project/Service), Enforce (hard cap) toggle, rate-limit
  field; create=POST, edit=PATCH, delete=DELETE. @hanzo/ui, mobile-responsive.
- e2e/budgets-responsive.spec.ts: mocked-contract render + no-horizontal-scroll proof
  at 1440px and 390px + inline edit-form open.

tsc clean; vitest 1796/1796; next build ok; budgets-responsive e2e green.

* docs: fix budgets e2e screenshot names in LLM.md note

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 22:41:00 -07:00
zeekayandClaude Opus 4.8 d8c77eeb5c fix(mfa): authorize console-native 2FA via query-param self-object (v8.4.106)
The bearer authenticated but Casbin denied /mfa/setup/* — IAM's authz filter
derives the request object by JSON-parsing the POST body, so a form-encoded body
yields an empty object and the self-access grant (objOwner==subOwner) never
matched. Fix: send the MFA params as the QUERY STRING with an empty body and
always include the pinned owner/name, so the filter reads them (its len(body)==0
branch) and grants self-access — the same rule that lets get-users?owner=<me>
through. No IAM change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:50:41 -07:00
zeekayandhanzo-dev 5e76d06a23 fix(mfa): authorize console-native 2FA via query-param self-object (v8.4.106)
The bearer authenticated but Casbin denied /mfa/setup/* — IAM's authz filter
derives the request object by JSON-parsing the POST body, so a form-encoded body
yields an empty object and the self-access grant (objOwner==subOwner) never
matched. Fix: send the MFA params as the QUERY STRING with an empty body and
always include the pinned owner/name, so the filter reads them (its len(body)==0
branch) and grants self-access — the same rule that lets get-users?owner=<me>
through. No IAM change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 15:50:41 -07:00
zeekayandClaude Opus 4.8 cb44d80dea feat(profile): console-native two-factor (TOTP) enrollment (v8.4.105)
MFA self-enrollment was unreachable: the console delegated 2FA to hanzo.id's
account page, but the custom hanzo.id login worker never establishes a Casdoor
account session, so a worker-authenticated user hits 'Unauthorized operation' on
the MFA setup endpoints (verified live). Fix: enroll 2FA IN the console.

- app/console/mfa/[action] BFF forwards initiate/verify/enable/disable to IAM as
  the caller's OWN user bearer (adminBearer(resolveUser)), owner/name PINNED
  server-side — a user can only manage their own 2FA. IAM's authz filter accepts
  the bearer and Casbin authorizes self-service MFA.
- lib/api/mfa.ts client + ProfileModule Security tab: real 'Set up authenticator
  app' flow (initiate → show TOTP secret/otpauth URI → enter 6-digit code →
  verify → enable), an On/Off state, and Turn-off. Password change still deep-links
  to IAM. MFA is enforced at login already (checkMfaEnable→NextMfa; SignInForm
  hands off to the hosted challenge).

tsc + 1785 vitest green; next build ✓ (/console/mfa/[action] registered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:41:49 -07:00
zeekayandhanzo-dev d0c68cb5c9 feat(profile): console-native two-factor (TOTP) enrollment (v8.4.105)
MFA self-enrollment was unreachable: the console delegated 2FA to hanzo.id's
account page, but the custom hanzo.id login worker never establishes a Casdoor
account session, so a worker-authenticated user hits 'Unauthorized operation' on
the MFA setup endpoints (verified live). Fix: enroll 2FA IN the console.

- app/console/mfa/[action] BFF forwards initiate/verify/enable/disable to IAM as
  the caller's OWN user bearer (adminBearer(resolveUser)), owner/name PINNED
  server-side — a user can only manage their own 2FA. IAM's authz filter accepts
  the bearer and Casbin authorizes self-service MFA.
- lib/api/mfa.ts client + ProfileModule Security tab: real 'Set up authenticator
  app' flow (initiate → show TOTP secret/otpauth URI → enter 6-digit code →
  verify → enable), an On/Off state, and Turn-off. Password change still deep-links
  to IAM. MFA is enforced at login already (checkMfaEnable→NextMfa; SignInForm
  hands off to the hosted challenge).

tsc + 1785 vitest green; next build ✓ (/console/mfa/[action] registered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 15:41:49 -07:00
zeekayandClaude Opus 4.8 0ccc964850 fix(team): persist invite-accept password — pin update-user columns (v8.4.104)
Live verification caught it: /console/accept ran update-user and it updated
displayName + signupApplication, but the password stayed EMPTY. Root cause:
casibase update-user with no `columns` param uses a default column set that
EXCLUDES password, and it only auto-appends the password columns when `columns`
is non-empty — so UpdateUserPassword hashed the value in memory but never wrote
the column (credential silently stayed unset). Fix: activateMember pins explicit
`columns=password,password_salt,password_type[,display_name,signup_application]`
so the hashed password + salt + type are persisted. Also mask the accept-page
password field (secureTextEntry+type=password, the @hanzo/gui workaround).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:22:33 -07:00
zeekayandhanzo-dev 7d4d843542 fix(team): persist invite-accept password — pin update-user columns (v8.4.104)
Live verification caught it: /console/accept ran update-user and it updated
displayName + signupApplication, but the password stayed EMPTY. Root cause:
casibase update-user with no `columns` param uses a default column set that
EXCLUDES password, and it only auto-appends the password columns when `columns`
is non-empty — so UpdateUserPassword hashed the value in memory but never wrote
the column (credential silently stayed unset). Fix: activateMember pins explicit
`columns=password,password_salt,password_type[,display_name,signup_application]`
so the hashed password + salt + type are persisted. Also mask the accept-page
password field (secureTextEntry+type=password, the @hanzo/gui workaround).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 15:22:33 -07:00
zeekayandClaude Opus 4.8 23ec09b338 feat(team): real invite→accept onboarding — shareable set-password link (no email), pending-member visibility, MFA state in roster (v8.4.103)
The Team invite created a passwordless zombie member with NO way to sign in
(email/OTP delivery is unwired; IAM send-invitation is a stub). This closes the
loop with a console-native, no-email, no-new-IAM-capability accept flow:

- POST /console/invite-link mints a sealed (AES-256-GCM, HKDF of the confidential
  client secret), 14-day, org-admin-gated accept token for a pending member.
- Public /accept page + GET/POST /console/accept lets the invitee set their OWN
  password (IAM hashes via update-user's passwordChanged path — never plaintext),
  then sign in and land in their org with the assigned role. Single-use for
  activation (refused once the member has a credential).
- InviteDialog surfaces the shareable link (honest 'email delivery isn't wired'
  copy); roster shows Pending vs Active + a 2FA badge, and a 'Copy invite link'
  row action for pending members (activates the pre-existing zombies too).
- identity.ts gains getMember/memberHasPassword/activateMember (same confidential
  client as createUser/moveUserToOrg). +8 invite-token tests. tsc + 1785 vitest
  green; next build ✓ (/accept, /console/accept, /console/invite-link registered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:07:15 -07:00
zeekayandhanzo-dev e16efacf5e feat(team): real invite→accept onboarding — shareable set-password link (no email), pending-member visibility, MFA state in roster (v8.4.103)
The Team invite created a passwordless zombie member with NO way to sign in
(email/OTP delivery is unwired; IAM send-invitation is a stub). This closes the
loop with a console-native, no-email, no-new-IAM-capability accept flow:

- POST /console/invite-link mints a sealed (AES-256-GCM, HKDF of the confidential
  client secret), 14-day, org-admin-gated accept token for a pending member.
- Public /accept page + GET/POST /console/accept lets the invitee set their OWN
  password (IAM hashes via update-user's passwordChanged path — never plaintext),
  then sign in and land in their org with the assigned role. Single-use for
  activation (refused once the member has a credential).
- InviteDialog surfaces the shareable link (honest 'email delivery isn't wired'
  copy); roster shows Pending vs Active + a 2FA badge, and a 'Copy invite link'
  row action for pending members (activates the pre-existing zombies too).
- identity.ts gains getMember/memberHasPassword/activateMember (same confidential
  client as createUser/moveUserToOrg). +8 invite-token tests. tsc + 1785 vitest
  green; next build ✓ (/accept, /console/accept, /console/invite-link registered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 15:07:15 -07:00
48de8e18bd feat(onboarding): $5 welcome grant + admin Grants/Projects + non-premium default + trial/prepaid balance (#112)
* feat(onboarding): grant $5 welcome credit on signup + self-heal on load

FIX 1 (onboarding paywall): a new signup lands at a $0 balance and 402s on
first chat because the idempotent commerce welcome-grant is never invoked.

- identity.ts: new server-only grantWelcomeCredit(org,user) — mints a user-bound
  bearer and POSTs the idempotent `/v1/billing/me/welcome` behind the gateway
  (X-Org-Id scoped). Best-effort, never throws, never blocks signup.
- signup route: award the grant after createUser (idempotent; failure is harmless).
- BillingApi.welcome() + claimWelcomeGrantOnce(owner): self-heal on first
  authenticated load (social-login + pre-existing $0 users), once per browser
  session via the per-tenant /billing/v1/me/welcome proxy.
- wired into SessionProvider.applyAccount; unit tests for the guard.

* fix(playground): default to a NON-PREMIUM chat model so a $5 trial user gets 200

FIX 3: the auto-default preferred the bare Zen flagship (zen5), which is PREMIUM —
a cold, trial-funded user 402d ('premium model requiring a paid balance') on the
very first Run.

- thread `premium` through the catalog: RichModel.premium → CatalogEntry (spread)
  → ModelOption.premium (useModels normalizer, from the /v1/models `premium` bool).
- default-model: choose from the NON-PREMIUM pool first (fallback to full only when
  every model is premium), and prefer the general-purpose tier (zen5-flash) over a
  specialized one (zen5-coder) via a generalist tiebreak.
- tests prove the default is provably non-premium (skips zen5/zen5-max/featured-premium).

* fix(onboarding): signup grant via commerce service-token grant-starter (not user token)

A fresh personal-org user cannot be resolved by the confidential hanzo-console
client (it lives in org 'hanzo'), so issue-user-token/password-grant fails. Use the
DESIGNED trusted-service path instead: commerce POST /v1/billing/grant-starter with
the COMMERCE SERVICE TOKEN the console already holds (billing-proxy).

- billing-proxy: export commerceBaseUrl() + commerceServiceToken() (DRY, one address).
- billing-grant.ts: grantWelcomeCredit(orgSlug) POSTs grant-starter (subject == the
  personal-org slug, X-Org-Id + user body, trigger tag for idempotent dedupe).
  Best-effort, swallows failures. identity.ts reverted (no cross-org token mint).
- signup route calls it after createUser; unit tests cover posting + swallow.

* feat(admin): per-row Grant quick action + Trial/Prepaid source selector

FIX 2: staff grant-credit gets a per-ROW quick action and a source bucket.

- admin-cockpit grantCredit accepts source?: 'trial'|'prepaid' (GrantSource type).
- CustomersModule: extract ONE reusable GrantCreditPanel (amount + source toggle,
  default Trial since comps are non-cash + reason) used by BOTH the detail view and
  a new per-row 'Grant' action (stopPropagation so it doesn't open the detail).
  Notice banner shows the granted bucket.

* feat(admin): Grants ledger + Projects boards (global-admin, admin.hanzo.ai)

DELIVERABLE 4 — two new admin-only catalog entries under Observe (admin:true,
hidden from customers), honest loading/empty/403 states, no fabricated rows.

- Grants (id fleet-grants): fleet credit-grant ledger + issuance. admin-grants.ts
  routes GET/POST /v1/admin/grants through the /admin/aggregate BFF (grants head added
  to ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS, following the compute pattern).
  Table (org, amount, Trial/Prepaid source badge, reason, staff actor, date) + a New
  grant form (org + amount + source toggle + reason → POST), refresh after issue.
- Projects (id fleet-projects): READ-ONLY cross-org deploy board. admin-projects.ts is a
  pure lens over the EXISTING global PlatformApi.apps() inventory (per neo — NO
  /v1/admin/projects endpoint): org, app, health, cluster, live URL, drift; drill by org
  via a filter. groupByOrg + toProjectRow unit-tested.
- registry: both entries (aliased FleetGrantsModule/FleetProjectsModule to avoid the
  customer 'projects' collision).

* feat(billing): show trial + prepaid balance buckets distinctly

DELIVERABLE 5 — surface the new commerce bucket split ($5 non-cash trial vs real
prepaid money) everywhere the org balance shows, reusing the ONE live-balance source.

- wallet.ts CloudBalance: add optional trialGranted/trialBalance/creditsGranted/
  creditsRemaining/prepaidBalance/prepaidAvailable (legacy build omits them → degrade
  to the combined total, never fabricated).
- live-balance.ts: trialCents/prepaidCents + balanceSplitLabel ('$5.00 trial + $X.XX
  credits'), null when neither bucket is reported. Unit-tested.
- SidebarWallet / WalletModule / BillingCredits render the split under the total.

* release(console): v8.4.102 — onboarding welcome-grant + admin Grants/Projects + trial/prepaid balance

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 14:54:46 -07:00
247590303d feat(onboarding): $5 welcome grant + admin Grants/Projects + non-premium default + trial/prepaid balance (#112)
* feat(onboarding): grant $5 welcome credit on signup + self-heal on load

FIX 1 (onboarding paywall): a new signup lands at a $0 balance and 402s on
first chat because the idempotent commerce welcome-grant is never invoked.

- identity.ts: new server-only grantWelcomeCredit(org,user) — mints a user-bound
  bearer and POSTs the idempotent `/v1/billing/me/welcome` behind the gateway
  (X-Org-Id scoped). Best-effort, never throws, never blocks signup.
- signup route: award the grant after createUser (idempotent; failure is harmless).
- BillingApi.welcome() + claimWelcomeGrantOnce(owner): self-heal on first
  authenticated load (social-login + pre-existing $0 users), once per browser
  session via the per-tenant /billing/v1/me/welcome proxy.
- wired into SessionProvider.applyAccount; unit tests for the guard.

* fix(playground): default to a NON-PREMIUM chat model so a $5 trial user gets 200

FIX 3: the auto-default preferred the bare Zen flagship (zen5), which is PREMIUM —
a cold, trial-funded user 402d ('premium model requiring a paid balance') on the
very first Run.

- thread `premium` through the catalog: RichModel.premium → CatalogEntry (spread)
  → ModelOption.premium (useModels normalizer, from the /v1/models `premium` bool).
- default-model: choose from the NON-PREMIUM pool first (fallback to full only when
  every model is premium), and prefer the general-purpose tier (zen5-flash) over a
  specialized one (zen5-coder) via a generalist tiebreak.
- tests prove the default is provably non-premium (skips zen5/zen5-max/featured-premium).

* fix(onboarding): signup grant via commerce service-token grant-starter (not user token)

A fresh personal-org user cannot be resolved by the confidential hanzo-console
client (it lives in org 'hanzo'), so issue-user-token/password-grant fails. Use the
DESIGNED trusted-service path instead: commerce POST /v1/billing/grant-starter with
the COMMERCE SERVICE TOKEN the console already holds (billing-proxy).

- billing-proxy: export commerceBaseUrl() + commerceServiceToken() (DRY, one address).
- billing-grant.ts: grantWelcomeCredit(orgSlug) POSTs grant-starter (subject == the
  personal-org slug, X-Org-Id + user body, trigger tag for idempotent dedupe).
  Best-effort, swallows failures. identity.ts reverted (no cross-org token mint).
- signup route calls it after createUser; unit tests cover posting + swallow.

* feat(admin): per-row Grant quick action + Trial/Prepaid source selector

FIX 2: staff grant-credit gets a per-ROW quick action and a source bucket.

- admin-cockpit grantCredit accepts source?: 'trial'|'prepaid' (GrantSource type).
- CustomersModule: extract ONE reusable GrantCreditPanel (amount + source toggle,
  default Trial since comps are non-cash + reason) used by BOTH the detail view and
  a new per-row 'Grant' action (stopPropagation so it doesn't open the detail).
  Notice banner shows the granted bucket.

* feat(admin): Grants ledger + Projects boards (global-admin, admin.hanzo.ai)

DELIVERABLE 4 — two new admin-only catalog entries under Observe (admin:true,
hidden from customers), honest loading/empty/403 states, no fabricated rows.

- Grants (id fleet-grants): fleet credit-grant ledger + issuance. admin-grants.ts
  routes GET/POST /v1/admin/grants through the /admin/aggregate BFF (grants head added
  to ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS, following the compute pattern).
  Table (org, amount, Trial/Prepaid source badge, reason, staff actor, date) + a New
  grant form (org + amount + source toggle + reason → POST), refresh after issue.
- Projects (id fleet-projects): READ-ONLY cross-org deploy board. admin-projects.ts is a
  pure lens over the EXISTING global PlatformApi.apps() inventory (per neo — NO
  /v1/admin/projects endpoint): org, app, health, cluster, live URL, drift; drill by org
  via a filter. groupByOrg + toProjectRow unit-tested.
- registry: both entries (aliased FleetGrantsModule/FleetProjectsModule to avoid the
  customer 'projects' collision).

* feat(billing): show trial + prepaid balance buckets distinctly

DELIVERABLE 5 — surface the new commerce bucket split ($5 non-cash trial vs real
prepaid money) everywhere the org balance shows, reusing the ONE live-balance source.

- wallet.ts CloudBalance: add optional trialGranted/trialBalance/creditsGranted/
  creditsRemaining/prepaidBalance/prepaidAvailable (legacy build omits them → degrade
  to the combined total, never fabricated).
- live-balance.ts: trialCents/prepaidCents + balanceSplitLabel ('$5.00 trial + $X.XX
  credits'), null when neither bucket is reported. Unit-tested.
- SidebarWallet / WalletModule / BillingCredits render the split under the total.

* release(console): v8.4.102 — onboarding welcome-grant + admin Grants/Projects + trial/prepaid balance

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 14:54:46 -07:00
hanzo-dev d0bd4390c4 feat(console): unified compute fleet + BYO cluster attach, customer self-service (v8.4.102)
Make the org's compute fleet easy to SEE and USE in one place, and open cluster
self-service to paying customers (drop the admin gate). Per-org customer console;
admin.hanzo.ai's cross-org compute boards stay separate.

- Kubernetes module is now the UNIFIED FLEET cockpit: managed + attached BYO
  clusters (GET /v1/clusters, MERGED, with kind + nvidia/amd GPU inventory + node
  count + status) and dialed-in BYO machines (GET /v1/machines, provider="byo"),
  in one honest view — loading / empty "no compute yet" / error BackendStateCard,
  never a fabricated row. Supersedes the old capacity-card design.
- Enable BYO cluster attach (replaces the dropped "import cluster" stub): a Register-
  cluster form (name + kubeconfig paste/upload + default toggle) -> POST /v1/clusters,
  with honest error paths (503 KMS-not-configured, 422 unreachable-kubeconfig,
  402 billing, 400 invalid). PlatformApi.attachCluster/detachCluster added; the
  Cluster type gains nvidiaGpu/amdGpu (matching the cloud clusterView).
- Show the three connect options so it is easy to DO: BYO cluster (the attach form),
  BYO box (copy-paste `hanzo gpu connect` + desktop auto-link note), and BYOC cloud
  account (honest "connect your AWS/GCP/Azure/DO account - coming").
- Un-hide the `clusters` + `kubernetes` catalog entries (drop admin:true) so a paying
  customer reaches cluster self-service from the unified console.
- visor.ts VisorMachine gains os (provider already present) so BYO boxes are labeled.
- Pure kubernetes/logic.ts (summarizeFleet / byoBoxes / clusterNodeTotal /
  describeAttachError / CONNECT_SNIPPET) + 16 vitest tests.

Verify: npm run typecheck (0 errors), npm test (1752 pass), next build (green, 15/15).
2026-07-04 14:26:09 -07:00
hanzo-dev ff0f5e0acf feat(console): unified compute fleet + BYO cluster attach, customer self-service (v8.4.102)
Make the org's compute fleet easy to SEE and USE in one place, and open cluster
self-service to paying customers (drop the admin gate). Per-org customer console;
admin.hanzo.ai's cross-org compute boards stay separate.

- Kubernetes module is now the UNIFIED FLEET cockpit: managed + attached BYO
  clusters (GET /v1/clusters, MERGED, with kind + nvidia/amd GPU inventory + node
  count + status) and dialed-in BYO machines (GET /v1/machines, provider="byo"),
  in one honest view — loading / empty "no compute yet" / error BackendStateCard,
  never a fabricated row. Supersedes the old capacity-card design.
- Enable BYO cluster attach (replaces the dropped "import cluster" stub): a Register-
  cluster form (name + kubeconfig paste/upload + default toggle) -> POST /v1/clusters,
  with honest error paths (503 KMS-not-configured, 422 unreachable-kubeconfig,
  402 billing, 400 invalid). PlatformApi.attachCluster/detachCluster added; the
  Cluster type gains nvidiaGpu/amdGpu (matching the cloud clusterView).
- Show the three connect options so it is easy to DO: BYO cluster (the attach form),
  BYO box (copy-paste `hanzo gpu connect` + desktop auto-link note), and BYOC cloud
  account (honest "connect your AWS/GCP/Azure/DO account - coming").
- Un-hide the `clusters` + `kubernetes` catalog entries (drop admin:true) so a paying
  customer reaches cluster self-service from the unified console.
- visor.ts VisorMachine gains os (provider already present) so BYO boxes are labeled.
- Pure kubernetes/logic.ts (summarizeFleet / byoBoxes / clusterNodeTotal /
  describeAttachError / CONNECT_SNIPPET) + 16 vitest tests.

Verify: npm run typecheck (0 errors), npm test (1752 pass), next build (green, 15/15).
2026-07-04 14:26:09 -07:00
hanzo-dev 5d68f27825 release(console): v8.4.101 — #58 design-system + #29 billing (rebump over concurrent v8.4.100) 2026-07-04 13:20:12 -07:00
hanzo-dev 8ee76912dc release(console): v8.4.101 — #58 design-system + #29 billing (rebump over concurrent v8.4.100) 2026-07-04 13:20:12 -07:00
hanzo-dev 686b017474 Merge origin/main (v8.4.100) into integration branch 2026-07-04 13:18:42 -07:00
hanzo-dev edf49b9c30 Merge origin/main (v8.4.100) into integration branch 2026-07-04 13:18:42 -07:00
zeekayandClaude Opus 4.8 abb87e5d0e feat(console): surface git.hanzo.ai (Gitea code host) as a Dev product tile
Add a first-class "Git" catalog entry linking to the self-hosted Gitea
code host at git.hanzo.ai, following the SAME external-launch pattern as
Automation (auto.hanzo.ai): kind:'external' + href, brands:['hanzo'] so
the URL never leaks onto a Lux/Zoo console. One CatalogEntry surfaces it
in the Dev nav, the catalog overview, the app launcher, ⌘K, favorites,
and the discover interstitial — no shell/route edits (the registry is the
single source of nav + routing truth). GitBranch icon (already imported);
no docs/repo field (git.hanzo.ai IS the code host, no docs.hanzo.ai page).

tsc --noEmit clean; vitest 1692/1692; next build ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:17:18 -07:00
zeekayandhanzo-dev 66f386c5d1 feat(console): surface git.hanzo.ai (Gitea code host) as a Dev product tile
Add a first-class "Git" catalog entry linking to the self-hosted Gitea
code host at git.hanzo.ai, following the SAME external-launch pattern as
Automation (auto.hanzo.ai): kind:'external' + href, brands:['hanzo'] so
the URL never leaks onto a Lux/Zoo console. One CatalogEntry surfaces it
in the Dev nav, the catalog overview, the app launcher, ⌘K, favorites,
and the discover interstitial — no shell/route edits (the registry is the
single source of nav + routing truth). GitBranch icon (already imported);
no docs/repo field (git.hanzo.ai IS the code host, no docs.hanzo.ai page).

tsc --noEmit clean; vitest 1692/1692; next build ✓.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 13:17:18 -07:00
hanzo-dev 0c822f9aa0 release(console): v8.4.100 — merge #58 design-system polish + #29 billing surface 2026-07-04 13:17:12 -07:00
hanzo-dev a556d10541 release(console): v8.4.100 — merge #58 design-system polish + #29 billing surface 2026-07-04 13:17:12 -07:00
hanzo-dev f16987b298 Merge feat/billing-surface-complete: in-console payments + subscriptions + hardened billing proxy (#29)
In-console Square payment methods (add/set-default/remove via use-square-card),
subscription manage, invoice PDF; RED-hardened /billing/v1 commerce proxy
(billing-proxy.ts, DRY on bearer-proxy — tenant isolation via X-Org-Id + full
billing-subject key set, last4 clamp). Two-tenant isolation e2e.

# Conflicts:
#	LLM.md
2026-07-04 13:16:43 -07:00
hanzo-dev e1f4451c4b Merge feat/billing-surface-complete: in-console payments + subscriptions + hardened billing proxy (#29)
In-console Square payment methods (add/set-default/remove via use-square-card),
subscription manage, invoice PDF; RED-hardened /billing/v1 commerce proxy
(billing-proxy.ts, DRY on bearer-proxy — tenant isolation via X-Org-Id + full
billing-subject key set, last4 clamp). Two-tenant isolation e2e.

# Conflicts:
#	LLM.md
2026-07-04 13:16:43 -07:00
hanzo-dev fd1fcb73ab Merge feat/console-ui-58: @hanzo/ui design-system polish (#58)
Org-brand sidebar header + Hanzo-H fallback, Material elevation/paper tokens,
alphabetical + selected-first product order (order.ts, DRY + tested), mobile nav
drawer opens LEFT and auto-closes on product tap, opacity-only hz-menu-in for
floating-ui anchored menus (SelectMenu/ComboBox) so they stay anchored.
@hanzo/gui shorthands only; no Svelte/Radix.
2026-07-04 13:12:55 -07:00
hanzo-dev e834d35437 Merge feat/console-ui-58: @hanzo/ui design-system polish (#58)
Org-brand sidebar header + Hanzo-H fallback, Material elevation/paper tokens,
alphabetical + selected-first product order (order.ts, DRY + tested), mobile nav
drawer opens LEFT and auto-closes on product tap, opacity-only hz-menu-in for
floating-ui anchored menus (SelectMenu/ComboBox) so they stay anchored.
@hanzo/gui shorthands only; no Svelte/Radix.
2026-07-04 13:12:55 -07:00
zandGitHub a2d2d1fa9b Merge pull request #109 from hanzoai/fix/mobile-ux
fix(mobile-ux): unblock cramped topbar search + stop chat bubble overlapping the composer (v8.4.99)
2026-07-04 13:12:25 -07:00
zandGitHub c849006d22 Merge pull request #109 from hanzoai/fix/mobile-ux
fix(mobile-ux): unblock cramped topbar search + stop chat bubble overlapping the composer (v8.4.99)
2026-07-04 13:12:25 -07:00
hanzo-dev 909514e4cc fix(mobile-ux): unblock cramped topbar search + stop chat bubble overlapping composer (v8.4.99)
Live mobile audit of console.hanzo.ai (v8.4.98) at 375/768/1440. Stacking is
solid (zero horizontal overflow anywhere) and all core mobile interactions work
(hamburger nav drawer, ⌘K palette, search, org/scope switchers, app launcher,
chat composer). Two clear defects fixed:

1. Topbar search truncated to "S…" on phones. Two flex:1 siblings — the search
   box and the right-side spacer — halved the box's width below lg, and the ⌘K
   chip (meaningless without a keyboard) ate the rest, so the placeholder was
   unreadable. The spacer now only flexes at lg+ (below lg the box fills the row)
   and the ⌘K hint is hidden below lg. Desktop layout is unchanged.

2. FloatingChat bubble overlapped the composer's send control on /chat and
   /playground (it's fixed bottom-right on every page). It is now suppressed on
   those two surfaces — which already have their own full composer — while
   staying one tap away everywhere else and still openable programmatically.

Gates: tsc --noEmit clean · vitest 1664/1664 · next build ✓.
2026-07-04 13:06:36 -07:00
hanzo-dev f35aab8297 fix(mobile-ux): unblock cramped topbar search + stop chat bubble overlapping composer (v8.4.99)
Live mobile audit of console.hanzo.ai (v8.4.98) at 375/768/1440. Stacking is
solid (zero horizontal overflow anywhere) and all core mobile interactions work
(hamburger nav drawer, ⌘K palette, search, org/scope switchers, app launcher,
chat composer). Two clear defects fixed:

1. Topbar search truncated to "S…" on phones. Two flex:1 siblings — the search
   box and the right-side spacer — halved the box's width below lg, and the ⌘K
   chip (meaningless without a keyboard) ate the rest, so the placeholder was
   unreadable. The spacer now only flexes at lg+ (below lg the box fills the row)
   and the ⌘K hint is hidden below lg. Desktop layout is unchanged.

2. FloatingChat bubble overlapped the composer's send control on /chat and
   /playground (it's fixed bottom-right on every page). It is now suppressed on
   those two surfaces — which already have their own full composer — while
   staying one tap away everywhere else and still openable programmatically.

Gates: tsc --noEmit clean · vitest 1664/1664 · next build ✓.
2026-07-04 13:06:36 -07:00
hanzo-dev d052760732 feat(console): unified resource overview — apps, GPUs, and nodes at a glance
The console home now shows the org's full resource picture in one place: top-line
stat tiles (apps running, GPUs online split cloud vs BYO, nodes) plus three
at-a-glance sections (Apps, GPUs, Nodes) that deep-link to the product owning each.
Read-only aggregation over three REAL per-org sources — apps (/v1/platform), GPUs
(/v1/gpus), machines (/v1/machines) — loaded independently, so a slow/denied/unrouted
source degrades to its own "not reporting" line and never blanks the board.

- normalizeMachine (visor) + normalizeGpu (compute) now pass `provider` through so a
  bring-your-own node/GPU (a DGX Spark GB10) badges BYO distinctly from Hanzo Cloud;
  normalizeGpu also tolerates a BYO GPU's string `memory` ("128GB"/"80 GiB"/"131072MB")
  → numeric VRAM. Additive, defensive; DOKS rows unchanged.
- ResourceOverview + pure resource-logic (provider kind, online cloud/BYO split), both
  unit-tested; PlatformAppsApi.listAllApps() de-dups the org-wide app read (DRY).

Full BYO render lands with the cloud redeploy that unions Visor DOKS inventory with
BYO fleet workers into /v1/machines + /v1/gpus.
2026-07-04 13:05:59 -07:00
hanzo-dev 04ea92885d feat(console): unified resource overview — apps, GPUs, and nodes at a glance
The console home now shows the org's full resource picture in one place: top-line
stat tiles (apps running, GPUs online split cloud vs BYO, nodes) plus three
at-a-glance sections (Apps, GPUs, Nodes) that deep-link to the product owning each.
Read-only aggregation over three REAL per-org sources — apps (/v1/platform), GPUs
(/v1/gpus), machines (/v1/machines) — loaded independently, so a slow/denied/unrouted
source degrades to its own "not reporting" line and never blanks the board.

- normalizeMachine (visor) + normalizeGpu (compute) now pass `provider` through so a
  bring-your-own node/GPU (a DGX Spark GB10) badges BYO distinctly from Hanzo Cloud;
  normalizeGpu also tolerates a BYO GPU's string `memory` ("128GB"/"80 GiB"/"131072MB")
  → numeric VRAM. Additive, defensive; DOKS rows unchanged.
- ResourceOverview + pure resource-logic (provider kind, online cloud/BYO split), both
  unit-tested; PlatformAppsApi.listAllApps() de-dups the org-wide app read (DRY).

Full BYO render lands with the cloud redeploy that unions Visor DOKS inventory with
BYO fleet workers into /v1/machines + /v1/gpus.
2026-07-04 13:05:59 -07:00
zandGitHub 063c5700cb Merge pull request #108 from hanzoai/feat/service-map
feat(map): Map — the org's deployment landscape on one live canvas (v8.4.99)
2026-07-04 13:00:28 -07:00
zandGitHub 44b8a9311a Merge pull request #108 from hanzoai/feat/service-map
feat(map): Map — the org's deployment landscape on one live canvas (v8.4.99)
2026-07-04 13:00:28 -07:00
hanzo-dev 7afda87461 feat(console): #58 anchored menus fade opacity-only (hz-menu-in) to stay anchored
floating-ui positions SelectMenu/ComboBox Popover.Content with an inline
transform; a transform-based entrance (hz-pop-in) overrides that inline value
for the animation duration and detaches the menu from its trigger. New
opacity-only hz-menu-in keeps the anchor exact; hz-pop-in stays for centered
Dialog surfaces. Reduced-motion snaps.
2026-07-04 12:58:32 -07:00
hanzo-dev d3015cf221 feat(console): #58 anchored menus fade opacity-only (hz-menu-in) to stay anchored
floating-ui positions SelectMenu/ComboBox Popover.Content with an inline
transform; a transform-based entrance (hz-pop-in) overrides that inline value
for the animation duration and detaches the menu from its trigger. New
opacity-only hz-menu-in keeps the anchor exact; hz-pop-in stays for centered
Dialog surfaces. Reduced-motion snaps.
2026-07-04 12:58:32 -07:00
hanzo-dev 65c7bf6a3e feat(console): #58 mobile nav drawer opens LEFT + auto-closes on product tap
Left-side drawer matches the top-left hamburger (account drawer stays right);
SidebarNav.openProduct calls onNavigate() so the off-canvas nav dismisses on a
product tap (desktop passes a no-op — stays open).
2026-07-04 12:56:39 -07:00
hanzo-dev 0a404b3bad feat(console): #58 mobile nav drawer opens LEFT + auto-closes on product tap
Left-side drawer matches the top-left hamburger (account drawer stays right);
SidebarNav.openProduct calls onNavigate() so the off-canvas nav dismisses on a
product tap (desktop passes a no-op — stays open).
2026-07-04 12:56:39 -07:00
hanzo-dev c0eb0011b5 feat(map): register Map as the lead Compute product
Adds the 'map' module (label 'Map', Network icon) at the top of Compute so the
deployment landscape reads as a primary view, not a buried subpage.
2026-07-04 12:55:27 -07:00
hanzo-dev b65b2b18c5 feat(map): register Map as the lead Compute product
Adds the 'map' module (label 'Map', Network icon) at the top of Compute so the
deployment landscape reads as a primary view, not a buried subpage.
2026-07-04 12:55:27 -07:00
hanzo-dev ef7ff6dd0f feat(map): @xyflow/react canvas + module — the 'see everything running' view
Monochrome node cards (@hanzo/gui tokens) with kind icon + live status dot
(running pulses; reduced-motion disables it), pan/zoom + fitView, minimap, controls.
One org-scoped read (PaasApi apps + ProvisioningApi data); polls via usePoll; honest
loading/empty/error states. Click a node -> side panel with details + deep links to
the product page and its /metrics observability. Canvas is client-only (dynamic
ssr:false); colorMode + edge hues track the real app theme.
2026-07-04 12:55:27 -07:00
hanzo-dev ac3d1e80ed feat(map): @xyflow/react canvas + module — the 'see everything running' view
Monochrome node cards (@hanzo/gui tokens) with kind icon + live status dot
(running pulses; reduced-motion disables it), pan/zoom + fitView, minimap, controls.
One org-scoped read (PaasApi apps + ProvisioningApi data); polls via usePoll; honest
loading/empty/error states. Click a node -> side panel with details + deep links to
the product page and its /metrics observability. Canvas is client-only (dynamic
ssr:false); colorMode + edge hues track the real app theme.
2026-07-04 12:55:27 -07:00
hanzo-dev 1f6dd3e3bc feat(map): pure graph-derivation logic + tests (nodes/edges from apps, data, domains)
Honest edges only: domain->app from app.domains; app->resource only where an
unmasked env value names the resource host/name (secrets are masked, so a drawn
edge is a real link, never invented). Deterministic 3-tier layered layout. 18 unit
tests cover status normalization, node/edge derivation, layout determinism, summary.
2026-07-04 12:55:27 -07:00
hanzo-dev e622514876 feat(map): pure graph-derivation logic + tests (nodes/edges from apps, data, domains)
Honest edges only: domain->app from app.domains; app->resource only where an
unmasked env value names the resource host/name (secrets are masked, so a drawn
edge is a real link, never invented). Deterministic 3-tier layered layout. 18 unit
tests cover status normalization, node/edge derivation, layout determinism, summary.
2026-07-04 12:55:27 -07:00
hanzo-dev a7a69a2c65 build(console): add @xyflow/react 12.11.1 for the Map canvas; bump to v8.4.99 2026-07-04 12:55:27 -07:00
hanzo-dev c17c300d13 build(console): add @xyflow/react 12.11.1 for the Map canvas; bump to v8.4.99 2026-07-04 12:55:27 -07:00
hanzo-dev dd09e7f4be wip(console): #58 batch1 — org-brand sidebar, elevation tokens, alpha/selected nav order, menu+CTA polish
Checkpoint of recovered in-progress work (dev agent hit session limit).
- DashboardShell: org name+avatar sidebar header + Hanzo-H fallback
- globals.css: Material paper/3D elevation utilities
- src/lib/products/order.ts(+test): DRY alphabetical + selected-first sort
- BrandLogo/brand: white-label brand marks
- SlideOver/CommandPalette/FloatingChat/SelectMenu/ComboBox: paper polish
Not yet reviewed or live-verified.
2026-07-04 12:19:01 -07:00
hanzo-dev 227aedf5df wip(console): #58 batch1 — org-brand sidebar, elevation tokens, alpha/selected nav order, menu+CTA polish
Checkpoint of recovered in-progress work (dev agent hit session limit).
- DashboardShell: org name+avatar sidebar header + Hanzo-H fallback
- globals.css: Material paper/3D elevation utilities
- src/lib/products/order.ts(+test): DRY alphabetical + selected-first sort
- BrandLogo/brand: white-label brand marks
- SlideOver/CommandPalette/FloatingChat/SelectMenu/ComboBox: paper polish
Not yet reviewed or live-verified.
2026-07-04 12:19:01 -07:00
zandGitHub bec70dbdd6 Merge pull request #107 from hanzoai/feat/port-old-console-views
console(port): close out old→console2 view port; drop dead superseded IAM/Applications cluster (v8.4.98)
2026-07-04 12:18:54 -07:00
zandGitHub 4b7325d699 Merge pull request #107 from hanzoai/feat/port-old-console-views
console(port): close out old→console2 view port; drop dead superseded IAM/Applications cluster (v8.4.98)
2026-07-04 12:18:54 -07:00
hanzo-dev 3a597f2dc9 console(port): drop dead superseded IAM/Applications view cluster (v8.4.98)
Close out the "port remaining old-console views" task. The old console
(hanzoai/console) and console2 have fully converged: /home/z/work/hanzo/console
is byte-identical to origin/main (v8.4.97), so nothing in "old" is missing from
"new". The only components not reachable from the product registry were an early
wave of views, since superseded and never re-wired:

- iam/{UserEditView,AppEditView,OrgView,logic}  (from wip savepoint f28d796)
  -> superseded by AdminModule's IamModule (Orgs/Users/Roles, full CRUD) +
     AuditModule, and per-org IAM apps managed live in tenants/TenantDetail.
- applications/{ApplicationListView,ApplicationEditView,logic} (from cf5d309)
  -> superseded by ApplicationsModule -> PaasApplications (the real deployed-apps
     surface over /v1/platform).
- lib/api/applications.ts (ApplicationApi) + the `Application` type
  -> the legacy casibase-era /v1/*-application(s) client, its only consumers were
     the deleted views; the modern paas.ts client is the one and only way now.

One way, no dead code. Gates: tsc 0 errors, vitest 130 files / 1664 tests green,
next build compiled.
2026-07-04 12:17:27 -07:00
hanzo-dev dd6134aacc console(port): drop dead superseded IAM/Applications view cluster (v8.4.98)
Close out the "port remaining old-console views" task. The old console
(hanzoai/console) and console2 have fully converged: /home/z/work/hanzo/console
is byte-identical to origin/main (v8.4.97), so nothing in "old" is missing from
"new". The only components not reachable from the product registry were an early
wave of views, since superseded and never re-wired:

- iam/{UserEditView,AppEditView,OrgView,logic}  (from wip savepoint 5a2573d)
  -> superseded by AdminModule's IamModule (Orgs/Users/Roles, full CRUD) +
     AuditModule, and per-org IAM apps managed live in tenants/TenantDetail.
- applications/{ApplicationListView,ApplicationEditView,logic} (from cf5d309)
  -> superseded by ApplicationsModule -> PaasApplications (the real deployed-apps
     surface over /v1/platform).
- lib/api/applications.ts (ApplicationApi) + the `Application` type
  -> the legacy casibase-era /v1/*-application(s) client, its only consumers were
     the deleted views; the modern paas.ts client is the one and only way now.

One way, no dead code. Gates: tsc 0 errors, vitest 130 files / 1664 tests green,
next build compiled.
2026-07-04 12:17:27 -07:00
hanzo-dev 84e131fecc fix(billing-proxy): RED-1..RED-5 harden the /billing/v1 commerce proxy (DRY on bearer-proxy)
RED reviewed the v8.4-billing-surface proxy (src/lib/server/billing-proxy.ts).
All five findings fixed by REUSING the already-hardened bearer-proxy.ts pattern
(pathIsClean + normalized-URL re-validation + streaming), not a new guard.

RED-1 [SHIP-BLOCKER] encoded path-traversal out of /v1/billing/. isSafeSegment
  rejected literal `..`/`/` but not `%2e%2e`/`.%2e`/`%2E%2E`/double-encoded
  `%252e%252e` (Next single-decodes -> `%2e%2e`), which undici normalizes to a
  real `..` and pops out of /v1/billing/ to the whole commerce API with the
  service Bearer. FIX: replace isSafeSegment with the shared pathIsClean
  (rejects empty, `.`/`..`, ANY `%XX`, matrix-param `;`) on the raw path, AND
  re-validate the NORMALIZED URL.pathname still begins with /v1/billing/ AFTER
  undici resolves it. Either check fails -> 400 with NO upstream fetch. Applies
  to GET/POST/DELETE (the check is before the body read + fetch).

RED-2 [MED] content-type confusion / missing nosniff. Upstream CT/Content-
  Disposition were forwarded verbatim, no nosniff. FIX: set
  X-Content-Type-Options: nosniff on EVERY billing response; the binary branch
  FORCES Content-Disposition: attachment with a SANITIZED filename (CR/LF/`;`/
  quote/path chars stripped, never the upstream's verbatim); an active textual
  type (text/html/xhtml/svg/js from a compromised/MITM'd plaintext hop) is
  served inert as text/plain so window.open can never execute it at our origin.

RED-3 [MED] unbounded buffering -> memory DoS. res.arrayBuffer()/res.text()
  buffered the whole body. FIX: STREAM res.body through on BOTH branches (matches
  bearer-proxy); a null-body status (204 detach) still carries null.

RED-4 [LOW] 502 leaked the raw upstream exception (internal host). FIX: generic
  "Billing upstream is unavailable." to the client; console.error the detail
  server-side (mirrors bearer-proxy).

RED-5 [LOW] regression-net gap. New tests: encoded-traversal `['%2e%2e',...]`,
  `['.%2e',...]`, `['%2E%2E',...]`, double-encoded, `%2f`, `..;`, and any
  `%`-containing segment -> 400 with NO fetch (all verbs); binary-branch PDF
  carries nosniff + attachment; nosniff on JSON; text/html+svg served inert;
  generic 502 with no leak; pure helpers.

CONFIRMED-SAFE properties untouched: CSRF-first ordering, resolveUser/401,
COMMERCE_TOKEN/501, scopedBillingSearch/Body subject pinning, X-Org-Id from the
validated session.

tsc --noEmit: 0 errors. vitest: billing-proxy 28/28 (was 11), billing suites
114/114, full 1698/1698.
2026-07-04 11:47:05 -07:00
hanzo-dev 0787f6b898 fix(billing-proxy): RED-1..RED-5 harden the /billing/v1 commerce proxy (DRY on bearer-proxy)
RED reviewed the v8.4-billing-surface proxy (src/lib/server/billing-proxy.ts).
All five findings fixed by REUSING the already-hardened bearer-proxy.ts pattern
(pathIsClean + normalized-URL re-validation + streaming), not a new guard.

RED-1 [SHIP-BLOCKER] encoded path-traversal out of /v1/billing/. isSafeSegment
  rejected literal `..`/`/` but not `%2e%2e`/`.%2e`/`%2E%2E`/double-encoded
  `%252e%252e` (Next single-decodes -> `%2e%2e`), which undici normalizes to a
  real `..` and pops out of /v1/billing/ to the whole commerce API with the
  service Bearer. FIX: replace isSafeSegment with the shared pathIsClean
  (rejects empty, `.`/`..`, ANY `%XX`, matrix-param `;`) on the raw path, AND
  re-validate the NORMALIZED URL.pathname still begins with /v1/billing/ AFTER
  undici resolves it. Either check fails -> 400 with NO upstream fetch. Applies
  to GET/POST/DELETE (the check is before the body read + fetch).

RED-2 [MED] content-type confusion / missing nosniff. Upstream CT/Content-
  Disposition were forwarded verbatim, no nosniff. FIX: set
  X-Content-Type-Options: nosniff on EVERY billing response; the binary branch
  FORCES Content-Disposition: attachment with a SANITIZED filename (CR/LF/`;`/
  quote/path chars stripped, never the upstream's verbatim); an active textual
  type (text/html/xhtml/svg/js from a compromised/MITM'd plaintext hop) is
  served inert as text/plain so window.open can never execute it at our origin.

RED-3 [MED] unbounded buffering -> memory DoS. res.arrayBuffer()/res.text()
  buffered the whole body. FIX: STREAM res.body through on BOTH branches (matches
  bearer-proxy); a null-body status (204 detach) still carries null.

RED-4 [LOW] 502 leaked the raw upstream exception (internal host). FIX: generic
  "Billing upstream is unavailable." to the client; console.error the detail
  server-side (mirrors bearer-proxy).

RED-5 [LOW] regression-net gap. New tests: encoded-traversal `['%2e%2e',...]`,
  `['.%2e',...]`, `['%2E%2E',...]`, double-encoded, `%2f`, `..;`, and any
  `%`-containing segment -> 400 with NO fetch (all verbs); binary-branch PDF
  carries nosniff + attachment; nosniff on JSON; text/html+svg served inert;
  generic 502 with no leak; pure helpers.

CONFIRMED-SAFE properties untouched: CSRF-first ordering, resolveUser/401,
COMMERCE_TOKEN/501, scopedBillingSearch/Body subject pinning, X-Org-Id from the
validated session.

tsc --noEmit: 0 errors. vitest: billing-proxy 28/28 (was 11), billing suites
114/114, full 1698/1698.
2026-07-04 11:47:05 -07:00
hanzo-dev 70196abf76 feat(billing): in-console payment methods + subscription manage + invoice PDF
Complete the Billing Center's three external-portal punts over the ONE per-tenant
/billing/v1/* commerce proxy — PCI posture unchanged, tenant scoping server-side.

- Payment Methods: in-console Add (Square iframe -> nonce -> POST payment-methods;
  RAW PAN never leaves the browser) + per-row Remove (confirm -> DELETE
  payment-methods/:id). Set-default fail-secure-skipped (customer-id-in-path not
  proxy-scopable).
- Subscriptions: in-console Cancel (at-period-end vs now -> POST :id/cancel) +
  Reactivate (POST :id/reactivate); row reflects cancelAtPeriodEnd/canceledAt.
- Invoices: Download builds the PDF URL from the invoice id via the same-origin
  proxy. Proxy extracted to tested lib/server/billing-proxy.ts: adds DELETE +
  binary passthrough (application/pdf streamed as raw bytes with Content-Type/
  Content-Disposition, no text() mangling), all auth/CSRF/scoping intact.
- DRY: useSquareCard hook shared by BillingCredits + PaymentMethodsModule.
- New BillingApi: createPaymentMethod/removePaymentMethod/cancelSubscription/
  reactivateSubscription. tsc clean; vitest 1689/1689 (+22 billing/proxy tests).
2026-07-04 11:37:41 -07:00
hanzo-dev c56070c103 feat(billing): in-console payment methods + subscription manage + invoice PDF
Complete the Billing Center's three external-portal punts over the ONE per-tenant
/billing/v1/* commerce proxy — PCI posture unchanged, tenant scoping server-side.

- Payment Methods: in-console Add (Square iframe -> nonce -> POST payment-methods;
  RAW PAN never leaves the browser) + per-row Remove (confirm -> DELETE
  payment-methods/:id). Set-default fail-secure-skipped (customer-id-in-path not
  proxy-scopable).
- Subscriptions: in-console Cancel (at-period-end vs now -> POST :id/cancel) +
  Reactivate (POST :id/reactivate); row reflects cancelAtPeriodEnd/canceledAt.
- Invoices: Download builds the PDF URL from the invoice id via the same-origin
  proxy. Proxy extracted to tested lib/server/billing-proxy.ts: adds DELETE +
  binary passthrough (application/pdf streamed as raw bytes with Content-Type/
  Content-Disposition, no text() mangling), all auth/CSRF/scoping intact.
- DRY: useSquareCard hook shared by BillingCredits + PaymentMethodsModule.
- New BillingApi: createPaymentMethod/removePaymentMethod/cancelSubscription/
  reactivateSubscription. tsc clean; vitest 1689/1689 (+22 billing/proxy tests).
2026-07-04 11:37:41 -07:00
82952d9a39 fix(billing): degrade gracefully through transient balance/usage 5xx (v8.4.97) (#106)
Two live capstone defects when billing/balance|usage 502s during a deploy roll:

1) Billing tiles hung on Loading forever (Projected had no error branch; no load timeout). 2) balance was refetched hundreds of times (636 in one session) with no backoff.

live-balance poll: fixed 30s setInterval -> self-scheduling timer with exponential backoff (base 2s, doubling, cap 60s) + equal-jitter, gated so automatic mount/focus/poll wait out the window; resets to 30s on first success; a user Refresh / balance-affecting action bypasses the breaker. Bounded call count during an outage instead of a storm. Public API + snapshot shape preserved.

Billing tiles: pure tileView() + useTimedOut() bound the spinner — on fetch error OR after a ~10s timeout the Cloud credit / Spend / Projected / Daily-spend tiles render the honest -/Unavailable fallback (the Dashboard/Machines pattern), never an infinite spinner. Happy path unchanged.

Author: Hanzo Dev <dev@hanzo.ai>

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 09:43:32 -07:00
a84ed79045 fix(billing): degrade gracefully through transient balance/usage 5xx (v8.4.97) (#106)
Two live capstone defects when billing/balance|usage 502s during a deploy roll:

1) Billing tiles hung on Loading forever (Projected had no error branch; no load timeout). 2) balance was refetched hundreds of times (636 in one session) with no backoff.

live-balance poll: fixed 30s setInterval -> self-scheduling timer with exponential backoff (base 2s, doubling, cap 60s) + equal-jitter, gated so automatic mount/focus/poll wait out the window; resets to 30s on first success; a user Refresh / balance-affecting action bypasses the breaker. Bounded call count during an outage instead of a storm. Public API + snapshot shape preserved.

Billing tiles: pure tileView() + useTimedOut() bound the spinner — on fetch error OR after a ~10s timeout the Cloud credit / Spend / Projected / Daily-spend tiles render the honest -/Unavailable fallback (the Dashboard/Machines pattern), never an infinite spinner. Happy path unchanged.

Author: Hanzo Dev <dev@hanzo.ai>

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 09:43:32 -07:00
hanzo-devandGitHub dff40364ce Merge pull request #105 from hanzoai/blue/hn-finish
console: redactInstance internal-TLD hardening (v8.4.96)
2026-07-04 04:37:16 -07:00
hanzo-devandGitHub d1fbffe02f Merge pull request #105 from hanzoai/blue/hn-finish
console: redactInstance internal-TLD hardening (v8.4.96)
2026-07-04 04:37:16 -07:00
hanzo-dev b6f3b4c731 console(hn): redactInstance drops internal-TLD scrape hosts too (v8.4.96)
Defense-in-depth on the Status-board endpoint redaction: also drop hosts
on internal TLDs (.internal/.intranet/.corp/.lan/.home/.cluster, and any
*.cluster.* path) in addition to .svc/loopback/RFC1918/IPv6. The audit's
real leak (*.svc:port, localhost, 127.0.0.1) was already covered; this
closes the theoretical case of a scrape label on a private DNS zone. One
extra alternation + test cases. Version → 8.4.96.
2026-07-04 04:36:54 -07:00
hanzo-dev 533b9487ae console(hn): redactInstance drops internal-TLD scrape hosts too (v8.4.96)
Defense-in-depth on the Status-board endpoint redaction: also drop hosts
on internal TLDs (.internal/.intranet/.corp/.lan/.home/.cluster, and any
*.cluster.* path) in addition to .svc/loopback/RFC1918/IPv6. The audit's
real leak (*.svc:port, localhost, 127.0.0.1) was already covered; this
closes the theoretical case of a scrape label on a private DNS zone. One
extra alternation + test cases. Version → 8.4.96.
2026-07-04 04:36:54 -07:00
hanzo-devandGitHub 323f565722 Merge pull request #104 from hanzoai/blue/hn-finish
console: least-privilege store heads on /cloud (v8.4.95)
2026-07-04 04:34:04 -07:00
hanzo-devandGitHub 69633c1ec6 Merge pull request #104 from hanzoai/blue/hn-finish
console: least-privilege store heads on /cloud (v8.4.95)
2026-07-04 04:34:04 -07:00
hanzo-dev 4f69460a44 console(hn): least-privilege store heads on /cloud (v8.4.95)
Harden the casibase store-admin allow-list added in v8.4.94: admit ONLY
the heads the console actually calls — get-stores/get-store (read),
add-store/update-store/delete-store/refresh-store-vectors (mutate).

Drop get-global-stores (a cross-tenant read the console never invokes —
StoreApi.listGlobal has zero callers) and get-store-names (unused) from
CLOUD_HEADS, so the /cloud proxy is not a wider tunnel than needed. Org
is always the Bearer owner server-side, but not exposing an unused
cross-tenant-read head is defense in depth. Pinned with a proxy-allow
test (positive + the two least-privilege negatives). Version → 8.4.95.
2026-07-04 04:33:43 -07:00
hanzo-dev c3f2fb5918 console(hn): least-privilege store heads on /cloud (v8.4.95)
Harden the casibase store-admin allow-list added in v8.4.94: admit ONLY
the heads the console actually calls — get-stores/get-store (read),
add-store/update-store/delete-store/refresh-store-vectors (mutate).

Drop get-global-stores (a cross-tenant read the console never invokes —
StoreApi.listGlobal has zero callers) and get-store-names (unused) from
CLOUD_HEADS, so the /cloud proxy is not a wider tunnel than needed. Org
is always the Bearer owner server-side, but not exposing an unused
cross-tenant-read head is defense in depth. Pinned with a proxy-allow
test (positive + the two least-privilege negatives). Version → 8.4.95.
2026-07-04 04:33:43 -07:00
hanzo-devandGitHub dbf107e0a1 Merge pull request #103 from hanzoai/blue/hn-finish
console: zero coming-soon finish + audit routing fixes (v8.4.94)
2026-07-04 04:27:58 -07:00
hanzo-devandGitHub 5dff22fd9e Merge pull request #103 from hanzoai/blue/hn-finish
console: zero coming-soon finish + audit routing fixes (v8.4.94)
2026-07-04 04:27:58 -07:00
hanzo-dev 42d8d44ad5 Merge remote-tracking branch 'origin/main' into blue/hn-finish
# Conflicts:
#	package.json
#	src/lib/api/canonical-paths.test.ts
#	src/lib/api/compute.ts
#	src/lib/api/embeddings.ts
2026-07-04 04:27:02 -07:00
hanzo-dev 3b1b9d4e14 Merge remote-tracking branch 'origin/main' into blue/hn-finish
# Conflicts:
#	package.json
#	src/lib/api/canonical-paths.test.ts
#	src/lib/api/compute.ts
#	src/lib/api/embeddings.ts
2026-07-04 04:27:02 -07:00
hanzo-dev 492f6b48ed console(hn): zero coming-soon finish + audit routing fixes (v8.4.90)
Make every console page real: no "coming soon", no dead/disabled stub
buttons, no false "not enabled"/"session expired". Ship live.

Zero coming-soon guarantee (structural):
- Remove ProductStatus 'soon' entirely (0/148 catalog entries used it) →
  type now forbids a coming-soon product. Drops the SOON badge, Preview
  label, and waitlist path from DashboardShell / AppLauncher /
  CategoryOverview; delete the orphaned WaitlistForm (zero importers).
- Providers: remove the BYO-Weights tab (redundant with Custom Models,
  only carrier of a future promise) + drop the soon badge from HonestTab.
- Wallet: HUSD "coming soon" → honest present-state + a real "Add credit
  on Billing" (Square) action.
- GPUs: remove every dead `disabled hint=` stub (Add GPUs / Import
  cluster / Connect DO/AWS / per-row kebab / Run diagnostics); the real
  path is "Connect a provider" → /secrets (KMS credentials). KEEP the
  real Prev/Next pagination (a boundary-disabled control, not a stub).
- Reword to present-state: gpus ledger caption, ProductSubpageStub,
  platform/state 'unavailable', tasks/detail Logs/Signals/Queries,
  OrgIntegrations (dead branch removed), hanzo-evm HUSD error strings.
- Finetuning: KEEP the registry entry (its module is already wired to the
  LIVE /v1/train/* via TrainApi), DELETE the orphaned dead finetuning/
  subdir + api/finetune.ts (hit the 404 /v1/finetune/*, zero importers).

Audit routing fixes (same class as the Vector /cloud fix — the live
ingress does NOT run next.config /v1/* rewrites, so bare-/v1/ clients
that rely on them fail; address the proxy EXPLICITLY):
- Commerce store clients → /commerce proxy (commerceProxyV1Url) instead
  of rewrite-dependent /v1/commerce/* → fixes the 6 store pages'
  FALSE "Not enabled for your account".
- Embeddings stores + ingest + cloud-usage ledger → /cloud bearer proxy
  (new cloudGet/cloudPost) + allow-list the casibase heads (get-stores,
  get-store, …, docs, get-files, get-cloud-usages) in proxy-allow →
  fixes the FALSE "session expired" 401 on Collections.
- Status board: redact internal scrape instance (host:port) at the
  source (redactInstance) so visor.hanzo.svc:19000 / localhost:8428 /
  127.0.0.1:8429 never render to a customer.

Tests: tsc clean; vitest 1636 green (new redactInstance suite + a
match-core anti-drift guard that a declared subpage must be routed or it
stubs; canonical-paths pins commerce→/commerce, stores→/cloud); next
build green. package.json 8.4.89 → 8.4.90.
2026-07-04 04:21:02 -07:00
hanzo-dev a5d13f9915 console(hn): zero coming-soon finish + audit routing fixes (v8.4.90)
Make every console page real: no "coming soon", no dead/disabled stub
buttons, no false "not enabled"/"session expired". Ship live.

Zero coming-soon guarantee (structural):
- Remove ProductStatus 'soon' entirely (0/148 catalog entries used it) →
  type now forbids a coming-soon product. Drops the SOON badge, Preview
  label, and waitlist path from DashboardShell / AppLauncher /
  CategoryOverview; delete the orphaned WaitlistForm (zero importers).
- Providers: remove the BYO-Weights tab (redundant with Custom Models,
  only carrier of a future promise) + drop the soon badge from HonestTab.
- Wallet: HUSD "coming soon" → honest present-state + a real "Add credit
  on Billing" (Square) action.
- GPUs: remove every dead `disabled hint=` stub (Add GPUs / Import
  cluster / Connect DO/AWS / per-row kebab / Run diagnostics); the real
  path is "Connect a provider" → /secrets (KMS credentials). KEEP the
  real Prev/Next pagination (a boundary-disabled control, not a stub).
- Reword to present-state: gpus ledger caption, ProductSubpageStub,
  platform/state 'unavailable', tasks/detail Logs/Signals/Queries,
  OrgIntegrations (dead branch removed), hanzo-evm HUSD error strings.
- Finetuning: KEEP the registry entry (its module is already wired to the
  LIVE /v1/train/* via TrainApi), DELETE the orphaned dead finetuning/
  subdir + api/finetune.ts (hit the 404 /v1/finetune/*, zero importers).

Audit routing fixes (same class as the Vector /cloud fix — the live
ingress does NOT run next.config /v1/* rewrites, so bare-/v1/ clients
that rely on them fail; address the proxy EXPLICITLY):
- Commerce store clients → /commerce proxy (commerceProxyV1Url) instead
  of rewrite-dependent /v1/commerce/* → fixes the 6 store pages'
  FALSE "Not enabled for your account".
- Embeddings stores + ingest + cloud-usage ledger → /cloud bearer proxy
  (new cloudGet/cloudPost) + allow-list the casibase heads (get-stores,
  get-store, …, docs, get-files, get-cloud-usages) in proxy-allow →
  fixes the FALSE "session expired" 401 on Collections.
- Status board: redact internal scrape instance (host:port) at the
  source (redactInstance) so visor.hanzo.svc:19000 / localhost:8428 /
  127.0.0.1:8429 never render to a customer.

Tests: tsc clean; vitest 1636 green (new redactInstance suite + a
match-core anti-drift guard that a declared subpage must be routed or it
stubs; canonical-paths pins commerce→/commerce, stores→/cloud); next
build green. package.json 8.4.89 → 8.4.90.
2026-07-04 04:21:02 -07:00
zandGitHub 32e41fb3c3 feat(console): native Automations product tile + admin-host silent SSO (#97)
Two changes on this branch, both green (tsc clean, 1645 vitest pass, next build success):

1. feat: native Automations product tile — adds the `automations` catalog entry
   pointing at the native /v1/automations engine (HIP-0106, repo hanzoai/cloud,
   700+ connectors), brand-scoped to hanzo. Backend (cloud#113) already merged;
   surface live at auto.hanzo.ai/automations (verified 200). Distinct from the
   existing standalone `auto` tile (repo hanzoai/auto). Follow-up: consider
   relabeling to disambiguate the two automation tiles in the grid.

2. fix(auth): admin-host silent SSO for the admin-console client — on admin.<brand>
   the signin page now auto-initiates admin-console PKCE OAuth against the live
   guard SSO session (no second manual login resolving the wrong identity).
   Loop-guarded; tenant hosts (e.g. console.hanzo.ai) UNCHANGED — still SignInForm.
2026-07-04 04:15:56 -07:00
zandGitHub 0fbc607e81 feat(console): native Automations product tile + admin-host silent SSO (#97)
Two changes on this branch, both green (tsc clean, 1645 vitest pass, next build success):

1. feat: native Automations product tile — adds the `automations` catalog entry
   pointing at the native /v1/automations engine (HIP-0106, repo hanzoai/cloud,
   700+ connectors), brand-scoped to hanzo. Backend (cloud#113) already merged;
   surface live at auto.hanzo.ai/automations (verified 200). Distinct from the
   existing standalone `auto` tile (repo hanzoai/auto). Follow-up: consider
   relabeling to disambiguate the two automation tiles in the grid.

2. fix(auth): admin-host silent SSO for the admin-console client — on admin.<brand>
   the signin page now auto-initiates admin-console PKCE OAuth against the live
   guard SSO session (no second manual login resolving the wrong identity).
   Loop-guarded; tenant hosts (e.g. console.hanzo.ai) UNCHANGED — still SignInForm.
2026-07-04 04:15:56 -07:00
zandGitHub a14fff2785 Merge pull request #102 from hanzoai/claude/console-polish-qa
fix(console): wire ~15 header-scoped modules to /cloud + canonical docs links + chat/crawl fixes (v8.4.93)
2026-07-04 03:55:47 -07:00
zandGitHub 10a30426f7 Merge pull request #102 from hanzoai/claude/console-polish-qa
fix(console): wire ~15 header-scoped modules to /cloud + canonical docs links + chat/crawl fixes (v8.4.93)
2026-07-04 03:55:47 -07:00
zeekayandClaude Opus 4.8 a3ed821eea chore(release): v8.4.93 — header-scoped modules → /cloud + canonical docs links + chat/crawl fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:55:14 -07:00
zeekayandhanzo-dev 6fbf243584 chore(release): v8.4.93 — header-scoped modules → /cloud + canonical docs links + chat/crawl fixes
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:55:14 -07:00
zeekayandClaude Opus 4.8 72c6c2384e test(functions): assert /cloud/v1/functions (matches the transport fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:53:32 -07:00
zeekayandhanzo-dev cec77ab060 test(functions): assert /cloud/v1/functions (matches the transport fix)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:53:32 -07:00
zeekayandClaude Opus 4.8 abbce51bdb fix(console): wire header-scoped modules to /cloud + canonical docs links + chat owner + crawl tabs
Header-scoped cloud heads (gpus, clusters, functions, platform/paas, vpcs,
load-balancers, builds, releases, pipelines, environments, indexers, oracles,
authz, embeddings search) 403 on the live ingress via bare /v1 (gateway strips
X-Org-Id, no minted bearer) — verified live. Route them through the /cloud
user-bearer proxy (cloudProxyV1Url), same class-fix as framework/s3/machines
(v8.4.70). Turns ~15 silently-broken modules into real per-org data.

Also: inline docs links -> canonical /docs/<slug> (edge/storage/machines/gpus/
agents/inference->gateway/crawl/functions/kms; embeddings landing kit); ChatView
carries the real chat owner (2-seg /chat/:owner/:name) instead of hardcoded
'admin' (non-admin orgs could not open saved chats); SearchModule derives its
base path so Crawl tabs/CTA stay under /crawl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:53:32 -07:00
zeekayandhanzo-dev 3b50b0e938 fix(console): wire header-scoped modules to /cloud + canonical docs links + chat owner + crawl tabs
Header-scoped cloud heads (gpus, clusters, functions, platform/paas, vpcs,
load-balancers, builds, releases, pipelines, environments, indexers, oracles,
authz, embeddings search) 403 on the live ingress via bare /v1 (gateway strips
X-Org-Id, no minted bearer) — verified live. Route them through the /cloud
user-bearer proxy (cloudProxyV1Url), same class-fix as framework/s3/machines
(v8.4.70). Turns ~15 silently-broken modules into real per-org data.

Also: inline docs links -> canonical /docs/<slug> (edge/storage/machines/gpus/
agents/inference->gateway/crawl/functions/kms; embeddings landing kit); ChatView
carries the real chat owner (2-seg /chat/:owner/:name) instead of hardcoded
'admin' (non-admin orgs could not open saved chats); SearchModule derives its
base path so Crawl tabs/CTA stay under /crawl.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:53:32 -07:00
hanzo-dev bf88b1f253 console(hn): integrations show only available/connected; data-resource tool tabs reframed from 'coming soon' to 'connect your own client' 2026-07-04 03:46:28 -07:00
hanzo-dev 3749d56b1c console(hn): integrations show only available/connected; data-resource tool tabs reframed from 'coming soon' to 'connect your own client' 2026-07-04 03:46:28 -07:00
hanzo-dev 6977258f3d console(hn): delete dead ComingSoon + railway-demo; wire Containers create→pipelines, drop no-backend buttons; drop K8s import stub; Edge live-empty copy (zt now wired) 2026-07-04 03:42:20 -07:00
hanzo-dev 6bef02712a console(hn): delete dead ComingSoon + railway-demo; wire Containers create→pipelines, drop no-backend buttons; drop K8s import stub; Edge live-empty copy (zt now wired) 2026-07-04 03:42:20 -07:00
zandGitHub c80f3719d0 Merge pull request #101 from hanzoai/fix/marketplace-provider-logos-57
fix(marketplace): model cards show the vendor's canonical brand logo (v8.4.92, #57)
2026-07-04 03:32:13 -07:00
zandGitHub 1fef9ece9f Merge pull request #101 from hanzoai/fix/marketplace-provider-logos-57
fix(marketplace): model cards show the vendor's canonical brand logo (v8.4.92, #57)
2026-07-04 03:32:13 -07:00
hanzo-dev 119fb7abb2 fix(marketplace): model cards show the vendor's canonical brand logo, not the Hanzo fallback (v8.4.92, #57)
Resolve a model's brand from its IDENTITY (id/name) first, then provider, so a
gateway-served model tagged provider "hanzo" (qwen3.5-397b, glm-5.2, kimi-k2.6,
minimax-m2.5) shows its true vendor instead of the house block-H. New pure
brandForModel() + model-aware ProviderLogo, wired at every per-model call site
(Marketplace card, Model Catalog detail, Playground picker). Hanzo stays ONLY the
fallback for genuinely-Hanzo/unknown models; Zen (id zen*) keeps the Hanzo mark.

Add canonical self-contained inline SVG vendor marks (no CDN, CSP-safe, theme-aware
white-on-brand-tile): Anthropic sunburst (new), OpenAI blossom knot (replaces the
invented asterisk), Google Gemini spark star (replaces the Gemma gem). Marketplace
card derives logo + vendor label (brandLabel) + house Verified badge from the one
brandForModel result, so a card is never Qwen-logo + Zen-label.

tsc clean; vitest 1667/1667 (+15 brand); marks render-validated headless.
2026-07-04 03:31:32 -07:00
hanzo-dev 89047abdff fix(marketplace): model cards show the vendor's canonical brand logo, not the Hanzo fallback (v8.4.92, #57)
Resolve a model's brand from its IDENTITY (id/name) first, then provider, so a
gateway-served model tagged provider "hanzo" (qwen3.5-397b, glm-5.2, kimi-k2.6,
minimax-m2.5) shows its true vendor instead of the house block-H. New pure
brandForModel() + model-aware ProviderLogo, wired at every per-model call site
(Marketplace card, Model Catalog detail, Playground picker). Hanzo stays ONLY the
fallback for genuinely-Hanzo/unknown models; Zen (id zen*) keeps the Hanzo mark.

Add canonical self-contained inline SVG vendor marks (no CDN, CSP-safe, theme-aware
white-on-brand-tile): Anthropic sunburst (new), OpenAI blossom knot (replaces the
invented asterisk), Google Gemini spark star (replaces the Gemma gem). Marketplace
card derives logo + vendor label (brandLabel) + house Verified badge from the one
brandForModel result, so a card is never Qwen-logo + Zen-label.

tsc clean; vitest 1667/1667 (+15 brand); marks render-validated headless.
2026-07-04 03:31:32 -07:00
b99c7506ad fix(console): COPY src/config/build-id.mjs into runtime image (v8.4.90 CrashLoopBackOff: next.config.mjs imports it at boot, was missing -> ERR_MODULE_NOT_FOUND) (#100)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:13:45 -07:00
a053b9c191 fix(console): COPY src/config/build-id.mjs into runtime image (v8.4.90 CrashLoopBackOff: next.config.mjs imports it at boot, was missing -> ERR_MODULE_NOT_FOUND) (#100)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 03:13:45 -07:00
a6d2c2b812 fix(responsive): pin mobile chat composer, 44px touch targets, drawer close, balanced KPI grid, capped chat width (v8.4.90) (#99)
Mobile/tablet responsive polish across the console shell + chat + overview. All
five are console2-local app compositions; the shared @hanzo/gui primitives were
already correct (bumping them globally would wreck desktop density).

- Chat composer sticky to the viewport bottom on phones/tablets (.hz-chat-dock),
  so it never first-paints below the fold; static in the capped column at lg+.
- Touch targets >=44px (WCAG 2.5.5): nav-drawer rows/controls via .hz-touch-target
  (the desktop sidebar is a separate mount and stays dense), the hamburger, and
  the chat send button.
- Nav drawer: explicit close X inside the drawer header (right-aligned), always
  reachable on a 390px phone (backdrop/Escape still close too).
- Overview KPI grid: responsive columns (1 / md:2 / xl:4) so a 4-card row balances
  instead of wrapping 3+1 at tablet widths.
- Chat conversation column capped at ~820px, centered, so it no longer runs
  edge-to-edge on ultra-wide displays.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 02:28:37 -07:00
d878d89cf1 fix(responsive): pin mobile chat composer, 44px touch targets, drawer close, balanced KPI grid, capped chat width (v8.4.90) (#99)
Mobile/tablet responsive polish across the console shell + chat + overview. All
five are console2-local app compositions; the shared @hanzo/gui primitives were
already correct (bumping them globally would wreck desktop density).

- Chat composer sticky to the viewport bottom on phones/tablets (.hz-chat-dock),
  so it never first-paints below the fold; static in the capped column at lg+.
- Touch targets >=44px (WCAG 2.5.5): nav-drawer rows/controls via .hz-touch-target
  (the desktop sidebar is a separate mount and stays dense), the hamburger, and
  the chat send button.
- Nav drawer: explicit close X inside the drawer header (right-aligned), always
  reachable on a 390px phone (backdrop/Escape still close too).
- Overview KPI grid: responsive columns (1 / md:2 / xl:4) so a 4-card row balances
  instead of wrapping 3+1 at tablet widths.
- Chat conversation column capped at ~820px, centered, so it no longer runs
  edge-to-edge on ultra-wide displays.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 02:28:37 -07:00
cafa9c692e fix(console): recover deep-link/refresh chunk-skew crash — global-error boundary + pinned build id (#98)
P0: hard-navigating/refreshing any sub-route (e.g. /machines) threw
"Application error: a client-side exception has occurred" and stayed dead
until a full reload. Root cause: on a rolling deploy the just-served HTML
references a content-hashed chunk that the replica/CDN the browser hits
does not have; the Next origin serves that missing chunk URL as HTTP 200
app-shell HTML (Content-Type text/html), so the browser parses HTML as JS
into "Unexpected token <" -> ChunkLoadError. The failure lands ABOVE every
segment boundary during first hydration, and there was NO app/global-error,
so Next fell to its built-in dead-ended fallback (no router -> a later nav
back to / stays dead).

Fix:
- app/global-error.tsx (NEW): the missing OUTERMOST boundary. Replaces Next's
  dead default; on a chunk skew self-heals with ONE reload per window, else a
  self-contained recovery card (owns its own html/body).
- ChunkGuard: add CAPTURE-phase resource-error detection so a raw chunk 404
  on initial deep-load is caught before webpack's loader rejects; route it
  through the shared once-per-window guard (no fast reload loop).
- boundary-logic: export CHUNK_RELOAD_AT_KEY — ONE loop-breaker shared by
  global-error, the dashboard segment, ProductErrorBoundary and ChunkGuard
  (DRY; removes two duplicated magic strings).
- generateBuildId pinned to the commit (src/config/build-id.mjs): SOURCE_COMMIT
  build-arg -> git HEAD -> package version, so every replica of a release shares
  ONE build id (default minted a random id per build). Dockerfile + CI pass the
  SHA as a build arg (alpine has no git binary).

Gates: tsc --noEmit clean, 1659 unit tests pass (7 new for resolveBuildId),
next build green. Curl-proof: deep-load /machines and /training serve 200, and
all 24 referenced chunks (incl global-error and the slug page) resolve 200 JS;
.next/BUILD_ID now equals git HEAD sha.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 02:13:18 -07:00
fcf5a331f0 fix(console): recover deep-link/refresh chunk-skew crash — global-error boundary + pinned build id (#98)
P0: hard-navigating/refreshing any sub-route (e.g. /machines) threw
"Application error: a client-side exception has occurred" and stayed dead
until a full reload. Root cause: on a rolling deploy the just-served HTML
references a content-hashed chunk that the replica/CDN the browser hits
does not have; the Next origin serves that missing chunk URL as HTTP 200
app-shell HTML (Content-Type text/html), so the browser parses HTML as JS
into "Unexpected token <" -> ChunkLoadError. The failure lands ABOVE every
segment boundary during first hydration, and there was NO app/global-error,
so Next fell to its built-in dead-ended fallback (no router -> a later nav
back to / stays dead).

Fix:
- app/global-error.tsx (NEW): the missing OUTERMOST boundary. Replaces Next's
  dead default; on a chunk skew self-heals with ONE reload per window, else a
  self-contained recovery card (owns its own html/body).
- ChunkGuard: add CAPTURE-phase resource-error detection so a raw chunk 404
  on initial deep-load is caught before webpack's loader rejects; route it
  through the shared once-per-window guard (no fast reload loop).
- boundary-logic: export CHUNK_RELOAD_AT_KEY — ONE loop-breaker shared by
  global-error, the dashboard segment, ProductErrorBoundary and ChunkGuard
  (DRY; removes two duplicated magic strings).
- generateBuildId pinned to the commit (src/config/build-id.mjs): SOURCE_COMMIT
  build-arg -> git HEAD -> package version, so every replica of a release shares
  ONE build id (default minted a random id per build). Dockerfile + CI pass the
  SHA as a build arg (alpine has no git binary).

Gates: tsc --noEmit clean, 1659 unit tests pass (7 new for resolveBuildId),
next build green. Curl-proof: deep-load /machines and /training serve 200, and
all 24 referenced chunks (incl global-error and the slug page) resolve 200 JS;
.next/BUILD_ID now equals git HEAD sha.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 02:13:18 -07:00
549730d0ac feat(o11y): native observability on every product + admin.hanzo.ai Fleet Observability board (v8.4.89) (#96)
* feat(o11y): reusable per-product ProductObservability panel on every product overview

One DRY <ProductObservability service=/> panel (RED metrics + recent logs +
recent traces) over the existing ApmApi (/cloud/v1/o11y query_range, the ZAP-fed
datastore), filtered to each product's OTel service.name via o11yServiceFor.
Rendered at the bottom of the shared NativeOverview so every product surfaces its
own live signals. Honest states: loading, o11y RuntimeNotice, connected-empty,
and a managed note when a product has no backing service.

* feat(o11y): admin.hanzo.ai global Fleet Observability board (global-admin only)

Cross-org fleet o11y god view reading /v1/admin/o11y (the server-gated aggregate
I built in cloud): fleet KPIs (requests/tokens/cost/errors/p95-p99/log volume/
active orgs/services/traces), usage + log-volume timeseries, and top orgs/models/
services leaderboards — all tenants aggregated from the ONE datastore.

- AdminO11yApi.global(range) via originGet('admin/o11y') → the console-origin
  admin-aggregate proxy (getAdminGate, fail-closed 403 for non-global-admin).
- 'o11y' added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS (same gated path as
  compute/finance — no new proxy/trust boundary).
- Defensive normalizer (snake+camel tolerant, garbage → honest zeros/empty).
- Registry entry 'fleet-o11y' (Observe, admin:true — hidden from customers);
  client OperatorAccessRequired gate mirrors the server gate.
- Tests: normalizer (real payload/snake-case/garbage/NaN) + allowAdminSurface
  admits o11y. Version 8.4.88 -> 8.4.89.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 01:53:18 -07:00
ddda4fd454 feat(o11y): native observability on every product + admin.hanzo.ai Fleet Observability board (v8.4.89) (#96)
* feat(o11y): reusable per-product ProductObservability panel on every product overview

One DRY <ProductObservability service=/> panel (RED metrics + recent logs +
recent traces) over the existing ApmApi (/cloud/v1/o11y query_range, the ZAP-fed
datastore), filtered to each product's OTel service.name via o11yServiceFor.
Rendered at the bottom of the shared NativeOverview so every product surfaces its
own live signals. Honest states: loading, o11y RuntimeNotice, connected-empty,
and a managed note when a product has no backing service.

* feat(o11y): admin.hanzo.ai global Fleet Observability board (global-admin only)

Cross-org fleet o11y god view reading /v1/admin/o11y (the server-gated aggregate
I built in cloud): fleet KPIs (requests/tokens/cost/errors/p95-p99/log volume/
active orgs/services/traces), usage + log-volume timeseries, and top orgs/models/
services leaderboards — all tenants aggregated from the ONE datastore.

- AdminO11yApi.global(range) via originGet('admin/o11y') → the console-origin
  admin-aggregate proxy (getAdminGate, fail-closed 403 for non-global-admin).
- 'o11y' added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS (same gated path as
  compute/finance — no new proxy/trust boundary).
- Defensive normalizer (snake+camel tolerant, garbage → honest zeros/empty).
- Registry entry 'fleet-o11y' (Observe, admin:true — hidden from customers);
  client OperatorAccessRequired gate mirrors the server gate.
- Tests: normalizer (real payload/snake-case/garbage/NaN) + allowAdminSurface
  admits o11y. Version 8.4.88 -> 8.4.89.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 01:53:18 -07:00
hanzo-devandGitHub 5051e16414 fix(console): finetuning/kubeflow 403 + API-keys CORS crack — mint user Bearer, same-origin keys route (v8.4.89) (#95)
The Finetuning + ML Pipelines pages showed 'Not enabled for your account' for a
signed-in customer, and Organization-Settings API keys couldn't be listed/minted.
Both are real money-path cracks — verified live as Dave (org maxpower).

ROOT CAUSE (proven live, NOT the iss theory):
- /training proxy forwarded the raw session COOKIE to cloud-api /v1/train/*, which
  authorizes on a validated JWT principal => 403 'no validated principal'. With a
  Bearer it is 200. (The token iss is already https://hanzo.id — IAM folds the
  in-cluster host to originFrontend[0]; cloud-api accepts it. functions/app-platform
  already work via the /cloud bearer proxy, so they were not the break.)
- keys.ts called cloud.hanzo.ai/v1/console/keys — a DIFFERENT origin than
  console.hanzo.ai => browser CORS 'Failed to fetch' (and cloud-api 501s that
  handler anyway).

FIX (surgical, isolation-safe, DRY):
- /training now mints a short-lived user-bound Bearer via adminBearer (the ONE
  per-user cache the /cloud proxy uses) and forwards Bearer + X-Org-Id (orgFor pin);
  the cookie is dropped upstream. Fails closed (502) if the token can't be minted.
  Org stays server-authoritative (token owner claim) — no tenant-isolation change.
- New same-origin app/keys/route.ts uses identity.ts mintUserKey/getUserKey/
  revokeUserKey (IAM confidential-client, the WORKING key path); keys.ts addresses
  <origin>/keys. CSRF-guarded, honest 501 when unconfigured, secret shown once.
- Bundle the edge-503 hygiene: interpretPlatformError maps 503 -> 'unavailable'
  (clean card), so a fail-closed zt backend never leaks ZT_CLIENT_* env to a customer.

tsc clean; vitest green; next build ok (/keys + /training routes registered).
2026-07-04 01:41:53 -07:00
hanzo-devandGitHub c8a9076d60 fix(console): finetuning/kubeflow 403 + API-keys CORS crack — mint user Bearer, same-origin keys route (v8.4.89) (#95)
The Finetuning + ML Pipelines pages showed 'Not enabled for your account' for a
signed-in customer, and Organization-Settings API keys couldn't be listed/minted.
Both are real money-path cracks — verified live as Dave (org maxpower).

ROOT CAUSE (proven live, NOT the iss theory):
- /training proxy forwarded the raw session COOKIE to cloud-api /v1/train/*, which
  authorizes on a validated JWT principal => 403 'no validated principal'. With a
  Bearer it is 200. (The token iss is already https://hanzo.id — IAM folds the
  in-cluster host to originFrontend[0]; cloud-api accepts it. functions/app-platform
  already work via the /cloud bearer proxy, so they were not the break.)
- keys.ts called cloud.hanzo.ai/v1/console/keys — a DIFFERENT origin than
  console.hanzo.ai => browser CORS 'Failed to fetch' (and cloud-api 501s that
  handler anyway).

FIX (surgical, isolation-safe, DRY):
- /training now mints a short-lived user-bound Bearer via adminBearer (the ONE
  per-user cache the /cloud proxy uses) and forwards Bearer + X-Org-Id (orgFor pin);
  the cookie is dropped upstream. Fails closed (502) if the token can't be minted.
  Org stays server-authoritative (token owner claim) — no tenant-isolation change.
- New same-origin app/keys/route.ts uses identity.ts mintUserKey/getUserKey/
  revokeUserKey (IAM confidential-client, the WORKING key path); keys.ts addresses
  <origin>/keys. CSRF-guarded, honest 501 when unconfigured, secret shown once.
- Bundle the edge-503 hygiene: interpretPlatformError maps 503 -> 'unavailable'
  (clean card), so a fail-closed zt backend never leaks ZT_CLIENT_* env to a customer.

tsc clean; vitest green; next build ok (/keys + /training routes registered).
2026-07-04 01:41:53 -07:00
zeekayandClaude Opus 4.8 ddde6818c6 chore(release): v8.4.88 — clean tag over main (docs-button cleanup + :tab routes + un-blank routes)
Deterministic release from main HEAD (includes the :tab route fix v8.4.86, the
6 dead-Docs-button removals v8.4.87, and #94 route un-blank). Tagged so the build
is immune to the main-push concurrency-cancel war on the contended v8.4.87.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:23:01 -07:00
zeekayandhanzo-dev e35e0e8c72 chore(release): v8.4.88 — clean tag over main (docs-button cleanup + :tab routes + un-blank routes)
Deterministic release from main HEAD (includes the :tab route fix v8.4.86, the
6 dead-Docs-button removals v8.4.87, and #94 route un-blank). Tagged so the build
is immune to the main-push concurrency-cancel war on the contended v8.4.87.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 01:23:01 -07:00
0b5fa4a205 fix(nav): un-blank every product route — slug aliases + mocked-network blank-audit proof (#94)
* test(console): mocked-network blank audit for every product route + product-content hook

* fix(nav): alias human product slugs (traces/deploy/plans-pricing/wallets/model-catalog/fine-tuning/web-search) → canonical ids

The single biggest source of 'half the pages are blank': a directly-navigated
URL whose slug != the registry id resolved to notfound -> a Next 404 the operator
read as a blank page. The nav/launcher/palette always open the canonical id, so
these 7 aliases exist only to keep a human slug (docs, bookmarks, the CTO e2e
list, a hand-typed URL) from 404ing. One map (SLUG_ALIASES), already applied by
resolveProductView via canonicalSlug. Proven end-to-end by the blank audit
(every alias slug now renders content, 0 notfound).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 01:17:16 -07:00
8c939ff700 fix(nav): un-blank every product route — slug aliases + mocked-network blank-audit proof (#94)
* test(console): mocked-network blank audit for every product route + product-content hook

* fix(nav): alias human product slugs (traces/deploy/plans-pricing/wallets/model-catalog/fine-tuning/web-search) → canonical ids

The single biggest source of 'half the pages are blank': a directly-navigated
URL whose slug != the registry id resolved to notfound -> a Next 404 the operator
read as a blank page. The nav/launcher/palette always open the canonical id, so
these 7 aliases exist only to keep a human slug (docs, bookmarks, the CTO e2e
list, a hand-typed URL) from 404ing. One map (SLUG_ALIASES), already applied by
resolveProductView via canonicalSlug. Proven end-to-end by the blank audit
(every alias slug now renders content, 0 notfound).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 01:17:16 -07:00
zeekayandClaude Opus 4.8 a29e164323 fix(registry): drop dead Docs buttons for 6 products with no docs page
accessibility, crm, erp, templates, markets, trading set docs: ${DOCS}/<slug>
but those docs.hanzo.ai/docs/<slug> pages don't exist (verified HTTP 404 live) and
have no honest target — a redirect would mislead. Remove the docs: field on those
6 entries; consumers fall back to config.docsUrl (docs root), so no dead deep link.
The docs lane handles the other previously-missing slugs via docs-side redirects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:11:51 -07:00
zeekayandhanzo-dev c1c1713d40 fix(registry): drop dead Docs buttons for 6 products with no docs page
accessibility, crm, erp, templates, markets, trading set docs: ${DOCS}/<slug>
but those docs.hanzo.ai/docs/<slug> pages don't exist (verified HTTP 404 live) and
have no honest target — a redirect would mislead. Remove the docs: field on those
6 entries; consumers fall back to config.docsUrl (docs root), so no dead deep link.
The docs lane handles the other previously-missing slugs via docs-side redirects.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 01:11:51 -07:00
a5fef9c37e fix(auth): admin host silent-SSOs the admin-console client — no second manual sign-in form (#93)
On admin.<brand> the admin guard has already authenticated the operator at the brand
IAM (a live SSO session), yet /signin rendered <SignInForm/> on EVERY host — forcing a
SECOND manual login that resolves the tenant identity (hanzo/z), not the operator
identity (admin/z), which breaks the operator panel.

Now the signin page auto-initiates admin-console OAuth on an admin host (isAdminHost):
startAdminSignin() mints a PKCE verifier, stashes it for the callback, and redirects to
IAM /v1/iam/oauth/authorize for client_id=admin-console in organization=admin — reusing
the guard SSO session and returning to /auth/callback with a code, no extra step. The
callback hands the stashed verifier to completeSignIn, which redeems it via the console
own BFF (PKCE, no secret). Tenant hosts are UNCHANGED — still <SignInForm/>, no verifier,
cloud-backend exchange.

Loop-guarded: fires once, never with a live session, never mid-callback.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 00:42:00 -07:00
8c8cf4333e fix(auth): admin host silent-SSOs the admin-console client — no second manual sign-in form (#93)
On admin.<brand> the admin guard has already authenticated the operator at the brand
IAM (a live SSO session), yet /signin rendered <SignInForm/> on EVERY host — forcing a
SECOND manual login that resolves the tenant identity (hanzo/z), not the operator
identity (admin/z), which breaks the operator panel.

Now the signin page auto-initiates admin-console OAuth on an admin host (isAdminHost):
startAdminSignin() mints a PKCE verifier, stashes it for the callback, and redirects to
IAM /v1/iam/oauth/authorize for client_id=admin-console in organization=admin — reusing
the guard SSO session and returning to /auth/callback with a code, no extra step. The
callback hands the stashed verifier to completeSignIn, which redeems it via the console
own BFF (PKCE, no secret). Tenant hosts are UNCHANGED — still <SignInForm/>, no verifier,
cloud-backend exchange.

Loop-guarded: fires once, never with a live session, never mid-callback.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 00:42:00 -07:00
zeekayandClaude Opus 4.8 17d3b048d9 fix(nav): declare :tab routes for Containers/Finetuning/Tasks + correct API-keys docs link
The Containers (Pods/Containers/Images/Namespaces/Events), Fine-tuning
(Datasets/Checkpoints/Models), and Tasks (Schedules/Queues/Workers) modules
render their tab bar as REAL sub-routes (go(t.id) -> /<id>/<tab>, reading
params.tab) but their registry entries declared only { path: '' } -- so every
tab 404'd (Tasks/Queues degraded to a stub). Declare the ':tab' route on each so
the tab bar resolves. Tasks keeps its 2-segment ':ns/:wid' detail route
(unambiguous by segment count).

Also fix the API Keys docs button: 'https://docs.hanzo.ai/api' -> the docs site
serves under /docs (a bare docs.hanzo.ai/<slug> 404s), so use
${config.docsUrl}/docs/api (white-labeled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:29:28 -07:00
zeekayandhanzo-dev f75e1e8508 fix(nav): declare :tab routes for Containers/Finetuning/Tasks + correct API-keys docs link
The Containers (Pods/Containers/Images/Namespaces/Events), Fine-tuning
(Datasets/Checkpoints/Models), and Tasks (Schedules/Queues/Workers) modules
render their tab bar as REAL sub-routes (go(t.id) -> /<id>/<tab>, reading
params.tab) but their registry entries declared only { path: '' } -- so every
tab 404'd (Tasks/Queues degraded to a stub). Declare the ':tab' route on each so
the tab bar resolves. Tasks keeps its 2-segment ':ns/:wid' detail route
(unambiguous by segment count).

Also fix the API Keys docs button: 'https://docs.hanzo.ai/api' -> the docs site
serves under /docs (a bare docs.hanzo.ai/<slug> 404s), so use
${config.docsUrl}/docs/api (white-labeled).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-04 00:29:28 -07:00
d76975b3d7 feat(observe): wire per-product Status/Logs/Metrics to the LIVE o11y (SigNoz) runtime, one DRY mechanism (v8.4.85) (#87)
The shared per-product sub-page system (Status/Logs/Metrics/Settings — already
routed for all 136 products) is now backed by live o11y, scoped per product by
its OTel service.name via ONE parameterized mechanism (no bespoke per-product
wiring). Reuses the existing ApmApi o11y client + RuntimeNotice + LivingOverview.

- sources.ts: o11yServiceFor(entry) — product -> OTel service.name (repoBase
  convention + tiny override), new o11yService field on subpageSourcesFor.
- apm.ts: per-service o11y filtering — listQueryPayload gains optional filters
  (back-compat), serviceFilterItem, ApmApi.logs/traceSearch(service?), and
  ApmApi.serviceHealth (pickService + serviceHealthOf RED verdict).
- Status: LIVE o11y RED-metrics health band (org-scoped, works for customers) +
  deployment state; managed card only when neither reports; never a fake green.
- Logs: live o11y logs filtered to the product's service (replaces the dead
  /paas/logs path); RuntimeNotice + honest empty states.
- Metrics: real ledger + live o11y p99 merged into the latency KPI (was stuck
  at '—'); latencyP95 -> latencyP99 (honest to the RED metric we have).

Rebased onto origin/main (v8.4.84). tsc clean; vitest 1643/1643 (129 files);
next build 14/14 pages. o11y is LIVE (o11y.hanzo.ai/api/v2/readyz=200).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 00:20:12 -07:00
33f64964e9 feat(observe): wire per-product Status/Logs/Metrics to the LIVE o11y (SigNoz) runtime, one DRY mechanism (v8.4.85) (#87)
The shared per-product sub-page system (Status/Logs/Metrics/Settings — already
routed for all 136 products) is now backed by live o11y, scoped per product by
its OTel service.name via ONE parameterized mechanism (no bespoke per-product
wiring). Reuses the existing ApmApi o11y client + RuntimeNotice + LivingOverview.

- sources.ts: o11yServiceFor(entry) — product -> OTel service.name (repoBase
  convention + tiny override), new o11yService field on subpageSourcesFor.
- apm.ts: per-service o11y filtering — listQueryPayload gains optional filters
  (back-compat), serviceFilterItem, ApmApi.logs/traceSearch(service?), and
  ApmApi.serviceHealth (pickService + serviceHealthOf RED verdict).
- Status: LIVE o11y RED-metrics health band (org-scoped, works for customers) +
  deployment state; managed card only when neither reports; never a fake green.
- Logs: live o11y logs filtered to the product's service (replaces the dead
  /paas/logs path); RuntimeNotice + honest empty states.
- Metrics: real ledger + live o11y p99 merged into the latency KPI (was stuck
  at '—'); latencyP95 -> latencyP99 (honest to the RED metric we have).

Rebased onto origin/main (v8.4.84). tsc clean; vitest 1643/1643 (129 files);
next build 14/14 pages. o11y is LIVE (o11y.hanzo.ai/api/v2/readyz=200).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-04 00:20:12 -07:00
hanzo-dev 3fa78b8c82 fix(data): route provisioning through /cloud bearer-proxy (false 'Not enabled' fix)
The managed data products (Vector, SQL, KV, Datastore, DocDB, Search) all
drive ProvisioningApi, which addressed a bare /v1/<kind>. On the live console
ingress a bare /v1/* is routed straight to hanzoai/gateway (bypassing Next),
where the provisioning backends authorize on the Bearer owner claim and 403 a
cookie-only call ('X-Org-Id required'). classifyBackend maps that 403 to
'access' -> the FALSE title 'Not enabled for your account' on pages whose data
is actually live and per-org.

Class-fix (same as storage.ts /v1/s3 + framework, v8.4.70): ProvisioningApi now
builds cloudProxyV1Url(kind) -> <origin>/cloud/v1/<kind>. app/cloud/[...path]
mints a short-lived user-bound IAM token from the session and forwards it, so
the org is resolved server-side and the real resources load. All seven data
kinds are already allow-listed in proxy-allow.ts CLOUD_HEADS; the /cloud route
already serves GET/POST/PUT/PATCH/DELETE. One-line-of-intent transport swap.

Genuinely-empty orgs now show the honest 'Create your first ...' empty state,
never 'not enabled'. 'Not enabled' remains only for a true 403 enablement gate.

Tests: pin every ProvisioningApi call to <origin>/cloud/v1/<kind> (never a bare
/v1) in provisioning.test.ts, and move provisioning into the canonical-paths
'proxy exceptions' block (it was wrongly asserted prefix-free, encoding the bug).
tsc + vitest (1620) + next build green.

v8.4.84
2026-07-04 00:02:29 -07:00
hanzo-dev b0e293b2a6 fix(data): route provisioning through /cloud bearer-proxy (false 'Not enabled' fix)
The managed data products (Vector, SQL, KV, Datastore, DocDB, Search) all
drive ProvisioningApi, which addressed a bare /v1/<kind>. On the live console
ingress a bare /v1/* is routed straight to hanzoai/gateway (bypassing Next),
where the provisioning backends authorize on the Bearer owner claim and 403 a
cookie-only call ('X-Org-Id required'). classifyBackend maps that 403 to
'access' -> the FALSE title 'Not enabled for your account' on pages whose data
is actually live and per-org.

Class-fix (same as storage.ts /v1/s3 + framework, v8.4.70): ProvisioningApi now
builds cloudProxyV1Url(kind) -> <origin>/cloud/v1/<kind>. app/cloud/[...path]
mints a short-lived user-bound IAM token from the session and forwards it, so
the org is resolved server-side and the real resources load. All seven data
kinds are already allow-listed in proxy-allow.ts CLOUD_HEADS; the /cloud route
already serves GET/POST/PUT/PATCH/DELETE. One-line-of-intent transport swap.

Genuinely-empty orgs now show the honest 'Create your first ...' empty state,
never 'not enabled'. 'Not enabled' remains only for a true 403 enablement gate.

Tests: pin every ProvisioningApi call to <origin>/cloud/v1/<kind> (never a bare
/v1) in provisioning.test.ts, and move provisioning into the canonical-paths
'proxy exceptions' block (it was wrongly asserted prefix-free, encoding the bug).
tsc + vitest (1620) + next build green.

v8.4.84
2026-07-04 00:02:29 -07:00
9f84043b18 feat(ai-proxy): allow-list the async video poll/download sub-paths (#92)
Video generation is now async (Sora-style): create returns a job id immediately,
the client polls GET /v1/videos/{id} and downloads GET /v1/videos/{id}/content.
The /ai proxy's allow-list was exact-match and only permitted the CREATE path
(v1/videos/generations), so the Playground could reach create but neither poll
nor download — the two dynamic sub-paths 404'd at the proxy.

Add a narrow, anchored pattern (v1/videos/{id} and /{id}/content, conservative
id charset) alongside the exact set. It stays a tight allow-list — anchored to
v1/videos/, create still only the exact path — never a general tunnel; method is
enforced by the backend (GET-only there). Complements hanzoai/ai#68.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:01:26 -07:00
934e2d0c6f feat(ai-proxy): allow-list the async video poll/download sub-paths (#92)
Video generation is now async (Sora-style): create returns a job id immediately,
the client polls GET /v1/videos/{id} and downloads GET /v1/videos/{id}/content.
The /ai proxy's allow-list was exact-match and only permitted the CREATE path
(v1/videos/generations), so the Playground could reach create but neither poll
nor download — the two dynamic sub-paths 404'd at the proxy.

Add a narrow, anchored pattern (v1/videos/{id} and /{id}/content, conservative
id charset) alongside the exact set. It stays a tight allow-list — anchored to
v1/videos/, create still only the exact path — never a general tunnel; method is
enforced by the backend (GET-only there). Complements hanzoai/ai#68.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 22:01:26 -07:00
8d31438a84 feat(tracker): native @hanzo/gui Tracker module (v8.4.83) (#91)
* feat(tracker): native @hanzo/gui Tracker module — issues grouped by status

Add a native console Tracker over the real cloud /v1/tracker surface,
proving a native FE renders issue ROWS GROUPED BY STATUS (the thing the
old Huly/Svelte hanzo.team tracker could not do).

- src/lib/api/tracker.ts: TrackerApi client modeled on crm.ts — same
  keyless originV1Url + plain-REST helpers; bare-array lists; defensive
  pure normalizers; projects + issues CRUD (PATCH updates).
- src/lib/api/client.ts: add restPatch (backend uses PATCH; restRequest
  gains 'PATCH') — one-way, minimal.
- src/components/products/TrackerModule.tsx: project list (create +
  drill-in) → per-project grouped List (one DataTable section per
  status, the proof view) + Board toggle (5 columns, same data);
  create/edit issue in a SlideOver with a quick one-click status control;
  Linear-grade `c` keyboard shortcut opens create (⌘K stays owned by the
  global CommandPalette). Reuses only the local ui/ kit.
- registry.tsx: one Platform catalog entry (id 'tracker', ClipboardList).
- next.config.mjs + proxy-allow.ts: allow-list the `tracker` head in both
  (BFF→cloud user-bearer proxy), exactly like `crm`.

typecheck (tsc --noEmit) clean; 1611 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): v8.4.83 — ship native Tracker module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:52:17 -07:00
2169d0d1e3 feat(tracker): native @hanzo/gui Tracker module (v8.4.83) (#91)
* feat(tracker): native @hanzo/gui Tracker module — issues grouped by status

Add a native console Tracker over the real cloud /v1/tracker surface,
proving a native FE renders issue ROWS GROUPED BY STATUS (the thing the
old Huly/Svelte hanzo.team tracker could not do).

- src/lib/api/tracker.ts: TrackerApi client modeled on crm.ts — same
  keyless originV1Url + plain-REST helpers; bare-array lists; defensive
  pure normalizers; projects + issues CRUD (PATCH updates).
- src/lib/api/client.ts: add restPatch (backend uses PATCH; restRequest
  gains 'PATCH') — one-way, minimal.
- src/components/products/TrackerModule.tsx: project list (create +
  drill-in) → per-project grouped List (one DataTable section per
  status, the proof view) + Board toggle (5 columns, same data);
  create/edit issue in a SlideOver with a quick one-click status control;
  Linear-grade `c` keyboard shortcut opens create (⌘K stays owned by the
  global CommandPalette). Reuses only the local ui/ kit.
- registry.tsx: one Platform catalog entry (id 'tracker', ClipboardList).
- next.config.mjs + proxy-allow.ts: allow-list the `tracker` head in both
  (BFF→cloud user-bearer proxy), exactly like `crm`.

typecheck (tsc --noEmit) clean; 1611 unit tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore(release): v8.4.83 — ship native Tracker module

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 21:52:17 -07:00
zeekayandClaude Opus 4.8 59f793f8ea feat(models+compute): distinct per-family model icons + clear CPU-credit vs GPU-prepay funding — v8.4.82
Fix 1 — per-family model icons (DRY, one map, every catalog surface):
Every model family rendered the same faint mark (the Zen ensō ring on every Zen
row — the biggest family — plus flat letter monograms Q/Me/DS/Mi/G/AI for the
third-party families), so nothing read as a recognizable brand. Added ONE curated
family→mark map (`src/components/ui/brand-marks.ts`, `BRAND_MARK` keyed by the
canonical BrandKey) of our own tasteful, monochrome inline-SVG marks, consumed
ONLY by `ProviderLogo` — so the Models catalog family headers + rows, the
playground ModelPicker rail + rows, Marketplace, Provider admin and Providers
explore all light up for free:
  - Zen (house brand) → the bold Hanzo block-H, knocked out of a filled tile —
    NEVER an upstream family glyph (brand policy); replaces the faint ensō circle.
  - Qwen → origami hexagon · DeepSeek → whale · Meta → infinity · Mistral → block-M
    · Google Gemma → cut gem · OpenAI GPT-OSS → six-point knot. (+ xAI/Moonshot/
    NVIDIA marks for the broader provider picker.) Each on its brand hue, each
    visually distinct; unknown providers keep the honest neutral-initials fallback.
No external logo hotlinks, no trademark files — avatars, `currentColor`,
theme-adaptive. brand.test locks every curated family to its OWN unique mark.

Fix 2 — CPU=credit vs GPU=prepay-card, made obvious:
The behavior was already enforced server-side; this makes the funding source clear
to the customer. New pure `fundingModel(kind,{creditCents,hasCard})` (machines/
logic.ts, unit-tested) is the one source of truth, rendered by a shared, visually
distinct `FundingNote`:
  - CPU / non-GPU → GREEN "Launches on your Hanzo credit · $X available · charged
    to credits · no card required" (real balance from /v1/billing/balance; empty →
    "Add credits"). Shown in the launch drawer AND the Machines overview.
  - GPU → YELLOW "Prepay only · charged to your card · 24-hour minimum" (never
    credits; no card on file → "Add a payment card & prepay"). Shown in the launch
    drawer AND the GPUs overview. Replaces the drawer's redundant no-card block.
Nothing fabricated — the credit figure is the real balance; copy matches what the
server charges.

typecheck clean · vitest 1611 pass (+ funding + brand-mark coverage) · next build ✓

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:14:53 -07:00
zeekayandhanzo-dev e92c1dd0a0 feat(models+compute): distinct per-family model icons + clear CPU-credit vs GPU-prepay funding — v8.4.82
Fix 1 — per-family model icons (DRY, one map, every catalog surface):
Every model family rendered the same faint mark (the Zen ensō ring on every Zen
row — the biggest family — plus flat letter monograms Q/Me/DS/Mi/G/AI for the
third-party families), so nothing read as a recognizable brand. Added ONE curated
family→mark map (`src/components/ui/brand-marks.ts`, `BRAND_MARK` keyed by the
canonical BrandKey) of our own tasteful, monochrome inline-SVG marks, consumed
ONLY by `ProviderLogo` — so the Models catalog family headers + rows, the
playground ModelPicker rail + rows, Marketplace, Provider admin and Providers
explore all light up for free:
  - Zen (house brand) → the bold Hanzo block-H, knocked out of a filled tile —
    NEVER an upstream family glyph (brand policy); replaces the faint ensō circle.
  - Qwen → origami hexagon · DeepSeek → whale · Meta → infinity · Mistral → block-M
    · Google Gemma → cut gem · OpenAI GPT-OSS → six-point knot. (+ xAI/Moonshot/
    NVIDIA marks for the broader provider picker.) Each on its brand hue, each
    visually distinct; unknown providers keep the honest neutral-initials fallback.
No external logo hotlinks, no trademark files — avatars, `currentColor`,
theme-adaptive. brand.test locks every curated family to its OWN unique mark.

Fix 2 — CPU=credit vs GPU=prepay-card, made obvious:
The behavior was already enforced server-side; this makes the funding source clear
to the customer. New pure `fundingModel(kind,{creditCents,hasCard})` (machines/
logic.ts, unit-tested) is the one source of truth, rendered by a shared, visually
distinct `FundingNote`:
  - CPU / non-GPU → GREEN "Launches on your Hanzo credit · $X available · charged
    to credits · no card required" (real balance from /v1/billing/balance; empty →
    "Add credits"). Shown in the launch drawer AND the Machines overview.
  - GPU → YELLOW "Prepay only · charged to your card · 24-hour minimum" (never
    credits; no card on file → "Add a payment card & prepay"). Shown in the launch
    drawer AND the GPUs overview. Replaces the drawer's redundant no-card block.
Nothing fabricated — the credit figure is the real balance; copy matches what the
server charges.

typecheck clean · vitest 1611 pass (+ funding + brand-mark coverage) · next build ✓

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 21:14:53 -07:00
hanzo-dev dddab4a0b5 chore(release): v8.4.81 — audience-scope operator admin bearer (close the admin.hanzo.ai operator-panel gate) 2026-07-03 21:10:40 -07:00
hanzo-dev e207b15051 chore(release): v8.4.81 — audience-scope operator admin bearer (close the admin.hanzo.ai operator-panel gate) 2026-07-03 21:10:40 -07:00
422b54d145 fix(admin): audience-scope the operator /v1/admin/* bearer so cloud accepts it (#90)
The admin-aggregate forwards a user bearer minted by issue-user-token for the
reserved-admin operator (admin/z). Its owner=admin + isAdmin=true are correct, but
its aud defaults to the target user's own app (admin-console), which is NOT in
cloud's audience allowlist — so SanitizeIdentity rejects the token, the request
resolves anonymous, and every /v1/admin/* 403s 'global admin required'.

Fix: scope the minted bearer to the brand cloud audience (<brand>-cloud, always in
cloud's BrandAudiences), host-aware, admin path only.
- config.cloudAudience(host) = BRANDS[brand].iamApp (correct even on admin hosts).
- issueUserToken(user, aud?) / adminBearer(user, aud?) cached per (user, audience).
- BearerProxyOpts.audience; admin-aggregate passes cloudAudience(host).
Tenant proxies omit audience → default (target-app) aud, unchanged; confidential
mint client (hanzo-console) and owner semantics untouched. No cloud change.

tsc clean; vitest 1603/1603; next build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 20:47:28 -07:00
3ad94ee7b3 fix(admin): audience-scope the operator /v1/admin/* bearer so cloud accepts it (#90)
The admin-aggregate forwards a user bearer minted by issue-user-token for the
reserved-admin operator (admin/z). Its owner=admin + isAdmin=true are correct, but
its aud defaults to the target user's own app (admin-console), which is NOT in
cloud's audience allowlist — so SanitizeIdentity rejects the token, the request
resolves anonymous, and every /v1/admin/* 403s 'global admin required'.

Fix: scope the minted bearer to the brand cloud audience (<brand>-cloud, always in
cloud's BrandAudiences), host-aware, admin path only.
- config.cloudAudience(host) = BRANDS[brand].iamApp (correct even on admin hosts).
- issueUserToken(user, aud?) / adminBearer(user, aud?) cached per (user, audience).
- BearerProxyOpts.audience; admin-aggregate passes cloudAudience(host).
Tenant proxies omit audience → default (target-app) aud, unchanged; confidential
mint client (hanzo-console) and owner semantics untouched. No cloud change.

tsc clean; vitest 1603/1603; next build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 20:47:28 -07:00
zeekayandClaude Opus 4.8 b94cafd099 fix(billing): Overview usage/spend — call /billing/v1/* directly (not /v1/billing)
The default Overview (and every product overview + o11y usage panel) showed the
"Access required" wall because GET /v1/billing/usage returned 403. Root cause
(proven live as davelorenzini/maxpower): on the console ingress /v1/* is routed to
the gateway-fronted cloud binary, which requires a JWT bearer (AUTH_PUBLIC_PATHS
excludes billing) — a cookie-only browser request has none, so cloud-api's
clients/console/billing.go resolveCaller finds no validated principal and 403s
("sign in to view billing"). Same class proven by /v1/functions -> 403
"X-Org-Id required". The bare /v1/billing/* NEVER reaches the console Next server
(so the next.config /v1/billing -> /billing/v1 rewrite never fires).

The console's OWN per-tenant proxy app/billing/v1/[...path]/route.ts (service token
+ server-pinned org subject) works perfectly: proven live /billing/v1/balance ->
{available:2046235,user:"maxpower"} and /billing/v1/usage -> 287 real ledger rows.
wallet.ts already addresses it directly; billing.ts + aimetrics.ts + SettlementModule
regressed to the bare /v1/billing/* form.

Fix (same class as v8.4.70 framework/s3 -> /cloud/v1): add ONE billingProxyV1Url
helper (client.ts, the billing twin of cloudProxyV1Url) and point every billing
client at /billing/v1/* directly. Kill wallet.ts's duplicate local appUrl (DRY).
canonical-paths.test pins billing into the proxy-exceptions block; aimetrics.test
asserts /billing/v1/usage. Tenant isolation unchanged (proxy still pins the subject
server-side). No cloud/backend change needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:56:49 -07:00
zeekayandhanzo-dev cd0f0506da fix(billing): Overview usage/spend — call /billing/v1/* directly (not /v1/billing)
The default Overview (and every product overview + o11y usage panel) showed the
"Access required" wall because GET /v1/billing/usage returned 403. Root cause
(proven live as davelorenzini/maxpower): on the console ingress /v1/* is routed to
the gateway-fronted cloud binary, which requires a JWT bearer (AUTH_PUBLIC_PATHS
excludes billing) — a cookie-only browser request has none, so cloud-api's
clients/console/billing.go resolveCaller finds no validated principal and 403s
("sign in to view billing"). Same class proven by /v1/functions -> 403
"X-Org-Id required". The bare /v1/billing/* NEVER reaches the console Next server
(so the next.config /v1/billing -> /billing/v1 rewrite never fires).

The console's OWN per-tenant proxy app/billing/v1/[...path]/route.ts (service token
+ server-pinned org subject) works perfectly: proven live /billing/v1/balance ->
{available:2046235,user:"maxpower"} and /billing/v1/usage -> 287 real ledger rows.
wallet.ts already addresses it directly; billing.ts + aimetrics.ts + SettlementModule
regressed to the bare /v1/billing/* form.

Fix (same class as v8.4.70 framework/s3 -> /cloud/v1): add ONE billingProxyV1Url
helper (client.ts, the billing twin of cloudProxyV1Url) and point every billing
client at /billing/v1/* directly. Kill wallet.ts's duplicate local appUrl (DRY).
canonical-paths.test pins billing into the proxy-exceptions block; aimetrics.test
asserts /billing/v1/usage. Tenant isolation unchanged (proxy still pins the subject
server-side). No cloud/backend change needed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:56:49 -07:00
zeekayandClaude Opus 4.8 1b1b529806 chore(release): v8.4.79 — per-product Billing/Usage/Metrics quick links
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:32 -07:00
zeekayandhanzo-dev dc3f46f3ae chore(release): v8.4.79 — per-product Billing/Usage/Metrics quick links
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:54:32 -07:00
zeekayandClaude Opus 4.8 8ed465491a feat(overview): per-product Billing/Usage/Metrics quick links on every product overview
One reusable ProductQuickLinks band, wired once in the product catch-all for
every product's Overview (native, living, or bespoke) — never hand-copied per
module. Each card is scoped to THAT product and links to a real destination:

- Billing → Cost Reports pre-filtered to the product's meter
  (/billing/reports?product=<tag>; unfiltered for the whole-ledger inference
  surfaces). BillingReports reads the ?product deep-link and filters the ledger.
- Usage   → the product's own Metrics sub-page (/<id>/metrics), the REAL
  /v1/billing/usage ledger scoped by metadata.product.
- Metrics → the same per-product Metrics dashboard.

Figures are REAL (one product-scoped UsageApi.overview read) or honest-empty —
never fabricated, never an Access-required wall; the links always work. The
product→meter decision reuses the ONE metricsScopeFor source (DRY), so Usage for
Models shows model usage, Usage for GPUs shows GPU usage. Suppressed for the
money/account/admin/rollup surfaces. Pure quick-links.ts is unit-tested (11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:22 -07:00
zeekayandhanzo-dev a73f1813e7 feat(overview): per-product Billing/Usage/Metrics quick links on every product overview
One reusable ProductQuickLinks band, wired once in the product catch-all for
every product's Overview (native, living, or bespoke) — never hand-copied per
module. Each card is scoped to THAT product and links to a real destination:

- Billing → Cost Reports pre-filtered to the product's meter
  (/billing/reports?product=<tag>; unfiltered for the whole-ledger inference
  surfaces). BillingReports reads the ?product deep-link and filters the ledger.
- Usage   → the product's own Metrics sub-page (/<id>/metrics), the REAL
  /v1/billing/usage ledger scoped by metadata.product.
- Metrics → the same per-product Metrics dashboard.

Figures are REAL (one product-scoped UsageApi.overview read) or honest-empty —
never fabricated, never an Access-required wall; the links always work. The
product→meter decision reuses the ONE metricsScopeFor source (DRY), so Usage for
Models shows model usage, Usage for GPUs shows GPU usage. Suppressed for the
money/account/admin/rollup surfaces. Pure quick-links.ts is unit-tested (11).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:54:22 -07:00
a8d07bfaa6 fix(auth): admin.<brand> redeems its OAuth code as admin-console (PKCE, no secret) — v8.4.76 (#89)
Leg 2 of the admin.hanzo.ai login. The credential login mints the code for the
PUBLIC admin-console app, but the redeem POSTed the console origin /v1/iam/signin,
which the ingress routes to the cloud backend (casibase) — and casibase redeems
with its confidential hanzo-cloud client, so IAM rejected it ("the token is for
wrong application (client_id)") and bounced the operator back to /signin.

The console now redeems the code ITSELF, host-aware: on an admin host iam-login
authorizes with PKCE (S256 codeChallenge in the login body) and completeSignIn
posts {code, codeVerifier} to the new BFF app/auth/signin, which runs
pkceCodeGrant(client_id=admin-console, code_verifier) with NO client secret —
RFC 7636 public-client path (verified in IAM GetAuthorizationCodeToken: empty
secret + matching S256 verifier is admitted; admin-console has the
authorization_code + refresh_token grants). Tenant hosts are unchanged (no
challenge; the cloud backend keeps redeeming with hanzo-cloud).

durableSessionClientId(host) is the ONE host->client decision (admin-console on
admin hosts, else the confidential hanzo-console); /auth/refresh uses it so an
admin session refreshes secretlessly with admin-console. accountOf + applyCookies
extracted to session.ts (one writer for /auth/session|refresh|signin); createPkce
is the one PKCE source. No new secret to provision.

tsc clean; npm test 1578/1578 (+durableSessionClientId/pkceCodeGrant/refresh
host-aware + PKCE S256 correctness); next build green (/auth/signin registered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:47:50 -07:00
3b8dda2a80 fix(auth): admin.<brand> redeems its OAuth code as admin-console (PKCE, no secret) — v8.4.76 (#89)
Leg 2 of the admin.hanzo.ai login. The credential login mints the code for the
PUBLIC admin-console app, but the redeem POSTed the console origin /v1/iam/signin,
which the ingress routes to the cloud backend (casibase) — and casibase redeems
with its confidential hanzo-cloud client, so IAM rejected it ("the token is for
wrong application (client_id)") and bounced the operator back to /signin.

The console now redeems the code ITSELF, host-aware: on an admin host iam-login
authorizes with PKCE (S256 codeChallenge in the login body) and completeSignIn
posts {code, codeVerifier} to the new BFF app/auth/signin, which runs
pkceCodeGrant(client_id=admin-console, code_verifier) with NO client secret —
RFC 7636 public-client path (verified in IAM GetAuthorizationCodeToken: empty
secret + matching S256 verifier is admitted; admin-console has the
authorization_code + refresh_token grants). Tenant hosts are unchanged (no
challenge; the cloud backend keeps redeeming with hanzo-cloud).

durableSessionClientId(host) is the ONE host->client decision (admin-console on
admin hosts, else the confidential hanzo-console); /auth/refresh uses it so an
admin session refreshes secretlessly with admin-console. accountOf + applyCookies
extracted to session.ts (one writer for /auth/session|refresh|signin); createPkce
is the one PKCE source. No new secret to provision.

tsc clean; npm test 1578/1578 (+durableSessionClientId/pkceCodeGrant/refresh
host-aware + PKCE S256 correctness); next build green (/auth/signin registered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:47:50 -07:00
zeekayandClaude Opus 4.8 1b4cbbdebe fix(gpus): catalog reads visor via /vm proxy; GPU launches are card-funded prepay w/ 24h minimum (8.4.77)
Problem 1 — GPU catalog unreachable ("Accelerators 0 available to launch").
ROOT CAUSE: the console host ingress routes /v1/* straight to the gateway
(-> cloud-api), BYPASSING Next -- so the next.config /v1/gpu-sizes -> /vm/v1/gpus
rewrite never runs and cloud-api serves NO visor catalog route -> empty catalog.
Same class as the framework/s3 fix (v8.4.70). Proven in-cluster: visor
/v1/{gpus,regions,sizes} = 200 real DO catalog; cloud-api /v1/gpu-sizes 404s.
FIX: new vmProxyBase/vmProxyV1Url (client.ts); VisorApi reads the catalog
(regions/sizes/gpus) through the /vm visor proxy EXPLICITLY and machines/
launch/quote/terminate through the /cloud user-bearer proxy -- never a bare
/v1/*. Regression-pinned in visor.test.ts (fetch-capture) + canonical-paths.test.ts.

Problem 2 — GPU launches bill a REAL card (prepay), never credits.
GPU billing policy surfaced in LaunchDrawer + enforced server-side (cloud-api +
commerce, tracked separately): PREPAY ONLY (card-funded prepaid balance, never
granted credits), CARD REQUIRED (no card on file -> launch BLOCKED with an add-card
CTA -> /billing/credits), 24-HOUR MINIMUM (hourly x 24 charged upfront, shown as the
Quote headline + "charged now" on Launch). Copy changed from "metered to your Hanzo
balance" -> prepay/card/24h-minimum. Card-on-file gate fails CLOSED (a failed
payment-methods read blocks the launch, never a silent credit fallback). CPU
machines unchanged (still metered to the credit balance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:34:46 -07:00
zeekayandhanzo-dev 848f22f7b2 fix(gpus): catalog reads visor via /vm proxy; GPU launches are card-funded prepay w/ 24h minimum (8.4.77)
Problem 1 — GPU catalog unreachable ("Accelerators 0 available to launch").
ROOT CAUSE: the console host ingress routes /v1/* straight to the gateway
(-> cloud-api), BYPASSING Next -- so the next.config /v1/gpu-sizes -> /vm/v1/gpus
rewrite never runs and cloud-api serves NO visor catalog route -> empty catalog.
Same class as the framework/s3 fix (v8.4.70). Proven in-cluster: visor
/v1/{gpus,regions,sizes} = 200 real DO catalog; cloud-api /v1/gpu-sizes 404s.
FIX: new vmProxyBase/vmProxyV1Url (client.ts); VisorApi reads the catalog
(regions/sizes/gpus) through the /vm visor proxy EXPLICITLY and machines/
launch/quote/terminate through the /cloud user-bearer proxy -- never a bare
/v1/*. Regression-pinned in visor.test.ts (fetch-capture) + canonical-paths.test.ts.

Problem 2 — GPU launches bill a REAL card (prepay), never credits.
GPU billing policy surfaced in LaunchDrawer + enforced server-side (cloud-api +
commerce, tracked separately): PREPAY ONLY (card-funded prepaid balance, never
granted credits), CARD REQUIRED (no card on file -> launch BLOCKED with an add-card
CTA -> /billing/credits), 24-HOUR MINIMUM (hourly x 24 charged upfront, shown as the
Quote headline + "charged now" on Launch). Copy changed from "metered to your Hanzo
balance" -> prepay/card/24h-minimum. Card-on-file gate fails CLOSED (a failed
payment-methods read blocks the launch, never a silent credit fallback). CPU
machines unchanged (still metered to the credit balance).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:34:46 -07:00
zeekayandClaude Opus 4.8 ab6b13ae74 chore(release): v8.4.76 — console design polish (type + tables)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:29:53 -07:00
zeekayandhanzo-dev 300dab360f chore(release): v8.4.76 — console design polish (type + tables)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:29:53 -07:00
zeekayandClaude Opus 4.8 c732df5917 polish(tables): DataTable skeleton-row loading, refined header, mono numeric columns, robust cell centering
The shared list primitive every module renders:
- Loading paints SKELETON ROWS in the real column layout (honest 'loading',
  not a centered spinner void).
- Header: quiet Medium ($color10), hairline underline instead of a heavy fill.
- Columns gain `align` + `mono`: numeric/amount/ID columns right-align and
  typeset in Geist Mono tabular figures (dashboard-grade, column-aligned data).
- Cells vertically center their content (justify=center); empty state calmer.
- Rows ease their hover fill (.hz-row, 140ms).

StorageModule: timestamps + byte sizes -> mono tabular; actions column ->
align:right + centered (drops the self=stretch flex=1 that floated the trash
icon below the row baseline). No other module used that pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:29:39 -07:00
zeekayandhanzo-dev fd81fedd14 polish(tables): DataTable skeleton-row loading, refined header, mono numeric columns, robust cell centering
The shared list primitive every module renders:
- Loading paints SKELETON ROWS in the real column layout (honest 'loading',
  not a centered spinner void).
- Header: quiet Medium ($color10), hairline underline instead of a heavy fill.
- Columns gain `align` + `mono`: numeric/amount/ID columns right-align and
  typeset in Geist Mono tabular figures (dashboard-grade, column-aligned data).
- Cells vertically center their content (justify=center); empty state calmer.
- Rows ease their hover fill (.hz-row, 140ms).

StorageModule: timestamps + byte sizes -> mono tabular; actions column ->
align:right + centered (drops the self=stretch flex=1 that floated the trash
icon below the row baseline). No other module used that pattern.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:29:39 -07:00
zeekayandClaude Opus 4.8 f88d36f32e polish(type): font-synthesis:none kills faux-bold app-wide; Basel Medium headings + tabular numerals
Basel ships only Book (400) + Medium (500). Every heading/label requested
600-900 was browser-SYNTHESIZED into a smeared faux-bold. One global invariant
(`* { font-synthesis: none }`, wins over Tamagui's runtime reset) makes every
requested heavy weight fall to the real Medium face — crisp, never synthetic.

- globals.css: font-synthesis:none (universal !important + body); add .hz-mono
  (Geist Mono + tabular) for dense data/code, keep .hz-tnum for display numerals.
- shared primitives normalized to the Book/Medium system: PageHeader, EmptyState,
  Metric (MetricCard/Panel/LegendDot) titles -> 500 with tight tracking.
- LivingOverview tiles: hero KPI / donut / distribution / series numbers -> Basel
  Medium + tabular figures (display numerals stay elegant; mono is reserved for
  tables/IDs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:28:32 -07:00
zeekayandhanzo-dev fe1cd2674f polish(type): font-synthesis:none kills faux-bold app-wide; Basel Medium headings + tabular numerals
Basel ships only Book (400) + Medium (500). Every heading/label requested
600-900 was browser-SYNTHESIZED into a smeared faux-bold. One global invariant
(`* { font-synthesis: none }`, wins over Tamagui's runtime reset) makes every
requested heavy weight fall to the real Medium face — crisp, never synthetic.

- globals.css: font-synthesis:none (universal !important + body); add .hz-mono
  (Geist Mono + tabular) for dense data/code, keep .hz-tnum for display numerals.
- shared primitives normalized to the Book/Medium system: PageHeader, EmptyState,
  Metric (MetricCard/Panel/LegendDot) titles -> 500 with tight tracking.
- LivingOverview tiles: hero KPI / donut / distribution / series numbers -> Basel
  Medium + tabular figures (display numerals stay elegant; mono is reserved for
  tables/IDs).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 19:28:32 -07:00
zeekayandClaude Opus 4.8 7dc17c8399 fix(console): null-guard 14 pre-existing next/navigation errors so next build goes green
`useSearchParams()`/`usePathname()` return `T | null` under React 19 / Next 15;
strict tsc flags 14 unguarded uses in 6 untouched files (auth/callback,
DashboardShell, Breadcrumbs, ComingSoon, Containers, OrgIntegrations) that
already failed `next build`'s type-check on clean main. DRY fix at each hook
site: `usePathname() ?? ''` and `useSearchParams() ?? new URLSearchParams()`.
Unblocks the Tenants / White-Label board deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:58:52 -07:00
zeekayandhanzo-dev 5767b0fa6c fix(console): null-guard 14 pre-existing next/navigation errors so next build goes green
`useSearchParams()`/`usePathname()` return `T | null` under React 19 / Next 15;
strict tsc flags 14 unguarded uses in 6 untouched files (auth/callback,
DashboardShell, Breadcrumbs, ComingSoon, Containers, OrgIntegrations) that
already failed `next build`'s type-check on clean main. DRY fix at each hook
site: `usePathname() ?? ''` and `useSearchParams() ?? new URLSearchParams()`.
Unblocks the Tenants / White-Label board deploy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 18:58:52 -07:00
zeekayandClaude Opus 4.8 769d21ffc7 feat(console): Tenants / White-Label board — data-driven tenant/package/domain/brand management (v8.4.72)
The global-admin surface for launching, branding, domain-binding, and managing
white-label tenants + resold sub-orgs. The MANAGEMENT UI over the platform's
provisioning — tenant/package/domain/brand RECORDS are the single source of truth,
nothing hardcoded as the canonical path; honest-state everywhere (real where a
backend answers, honest not-connected where a platform endpoint isn't bound yet).

- TenantsModule (admin: true, category Platform) + tenants/: tenants list COMPOSED
  from IAM orgs (brand) + admin cockpit (plan/wallet/status) + platform clusters;
  reseller TREE derived from metadata.parentOrg or (honestly flagged) owner email;
  package catalog read from the platform (DATA, seeded from platform-seed/packages.json,
  never a hardcoded const); New-tenant create (real IAM org); per-tenant manage
  (brand write REAL via IAM org fields, cluster provision REAL, domain list+bind,
  IAM apps, package grant, suspend/reactivate REAL).
- BFF: no new proxy — all platform calls ride the existing /paas catch-all (light up
  when the platform serves them; honest 404 today). Only server change:
  add/update/delete-organization added to the /admin/iam allow-list (global-admin
  gated, org-name pinned) so tenant-create + brand-write are REAL.
- Data-driven brand resolver: TenantsApi.brandConfig(host) + TenantBrandConfig
  replace the hardcoded BRANDS/HOST_BRANDS map in config.ts (marked deprecated with
  the precise migration; not swapped this pass — it's a build-time OAuth boundary).
- Missing platform endpoints flagged for the foundation phase: GET /v1/packages +
  package table, POST/DELETE /v1/org/{org}/package/{id} (composite provisionPackage),
  GET|POST /v1/org/{org}/domain (auto ingress+DNS+cert), GET /v1/brand?host=,
  parentOrgId column.
- Tests: +41 (packages normalizer+seed, model compose/tree/infer, tenants-API
  path/normalizer) all green; tsc clean for all new files; /tenants + /tenants/packages
  render 200 with zero page errors through the admin-gated catch-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:58:52 -07:00
zeekayandhanzo-dev 73bbaa95f5 feat(console): Tenants / White-Label board — data-driven tenant/package/domain/brand management (v8.4.72)
The global-admin surface for launching, branding, domain-binding, and managing
white-label tenants + resold sub-orgs. The MANAGEMENT UI over the platform's
provisioning — tenant/package/domain/brand RECORDS are the single source of truth,
nothing hardcoded as the canonical path; honest-state everywhere (real where a
backend answers, honest not-connected where a platform endpoint isn't bound yet).

- TenantsModule (admin: true, category Platform) + tenants/: tenants list COMPOSED
  from IAM orgs (brand) + admin cockpit (plan/wallet/status) + platform clusters;
  reseller TREE derived from metadata.parentOrg or (honestly flagged) owner email;
  package catalog read from the platform (DATA, seeded from platform-seed/packages.json,
  never a hardcoded const); New-tenant create (real IAM org); per-tenant manage
  (brand write REAL via IAM org fields, cluster provision REAL, domain list+bind,
  IAM apps, package grant, suspend/reactivate REAL).
- BFF: no new proxy — all platform calls ride the existing /paas catch-all (light up
  when the platform serves them; honest 404 today). Only server change:
  add/update/delete-organization added to the /admin/iam allow-list (global-admin
  gated, org-name pinned) so tenant-create + brand-write are REAL.
- Data-driven brand resolver: TenantsApi.brandConfig(host) + TenantBrandConfig
  replace the hardcoded BRANDS/HOST_BRANDS map in config.ts (marked deprecated with
  the precise migration; not swapped this pass — it's a build-time OAuth boundary).
- Missing platform endpoints flagged for the foundation phase: GET /v1/packages +
  package table, POST/DELETE /v1/org/{org}/package/{id} (composite provisionPackage),
  GET|POST /v1/org/{org}/domain (auto ingress+DNS+cert), GET /v1/brand?host=,
  parentOrgId column.
- Tests: +41 (packages normalizer+seed, model compose/tree/infer, tenants-API
  path/normalizer) all green; tsc clean for all new files; /tenants + /tenants/packages
  render 200 with zero page errors through the admin-gated catch-all.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 18:58:52 -07:00
hanzo-dev 33ab064acc fix(nav): resolve Automation + ML Pipelines URLs — zero 404 nav items (8.4.74)
Full-console walkthrough flagged /automation, /automations, /ml-pipelines,
/mlpipelines as raw 404s. Root cause: the nav opens each entry by its canonical
id (auto → auto.hanzo.ai external launch; kubeflow → in-console module), so a
directly-navigated/bookmarked conventional slug matched no entry and hit
notFound(). DNS + Zero-Trust already render honest BackendStateCard degraded
states (verified — no change).

- ML Pipelines: rename entry id kubeflow → ml-pipelines so the id IS the
  intuitive slug (label was already 'ML Pipelines'; Kubeflow is the engine, in
  the description). /ml-pipelines is now the canonical route.
- Slug aliases: ONE SLUG_ALIASES table in match-core (the single resolver) maps
  automation/automations → auto, mlpipelines/kubeflow → ml-pipelines. canonicalSlug
  rewrites the head segment up front in resolveProductView, so every branch reasons
  over the canonical id. A module alias resolves to the real route; an external
  target resolves to a new 'external' view kind.
- Catch-all: an 'external' view renders ProductInterstitial (the in-console
  discover page with an Open button that launches the product's own domain) —
  never a 404 for a hand-typed external-product URL.

DRY: aliasing in one place, no duplicate modules, no fake soon states, honest
by construction. tsc clean; vitest 1534/1534 (+10 match-core alias/external);
next build ✓ 14/14.
2026-07-03 18:55:58 -07:00
hanzo-dev 0d0077dcf6 fix(nav): resolve Automation + ML Pipelines URLs — zero 404 nav items (8.4.74)
Full-console walkthrough flagged /automation, /automations, /ml-pipelines,
/mlpipelines as raw 404s. Root cause: the nav opens each entry by its canonical
id (auto → auto.hanzo.ai external launch; kubeflow → in-console module), so a
directly-navigated/bookmarked conventional slug matched no entry and hit
notFound(). DNS + Zero-Trust already render honest BackendStateCard degraded
states (verified — no change).

- ML Pipelines: rename entry id kubeflow → ml-pipelines so the id IS the
  intuitive slug (label was already 'ML Pipelines'; Kubeflow is the engine, in
  the description). /ml-pipelines is now the canonical route.
- Slug aliases: ONE SLUG_ALIASES table in match-core (the single resolver) maps
  automation/automations → auto, mlpipelines/kubeflow → ml-pipelines. canonicalSlug
  rewrites the head segment up front in resolveProductView, so every branch reasons
  over the canonical id. A module alias resolves to the real route; an external
  target resolves to a new 'external' view kind.
- Catch-all: an 'external' view renders ProductInterstitial (the in-console
  discover page with an Open button that launches the product's own domain) —
  never a 404 for a hand-typed external-product URL.

DRY: aliasing in one place, no duplicate modules, no fake soon states, honest
by construction. tsc clean; vitest 1534/1534 (+10 match-core alias/external);
next build ✓ 14/14.
2026-07-03 18:55:58 -07:00
68b4cb71a7 fix(auth): admin.<brand> logs into admin-console IN the reserved admin org (#88)
On an admin console host the operator could not sign into the cockpit: after the
admin-guard passed, console2s own app-login failed with

  oauth2 invalid_grant: the token is for wrong application,
  application.Name:[hanzo-cloud], token.Application:[admin-console]

Root cause: admin.<brand> switched the OAuth APP to admin-console but never
switched the ORG, so login resolved into the brand tenant org (hanzo) instead of
the reserved global-admin org (admin) where admin-console is registered — the
code was minted in the wrong org and the token audience mismatched.

BUG #1 (src/config/index.ts resolveConfig): iamOrgName never switched on an admin
host. The app and the org now travel together — a sibling of the existing
app-switch: const admin = isAdminHost(host); app = admin ? adminApp : iamApp;
org = admin ? ADMIN_ORG : b.iamOrgName. NEXT_PUBLIC_* override precedence
unchanged. ADMIN_ORG (=admin) is ONE global org across every brand.

BUG #2 (src/lib/auth/iam-login.ts): the direct credential login hardcoded
organization: (a TENANT multi-org behaviour) on every host, so the admin host
never pinned org=admin. Now organization = isAdminHost(window.location.hostname)
? config.iamOrgName : — admin.<brand> authenticates INTO admin, tenants keep the
resolve-across-orgs-by-email behaviour (unchanged). The login wire already
derives its host + redirectUri client-side from window.location, so the SSR
build-time default host never enters the login flow.

Tenant (console.hanzo.ai) auth and the admin-guard org-membership gate are
untouched.

Tests: resolveConfig(admin.hanzo.ai) -> {admin-console, admin-console, admin};
resolveConfig(console.hanzo.ai) unchanged; new iam-login.test.ts proves the
credential POST carries client_id/application=admin-console + organization=admin
on admin.hanzo.ai and brand-app + empty org on a tenant host.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 18:39:53 -07:00
9b8ed62bd5 fix(auth): admin.<brand> logs into admin-console IN the reserved admin org (#88)
On an admin console host the operator could not sign into the cockpit: after the
admin-guard passed, console2s own app-login failed with

  oauth2 invalid_grant: the token is for wrong application,
  application.Name:[hanzo-cloud], token.Application:[admin-console]

Root cause: admin.<brand> switched the OAuth APP to admin-console but never
switched the ORG, so login resolved into the brand tenant org (hanzo) instead of
the reserved global-admin org (admin) where admin-console is registered — the
code was minted in the wrong org and the token audience mismatched.

BUG #1 (src/config/index.ts resolveConfig): iamOrgName never switched on an admin
host. The app and the org now travel together — a sibling of the existing
app-switch: const admin = isAdminHost(host); app = admin ? adminApp : iamApp;
org = admin ? ADMIN_ORG : b.iamOrgName. NEXT_PUBLIC_* override precedence
unchanged. ADMIN_ORG (=admin) is ONE global org across every brand.

BUG #2 (src/lib/auth/iam-login.ts): the direct credential login hardcoded
organization: (a TENANT multi-org behaviour) on every host, so the admin host
never pinned org=admin. Now organization = isAdminHost(window.location.hostname)
? config.iamOrgName : — admin.<brand> authenticates INTO admin, tenants keep the
resolve-across-orgs-by-email behaviour (unchanged). The login wire already
derives its host + redirectUri client-side from window.location, so the SSR
build-time default host never enters the login flow.

Tenant (console.hanzo.ai) auth and the admin-guard org-membership gate are
untouched.

Tests: resolveConfig(admin.hanzo.ai) -> {admin-console, admin-console, admin};
resolveConfig(console.hanzo.ai) unchanged; new iam-login.test.ts proves the
credential POST carries client_id/application=admin-console + organization=admin
on admin.hanzo.ai and brand-app + empty org on a tenant host.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 18:39:53 -07:00
8b6f0ebb6a test(scope): lock the project-scope -> X-Project-Id header contract (#86)
The org > project selector ships already (ScopeSwitcher in the shell,
ScopeProvider in app/(dashboard)/layout.tsx, selection persisted via
lib/scope + localStorage). scope.test.ts proves the STORE; nothing proved
the WIRE — that a selected project actually reaches X-Project-Id on every
cloud call (the header visor attributes org>app>project usage by, spoof-
proofed at cloud.SanitizeIdentity).

Black-box test through a real get() over a stubbed global fetch:
  - org-level (no project): org-scoped call, NO X-Project-Id
  - project selected: X-Project-Id stamped, still under X-Org-Id
  - project cleared: X-Project-Id drops back to org-level
  - active environment rides along as X-Environment

No production change; locks the contract a customer scoping by project relies on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 17:57:20 -07:00
0324ae2d7c test(scope): lock the project-scope -> X-Project-Id header contract (#86)
The org > project selector ships already (ScopeSwitcher in the shell,
ScopeProvider in app/(dashboard)/layout.tsx, selection persisted via
lib/scope + localStorage). scope.test.ts proves the STORE; nothing proved
the WIRE — that a selected project actually reaches X-Project-Id on every
cloud call (the header visor attributes org>app>project usage by, spoof-
proofed at cloud.SanitizeIdentity).

Black-box test through a real get() over a stubbed global fetch:
  - org-level (no project): org-scoped call, NO X-Project-Id
  - project selected: X-Project-Id stamped, still under X-Org-Id
  - project cleared: X-Project-Id drops back to org-level
  - active environment rides along as X-Environment

No production change; locks the contract a customer scoping by project relies on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 17:57:20 -07:00
b36f2ad450 fix(playground): resilient model catalog (no 502) + promoted-Zen default (8.4.73) (#85)
Live: console.hanzo.ai/playground → "Could not reach the backend — HTTP 502",
model selector empty ("Choose a model"), Run blocked. ROOT CAUSE: the
ChatPlayground catalog fetch (aicatalog.fetchCatalog via useModels) hard-depended
on /v1/pricing/models — that endpoint 502s on the live ingress and its restGet had
NO .catch, so the whole Promise.all rejected even though /v1/models (200, the full
~59-model DO-first catalog incl the zen5 family) succeeded right beside it. The
working /v1/models result was discarded → 502 card → no model preselected.

- Fix 1 (catalog reachability, DRY): fetchCatalog now makes /v1/models the PRIMARY,
  always-routed source and /v1/pricing/models a best-effort overlay (the EXACT
  resilience CloudModelApi.list already uses). When pricing 502s it falls through to
  the live set; live-only entries are normalized name<-id, provider<-owned_by so the
  picker row never renders blank (also fixes a pre-existing latent blank-name).
  Throws only if the live /v1/models set itself is unreachable. Marketplace +
  ModelCatalog (the other fetchCatalog consumers) get the same resilience.
- Fix 2 (auto-select promoted Zen): new pure default-model.ts (defaultModelId,
  extracted from useModels so it's node-testable without the hook's UI imports;
  re-exported, callers unchanged). Default = latest PROMOTED Zen flagship: honor an
  explicit catalog `featured` flag first (auto-tracks zen6 with no code change), else
  the bare Zen flagship by name (zen5) over a mini/flash/coder tier, else any servable
  text model. ModelOption carries `featured`. ChatPlayground seed effect is retry-safe.

tsc --noEmit=0; vitest 1502 pass (+9 default-model, +2 aicatalog 502-resilience;
the pre-existing canonical-paths.test.ts s3 case is RED on origin/main HEAD — the
v8.4.70 storage.ts->cloudProxyV1Url move left that expectation stale — UNRELATED:
storage.ts/canonical-paths.test.ts/client.ts untouched); next build 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:39:32 -07:00
0ba18d90f3 fix(playground): resilient model catalog (no 502) + promoted-Zen default (8.4.73) (#85)
Live: console.hanzo.ai/playground → "Could not reach the backend — HTTP 502",
model selector empty ("Choose a model"), Run blocked. ROOT CAUSE: the
ChatPlayground catalog fetch (aicatalog.fetchCatalog via useModels) hard-depended
on /v1/pricing/models — that endpoint 502s on the live ingress and its restGet had
NO .catch, so the whole Promise.all rejected even though /v1/models (200, the full
~59-model DO-first catalog incl the zen5 family) succeeded right beside it. The
working /v1/models result was discarded → 502 card → no model preselected.

- Fix 1 (catalog reachability, DRY): fetchCatalog now makes /v1/models the PRIMARY,
  always-routed source and /v1/pricing/models a best-effort overlay (the EXACT
  resilience CloudModelApi.list already uses). When pricing 502s it falls through to
  the live set; live-only entries are normalized name<-id, provider<-owned_by so the
  picker row never renders blank (also fixes a pre-existing latent blank-name).
  Throws only if the live /v1/models set itself is unreachable. Marketplace +
  ModelCatalog (the other fetchCatalog consumers) get the same resilience.
- Fix 2 (auto-select promoted Zen): new pure default-model.ts (defaultModelId,
  extracted from useModels so it's node-testable without the hook's UI imports;
  re-exported, callers unchanged). Default = latest PROMOTED Zen flagship: honor an
  explicit catalog `featured` flag first (auto-tracks zen6 with no code change), else
  the bare Zen flagship by name (zen5) over a mini/flash/coder tier, else any servable
  text model. ModelOption carries `featured`. ChatPlayground seed effect is retry-safe.

tsc --noEmit=0; vitest 1502 pass (+9 default-model, +2 aicatalog 502-resilience;
the pre-existing canonical-paths.test.ts s3 case is RED on origin/main HEAD — the
v8.4.70 storage.ts->cloudProxyV1Url move left that expectation stale — UNRELATED:
storage.ts/canonical-paths.test.ts/client.ts untouched); next build 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:39:32 -07:00
bf26207abd fix(console): restore main to green + lock the s3/framework + family↔brand contracts (v8.4.72) (#84)
The v8.4.70 build break (framework/client.ts + storage.ts imported the
cloudProxyV1Url that #81 DELETED → tsc TS2305; next build type-checks, so CI
shipped no new image) blocked BOTH live CTO reports from deploying: the authed
overview (sound code — UsageApi.overview → /v1/billing/usage, LivingOverview
degrades to an honest ErrorState) and the Qwen/Llama/DeepSeek model brand
icons (already fixed in 6dfa9c059: Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE).

The build fix landed concurrently in c458efa8f (re-add cloudProxyV1Url — the
prod-correct variant: the live ingress does NOT rewrite bare /v1/s3, /v1/framework
to the console app, so those heads address /cloud EXPLICITLY). But it left vitest
RED: canonical-paths.test.ts still asserted the old prefix-free
StorageApi.buckets → /v1/s3/buckets. This restores green + pins both invariants:

- canonical-paths.test.ts: drop the stale prefix-free s3 assertion; add a
  documented cloud-proxy-exceptions block pinning s3 + framework to /cloud/v1/*
  (so a future canonicalization can't repoint them to a bare /v1/ that 403s live).
- families.test.ts: a families↔brand contract test — every curated family logo
  resolves through the ONE normalizeBrand→BRANDS resolver to a real colour + icon,
  keyed off the exact live pricing.json providers (Qwen/Meta/DeepSeek). Permanent
  guard for the 'icons blank' report.

Test-only (runtime fixes already on main). tsc 0; vitest 1507/1507 (121 files);
next build ✓ 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:35:12 -07:00
4e7baf8ee3 fix(console): restore main to green + lock the s3/framework + family↔brand contracts (v8.4.72) (#84)
The v8.4.70 build break (framework/client.ts + storage.ts imported the
cloudProxyV1Url that #81 DELETED → tsc TS2305; next build type-checks, so CI
shipped no new image) blocked BOTH live CTO reports from deploying: the authed
overview (sound code — UsageApi.overview → /v1/billing/usage, LivingOverview
degrades to an honest ErrorState) and the Qwen/Llama/DeepSeek model brand
icons (already fixed in f6bcb5b94: Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE).

The build fix landed concurrently in d3f4081a2 (re-add cloudProxyV1Url — the
prod-correct variant: the live ingress does NOT rewrite bare /v1/s3, /v1/framework
to the console app, so those heads address /cloud EXPLICITLY). But it left vitest
RED: canonical-paths.test.ts still asserted the old prefix-free
StorageApi.buckets → /v1/s3/buckets. This restores green + pins both invariants:

- canonical-paths.test.ts: drop the stale prefix-free s3 assertion; add a
  documented cloud-proxy-exceptions block pinning s3 + framework to /cloud/v1/*
  (so a future canonicalization can't repoint them to a bare /v1/ that 403s live).
- families.test.ts: a families↔brand contract test — every curated family logo
  resolves through the ONE normalizeBrand→BRANDS resolver to a real colour + icon,
  keyed off the exact live pricing.json providers (Qwen/Meta/DeepSeek). Permanent
  guard for the 'icons blank' report.

Test-only (runtime fixes already on main). tsc 0; vitest 1507/1507 (121 files);
next build ✓ 14/14.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 17:35:12 -07:00
hanzo-dev 8ee5d0289b docs(cms): v8.4.70 routing fix + S3-backend-500 infra note (LLM.md) 2026-07-03 17:23:41 -07:00
hanzo-dev c7715a935f docs(cms): v8.4.70 routing fix + S3-backend-500 infra note (LLM.md) 2026-07-03 17:23:41 -07:00
hanzo-dev a8b2a41d73 feat(integrations): customer-facing Connect page over /v1/integrations
Org-authed Integrations page: a logged-in org connects Slack/GitHub via
Connect buttons (card grid) that run the OAuth flow through the canonical
/v1 client — answers 'I can't just hit an API endpoint unauthed'.

- src/lib/api/integrations.ts (+.test.ts): IntegrationsApi (list/get/connect/
  disconnect) over originV1Url('integrations/...') + pure defensive normalizers,
  mirroring crm.ts. 9 tests.
- OrgIntegrationsModule.tsx: PageHeader + wrapping flexWrap card grid; per-card
  ProviderLogo/StatusTag/connectedAt + Connect (nav to authorizeUrl) / Disconnect
  (confirm+refetch); ?connected/?error callback-return toasts; available:false →
  disabled Connect + 'Not yet available'. Honest loading/BackendStateCard/empty.
- ProviderLogo: inline-SVG Slack (4-color hash) + GitHub (octocat) marks.
- StatusTag: 'connected' → green.
- registry: repoint 'integrations' entry → OrgIntegrationsModule (category
  Settings, slug /integrations = callback target); removed the old read-only
  DataTable surface (one integrations surface).
- Allow-listed 'integrations' in next.config.mjs CLOUD_V1_HEADS + proxy-allow.ts
  CLOUD_HEADS (defense in depth).

typecheck clean; vitest 1448/1448; next build ✓ (14/14).
2026-07-03 17:18:32 -07:00
hanzo-dev ed94d4ca3a feat(integrations): customer-facing Connect page over /v1/integrations
Org-authed Integrations page: a logged-in org connects Slack/GitHub via
Connect buttons (card grid) that run the OAuth flow through the canonical
/v1 client — answers 'I can't just hit an API endpoint unauthed'.

- src/lib/api/integrations.ts (+.test.ts): IntegrationsApi (list/get/connect/
  disconnect) over originV1Url('integrations/...') + pure defensive normalizers,
  mirroring crm.ts. 9 tests.
- OrgIntegrationsModule.tsx: PageHeader + wrapping flexWrap card grid; per-card
  ProviderLogo/StatusTag/connectedAt + Connect (nav to authorizeUrl) / Disconnect
  (confirm+refetch); ?connected/?error callback-return toasts; available:false →
  disabled Connect + 'Not yet available'. Honest loading/BackendStateCard/empty.
- ProviderLogo: inline-SVG Slack (4-color hash) + GitHub (octocat) marks.
- StatusTag: 'connected' → green.
- registry: repoint 'integrations' entry → OrgIntegrationsModule (category
  Settings, slug /integrations = callback target); removed the old read-only
  DataTable surface (one integrations surface).
- Allow-listed 'integrations' in next.config.mjs CLOUD_V1_HEADS + proxy-allow.ts
  CLOUD_HEADS (defense in depth).

typecheck clean; vitest 1448/1448; next build ✓ (14/14).
2026-07-03 17:18:32 -07:00
hanzo-dev c458efa8f2 fix(client): re-add cloudProxyV1Url — the framework/s3 clients import it (v8.4.70 build fix)
v8.4.70's framework + storage clients import cloudProxyV1Url to address the /cloud
bearer proxy explicitly (a bare /v1/framework or /v1/s3 hits hanzoai/gateway with no
principal → 403). But that helper had been deleted from client.ts by the earlier
/v1-canonicalization, so the build failed: "'cloudProxyV1Url' is not exported from
'./client'". Re-add cloudProxyBase + cloudProxyV1Url (the <origin>/cloud/v1/<path>
builder) so the CMS/ERP/Help framework calls and the media DAM S3 calls resolve and
reach the working proxy. next build green (14/14).
2026-07-03 16:58:34 -07:00
hanzo-dev d3f4081a2d fix(client): re-add cloudProxyV1Url — the framework/s3 clients import it (v8.4.70 build fix)
v8.4.70's framework + storage clients import cloudProxyV1Url to address the /cloud
bearer proxy explicitly (a bare /v1/framework or /v1/s3 hits hanzoai/gateway with no
principal → 403). But that helper had been deleted from client.ts by the earlier
/v1-canonicalization, so the build failed: "'cloudProxyV1Url' is not exported from
'./client'". Re-add cloudProxyBase + cloudProxyV1Url (the <origin>/cloud/v1/<path>
builder) so the CMS/ERP/Help framework calls and the media DAM S3 calls resolve and
reach the working proxy. next build green (14/14).
2026-07-03 16:58:34 -07:00
hanzo-dev 1b07d623bf fix(s3): storage client uses the /cloud bearer proxy, not bare /v1 (CMS media DAM)
Same ingress-bypass class as the framework fix: StorageApi built its URLs with
originV1Url → `/v1/s3/*`, which console.hanzo.ai's ingress routes DIRECTLY to
hanzoai/gateway (bypassing Next), so the request lands with no principal → 403
"valid principal required". This broke BOTH the S3 file-manager product AND the CMS
media DAM (media-upload.ts presigns uploads via StorageApi).

Fix: cloudProxyV1Url → `/cloud/v1/s3/*` (the `/cloud` route reaches app/cloud's
bearer proxy; `s3` is allow-listed in proxy-allow.ts CLOUD_HEADS). Presigned PUT/GET
URLs are absolute S3 and unaffected — only the minting calls (buckets/objects/
presign) move to the proxy. Tests updated to the corrected /cloud/v1/s3 path (15
pass). Ships in v8.4.70 alongside the framework fix.
2026-07-03 16:52:22 -07:00
hanzo-dev edfdc62844 fix(s3): storage client uses the /cloud bearer proxy, not bare /v1 (CMS media DAM)
Same ingress-bypass class as the framework fix: StorageApi built its URLs with
originV1Url → `/v1/s3/*`, which console.hanzo.ai's ingress routes DIRECTLY to
hanzoai/gateway (bypassing Next), so the request lands with no principal → 403
"valid principal required". This broke BOTH the S3 file-manager product AND the CMS
media DAM (media-upload.ts presigns uploads via StorageApi).

Fix: cloudProxyV1Url → `/cloud/v1/s3/*` (the `/cloud` route reaches app/cloud's
bearer proxy; `s3` is allow-listed in proxy-allow.ts CLOUD_HEADS). Presigned PUT/GET
URLs are absolute S3 and unaffected — only the minting calls (buckets/objects/
presign) move to the proxy. Tests updated to the corrected /cloud/v1/s3 path (15
pass). Ships in v8.4.70 alongside the framework fix.
2026-07-03 16:52:22 -07:00
hanzo-dev 8b278dc279 fix(cms): framework client must use the /cloud bearer proxy, not bare /v1 (v8.4.70)
The CMS "Content" page showed "Not enabled for your account" for a real user whose
org HAS the cms module installed. Root cause: the framework client built its URLs
with originV1Url → `/v1/framework/*`, but on console.hanzo.ai the INGRESS routes
`/v1/*` DIRECTLY to hanzoai/gateway (bypassing the Next.js app), so the next.config
`/v1/framework → /cloud/v1/framework` rewrite never runs. The gateway has no
principal for that path and returns 403 "valid principal required" → the module
renders its honest access-denied card.

Fix: build framework URLs with cloudProxyV1Url → `/cloud/v1/framework/*` (the same
per-tenant bearer-proxy path CRM/Prompts/Agents use, allow-listed as the `framework`
head in proxy-allow.ts). The `/cloud` route DOES reach Next's app/cloud proxy, which
mints a short-lived user-bound token and forwards to cloud-api with the org resolved
from the token owner. Verified live: `/cloud/v1/framework/doctypes` = 200 with the
real doctypes for maxpower; `/v1/framework/doctypes` = 403 (gateway). One-line import
swap to an already-exported, already-used helper; tsc clean, framework client tests
pass.
2026-07-03 16:49:08 -07:00
hanzo-dev fd20f03679 fix(cms): framework client must use the /cloud bearer proxy, not bare /v1 (v8.4.70)
The CMS "Content" page showed "Not enabled for your account" for a real user whose
org HAS the cms module installed. Root cause: the framework client built its URLs
with originV1Url → `/v1/framework/*`, but on console.hanzo.ai the INGRESS routes
`/v1/*` DIRECTLY to hanzoai/gateway (bypassing the Next.js app), so the next.config
`/v1/framework → /cloud/v1/framework` rewrite never runs. The gateway has no
principal for that path and returns 403 "valid principal required" → the module
renders its honest access-denied card.

Fix: build framework URLs with cloudProxyV1Url → `/cloud/v1/framework/*` (the same
per-tenant bearer-proxy path CRM/Prompts/Agents use, allow-listed as the `framework`
head in proxy-allow.ts). The `/cloud` route DOES reach Next's app/cloud proxy, which
mints a short-lived user-bound token and forwards to cloud-api with the org resolved
from the token owner. Verified live: `/cloud/v1/framework/doctypes` = 200 with the
real doctypes for maxpower; `/v1/framework/doctypes` = 403 (gateway). One-line import
swap to an already-exported, already-used helper; tsc clean, framework client tests
pass.
2026-07-03 16:49:08 -07:00
hanzo-dev f930c0f504 release: v8.4.69 — branch integration (trading-bots, 7stars/yotoda brands, nodes-chains #71, inference-log-detail #58); 14 superseded branches verified already in main 2026-07-03 16:42:47 -07:00
hanzo-dev 0d22a9240e release: v8.4.69 — branch integration (trading-bots, 7stars/yotoda brands, nodes-chains #71, inference-log-detail #58); 14 superseded branches verified already in main 2026-07-03 16:42:47 -07:00
hanzo-devandGitHub 1edd87c226 feat(inference): clickable log rows open a real per-call detail drawer (v8.4.39) (#58)
The Inference · Logs view already streamed the org's REAL recorded inference
calls (one commerce-usage-ledger row per billed call), but the rows were not
clickable and the LogLine projection discarded the rich per-call fields. Close
that gap — each row now opens the shared DetailPane showing what actually
happened for that call: model, provider, outcome, cost, prompt/completion/total
tokens, streamed, tier, product/agent attribution (only when the ledger tagged
it), request + transaction id, and time — every value REAL from the ledger
record, honest em-dash for absent. The full prompt/response TEXT is not on the
ledger row, so it is honestly stated as streaming from observability once its
trace runtime is connected — never fabricated.

DRY, no new surface: enrich LogLine with its source UsageRecord + one pure
logDetailFacts projection (logic.ts), one openLogDetail slide-over over the
existing DetailPane (panes.tsx, identical descriptor form to openEndpointDetail),
and wire onRowPress + a chevron affordance (LogsView.tsx). Reuses the shared Fact
row, StatusDot, and PrimaryButton — same look and feel as every other detail
surface.

Verification: tsc --noEmit clean; vitest 1136/1136 (+4 logDetailFacts /
LogLine.record); next build compiled successfully. Live authenticated render is
gated behind the console's server-cookie AuthGate (no backend session locally),
so verified via the component prop-level tests + a clean /[...slug] compile that
serves /inference/logs 200 in the dev server.
2026-07-03 16:38:46 -07:00
hanzo-devandGitHub a5f5edb5cf feat(inference): clickable log rows open a real per-call detail drawer (v8.4.39) (#58)
The Inference · Logs view already streamed the org's REAL recorded inference
calls (one commerce-usage-ledger row per billed call), but the rows were not
clickable and the LogLine projection discarded the rich per-call fields. Close
that gap — each row now opens the shared DetailPane showing what actually
happened for that call: model, provider, outcome, cost, prompt/completion/total
tokens, streamed, tier, product/agent attribution (only when the ledger tagged
it), request + transaction id, and time — every value REAL from the ledger
record, honest em-dash for absent. The full prompt/response TEXT is not on the
ledger row, so it is honestly stated as streaming from observability once its
trace runtime is connected — never fabricated.

DRY, no new surface: enrich LogLine with its source UsageRecord + one pure
logDetailFacts projection (logic.ts), one openLogDetail slide-over over the
existing DetailPane (panes.tsx, identical descriptor form to openEndpointDetail),
and wire onRowPress + a chevron affordance (LogsView.tsx). Reuses the shared Fact
row, StatusDot, and PrimaryButton — same look and feel as every other detail
surface.

Verification: tsc --noEmit clean; vitest 1136/1136 (+4 logDetailFacts /
LogLine.record); next build compiled successfully. Live authenticated render is
gated behind the console's server-cookie AuthGate (no backend session locally),
so verified via the component prop-level tests + a clean /[...slug] compile that
serves /inference/logs 200 in the dev server.
2026-07-03 16:38:46 -07:00
c100ba40c7 feat(nodes): surface primary-network chains per network (getBlockchains) (#71)
The Nodes surface (Network category, enabled on lux/zoo/pars + hanzo) showed
validators + peers per luxd primary network, but not the network's chains. This
adds the live primary-network chain set — the letter chains X C D Q A B T Z G K
plus the P-Chain — read from `platform.getBlockchains` through the same
same-origin, session-gated, method-allowlisted `/nodes` proxy.

- `/nodes` proxy: `platform.getBlockchains` added as the 5th (and only new)
  allowlisted luxd read method. A network counts as reporting if validators,
  peers, OR chains answered; chains are best-effort (a network can report
  validators yet not answer getBlockchains → honest empty chain list, never
  fabricated chains).
- `nodes.ts`: `RawBlockchain`/`ChainInfo` types + PURE `normalizeChains`
  (prepends the P-Chain, which getBlockchains omits; preserves reported order;
  drops id-less chains). `NetworkInventory.chains` added.
- `NodesModule`: renamed to "Networks & Nodes"; per-network card gains a Chains
  count + live chain chips; a Chains table (Network · Chain · Blockchain ID · VM)
  renders above the validators/peers table, honoring the network filter.
- Tests: +3 normalizeChains cases over the real devnet wire shape (33/33 pass).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:37:09 -07:00
627763d0c3 feat(nodes): surface primary-network chains per network (getBlockchains) (#71)
The Nodes surface (Network category, enabled on lux/zoo/pars + hanzo) showed
validators + peers per luxd primary network, but not the network's chains. This
adds the live primary-network chain set — the letter chains X C D Q A B T Z G K
plus the P-Chain — read from `platform.getBlockchains` through the same
same-origin, session-gated, method-allowlisted `/nodes` proxy.

- `/nodes` proxy: `platform.getBlockchains` added as the 5th (and only new)
  allowlisted luxd read method. A network counts as reporting if validators,
  peers, OR chains answered; chains are best-effort (a network can report
  validators yet not answer getBlockchains → honest empty chain list, never
  fabricated chains).
- `nodes.ts`: `RawBlockchain`/`ChainInfo` types + PURE `normalizeChains`
  (prepends the P-Chain, which getBlockchains omits; preserves reported order;
  drops id-less chains). `NetworkInventory.chains` added.
- `NodesModule`: renamed to "Networks & Nodes"; per-network card gains a Chains
  count + live chain chips; a Chains table (Network · Chain · Blockchain ID · VM)
  renders above the validators/peers table, honoring the network filter.
- Tests: +3 normalizeChains cases over the real devnet wire shape (33/33 pass).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:37:09 -07:00
9ae33beea4 fix(o11y): canonical /v1/o11y reads + un-trap Observations/Users nav (8.4.68) (#83)
The still-valid residual of #77 (which #81's canonicalization couldn't carry — #77
imported the now-deleted cloudProxyV1Url). Two real bugs, redone canonically on main:

1. o11y annotation-queues + users reads hit the DIRECT-cloud origin (`v1Url` ->
   config.cloudUrl). The o11y runtime scopes tenancy by the minted bearer's owner and
   403s a cookie-only call in prod, so those 4 reads (annotationQueues/annotationQueue/
   annotationQueueItems/users) were dead on the deployed console. Switched to the ONE
   canonical `originV1Url('o11y/…')` -> `/v1/o11y/…`; next.config rewrites the o11y head
   to the `/cloud` bearer proxy (server) and the static embed reaches it directly — the
   exact transport every other o11y read (ServiceMap/Alerts) already uses. The #41 sweep
   only missed these because they were `v1Url`, not the deleted prefixed helper.

2. The `observations` + `users` catalog entries were TRAPPED inside registry.tsx's
   opening JSDoc block (the `/**` never closed before them), so they never registered in
   nav/routing despite ObservationsModule/UsersModule existing, being imported, and
   fetching real data. Closed the comment and moved both entries into the active catalog
   under Observe (beside Annotation Queues), so they render in the sidebar and route via
   the catch-all like their siblings.

Supersedes #77 (unmergeable — it referenced the deleted helper). Verify: tsc --noEmit
= 0; vitest 1475/1475 (120 files); next build ✓ (14/14, the /[...slug] catch-all that
renders the catalog compiles). Authenticated visual e2e (the 4 o11y reads returning
real data; Observations/Users in the sidebar) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:33:39 -07:00
3c53e816cc fix(o11y): canonical /v1/o11y reads + un-trap Observations/Users nav (8.4.68) (#83)
The still-valid residual of #77 (which #81's canonicalization couldn't carry — #77
imported the now-deleted cloudProxyV1Url). Two real bugs, redone canonically on main:

1. o11y annotation-queues + users reads hit the DIRECT-cloud origin (`v1Url` ->
   config.cloudUrl). The o11y runtime scopes tenancy by the minted bearer's owner and
   403s a cookie-only call in prod, so those 4 reads (annotationQueues/annotationQueue/
   annotationQueueItems/users) were dead on the deployed console. Switched to the ONE
   canonical `originV1Url('o11y/…')` -> `/v1/o11y/…`; next.config rewrites the o11y head
   to the `/cloud` bearer proxy (server) and the static embed reaches it directly — the
   exact transport every other o11y read (ServiceMap/Alerts) already uses. The #41 sweep
   only missed these because they were `v1Url`, not the deleted prefixed helper.

2. The `observations` + `users` catalog entries were TRAPPED inside registry.tsx's
   opening JSDoc block (the `/**` never closed before them), so they never registered in
   nav/routing despite ObservationsModule/UsersModule existing, being imported, and
   fetching real data. Closed the comment and moved both entries into the active catalog
   under Observe (beside Annotation Queues), so they render in the sidebar and route via
   the catch-all like their siblings.

Supersedes #77 (unmergeable — it referenced the deleted helper). Verify: tsc --noEmit
= 0; vitest 1475/1475 (120 files); next build ✓ (14/14, the /[...slug] catch-all that
renders the catalog compiles). Authenticated visual e2e (the 4 o11y reads returning
real data; Observations/Users in the sidebar) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:33:39 -07:00
dcc8a3f8dd feat(brands): add 7stars + yotoda white-label cloud tenants
Two new white-label brands recognized by the unified console so their hosts
render branded, mirroring lux/zoo/pars:

- config: BrandId gains '7stars' | 'yotoda'; BRANDS + HOST_BRANDS entries.
  Both are general Hanzo-cloud customers seeded AS ORGS in the hanzo IAM
  (hanzo.id) — they have NO own .id issuer, so iamUrl = https://hanzo.id with
  the per-brand iamOrgName (7stars/yotoda) + iamApp (7stars-cloud/yotoda-cloud).
  Login resolves against hanzo.id, org-scoped by the JWT owner (aud=<brand>-cloud),
  matching how the orgs/apps were provisioned. Own billing.<domain>/docs.<domain>.
  HOST_BRANDS suffixes 7stars.dev / yotoda.tech cover every subdomain
  (cloud.*, console.*, admin.*) via the endsWith('.'+suffix) match.

- brand-scope: BRAND_CATEGORIES null (FULL AI-cloud catalog, like hanzo — they
  are general cloud customers, not web3-only like the sovereign-chain brands).
  BRAND_NODE_NETWORKS [] — they own no chain, so the Nodes surface reports on no
  networks (never another brand's chain).

- branding/brands: BRANDS registry gains 7Stars/Yotoda with their own
  brandName/orgName/websiteUrl/adminDomain (adminDomain is the admin-gate email
  boundary — @7stars.dev / @yotoda.tech match the seeded owners z@7stars.dev /
  z@yotoda.tech). Logo falls back to the generic Hanzo blocky-H mark (no bespoke
  asset yet); swap logoContent when a real mark ships.

Tests: index.test.ts (host resolution, hanzo.id issuer, per-brand billing/docs,
admin app) + registry-brand.test.ts (full-catalog scope, zero node networks).
npm test 975/975 green. tsc/next build add zero new type errors (diff-proven
identical to origin/main; the pre-existing next/navigation nullable errors are a
local Node 26 vs CI Node 24 toolchain drift, not from these files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:31:47 -07:00
zeekayandhanzo-dev c4ef2b68a6 feat(brands): add 7stars + yotoda white-label cloud tenants
Two new white-label brands recognized by the unified console so their hosts
render branded, mirroring lux/zoo/pars:

- config: BrandId gains '7stars' | 'yotoda'; BRANDS + HOST_BRANDS entries.
  Both are general Hanzo-cloud customers seeded AS ORGS in the hanzo IAM
  (hanzo.id) — they have NO own .id issuer, so iamUrl = https://hanzo.id with
  the per-brand iamOrgName (7stars/yotoda) + iamApp (7stars-cloud/yotoda-cloud).
  Login resolves against hanzo.id, org-scoped by the JWT owner (aud=<brand>-cloud),
  matching how the orgs/apps were provisioned. Own billing.<domain>/docs.<domain>.
  HOST_BRANDS suffixes 7stars.dev / yotoda.tech cover every subdomain
  (cloud.*, console.*, admin.*) via the endsWith('.'+suffix) match.

- brand-scope: BRAND_CATEGORIES null (FULL AI-cloud catalog, like hanzo — they
  are general cloud customers, not web3-only like the sovereign-chain brands).
  BRAND_NODE_NETWORKS [] — they own no chain, so the Nodes surface reports on no
  networks (never another brand's chain).

- branding/brands: BRANDS registry gains 7Stars/Yotoda with their own
  brandName/orgName/websiteUrl/adminDomain (adminDomain is the admin-gate email
  boundary — @7stars.dev / @yotoda.tech match the seeded owners z@7stars.dev /
  z@yotoda.tech). Logo falls back to the generic Hanzo blocky-H mark (no bespoke
  asset yet); swap logoContent when a real mark ships.

Tests: index.test.ts (host resolution, hanzo.id issuer, per-brand billing/docs,
admin app) + registry-brand.test.ts (full-catalog scope, zero node networks).
npm test 975/975 green. tsc/next build add zero new type errors (diff-proven
identical to origin/main; the pre-existing next/navigation nullable errors are a
local Node 26 vs CI Node 24 toolchain drift, not from these files).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:31:47 -07:00
hanzo-dev 105656e4fb fix(trading): DataTable onRowPress→onOpen (main's @hanzo/data API) 2026-07-03 16:23:25 -07:00
hanzo-dev 2913917806 fix(trading): DataTable onRowPress→onOpen (main's @hanzo/data API) 2026-07-03 16:23:25 -07:00
139a9890fd chore(console): v8.4.66 — Trading + Markets (Lux DEX bots + economy dashboard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
zeekayandhanzo-dev 8d98a0a745 chore(console): v8.4.66 — Trading + Markets (Lux DEX bots + economy dashboard)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:23:25 -07:00
11370836a3 console(markets): Lux Economy dashboard — DeFiLlama-style DEX analytics
Adds a "Markets" product (Web3) — the analytics/management plane for the Lux DEX
economy, the twin of the Trading (deploy/manage) module.

- lib/api/economy.ts — client + pure normalizers for the `dex` subgraph
  (markets/fills/day-data). Honest to the CLOB reality: 24h volume, trades, book
  depth, best-bid/ask, last price are real fields; USD TVL is NOT fabricated (a
  CLOB has depth, not pooled TVL); the day-history series is empty until the
  subgraph's MarketDayData producer emits.
- overview/living: fromLuxIndexer adapter + a `lux-economy` LivingOverview config
  (KPIs, volume/trades/depth donuts, recent-trade feed, maker-health row) — the
  reusable board machinery, one config + one adapter, no new overview UI.
- app/economy/[...path]/route.ts — session-gated, brand-scoped GraphQL proxy to
  graphd's `dex` subgraph (ONE fixed query, no client GraphQL); honest not-reporting
  when unreachable.
- MarketsModule — the living board + the DeFiLlama-style per-market table, both over
  the /economy proxy. Registered under Web3 (lux.cloud shows it).

Data source: luxfi/graph `dex` subgraph (markets/fills) + the maker :2112 metrics.
Tests: 23 new (economy normalizers + fromLuxIndexer); full suite 1468 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
zeekayandhanzo-dev bf817de9db console(markets): Lux Economy dashboard — DeFiLlama-style DEX analytics
Adds a "Markets" product (Web3) — the analytics/management plane for the Lux DEX
economy, the twin of the Trading (deploy/manage) module.

- lib/api/economy.ts — client + pure normalizers for the `dex` subgraph
  (markets/fills/day-data). Honest to the CLOB reality: 24h volume, trades, book
  depth, best-bid/ask, last price are real fields; USD TVL is NOT fabricated (a
  CLOB has depth, not pooled TVL); the day-history series is empty until the
  subgraph's MarketDayData producer emits.
- overview/living: fromLuxIndexer adapter + a `lux-economy` LivingOverview config
  (KPIs, volume/trades/depth donuts, recent-trade feed, maker-health row) — the
  reusable board machinery, one config + one adapter, no new overview UI.
- app/economy/[...path]/route.ts — session-gated, brand-scoped GraphQL proxy to
  graphd's `dex` subgraph (ONE fixed query, no client GraphQL); honest not-reporting
  when unreachable.
- MarketsModule — the living board + the DeFiLlama-style per-market table, both over
  the /economy proxy. Registered under Web3 (lux.cloud shows it).

Data source: luxfi/graph `dex` subgraph (markets/fills) + the maker :2112 metrics.
Tests: 23 new (economy normalizers + fromLuxIndexer); full suite 1468 green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:23:25 -07:00
8a25a1c287 console(trading): Trading module — deploy + manage the Lux DEX bots as cloud apps
Adds a first-class "Trading" product under Web3: deploy the market-maker and
trader bots to the Hanzo PaaS from a config form, list the org's deployed bots,
watch each one's live quote quality (the maker's :2112 metrics) and DEX order
book, and control them (start/stop/redeploy/logs).

- lib/products/trading/templates.ts — the two deployable-app definitions
  (maker + trader) with a typed config schema; toCreateAppInput maps a filled
  config → a PaaS git app (BuildKit builds luxfi/{maker,trader} → GHCR).
  Signer keys are secretRef fields (KMS-synced), never typed in the browser.
- lib/api/trading.ts — pure Prometheus-metrics + order-book normalizers.
- app/trading/[...path]/route.ts — session-gated, brand-scoped, method-allowlisted
  proxy (mirrors /nodes): scrapes the maker :2112 metrics + reads the DEX book,
  honest not-reporting when unreachable.
- components/products/TradingModule.tsx (+ trading/{logic,DeployForm}) — the
  list/status/orderbook views + deploy/start/stop/redeploy/logs, over the existing
  PaasApi control plane (one deploy path; the bots are ordinary PaaS git apps).
- registry: Trading entry (Web3, brand-agnostic; data brand-scoped in the proxy
  so lux.cloud sees only Lux networks).

Tests: 34 new (templates/normalizers/logic), full suite 1311 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:23:25 -07:00
zeekayandhanzo-dev bb38021d8b console(trading): Trading module — deploy + manage the Lux DEX bots as cloud apps
Adds a first-class "Trading" product under Web3: deploy the market-maker and
trader bots to the Hanzo PaaS from a config form, list the org's deployed bots,
watch each one's live quote quality (the maker's :2112 metrics) and DEX order
book, and control them (start/stop/redeploy/logs).

- lib/products/trading/templates.ts — the two deployable-app definitions
  (maker + trader) with a typed config schema; toCreateAppInput maps a filled
  config → a PaaS git app (BuildKit builds luxfi/{maker,trader} → GHCR).
  Signer keys are secretRef fields (KMS-synced), never typed in the browser.
- lib/api/trading.ts — pure Prometheus-metrics + order-book normalizers.
- app/trading/[...path]/route.ts — session-gated, brand-scoped, method-allowlisted
  proxy (mirrors /nodes): scrapes the maker :2112 metrics + reads the DEX book,
  honest not-reporting when unreachable.
- components/products/TradingModule.tsx (+ trading/{logic,DeployForm}) — the
  list/status/orderbook views + deploy/start/stop/redeploy/logs, over the existing
  PaasApi control plane (one deploy path; the bots are ordinary PaaS git apps).
- registry: Trading entry (Web3, brand-agnostic; data brand-scoped in the proxy
  so lux.cloud sees only Lux networks).

Tests: 34 new (templates/normalizers/logic), full suite 1311 green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:23:25 -07:00
hanzo-dev fb49a9522f feat(cms): finish the native CMS — Payload-parity Lexical WYSIWYG, content-type builder, DAM, project scope (v8.4.67)
Every /cms/* URL now renders the native DocType renderer over /v1/framework/* — no
iframe, no raw JSON, no 404. Fixes the live console.hanzo.ai/cms/collections/Article
-> {"error":"Not found"} bug (the deployed image lacked the wired CMS sub-routes, so
the catch-all resolved them to notFound() and a Next RSC navigation served a JSON
404). The routes were declared correctly; this ships them complete.

- Rich text = native Lexical (the same engine Payload's MIT richtext-lexical uses),
  built fresh + thin on core lexical@0.46.0 and registered over @hanzo/data's
  `richText` type in Provider.tsx (registerField override, no fork). Toolbar:
  bold/italic/underline, H1-3 + paragraph + quote, bullet/number lists, links,
  undo/redo. Stores the Lexical EditorState JSON; read view -> sanitized HTML via
  $generateHtmlFromNodes. Pure serialization round-trips + migrates legacy plain
  Text bodies (never throws). A DocType field typed RichText renders it.
- Content-type builder: "New collection" defines a DocType's name + typed fields
  on-page (add/remove/reorder/require/list, every framework fieldtype with the extra
  inputs each needs). Pure builder-logic.ts.
- Media = real DAM: drag/drop or pick -> uploads to the org's own S3 (cms-media
  bucket, the same /v1/s3 SeaweedFS presigned-PUT as Storage) -> Media doc with the
  stable object key -> thumbnails presigned on-view; delete removes doc + object.
- Publish/Unpublish (+ Submit/Cancel) in the record editor.
- Project scope: the org->project ScopeSwitcher filters the records list and stamps
  new records, only on collections that declare a `project` field. One engine,
  project is a filter — no per-project/per-org CMS instances.

tsc clean; vitest 1418 pass (+richtext/builder/media/project/richText round-trip);
next build 14/14. Needs cloud v1.786.52+ (RichText fieldtype) deployed to accept a
RichText field live.
2026-07-03 16:22:02 -07:00
hanzo-dev fd3698dc4a feat(cms): finish the native CMS — Payload-parity Lexical WYSIWYG, content-type builder, DAM, project scope (v8.4.67)
Every /cms/* URL now renders the native DocType renderer over /v1/framework/* — no
iframe, no raw JSON, no 404. Fixes the live console.hanzo.ai/cms/collections/Article
-> {"error":"Not found"} bug (the deployed image lacked the wired CMS sub-routes, so
the catch-all resolved them to notFound() and a Next RSC navigation served a JSON
404). The routes were declared correctly; this ships them complete.

- Rich text = native Lexical (the same engine Payload's MIT richtext-lexical uses),
  built fresh + thin on core lexical@0.46.0 and registered over @hanzo/data's
  `richText` type in Provider.tsx (registerField override, no fork). Toolbar:
  bold/italic/underline, H1-3 + paragraph + quote, bullet/number lists, links,
  undo/redo. Stores the Lexical EditorState JSON; read view -> sanitized HTML via
  $generateHtmlFromNodes. Pure serialization round-trips + migrates legacy plain
  Text bodies (never throws). A DocType field typed RichText renders it.
- Content-type builder: "New collection" defines a DocType's name + typed fields
  on-page (add/remove/reorder/require/list, every framework fieldtype with the extra
  inputs each needs). Pure builder-logic.ts.
- Media = real DAM: drag/drop or pick -> uploads to the org's own S3 (cms-media
  bucket, the same /v1/s3 SeaweedFS presigned-PUT as Storage) -> Media doc with the
  stable object key -> thumbnails presigned on-view; delete removes doc + object.
- Publish/Unpublish (+ Submit/Cancel) in the record editor.
- Project scope: the org->project ScopeSwitcher filters the records list and stamps
  new records, only on collections that declare a `project` field. One engine,
  project is a filter — no per-project/per-org CMS instances.

tsc clean; vitest 1418 pass (+richtext/builder/media/project/richText round-trip);
next build 14/14. Needs cloud v1.786.52+ (RichText fieldtype) deployed to accept a
RichText field live.
2026-07-03 16:22:02 -07:00
hanzo-dev a743e75ae4 feat(console): surface Hanzo Auto (workflow automation) as an Automation tile
hanzoai/auto (auto.hanzo.ai) — visual AI workflow automation over 400+ MCP tools
and agents (the n8n/Zapier surface) — was not in the console. Add it to the AI
category as an external launch tile: it's a standalone app with its own full UI
on shared Hanzo IAM, so the tile opens it already-signed-in (like the Lux/Zoo
chain apps). Scoped brands:['hanzo'] so the auto.hanzo.ai URL never leaks onto a
Lux/Zoo white-label console.

Tests: 90 registry/brand tests pass; tsc clean.
2026-07-03 16:13:42 -07:00
hanzo-dev 0b3b377198 feat(console): surface Hanzo Auto (workflow automation) as an Automation tile
hanzoai/auto (auto.hanzo.ai) — visual AI workflow automation over 400+ MCP tools
and agents (the n8n/Zapier surface) — was not in the console. Add it to the AI
category as an external launch tile: it's a standalone app with its own full UI
on shared Hanzo IAM, so the tile opens it already-signed-in (like the Lux/Zoo
chain apps). Scoped brands:['hanzo'] so the auto.hanzo.ai URL never leaks onto a
Lux/Zoo white-label console.

Tests: 90 registry/brand tests pass; tsc clean.
2026-07-03 16:13:42 -07:00
62901a6f3a fix(console): finish /v1 canonicalization — delete the last 3 prefixed URL builders (#81)
The CTO contract is "nothing before /v1/". PR #79 (482251e) canonicalized 7 clients
but 3 helpers still hand-rolled a service-prefixed `<origin>/<svc>/v1/` URL —
aiV1Url (/ai), cloudProxyV1Url (/cloud), commerceProxyV1Url (/commerce) — fanning
out to 6 data-product clients AND ~13 product modules. DELETING them (not just
redefining) makes a non-canonical path COMPILER-IMPOSSIBLE: there is now ONE url
builder for the whole /v1 surface (originV1Url), exactly like billing/visor/
provisioning post-#79. Every remaining caller builds the bare, prefix-free
`/v1/<resource>`; next.config rewrites each head to its UNCHANGED same-origin BFF
proxy (app/ai, app/cloud, app/commerce — service-token / user-bearer injection intact).

- delete aiV1Url/cloudProxyV1Url/commerceProxyV1Url + aiBase/cloudProxyBase/
  commerceProxyBase; repoint all callers to originV1Url (compiler-enforced, no caller left).
- aicatalog/embeddings: /ai/v1/{pricing,plans,models,embeddings} -> /v1/… ; add
  `pricing`+`plans` to AI_V1_HEADS (already in the /ai proxy ALLOWED set).
- functions/paas/framework + Builds/Environments/Pipelines/Releases modules:
  /cloud/v1/<h> -> /v1/<h>; new CLOUD_PRODUCT_V1_HEADS (functions/framework/
  environments/pipelines/builds/releases) rewrites -> /cloud (already in proxy-allow
  CLOUD_HEADS). apm(o11y) + paas(platform) heads were already rewritten.
- commerce: /commerce/v1/<x> -> the canonical namespace /v1/commerce/<x>, ONE rewrite
  -> /commerce/v1/ (the billing twin) — collision-proof vs the generic store heads
  (product/order/user/store). Local cUrl() namespaces once, in one place.
- extend canonical-paths.test.ts: aicatalog/apm/commerce/embeddings/functions/paas each
  assert /v1/<resource> + never /<svc>/v1/. Realign functions.test + client-retry
  illustrative URLs to the canonical form.

grep -rE '/(cloud|vm|ai|billing|org|commerce)/v1' src/lib/api/*.ts is clean.
tsc --noEmit ok; vitest 1439 pass; next build ok. Every console API call is now /v1/<resource>.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:10:32 -07:00
e364918e34 fix(console): finish /v1 canonicalization — delete the last 3 prefixed URL builders (#81)
The CTO contract is "nothing before /v1/". PR #79 (4507cf5) canonicalized 7 clients
but 3 helpers still hand-rolled a service-prefixed `<origin>/<svc>/v1/` URL —
aiV1Url (/ai), cloudProxyV1Url (/cloud), commerceProxyV1Url (/commerce) — fanning
out to 6 data-product clients AND ~13 product modules. DELETING them (not just
redefining) makes a non-canonical path COMPILER-IMPOSSIBLE: there is now ONE url
builder for the whole /v1 surface (originV1Url), exactly like billing/visor/
provisioning post-#79. Every remaining caller builds the bare, prefix-free
`/v1/<resource>`; next.config rewrites each head to its UNCHANGED same-origin BFF
proxy (app/ai, app/cloud, app/commerce — service-token / user-bearer injection intact).

- delete aiV1Url/cloudProxyV1Url/commerceProxyV1Url + aiBase/cloudProxyBase/
  commerceProxyBase; repoint all callers to originV1Url (compiler-enforced, no caller left).
- aicatalog/embeddings: /ai/v1/{pricing,plans,models,embeddings} -> /v1/… ; add
  `pricing`+`plans` to AI_V1_HEADS (already in the /ai proxy ALLOWED set).
- functions/paas/framework + Builds/Environments/Pipelines/Releases modules:
  /cloud/v1/<h> -> /v1/<h>; new CLOUD_PRODUCT_V1_HEADS (functions/framework/
  environments/pipelines/builds/releases) rewrites -> /cloud (already in proxy-allow
  CLOUD_HEADS). apm(o11y) + paas(platform) heads were already rewritten.
- commerce: /commerce/v1/<x> -> the canonical namespace /v1/commerce/<x>, ONE rewrite
  -> /commerce/v1/ (the billing twin) — collision-proof vs the generic store heads
  (product/order/user/store). Local cUrl() namespaces once, in one place.
- extend canonical-paths.test.ts: aicatalog/apm/commerce/embeddings/functions/paas each
  assert /v1/<resource> + never /<svc>/v1/. Realign functions.test + client-retry
  illustrative URLs to the canonical form.

grep -rE '/(cloud|vm|ai|billing|org|commerce)/v1' src/lib/api/*.ts is clean.
tsc --noEmit ok; vitest 1439 pass; next build ok. Every console API call is now /v1/<resource>.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 16:10:32 -07:00
f1e97b20f9 debrand: langfuse -> o11y/observability in our prose & comments (#80)
Drop the Langfuse brand from our own strings (code comments, docs,
config labels), mirroring the signoz->o11y product rename. Meaning preserved;
comments/docs/labels only, no functional change.

Intentionally KEPT (references to the external Langfuse product / upstream
dependency / integration contract, not our brand):
- LiteLLM success_callback/failure_callback ["langfuse"] + LANGFUSE_* env
  var names (the litellm langfuse-callback contract; renaming breaks emission)
- infra/k8s/langfuse/* (deploys upstream langfuse/langfuse:3 OSS image)
- o11y/langfuse-otlp-fanout.yaml + console-langfuse-keys (trace-fanout lane)
- console NOTICE (MIT attribution to Langfuse GmbH for clean-room UX)

Trace pipeline (ai emit -> collector -> backend -> console Observe) unchanged.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:06:18 -07:00
012eaa7092 debrand: langfuse -> o11y/observability in our prose & comments (#80)
Drop the Langfuse brand from our own strings (code comments, docs,
config labels), mirroring the signoz->o11y product rename. Meaning preserved;
comments/docs/labels only, no functional change.

Intentionally KEPT (references to the external Langfuse product / upstream
dependency / integration contract, not our brand):
- LiteLLM success_callback/failure_callback ["langfuse"] + LANGFUSE_* env
  var names (the litellm langfuse-callback contract; renaming breaks emission)
- infra/k8s/langfuse/* (deploys upstream langfuse/langfuse:3 OSS image)
- o11y/langfuse-otlp-fanout.yaml + console-langfuse-keys (trace-fanout lane)
- console NOTICE (MIT attribution to Langfuse GmbH for clean-room UX)

Trace pipeline (ai emit -> collector -> backend -> console Observe) unchanged.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 16:06:18 -07:00
84b53753a6 fix(playground): image/video gen calls the /ai bearer proxy directly (v8.4.66)
On console.hanzo.ai the ingress routes /v1/* straight to cloud-api, bypassing the
keyless /ai bearer proxy — so a /v1 image call reaches cloud with NO user Bearer
and 401s (premium image gen requires auth). Call /ai/v1/images|videos/generations
directly (this app → forwardWithUserBearer mints the user-bound bearer) so the
Playground Image/Video tabs generate real, per-user-metered media.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:56:30 -07:00
56069d7359 fix(playground): image/video gen calls the /ai bearer proxy directly (v8.4.66)
On console.hanzo.ai the ingress routes /v1/* straight to cloud-api, bypassing the
keyless /ai bearer proxy — so a /v1 image call reaches cloud with NO user Bearer
and 401s (premium image gen requires auth). Call /ai/v1/images|videos/generations
directly (this app → forwardWithUserBearer mints the user-bound bearer) so the
Playground Image/Video tabs generate real, per-user-metered media.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:56:30 -07:00
hanzo-dev 8e2718f98d Merge native ERP + Help Center over the generic DocType renderer — kill the last iframe (console v8.4.66)
feat/erp-help-native (e2baba7): ERP + Help Center now render through the SAME
generic DocType renderer (src/components/doctype/*) that draws CMS, over the ONE
framework surface /v1/framework/*. Deletes the EmbeddedApp iframe subtree
entirely: EmbeddedApp.tsx, ProvisionPanel.tsx, embed-hosts, embed-probe,
api/embed, api/cms, api/erp, and the app/{cms,erp}/[...path] proxy routes
(-1872 lines). ERP + Help + CMS are ALL native now — zero iframe in the console.

Resolved package.json version to 8.4.66 (above live v8.4.65).
2026-07-03 15:54:44 -07:00
hanzo-dev 1c95406791 Merge native ERP + Help Center over the generic DocType renderer — kill the last iframe (console v8.4.66)
feat/erp-help-native (72ac722): ERP + Help Center now render through the SAME
generic DocType renderer (src/components/doctype/*) that draws CMS, over the ONE
framework surface /v1/framework/*. Deletes the EmbeddedApp iframe subtree
entirely: EmbeddedApp.tsx, ProvisionPanel.tsx, embed-hosts, embed-probe,
api/embed, api/cms, api/erp, and the app/{cms,erp}/[...path] proxy routes
(-1872 lines). ERP + Help + CMS are ALL native now — zero iframe in the console.

Resolved package.json version to 8.4.66 (above live v8.4.65).
2026-07-03 15:54:44 -07:00
482251e389 fix(console): route all data-product clients through the canonical /v1/* client (#79)
Decomplect: make a non-canonical API path architecturally impossible for the
data-product surface. The 7 clients that hand-rolled a service-prefixed
/<svc>/v1/… path (billing, aimetrics, compute, visor, platform, provisioning,
storage) plus the Settlement component now build a bare /v1/<resource> via the
one originV1Url helper; next.config rewrites each head to its hardened
same-origin BFF proxy (service-token / user-bearer injection unchanged). Also
stamp X-Actor-Id (the signed-in user) in baseHeaders alongside
X-Org-Id/X-Project-Id, so org+project+user pass on EVERY call.

- billing/aimetrics: /billing/v1/<x> -> /v1/billing/<x>  (rewrite -> app/billing/v1)
- compute:  /cloud/v1/gpus[/alerts|/pools] -> /v1/gpus…  (rewrite -> /cloud)
- visor:    /cloud/v1/machines… -> /v1/machines…; /vm/v1/{regions,sizes} ->
            /v1/{regions,sizes}; /vm/v1/gpus (catalog) -> /v1/gpu-sizes
            (DISTINCT head: /v1/gpus is the cloud-api INVENTORY, not the catalog)
- platform: /cloud/v1/{clusters…,org/…/cluster} -> /v1/…  (rewrite -> /cloud)
- provisioning/storage: /cloud/v1/{sql,vector,…,s3/…} -> /v1/…  (rewrite -> /cloud)
- delete the per-client base-path builders (billingUrl / vm / clustersUrl-via-cloud);
  grep -rE '/(cloud|vm|ai|billing|org)/v1' src/lib/api/*.ts is clean (only the
  client.ts BFF-helper docs for the out-of-scope clients remain).
- X-Actor-Id sourced from a new lib/actor-scope (SessionProvider keeps it in
  lockstep with the resolved account: the auth twin of org-scope).

tsc --noEmit ok; vitest 1432 pass; next build ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:47:19 -07:00
4507cf504c fix(console): route all data-product clients through the canonical /v1/* client (#79)
Decomplect: make a non-canonical API path architecturally impossible for the
data-product surface. The 7 clients that hand-rolled a service-prefixed
/<svc>/v1/… path (billing, aimetrics, compute, visor, platform, provisioning,
storage) plus the Settlement component now build a bare /v1/<resource> via the
one originV1Url helper; next.config rewrites each head to its hardened
same-origin BFF proxy (service-token / user-bearer injection unchanged). Also
stamp X-Actor-Id (the signed-in user) in baseHeaders alongside
X-Org-Id/X-Project-Id, so org+project+user pass on EVERY call.

- billing/aimetrics: /billing/v1/<x> -> /v1/billing/<x>  (rewrite -> app/billing/v1)
- compute:  /cloud/v1/gpus[/alerts|/pools] -> /v1/gpus…  (rewrite -> /cloud)
- visor:    /cloud/v1/machines… -> /v1/machines…; /vm/v1/{regions,sizes} ->
            /v1/{regions,sizes}; /vm/v1/gpus (catalog) -> /v1/gpu-sizes
            (DISTINCT head: /v1/gpus is the cloud-api INVENTORY, not the catalog)
- platform: /cloud/v1/{clusters…,org/…/cluster} -> /v1/…  (rewrite -> /cloud)
- provisioning/storage: /cloud/v1/{sql,vector,…,s3/…} -> /v1/…  (rewrite -> /cloud)
- delete the per-client base-path builders (billingUrl / vm / clustersUrl-via-cloud);
  grep -rE '/(cloud|vm|ai|billing|org)/v1' src/lib/api/*.ts is clean (only the
  client.ts BFF-helper docs for the out-of-scope clients remain).
- X-Actor-Id sourced from a new lib/actor-scope (SessionProvider keeps it in
  lockstep with the resolved account: the auth twin of org-scope).

tsc --noEmit ok; vitest 1432 pass; next build ok.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:47:19 -07:00
79d495efe8 fix(console): honest machine-load errors, chat Enter-to-send, collapse chat reasoning, logs default tab (#78)
Frontend CX defects found in a live deep-test of console.hanzo.ai:

- Compute/Machines: a non-200 from /cloud/v1/machines (esp. 403/5xx) rendered
  the empty "Launch your first machine" state — a permission/load error
  masquerading as "you have none", the opposite of the page's "nothing is
  fabricated" promise. interpretVisorError now maps 401 -> sign-in, 403 ->
  honest permission state, and any other non-200 -> a retryable load error;
  CustomerMachines shows the empty/launch state ONLY on a real 200-with-zero.

- Chat: Enter did nothing but insert a newline. @hanzogui/input swallows the
  onKeyPress prop (never wired to the DOM) and forwards onKeyDown; the newline
  default fires on keydown, so the send handler must live there. Enter sends,
  Shift+Enter is a newline, IME composition never sends.

- Chat: model chain-of-thought leaked into the answer bubble. New pure
  splitThinking() separates a final answer from <think> reasoning (streaming-
  safe); the bubble renders only the answer with reasoning behind an optional,
  collapsed disclosure.

- Observe/Logs: landed on the empty "Application logs" tab while "Request
  activity" (always real for the org) had data. Request activity now leads and
  is the default tab.

Team members (#4) already routes through the single canonical /org/iam
get-users path at HEAD — no dead-endpoint waterfall remains to remove.

Build gate: tsc --noEmit, vitest (1418 tests), next build — all green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:09:14 -07:00
98f24997e7 fix(console): honest machine-load errors, chat Enter-to-send, collapse chat reasoning, logs default tab (#78)
Frontend CX defects found in a live deep-test of console.hanzo.ai:

- Compute/Machines: a non-200 from /cloud/v1/machines (esp. 403/5xx) rendered
  the empty "Launch your first machine" state — a permission/load error
  masquerading as "you have none", the opposite of the page's "nothing is
  fabricated" promise. interpretVisorError now maps 401 -> sign-in, 403 ->
  honest permission state, and any other non-200 -> a retryable load error;
  CustomerMachines shows the empty/launch state ONLY on a real 200-with-zero.

- Chat: Enter did nothing but insert a newline. @hanzogui/input swallows the
  onKeyPress prop (never wired to the DOM) and forwards onKeyDown; the newline
  default fires on keydown, so the send handler must live there. Enter sends,
  Shift+Enter is a newline, IME composition never sends.

- Chat: model chain-of-thought leaked into the answer bubble. New pure
  splitThinking() separates a final answer from <think> reasoning (streaming-
  safe); the bubble renders only the answer with reasoning behind an optional,
  collapsed disclosure.

- Observe/Logs: landed on the empty "Application logs" tab while "Request
  activity" (always real for the org) had data. Request activity now leads and
  is the default tab.

Team members (#4) already routes through the single canonical /org/iam
get-users path at HEAD — no dead-endpoint waterfall remains to remove.

Build gate: tsc --noEmit, vitest (1418 tests), next build — all green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 15:09:14 -07:00
z 3595ad2644 chore(console): v8.4.65 — release image + video playground tabs 2026-07-03 14:55:46 -07:00
z 680a90e03a chore(console): v8.4.65 — release image + video playground tabs 2026-07-03 14:55:46 -07:00
hanzo-dev e2baba7c8e feat(erp+help): native ERP + Help Center over the generic DocType renderer; kill the last 2 iframes (8.4.64)
ERP and Help Center join CMS as NATIVE lanes on the Hanzo Framework — thin
hosts scoping the SAME generic renderer (components/doctype/*) to module=erp /
module=help, with ZERO per-doctype UI code (the DRY proof). This finishes the
Great Unification: CMS + CRM + ERP + Help all native DocTypes, all iframes dead.

- ErpModule/HelpModule rewritten as thin hosts (like CmsModule): collections
  browser + records list + record detail, routed under /erp/collections and
  /helpdesk/collections. Install CTA installs the lane's DocTypes/hooks;
  submit/cancel + status flow come from the schema (no ERP/Help-specific UI).
- registry: erp + helpdesk -> native module routes (collections/:doctype +
  :name), repo hanzoai/cloud, native descriptions.
- CollectionsBrowser: additive optional setupDescription/setupBullets so the
  pre-install empty state reads correctly per lane. CMS default byte-identical
  -- no behavior/permission/proxy change (the RED-passed path is unchanged).
- Kill the iframe/embed subtree ENTIRELY (finishes the unification): the
  Frappe/Payload proxy route handlers app/erp + app/cms are Next catch-alls that
  SHADOWED the native /*/collections SPA routes (a route handler wins over the
  [...slug] page) -> deleting them unshadows native ERP AND native CMS (CMS was
  latently shadow-broken since 8.4.63). Removed the now-dead EmbeddedApp /
  ProvisionPanel / EmbedApi / CmsApi / ErpApi + embed-hosts + embed-probe + their
  tests. No iframe/embed path remains anywhere in the console.

typecheck clean; vitest 1360/1360 (109 files); next build green (the /cms +
/erp proxy routes are gone from the manifest, so /*/collections reach the SPA).
2026-07-03 14:39:58 -07:00
hanzo-dev 72ac7221bb feat(erp+help): native ERP + Help Center over the generic DocType renderer; kill the last 2 iframes (8.4.64)
ERP and Help Center join CMS as NATIVE lanes on the Hanzo Framework — thin
hosts scoping the SAME generic renderer (components/doctype/*) to module=erp /
module=help, with ZERO per-doctype UI code (the DRY proof). This finishes the
Great Unification: CMS + CRM + ERP + Help all native DocTypes, all iframes dead.

- ErpModule/HelpModule rewritten as thin hosts (like CmsModule): collections
  browser + records list + record detail, routed under /erp/collections and
  /helpdesk/collections. Install CTA installs the lane's DocTypes/hooks;
  submit/cancel + status flow come from the schema (no ERP/Help-specific UI).
- registry: erp + helpdesk -> native module routes (collections/:doctype +
  :name), repo hanzoai/cloud, native descriptions.
- CollectionsBrowser: additive optional setupDescription/setupBullets so the
  pre-install empty state reads correctly per lane. CMS default byte-identical
  -- no behavior/permission/proxy change (the RED-passed path is unchanged).
- Kill the iframe/embed subtree ENTIRELY (finishes the unification): the
  Frappe/Payload proxy route handlers app/erp + app/cms are Next catch-alls that
  SHADOWED the native /*/collections SPA routes (a route handler wins over the
  [...slug] page) -> deleting them unshadows native ERP AND native CMS (CMS was
  latently shadow-broken since 8.4.63). Removed the now-dead EmbeddedApp /
  ProvisionPanel / EmbedApi / CmsApi / ErpApi + embed-hosts + embed-probe + their
  tests. No iframe/embed path remains anywhere in the console.

typecheck clean; vitest 1360/1360 (109 files); next build green (the /cms +
/erp proxy routes are gone from the manifest, so /*/collections reach the SPA).
2026-07-03 14:39:58 -07:00
zandClaude Opus 4.8 af7ae5a085 feat(playground): image + video generation tabs (text→image/video via /v1)
Adds Image and Video tabs to the Playground, symmetric to Audio/Chat:
- ImagePlayground: Zen image model + prompt + size → POST /v1/images/generations
  → renders the real image (hosted url or inline b64).
- VideoPlayground: Zen video model + prompt → POST /v1/videos/generations
  → renders the real clip (base64 MP4 blob or url).
- PlaygroundApi.images/videos (src/lib/api/playground.ts) ride the SAME keyless
  /ai bearer proxy chat/audio use; invalidateBalance() after each (metered).
- Open images/videos in the /ai proxy allow-list (route.ts) and the
  next.config.mjs AI_V1_HEADS rewrite. No new auth, no billing bypass.

Zen-brand model ids only; pickers filter to the image/video families.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:37:23 -07:00
zandhanzo-dev ed4e4a22a8 feat(playground): image + video generation tabs (text→image/video via /v1)
Adds Image and Video tabs to the Playground, symmetric to Audio/Chat:
- ImagePlayground: Zen image model + prompt + size → POST /v1/images/generations
  → renders the real image (hosted url or inline b64).
- VideoPlayground: Zen video model + prompt → POST /v1/videos/generations
  → renders the real clip (base64 MP4 blob or url).
- PlaygroundApi.images/videos (src/lib/api/playground.ts) ride the SAME keyless
  /ai bearer proxy chat/audio use; invalidateBalance() after each (metered).
- Open images/videos in the /ai proxy allow-list (route.ts) and the
  next.config.mjs AI_V1_HEADS rewrite. No new auth, no billing bypass.

Zen-brand model ids only; pickers filter to the image/video families.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 14:37:23 -07:00
hanzo-dev 6dfa9c0598 fix(catalog): consistent brand marks — Zen always ensō, real third-party logos
The model catalog rendered logos from each model's raw provider string, so the
Zen family flip-flopped (models tagged 'hanzo' → block-H, others → ensō) and
every third-party family fell through to a gray initials chip ('QW'). Fixes:

- Rows + detail now render the model's FAMILY brand (not raw provider), so a
  family is internally consistent: Zen is ALWAYS the ensō; Qwen/OpenAI/etc. each
  show one mark. (Directive: Zen provider always uses Zen.)
- Extract pure brand resolution into ui/brand.ts (normalizeBrand + BRANDS
  registry) — unit tested, no GUI deps. Every curated family key resolves to a
  real brand-colored tile (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google + Anthropic/
  GLM/Kimi/MiniMax/Nvidia/xAI); only genuinely-unknown providers get neutral
  initials (honest, no fabricated trademark logos).
- Mobile: row hides the context column on phones + narrows numeric columns so
  rows never overflow (mobile-first, $md restores the desktop layout).

Tests: 27 pass (brand 6 + families 21). tsc --noEmit clean.
2026-07-03 14:19:27 -07:00
hanzo-dev f6bcb5b943 fix(catalog): consistent brand marks — Zen always ensō, real third-party logos
The model catalog rendered logos from each model's raw provider string, so the
Zen family flip-flopped (models tagged 'hanzo' → block-H, others → ensō) and
every third-party family fell through to a gray initials chip ('QW'). Fixes:

- Rows + detail now render the model's FAMILY brand (not raw provider), so a
  family is internally consistent: Zen is ALWAYS the ensō; Qwen/OpenAI/etc. each
  show one mark. (Directive: Zen provider always uses Zen.)
- Extract pure brand resolution into ui/brand.ts (normalizeBrand + BRANDS
  registry) — unit tested, no GUI deps. Every curated family key resolves to a
  real brand-colored tile (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google + Anthropic/
  GLM/Kimi/MiniMax/Nvidia/xAI); only genuinely-unknown providers get neutral
  initials (honest, no fabricated trademark logos).
- Mobile: row hides the context column on phones + narrows numeric columns so
  rows never overflow (mobile-first, $md restores the desktop layout).

Tests: 27 pass (brand 6 + families 21). tsc --noEmit clean.
2026-07-03 14:19:27 -07:00
hanzo-dev 2d7f5b1512 feat(embeddings): Ingest surface (text/GitHub/crawl) — no bespoke jobs, async→Tasks
Replaces the 'Jobs' tab/JobsView (a bespoke async-tracker) with an Ingest surface over
the ONE /v1/docs/ingest endpoint: three real sources (pasted text · GitHub repo · website)
+ a target collection. Text indexes inline; a repo or crawl returns a durable hanzoai/tasks
workflow id and the UI links to the ONE Tasks product to track it ('Track in Tasks →',
/tasks/<org>/<wid>) — there is no second async system. Lower panel = the store's REAL
indexed files (get-files), reframed honestly as 'Indexed files' not a job log. Tab + subpage
renamed jobs→ingest. EmbeddingsApi gains ingestGitHub/ingestCrawl; IngestStats gains
async/workflowId. tsc clean.
2026-07-03 14:10:23 -07:00
hanzo-dev e8494ae60e feat(embeddings): Ingest surface (text/GitHub/crawl) — no bespoke jobs, async→Tasks
Replaces the 'Jobs' tab/JobsView (a bespoke async-tracker) with an Ingest surface over
the ONE /v1/docs/ingest endpoint: three real sources (pasted text · GitHub repo · website)
+ a target collection. Text indexes inline; a repo or crawl returns a durable hanzoai/tasks
workflow id and the UI links to the ONE Tasks product to track it ('Track in Tasks →',
/tasks/<org>/<wid>) — there is no second async system. Lower panel = the store's REAL
indexed files (get-files), reframed honestly as 'Indexed files' not a job log. Tab + subpage
renamed jobs→ingest. EmbeddingsApi gains ingestGitHub/ingestCrawl; IngestStats gains
async/workflowId. tsc clean.
2026-07-03 14:10:23 -07:00
hanzo-dev bb88a5bdbc feat(cms): native CMS on the Hanzo Framework — kill the Payload iframe (8.4.63)
Replaces the cms.<brand> Payload iframe/Studio embed with a NATIVE, metadata-
driven surface over the LIVE /v1/framework/* DocType engine. Ships the DRY
foundation the ERP/CRM/Helpdesk lanes reuse: ONE generic framework client + ONE
generic DocType renderer (the 'one engine + one renderer renders every app' model).

- src/lib/framework/{types,client,fields}.ts — the ONE FrameworkApi client
  (doctypes/records/modules/roles over the /cloud bearer proxy, allow-listed as
  the new 'framework' head) + the pure mapper DocType metadata <-> @hanzo/data
  FieldDefinition/record for EVERY fieldtype (relation/select/currency/attach/
  check/datetime/…), relation label enrichment, slugify (URL-safe names),
  publish/media/collection helpers. 32 pure unit tests.
- src/components/doctype/* — the generic renderer over @hanzo/data's RecordsView/
  RecordDetail/RecordForm: CollectionsBrowser (module doctypes + first-run install
  + new-collection), DocTypeRecords (table, or the MediaGrid gallery for a media
  doctype; inline edit sends the FULL validated body), DocTypeDetail (view/edit/
  create/delete + publish/unpublish + submit/cancel). Zero per-doctype code.
- CmsModule.tsx is now a thin host scoping the generic renderer to module=cms.
- proxy-allow: the 'framework' head; registry: cms native routes, repo hanzoai/cloud.

Names are slug/hex only (slugify + isValidDoctypeName) so they are space-/%-free —
correct on the live engine AND through the console's own pathIsClean bearer proxy.
Per-org + honest-empty by construction: the engine enforces tenancy (principal.
Tenant) + per-DocType permissions server-side.

Cloud side (hanzoai/cloud): the CMS content model (Page/Post/Article/Media/
Navigation/Author, module 'cms') + the generic app-lane install
(POST /v1/framework/modules/cms/install).

Verify: tsc clean; vitest (framework 32); next build ✓ (14/14 pages).
2026-07-03 12:43:15 -07:00
hanzo-dev 7592018c87 feat(cms): native CMS on the Hanzo Framework — kill the Payload iframe (8.4.63)
Replaces the cms.<brand> Payload iframe/Studio embed with a NATIVE, metadata-
driven surface over the LIVE /v1/framework/* DocType engine. Ships the DRY
foundation the ERP/CRM/Helpdesk lanes reuse: ONE generic framework client + ONE
generic DocType renderer (the 'one engine + one renderer renders every app' model).

- src/lib/framework/{types,client,fields}.ts — the ONE FrameworkApi client
  (doctypes/records/modules/roles over the /cloud bearer proxy, allow-listed as
  the new 'framework' head) + the pure mapper DocType metadata <-> @hanzo/data
  FieldDefinition/record for EVERY fieldtype (relation/select/currency/attach/
  check/datetime/…), relation label enrichment, slugify (URL-safe names),
  publish/media/collection helpers. 32 pure unit tests.
- src/components/doctype/* — the generic renderer over @hanzo/data's RecordsView/
  RecordDetail/RecordForm: CollectionsBrowser (module doctypes + first-run install
  + new-collection), DocTypeRecords (table, or the MediaGrid gallery for a media
  doctype; inline edit sends the FULL validated body), DocTypeDetail (view/edit/
  create/delete + publish/unpublish + submit/cancel). Zero per-doctype code.
- CmsModule.tsx is now a thin host scoping the generic renderer to module=cms.
- proxy-allow: the 'framework' head; registry: cms native routes, repo hanzoai/cloud.

Names are slug/hex only (slugify + isValidDoctypeName) so they are space-/%-free —
correct on the live engine AND through the console's own pathIsClean bearer proxy.
Per-org + honest-empty by construction: the engine enforces tenancy (principal.
Tenant) + per-DocType permissions server-side.

Cloud side (hanzoai/cloud): the CMS content model (Page/Post/Article/Media/
Navigation/Author, module 'cms') + the generic app-lane install
(POST /v1/framework/modules/cms/install).

Verify: tsc clean; vitest (framework 32); next build ✓ (14/14 pages).
2026-07-03 12:43:15 -07:00
d00eef1853 feat(observe): wire Logs + trace-search to the live o11y (SigNoz) runtime (8.4.62) (#76)
o11y's last two query signals — application LOGS and trace search — are the
composite POST /api/v3/query_range (GET /api/v1/logs is a stub). Added to the
existing ApmApi (DRY, one o11y client, same /cloud/v1/o11y/* convention as
ServiceMap + Alerts): logs()/traceSearch() + pure builders/parsers
(listQueryPayload, parseListRows, toIso, normalizeLogRow/Logs, normalizeTraceSpan/
Spans). LogsModule is now two real lenses — Application logs (live o11y logs,
range + severity/service filters, honest RuntimeNotice/empty states) and the
prior Request activity ledger lens (kept, always-real fallback). Traces/
Observations stay on /v1/evals (LLM domain), Metrics on VictoriaMetrics — no
regression. tsc 0 errors; vitest 1373/1373 (+13 apm); next build ✓.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 12:20:13 -07:00
d0c535a3dd feat(observe): wire Logs + trace-search to the live o11y (SigNoz) runtime (8.4.62) (#76)
o11y's last two query signals — application LOGS and trace search — are the
composite POST /api/v3/query_range (GET /api/v1/logs is a stub). Added to the
existing ApmApi (DRY, one o11y client, same /cloud/v1/o11y/* convention as
ServiceMap + Alerts): logs()/traceSearch() + pure builders/parsers
(listQueryPayload, parseListRows, toIso, normalizeLogRow/Logs, normalizeTraceSpan/
Spans). LogsModule is now two real lenses — Application logs (live o11y logs,
range + severity/service filters, honest RuntimeNotice/empty states) and the
prior Request activity ledger lens (kept, always-real fallback). Traces/
Observations stay on /v1/evals (LLM domain), Metrics on VictoriaMetrics — no
regression. tsc 0 errors; vitest 1373/1373 (+13 apm); next build ✓.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 12:20:13 -07:00
hanzo-dev 90c7501c8e chore(console): release 8.4.61 — fun adjective-animal name auto-fill on machine launch
The LaunchDrawer pre-fills a fun `adjective-animal` name (dark-llama, cosmic-axolotl,
turbo-wombat) so launch is click-and-go; a 🎲 button re-rolls it, it re-rolls after each
launch, and it stays fully editable. Pure curated word lists (src/lib/naming.ts), no deps.

tsc clean; vitest 1355/1355; next build ✓. Completes #43.
2026-07-03 11:21:28 -07:00
hanzo-dev 1f1e547cde chore(console): release 8.4.61 — fun adjective-animal name auto-fill on machine launch
The LaunchDrawer pre-fills a fun `adjective-animal` name (dark-llama, cosmic-axolotl,
turbo-wombat) so launch is click-and-go; a 🎲 button re-rolls it, it re-rolls after each
launch, and it stays fully editable. Pure curated word lists (src/lib/naming.ts), no deps.

tsc clean; vitest 1355/1355; next build ✓. Completes #43.
2026-07-03 11:21:28 -07:00
hanzo-dev 38a588f905 feat(machines): fun random name auto-fill on the launch form
Pre-fill the machine/GPU launch drawer's Name field with a Docker/Heroku-style
adjective-animal name (dark-llama, cosmic-axolotl, turbo-wombat) so a user can
click-and-go and rapid-launch. A 🎲 button re-rolls on demand, and the name
re-rolls after each successful launch so repeat-clicking Launch keeps getting a
fresh fun name. Still fully editable.

- src/lib/naming.ts — pure adjective-animal generator (curated lists, no deps);
  randomName({ suffix }) adds a short base36 token only when uniqueness is needed.
- LaunchDrawer: lazy useState(randomName) auto-fills on mount; post-launch
  re-roll (the drawer instance persists across the DetailPane close/reopen);
  the 🎲 re-roll button sits beside the field.

The launch POST already carries this name (VisorApi.launch → /v1/machines/launch);
end-to-end launch is gated on the in-flight /v1/machines gateway route.

tsc clean; vitest 1279/1279 (+3 naming); next build ✓.
2026-07-03 11:18:20 -07:00
hanzo-dev 7ce3451a12 feat(machines): fun random name auto-fill on the launch form
Pre-fill the machine/GPU launch drawer's Name field with a Docker/Heroku-style
adjective-animal name (dark-llama, cosmic-axolotl, turbo-wombat) so a user can
click-and-go and rapid-launch. A 🎲 button re-rolls on demand, and the name
re-rolls after each successful launch so repeat-clicking Launch keeps getting a
fresh fun name. Still fully editable.

- src/lib/naming.ts — pure adjective-animal generator (curated lists, no deps);
  randomName({ suffix }) adds a short base36 token only when uniqueness is needed.
- LaunchDrawer: lazy useState(randomName) auto-fills on mount; post-launch
  re-roll (the drawer instance persists across the DetailPane close/reopen);
  the 🎲 re-roll button sits beside the field.

The launch POST already carries this name (VisorApi.launch → /v1/machines/launch);
end-to-end launch is gated on the in-flight /v1/machines gateway route.

tsc clean; vitest 1279/1279 (+3 naming); next build ✓.
2026-07-03 11:18:20 -07:00
hanzo-dev 98fcb41e13 feat(admin): operator cockpit surfaces — Customers/Revenue/Analytics + Enablement (8.4.60)
admin.hanzo.ai fleet management (admin:true, global-admin gated via getAdminGate aggregate proxy):
- Customers (fleet-customers): live customer list + detail + AUDITED actions (grant credit, suspend/reactivate).
- Revenue (fleet-revenue): balances/spend/MRR/ARPU + per-customer table + spend trend.
- Analytics (retention): cohort retention HEATMAP + growth/churn/DAU-WAU-MAU/ARPU, honest-empty via computed[] (no fabricated curves).
- Enablement (enablement): global off/beta/ga tri-state board (#30/#31).
- Beta features (customer, non-admin): self-service opt-in (scoped to caller's own org).

Wiring: +customers/revenue/analytics/enablement to ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS; +enablement to
CLOUD_V1_HEADS + CLOUD_HEADS (user proxy); PUT on the admin aggregate route (enablement set). Client:
AdminCockpitApi (casibase) + EnablementApi (plain JSON). Reuses DataTable/Charts/MetricCard/States, @hanzo/gui v5.
Verify: tsc clean, vitest 1337/1337 (+3 wiring), next build ✓ (/admin/aggregate registered).
2026-07-03 10:30:04 -07:00
hanzo-dev df38facc19 feat(admin): operator cockpit surfaces — Customers/Revenue/Analytics + Enablement (8.4.60)
admin.hanzo.ai fleet management (admin:true, global-admin gated via getAdminGate aggregate proxy):
- Customers (fleet-customers): live customer list + detail + AUDITED actions (grant credit, suspend/reactivate).
- Revenue (fleet-revenue): balances/spend/MRR/ARPU + per-customer table + spend trend.
- Analytics (retention): cohort retention HEATMAP + growth/churn/DAU-WAU-MAU/ARPU, honest-empty via computed[] (no fabricated curves).
- Enablement (enablement): global off/beta/ga tri-state board (#30/#31).
- Beta features (customer, non-admin): self-service opt-in (scoped to caller's own org).

Wiring: +customers/revenue/analytics/enablement to ADMIN_V1_HEADS + ADMIN_AGGREGATE_HEADS; +enablement to
CLOUD_V1_HEADS + CLOUD_HEADS (user proxy); PUT on the admin aggregate route (enablement set). Client:
AdminCockpitApi (casibase) + EnablementApi (plain JSON). Reuses DataTable/Charts/MetricCard/States, @hanzo/gui v5.
Verify: tsc clean, vitest 1337/1337 (+3 wiring), next build ✓ (/admin/aggregate registered).
2026-07-03 10:30:04 -07:00
hanzo-devandGitHub 49eff594c1 feat(compute): user-facing PaaS over cloud /v1/platform (App Platform) (#75)
A minimal, honest console for the per-org Hanzo PaaS — cloud's native
/v1/platform control plane (hanzoai/cloud clients/platform). A signed-in org
member manages their OWN container apps: list + live status, deploy/stop/start,
source-tagged deployment logs (cloud#75), KMS-sealed env (secret values ALWAYS
masked), and verified custom domains (DNS challenge records + Verify).

DISTINCT from the admin `applications` fleet board (/v1/apps) and from
internal-admin platform.hanzo.ai. Org-scoped by the Bearer owner via the /cloud
bearer proxy (the raw session cookie never reaches cloud-api).

- lib/api/platform-apps.ts — typed plain-REST client for /v1/platform/* over
  originV1Url → /cloud proxy (`platform` already allow-listed in proxy-allow.ts;
  added to next.config CLOUD_V1_HEADS so /v1/platform/* rewrites to /cloud).
- components/products/PlatformAppsModule.tsx — list + SlideOver detail
  (overview/deploy, env masked, domains + verify, source-tagged logs). Honest
  states throughout: Loader, EmptyState (create-via-CLI), BackendStateCard for a
  /v1 failure — never fabricated rows.
- components/products/platform-apps/logic.ts (+ .test.ts, 9 tests) — pure view
  logic; maskedEnvRows ASSERTS a secret's plaintext never renders.
- registry: one new 'app-platform' Compute entry.

Verify: tsc --noEmit clean, vitest 1343/1343, next build ✓ compiled. Authed
visual e2e is post-deploy (console convention).
2026-07-03 09:43:13 -07:00
hanzo-devandGitHub 3979ba9c4a feat(compute): user-facing PaaS over cloud /v1/platform (App Platform) (#75)
A minimal, honest console for the per-org Hanzo PaaS — cloud's native
/v1/platform control plane (hanzoai/cloud clients/platform). A signed-in org
member manages their OWN container apps: list + live status, deploy/stop/start,
source-tagged deployment logs (cloud#75), KMS-sealed env (secret values ALWAYS
masked), and verified custom domains (DNS challenge records + Verify).

DISTINCT from the admin `applications` fleet board (/v1/apps) and from
internal-admin platform.hanzo.ai. Org-scoped by the Bearer owner via the /cloud
bearer proxy (the raw session cookie never reaches cloud-api).

- lib/api/platform-apps.ts — typed plain-REST client for /v1/platform/* over
  originV1Url → /cloud proxy (`platform` already allow-listed in proxy-allow.ts;
  added to next.config CLOUD_V1_HEADS so /v1/platform/* rewrites to /cloud).
- components/products/PlatformAppsModule.tsx — list + SlideOver detail
  (overview/deploy, env masked, domains + verify, source-tagged logs). Honest
  states throughout: Loader, EmptyState (create-via-CLI), BackendStateCard for a
  /v1 failure — never fabricated rows.
- components/products/platform-apps/logic.ts (+ .test.ts, 9 tests) — pure view
  logic; maskedEnvRows ASSERTS a secret's plaintext never renders.
- registry: one new 'app-platform' Compute entry.

Verify: tsc --noEmit clean, vitest 1343/1343, next build ✓ compiled. Authed
visual e2e is post-deploy (console convention).
2026-07-03 09:43:13 -07:00
zeekayandClaude Opus 4.8 eca3c36e9f chore(console): release 8.4.59 — uniform BFF CSRF gate + wallet/training authz hardening
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:32:49 -07:00
zeekayandhanzo-dev 9244c397f2 chore(console): release 8.4.59 — uniform BFF CSRF gate + wallet/training authz hardening
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 09:32:49 -07:00
zeekayandClaude Opus 4.8 098a236645 harden(console): uniform same-origin CSRF gate on every hand-rolled BFF route
The same-origin CSRF guard (`sameOriginOK`) was enforced ONLY inside the shared
`forwardWithUserBearer` (the user-bearer proxies: /cloud, /ai, /vm, /commerce,
/cms, /superbase, /tasksd, /admin/aggregate). Every HAND-ROLLED cookie-auth
mutating route lacked it — so a cross-site page carrying the victim's auto-sent
cookie could drive a state change: KMS secret create/rotate/delete (/admin/kms),
PaaS control-plane deploy/scale/delete (/paas), IAM user/org/project mutations
(/admin/iam, /org/iam), billing writes + wallet credit, key mint/revoke, org
onboard, waitlist join, login/logout. `hz_session` is SameSite=Lax, but the
fallback casibase cookie's SameSite is not controlled by the console — so this
defense-in-depth guard is required, not optional.

Decomplected into ONE guard, `csrfRefusal(req, shape)` (co-located with the pure
`sameOriginOK` in bearer-proxy.ts): null on a same-origin request or a safe
method, else a fail-closed 403 in the caller's error envelope. Reads only headers
(never the body), so it composes before any req.text()/json(). `forwardWithUserBearer`
now calls it too — one policy, one place, applied to the WHOLE BFF.

Applied at the top of: forwardIam (→ /admin/iam + /org/iam), /admin/kms, /paas,
/billing/v1, /billing/v1/topup/wallet, /training, /keys, /onboard, /waitlist,
/auth/{session,refresh,signup}.

Also hardened, same "never trust the client" principle:
- /training now server-resolves X-Org-Id via `orgFor` (pins a non-global admin to
  their own org) instead of forwarding the raw browser header — matches /paas +
  /admin/kms, so a brand admin can't drive another tenant's training jobs even if
  the backend trusted the forwarded header.
- /billing/v1/topup/wallet now requires a session (`resolveUser`) and credits the
  SERVER-RESOLVED billing subject, never the client-supplied `userId` (which let a
  caller credit an arbitrary account); stamps X-Org-Id for correct ledger
  namespacing. (Commerce must still dedupe on (network, txHash) — RED handoff.)

Tests: +6 csrfRefusal cases; full suite 1340 passing, tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:32:49 -07:00
zeekayandhanzo-dev 2c0d932810 harden(console): uniform same-origin CSRF gate on every hand-rolled BFF route
The same-origin CSRF guard (`sameOriginOK`) was enforced ONLY inside the shared
`forwardWithUserBearer` (the user-bearer proxies: /cloud, /ai, /vm, /commerce,
/cms, /superbase, /tasksd, /admin/aggregate). Every HAND-ROLLED cookie-auth
mutating route lacked it — so a cross-site page carrying the victim's auto-sent
cookie could drive a state change: KMS secret create/rotate/delete (/admin/kms),
PaaS control-plane deploy/scale/delete (/paas), IAM user/org/project mutations
(/admin/iam, /org/iam), billing writes + wallet credit, key mint/revoke, org
onboard, waitlist join, login/logout. `hz_session` is SameSite=Lax, but the
fallback casibase cookie's SameSite is not controlled by the console — so this
defense-in-depth guard is required, not optional.

Decomplected into ONE guard, `csrfRefusal(req, shape)` (co-located with the pure
`sameOriginOK` in bearer-proxy.ts): null on a same-origin request or a safe
method, else a fail-closed 403 in the caller's error envelope. Reads only headers
(never the body), so it composes before any req.text()/json(). `forwardWithUserBearer`
now calls it too — one policy, one place, applied to the WHOLE BFF.

Applied at the top of: forwardIam (→ /admin/iam + /org/iam), /admin/kms, /paas,
/billing/v1, /billing/v1/topup/wallet, /training, /keys, /onboard, /waitlist,
/auth/{session,refresh,signup}.

Also hardened, same "never trust the client" principle:
- /training now server-resolves X-Org-Id via `orgFor` (pins a non-global admin to
  their own org) instead of forwarding the raw browser header — matches /paas +
  /admin/kms, so a brand admin can't drive another tenant's training jobs even if
  the backend trusted the forwarded header.
- /billing/v1/topup/wallet now requires a session (`resolveUser`) and credits the
  SERVER-RESOLVED billing subject, never the client-supplied `userId` (which let a
  caller credit an arbitrary account); stamps X-Org-Id for correct ledger
  namespacing. (Commerce must still dedupe on (network, txHash) — RED handoff.)

Tests: +6 csrfRefusal cases; full suite 1340 passing, tsc --noEmit clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 09:32:49 -07:00
8c95f1ba33 feat(embed): terminate console server routes at the cloud one-binary (task #41) (#74)
Retire the remaining standalone Next server routes so the console is a pure
static SPA calling same-origin /v1/console/* (served by the go:embed one-binary,
routed through the gateway in the split deploy — one way, both topologies):

  - waitlist, embed-status, billing/v1/topup/wallet -> ported to cloud
    /v1/console/{waitlist,embed-status,topup/wallet} (real server work: Go
    handlers land in hanzoai/cloud).
  - keys, onboard -> repointed to the already-merged cloud /v1/console/{keys,
    onboard} (completes cloud#74's console side; they were still calling the old
    /keys,/onboard handlers, which broke under the static export).
  - docs -> a client redirect page (app/docs/page.tsx): a host->docsUrl map the
    browser already has (config.docsUrl); no server work, so no handler. Resolves
    the brand in an effect (no SSR/CSR hydration mismatch). Replaces the 308
    route the static export cannot run.

All calls go through the central client's v1Url() (config.cloudUrl, same-origin),
so the error envelope, cookie creds, and retry/refresh are unchanged. The ported
route.ts handlers are deleted (build:embed already stashed every route.ts; these
simply no longer exist).

Verified: tsc --noEmit clean; vitest 1334/1334 green; `npm run build:embed`
emits the full static out/ (real @hanzo/gui bundle, /docs prerendered).

NOTE: deploy the cloud image carrying the /v1/console/* handlers BEFORE this
console build (the SPA now depends on them). The remaining BFF proxies
(/cloud,/ai,/commerce,/billing catch-alls) are a separate, larger repoint for
full embed functionality and are out of this change's scope.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 09:30:34 -07:00
5a0619c9d4 feat(embed): terminate console server routes at the cloud one-binary (task #41) (#74)
Retire the remaining standalone Next server routes so the console is a pure
static SPA calling same-origin /v1/console/* (served by the go:embed one-binary,
routed through the gateway in the split deploy — one way, both topologies):

  - waitlist, embed-status, billing/v1/topup/wallet -> ported to cloud
    /v1/console/{waitlist,embed-status,topup/wallet} (real server work: Go
    handlers land in hanzoai/cloud).
  - keys, onboard -> repointed to the already-merged cloud /v1/console/{keys,
    onboard} (completes cloud#74's console side; they were still calling the old
    /keys,/onboard handlers, which broke under the static export).
  - docs -> a client redirect page (app/docs/page.tsx): a host->docsUrl map the
    browser already has (config.docsUrl); no server work, so no handler. Resolves
    the brand in an effect (no SSR/CSR hydration mismatch). Replaces the 308
    route the static export cannot run.

All calls go through the central client's v1Url() (config.cloudUrl, same-origin),
so the error envelope, cookie creds, and retry/refresh are unchanged. The ported
route.ts handlers are deleted (build:embed already stashed every route.ts; these
simply no longer exist).

Verified: tsc --noEmit clean; vitest 1334/1334 green; `npm run build:embed`
emits the full static out/ (real @hanzo/gui bundle, /docs prerendered).

NOTE: deploy the cloud image carrying the /v1/console/* handlers BEFORE this
console build (the SPA now depends on them). The remaining BFF proxies
(/cloud,/ai,/commerce,/billing catch-alls) are a separate, larger repoint for
full embed functionality and are out of this change's scope.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 09:30:34 -07:00
zeekayandClaude Opus 4.8 14401da5ca chore(console): release 8.4.58 — design unification (Basel Grotesk + Geist Mono typography)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:58:26 -07:00
zeekayandhanzo-dev e7e6ebf202 chore(console): release 8.4.58 — design unification (Basel Grotesk + Geist Mono typography)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 08:58:26 -07:00
Hanzo AI edd24e3f31 chore: release 8.4.57 — in-console Square card top-up 2026-07-03 04:16:51 -07:00
Hanzo AI 75846d3554 chore: release 8.4.57 — in-console Square card top-up 2026-07-03 04:16:51 -07:00
Hanzo AI 0b86e0dd67 feat(billing): in-console Square card top-up → canonical ledger
Replace the external-portal / HUSD-only dead-end with a real card top-up IN the
console. 'Add credits' (BillingOverview, HomeSummary), 'Top up' (SidebarWallet),
and the Billing → Credits tab now open /billing/credits: a Square Web Payments
card form that credits the org's canonical cloud-credit balance — the SAME ledger
the gateway debits for AI usage.

- BillingCredits.tsx: amount picker + Square card iframe (PCI SAQ-A — the PAN is
  entered into Square's iframe and tokenized in-browser; we only ever hold the
  single-use nonce). Pay → POST /billing/v1/topup/token via the same-origin proxy
  (service token + server-pinned subject) → commerce charges + credits the org.
  Button locks in-flight (no double-submit); nonce is single-use (no double-charge);
  honest states (config-unavailable, decline, success). Sandbox badge + test-card
  hint when the deployment is Square sandbox.
- lib/billing/square.ts: typed Web Payments SDK surface, fail-safe env→CDN map
  (non-'production' → sandbox tokenizer), pure amount validators, idempotent loader.
- BillingApi.paymentConfig() + topupWithCard() over the existing /billing/v1 proxy.
- HUSD crypto stays a secondary option (link to /wallet); no external billing.hanzo.ai.

Tests: square.test.ts (11) green; tsc strict + next build clean; full suite 1324 green.
2026-07-03 04:16:51 -07:00
Hanzo AI fc5fa495a2 feat(billing): in-console Square card top-up → canonical ledger
Replace the external-portal / HUSD-only dead-end with a real card top-up IN the
console. 'Add credits' (BillingOverview, HomeSummary), 'Top up' (SidebarWallet),
and the Billing → Credits tab now open /billing/credits: a Square Web Payments
card form that credits the org's canonical cloud-credit balance — the SAME ledger
the gateway debits for AI usage.

- BillingCredits.tsx: amount picker + Square card iframe (PCI SAQ-A — the PAN is
  entered into Square's iframe and tokenized in-browser; we only ever hold the
  single-use nonce). Pay → POST /billing/v1/topup/token via the same-origin proxy
  (service token + server-pinned subject) → commerce charges + credits the org.
  Button locks in-flight (no double-submit); nonce is single-use (no double-charge);
  honest states (config-unavailable, decline, success). Sandbox badge + test-card
  hint when the deployment is Square sandbox.
- lib/billing/square.ts: typed Web Payments SDK surface, fail-safe env→CDN map
  (non-'production' → sandbox tokenizer), pure amount validators, idempotent loader.
- BillingApi.paymentConfig() + topupWithCard() over the existing /billing/v1 proxy.
- HUSD crypto stays a secondary option (link to /wallet); no external billing.hanzo.ai.

Tests: square.test.ts (11) green; tsc strict + next build clean; full suite 1324 green.
2026-07-03 04:16:51 -07:00
hanzo-dev e4b4ce0605 release: v8.4.56 — go-live UX fixes (signup, API-key CTA, per-org metrics/logs) 2026-07-03 04:06:38 -07:00
hanzo-dev c4d979b24a release: v8.4.56 — go-live UX fixes (signup, API-key CTA, per-org metrics/logs) 2026-07-03 04:06:38 -07:00
hanzo-dev 3fe306224b console: go-live UX — email signup, Get API key CTA, per-org Metrics/Logs 2026-07-03 04:05:20 -07:00
hanzo-dev 6edbb4d641 console: go-live UX — email signup, Get API key CTA, per-org Metrics/Logs 2026-07-03 04:05:20 -07:00
zandGitHub 178e447f04 Merge pull request #73 from hanzoai/feat/design-landings-ship
feat(design): RailwayDeploy animation + ProductLanding kit + embeddings uplift (8.4.55)
2026-07-03 03:13:48 -07:00
zandGitHub 2cf2f74272 Merge pull request #73 from hanzoai/feat/design-landings-ship
feat(design): RailwayDeploy animation + ProductLanding kit + embeddings uplift (8.4.55)
2026-07-03 03:13:48 -07:00
hanzo-dev d1ae703521 chore: release 8.4.55 — RailwayDeploy animation + ProductLanding kit + embeddings uplift 2026-07-03 03:13:41 -07:00
hanzo-dev e3fc2e9f79 chore: release 8.4.55 — RailwayDeploy animation + ProductLanding kit + embeddings uplift 2026-07-03 03:13:41 -07:00
hanzo-dev 87341083d9 fix(landing): use maxW shorthand on the design-reference route
next build's strict type-check (onlyShorthandStyleProps) rejects the maxWidth
longhand on a Stack; tsc --noEmit did not surface it. Also relabel the route
honestly (it is a reachable design-reference, not 'not shipped').

(cherry picked from commit 9ce0625ce413e8c3e52eaadddd327c0d2f30a759)
2026-07-03 03:13:16 -07:00
hanzo-dev 306a2d6c58 fix(landing): use maxW shorthand on the design-reference route
next build's strict type-check (onlyShorthandStyleProps) rejects the maxWidth
longhand on a Stack; tsc --noEmit did not surface it. Also relabel the route
honestly (it is a reachable design-reference, not 'not shipped').

(cherry picked from commit 9ce0625ce413e8c3e52eaadddd327c0d2f30a759)
2026-07-03 03:13:16 -07:00
hanzo-dev 5b05304535 wip(product-landings): pick up prior agent RailwayDeploy + ProductLanding kit + embeddings uplift
Preserved from prior agent (uncommitted) before rebase onto main.

(cherry picked from commit c05ccacffceef218e54db8e6b2f67afedb9df81f)
2026-07-03 03:13:16 -07:00
hanzo-dev 526fe10f5d wip(product-landings): pick up prior agent RailwayDeploy + ProductLanding kit + embeddings uplift
Preserved from prior agent (uncommitted) before rebase onto main.

(cherry picked from commit c05ccacffceef218e54db8e6b2f67afedb9df81f)
2026-07-03 03:13:16 -07:00
zandGitHub 249edbf5e0 Merge pull request #72 from hanzoai/fix/console-e2e-bugs
fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
2026-07-03 02:34:27 -07:00
zandGitHub 57b8baa9d6 Merge pull request #72 from hanzoai/fix/console-e2e-bugs
fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
2026-07-03 02:34:27 -07:00
hanzo-dev 9b1d45ca7c fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
Five "advertised-but-broken" surfaces the live E2E suite flagged, fixed honestly
in the client (no fabrication):

- Vector module rendered nothing: normalizeResourceList validates + unwraps the
  provisioning list at the transport boundary (bare array, or a
  data/items/results/resources/collections/list/rows wrapper incl. one level of
  nesting e.g. Qdrant result.collections), honest [] fallback. A wrapped 200 body
  was reaching the list view's for..of and throwing behind the error boundary while
  SQL/KV (bare arrays) rendered. ONE place, every kind.
- /chat reply now STREAMS token-by-token via AiApi.ragChatStream (grounded RAG
  headers ride PlaygroundApi.streamChat). SSE parser canonical home moved to
  lib/api/stream.ts (one definition, re-exported from playground/stream.ts). The
  error card's Retry now re-runs the last user turn (was a no-op).
- Functions list self-freshens: useReloadOnFocus refetches on window focus /
  tab-visible so an API/CLI-deployed function appears without a reload; + Refresh.
- Sign-out redirects deterministically to /signin after DELETE /auth/session
  (AuthGate's reactive redirect could be pre-empted by an in-flight session
  re-hydrate, stranding the user on /).
- CRM summary rollup lag is BACKEND (materialized rollup eventual consistency);
  the console already refetches /v1/crm/summary after every create/delete —
  flagged, NOT faked.

tsc --noEmit clean · vitest 1290/1290 (3 new suites) · next build ok.
2026-07-03 02:32:42 -07:00
hanzo-dev 86888979d0 fix(console): live E2E product bugs — vector/chat/functions/sign-out (v8.4.54)
Five "advertised-but-broken" surfaces the live E2E suite flagged, fixed honestly
in the client (no fabrication):

- Vector module rendered nothing: normalizeResourceList validates + unwraps the
  provisioning list at the transport boundary (bare array, or a
  data/items/results/resources/collections/list/rows wrapper incl. one level of
  nesting e.g. Qdrant result.collections), honest [] fallback. A wrapped 200 body
  was reaching the list view's for..of and throwing behind the error boundary while
  SQL/KV (bare arrays) rendered. ONE place, every kind.
- /chat reply now STREAMS token-by-token via AiApi.ragChatStream (grounded RAG
  headers ride PlaygroundApi.streamChat). SSE parser canonical home moved to
  lib/api/stream.ts (one definition, re-exported from playground/stream.ts). The
  error card's Retry now re-runs the last user turn (was a no-op).
- Functions list self-freshens: useReloadOnFocus refetches on window focus /
  tab-visible so an API/CLI-deployed function appears without a reload; + Refresh.
- Sign-out redirects deterministically to /signin after DELETE /auth/session
  (AuthGate's reactive redirect could be pre-empted by an in-flight session
  re-hydrate, stranding the user on /).
- CRM summary rollup lag is BACKEND (materialized rollup eventual consistency);
  the console already refetches /v1/crm/summary after every create/delete —
  flagged, NOT faked.

tsc --noEmit clean · vitest 1290/1290 (3 new suites) · next build ok.
2026-07-03 02:32:42 -07:00
zeekayandClaude Opus 4.8 47310fd53a chore(console): v8.4.53 — canonical Hanzo typography + no-blank model rows
Integrates the Basel Grotesk (UI) + Geist Mono (code) typography pass with the
family model-browser blank-row fix. Strict superset of v8.4.52.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:58:34 -07:00
zeekayandhanzo-dev 48bbe54eb4 chore(console): v8.4.53 — canonical Hanzo typography + no-blank model rows
Integrates the Basel Grotesk (UI) + Geist Mono (code) typography pass with the
family model-browser blank-row fix. Strict superset of v8.4.52.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:58:34 -07:00
zeekayandClaude Opus 4.8 4c722b11bc fix(models): no blank rows — exclude meta-routers + id fallback label
The Zen family showed two nameless rows: the gateway's meta-routers (router:general,
…) bucket into Zen by provider but carry no display name, and modelDisplayName
returns '' when a record has no name. Fix both: isChatModel now excludes router:*
(a routing policy, not a pickable model — it lives in the Routing tab), and the row
label falls back to the raw id when there's no display name (displayLabel). 21 unit
tests (router exclusion + never-blank label).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:58:12 -07:00
zeekayandhanzo-dev 36218d4a28 fix(models): no blank rows — exclude meta-routers + id fallback label
The Zen family showed two nameless rows: the gateway's meta-routers (router:general,
…) bucket into Zen by provider but carry no display name, and modelDisplayName
returns '' when a record has no name. Fix both: isChatModel now excludes router:*
(a routing policy, not a pickable model — it lives in the Routing tab), and the row
label falls back to the raw id when there's no display name (displayLabel). 21 unit
tests (router exclusion + never-blank label).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:58:12 -07:00
zeekayandClaude Opus 4.8 ef8f531319 design: Basel Grotesk (UI) + Geist Mono (code) typography
Converge the console onto the canonical Hanzo typography without ripping the
Tamagui mechanism:
- Self-host Basel Grotesk (Book 400 + Medium 500) via @font-face in
  app/globals.css and override the @hanzo/gui (Tamagui) v5 body + heading font
  family to 'Basel' in gui.config.ts, so every Text/Paragraph/H* renders Basel
  (one place, whole product). Replaces the default system-font stack.
- Geist Mono for code/data via CDN import + a code/pre/kbd/samp rule.

Sidebar toggle (lucide PanelLeft) + true-black tokens already shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:49:19 -07:00
zeekayandhanzo-dev 502d490364 design: Basel Grotesk (UI) + Geist Mono (code) typography
Converge the console onto the canonical Hanzo typography without ripping the
Tamagui mechanism:
- Self-host Basel Grotesk (Book 400 + Medium 500) via @font-face in
  app/globals.css and override the @hanzo/gui (Tamagui) v5 body + heading font
  family to 'Basel' in gui.config.ts, so every Text/Paragraph/H* renders Basel
  (one place, whole product). Replaces the default system-font stack.
- Geist Mono for code/data via CDN import + a code/pre/kbd/samp rule.

Sidebar toggle (lucide PanelLeft) + true-black tokens already shipped.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:49:19 -07:00
zeekay 7962ca05e6 Merge remote-tracking branch 'origin/main' into fix/console-true-black
# Conflicts:
#	app/globals.css
#	package.json
#	src/components/products/ModelCatalogModule.tsx
2026-07-03 01:42:46 -07:00
zeekay d792d1d956 Merge remote-tracking branch 'origin/main' into fix/console-true-black
# Conflicts:
#	app/globals.css
#	package.json
#	src/components/products/ModelCatalogModule.tsx
2026-07-03 01:42:46 -07:00
zeekayandClaude Opus 4.8 ea47eb0246 chore(console): v8.4.52 — unified family model browser + Linear-caliber craft
Release: the Models module is now the unified, family-grouped model browser at
chat parity (Zen first + Qwen/Meta Llama/DeepSeek/Mistral/Google Gemma/OpenAI
GPT-OSS), true-black surface-depth ladder, skeleton loading, tabular numerals
across all metric cards + the model browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:38:43 -07:00
zeekayandhanzo-dev 7c92a0975a chore(console): v8.4.52 — unified family model browser + Linear-caliber craft
Release: the Models module is now the unified, family-grouped model browser at
chat parity (Zen first + Qwen/Meta Llama/DeepSeek/Mistral/Google Gemma/OpenAI
GPT-OSS), true-black surface-depth ladder, skeleton loading, tabular numerals
across all metric cards + the model browser.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:38:43 -07:00
zeekayandClaude Opus 4.8 fc96e45617 polish(console): Linear-caliber craft on the model browser + surface depth
CTO design bar (Linear/Vercel/Stripe): elevate the flagship model surface and the
whole shell's depth.

- Surface depth ladder over true-black: $color1 #050505 (resting panels), $color2
  #0a0a0a, $color3/$color4 #171717/#1f1f1f (interactive/elevated). Cards now read
  with real depth over the #000 canvas instead of flat black — applied globally, so
  every panel (agents, metrics, cards) gains the same layering. Text-contrast scale
  ($color10–12) untouched.
- Model browser: designed skeleton loading (shimmer family cards, no spinner),
  staggered fade-in entrance (40ms), tabular numerals on every numeric column
  (context / $-per-Mtok / counts / stats) so figures align, hairline stat dividers,
  tighter type scale (family $5/800, stat $7/800 -0.5 tracking, uppercase labels),
  crisp hover rows, and a proper icon empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:37:35 -07:00
zeekayandhanzo-dev 3b7ecf3d23 polish(console): Linear-caliber craft on the model browser + surface depth
CTO design bar (Linear/Vercel/Stripe): elevate the flagship model surface and the
whole shell's depth.

- Surface depth ladder over true-black: $color1 #050505 (resting panels), $color2
  #0a0a0a, $color3/$color4 #171717/#1f1f1f (interactive/elevated). Cards now read
  with real depth over the #000 canvas instead of flat black — applied globally, so
  every panel (agents, metrics, cards) gains the same layering. Text-contrast scale
  ($color10–12) untouched.
- Model browser: designed skeleton loading (shimmer family cards, no spinner),
  staggered fade-in entrance (40ms), tabular numerals on every numeric column
  (context / $-per-Mtok / counts / stats) so figures align, hairline stat dividers,
  tighter type scale (family $5/800, stat $7/800 -0.5 tracking, uppercase labels),
  crisp hover rows, and a proper icon empty state.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:37:35 -07:00
zeekayandClaude Opus 4.8 88d729ed01 style(console): pure #000 first paint (SSR bg + themeColor)
The <html> inline background and viewport themeColor were still #0a0a0a — a hair
off the true-black the .t_dark CSS override paints. Match them to #000000 so the
very first paint (before CSS) and the mobile browser chrome are pure black too,
consistent with hanzo.ai + hanzo.chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:34:09 -07:00
zeekayandhanzo-dev 827f88f796 style(console): pure #000 first paint (SSR bg + themeColor)
The <html> inline background and viewport themeColor were still #0a0a0a — a hair
off the true-black the .t_dark CSS override paints. Match them to #000000 so the
very first paint (before CSS) and the mobile browser chrome are pure black too,
consistent with hanzo.ai + hanzo.chat.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:34:09 -07:00
zeekayandClaude Opus 4.8 a2551bf302 feat(models): family-grouped model browser (chat parity)
Replace the flat 444-row catalog table with a family-grouped browser matching
hanzo.chat's picker exactly: collapsible sections per family — Zen first (with
zen5-mini flagged Default), then Qwen · Meta Llama · DeepSeek · Mistral · Google
Gemma · OpenAI GPT-OSS — each nesting its current-gen chat models with real
context, $/Mtok price, and live-vs-catalog availability. Click a model for the
full specs/pricing/features detail panel (unchanged). Search filters across every
family; a stats strip shows families / models / available-now. Reuses ProviderLogo
+ formatters; grouping is the pure, unit-tested groupByFamily. One console home for
model selection, the same families the user sees in chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:33:36 -07:00
zeekayandhanzo-dev 5465fc2241 feat(models): family-grouped model browser (chat parity)
Replace the flat 444-row catalog table with a family-grouped browser matching
hanzo.chat's picker exactly: collapsible sections per family — Zen first (with
zen5-mini flagged Default), then Qwen · Meta Llama · DeepSeek · Mistral · Google
Gemma · OpenAI GPT-OSS — each nesting its current-gen chat models with real
context, $/Mtok price, and live-vs-catalog availability. Click a model for the
full specs/pricing/features detail panel (unchanged). Search filters across every
family; a stats strip shows families / models / available-now. Reuses ProviderLogo
+ formatters; grouping is the pure, unit-tested groupByFamily. One console home for
model selection, the same families the user sees in chat.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:33:36 -07:00
zeekayandClaude Opus 4.8 ccbf455f53 feat(models): curated chat-family taxonomy + live-Zen catalog merge
The unified model browser groups the live catalog into hanzo.chat's exact 7
families — Zen (house brand) first, then Qwen · Meta Llama · DeepSeek · Mistral ·
Google Gemma · OpenAI GPT-OSS. Data-driven from /v1/pricing/models joined with
/v1/models: provider-defined families match the provider string; the named slices
(Gemma ⊂ Google, GPT-OSS ⊂ OpenAI) match an id slice, so provider-grouping's
Gemini/GPT-5 noise and the HuggingFace hub mirror stay out. Current-gen chat only
(drops zen4/qwen2 sunset gens + embedding/rerank/tts/asr/image/guard modalities +
:free dup aliases). Empty families are dropped — honest to what the gateway serves.

fetchCatalog now merges live-only models the older pricing bundle omits (the
current Zen set: zen5-flash/coder/nano-*), deduped by id and name, marked Available.

Pure + unit-tested (19 cases): chat-exact curation, distill disambiguation, slice
matching, sunset filtering, Zen-first ordering, zen5-mini default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:30:35 -07:00
zeekayandhanzo-dev 084d8eaf55 feat(models): curated chat-family taxonomy + live-Zen catalog merge
The unified model browser groups the live catalog into hanzo.chat's exact 7
families — Zen (house brand) first, then Qwen · Meta Llama · DeepSeek · Mistral ·
Google Gemma · OpenAI GPT-OSS. Data-driven from /v1/pricing/models joined with
/v1/models: provider-defined families match the provider string; the named slices
(Gemma ⊂ Google, GPT-OSS ⊂ OpenAI) match an id slice, so provider-grouping's
Gemini/GPT-5 noise and the HuggingFace hub mirror stay out. Current-gen chat only
(drops zen4/qwen2 sunset gens + embedding/rerank/tts/asr/image/guard modalities +
:free dup aliases). Empty families are dropped — honest to what the gateway serves.

fetchCatalog now merges live-only models the older pricing bundle omits (the
current Zen set: zen5-flash/coder/nano-*), deduped by id and name, marked Available.

Pure + unit-tested (19 cases): chat-exact curation, distill disambiguation, slice
matching, sunset filtering, Zen-first ordering, zen5-mini default.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:30:35 -07:00
zandGitHub e3b174dac8 Merge pull request #70 from hanzoai/fix/build-embed-static-export
fix(embed): neutralize root-layout headers() so build:embed static export succeeds
2026-07-03 01:20:18 -07:00
zandGitHub 7d78b5efb6 Merge pull request #70 from hanzoai/fix/build-embed-static-export
fix(embed): neutralize root-layout headers() so build:embed static export succeeds
2026-07-03 01:20:18 -07:00
hanzo-dev 32146f8cea fix(embed): neutralize root-layout headers() so build:embed static export succeeds
`output: 'export'` prerenders every page THROUGH app/layout.tsx, whose
generateMetadata reads the Host header (next/headers `headers()`) to brand the
SSR <title> per host. A static export has no request, so that request-time read
throws in the Server Components render for ALL pages — the export aborted on
/_not-found (and /signin), so `npm run build:embed` emitted no out/ and the
hanzoai/cloud one-binary silently shipped the fallback shell instead of the real
console.

build-embed.mjs now, for the export ONLY, drops the layout's next/headers import
and resolves the host to undefined (→ the build-time default brand; the embed is
same-origin and re-resolves the real brand client-side from window.location),
then restores the pristine layout in the finally. The normal `npm run build`
(server build, per-host SSR <title>) is untouched.

Verified: build:embed now emits out/ (index.html ~369 KB, /_next/static
assets); app/layout.tsx is restored to pristine after the run.
2026-07-03 01:17:03 -07:00
hanzo-dev 40af4bf744 fix(embed): neutralize root-layout headers() so build:embed static export succeeds
`output: 'export'` prerenders every page THROUGH app/layout.tsx, whose
generateMetadata reads the Host header (next/headers `headers()`) to brand the
SSR <title> per host. A static export has no request, so that request-time read
throws in the Server Components render for ALL pages — the export aborted on
/_not-found (and /signin), so `npm run build:embed` emitted no out/ and the
hanzoai/cloud one-binary silently shipped the fallback shell instead of the real
console.

build-embed.mjs now, for the export ONLY, drops the layout's next/headers import
and resolves the host to undefined (→ the build-time default brand; the embed is
same-origin and re-resolves the real brand client-side from window.location),
then restores the pristine layout in the finally. The normal `npm run build`
(server build, per-host SSR <title>) is untouched.

Verified: build:embed now emits out/ (index.html ~369 KB, /_next/static
assets); app/layout.tsx is restored to pristine after the run.
2026-07-03 01:17:03 -07:00
6b838994e6 feat(console): admin AI-Providers control board — enable/disable/set-primary over gated /v1/admin/providers (#67)
* feat(console): admin AI-provider control board — enable/disable + set-primary over gated /v1/admin/providers

Adds the platform-wide provider MANAGEMENT dashboard (admin.hanzo.ai) for the
shared-gateway upstream providers (do-ai, openrouter, fireworks, openai-direct,
zen): one row per provider with a working Enabled toggle, a Primary badge +
Make-primary action, model count, a key present/missing pill (NEVER the key),
and an honest DERIVED health verdict (enabled+keyPresent=Ready, enabled+no-key=
No key, disabled=Off — labeled derived, not a live probe). DISTINCT from the
customer 'providers' catalog entry (model catalog + BYOK per-org CRUD).

Server: 'providers' added to the global-admin-gated admin-aggregate heads
(ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS); the aggregate route now
exports POST (same getAdminGate fail-closed 403 + same-origin CSRF the shared
forwardWithUserBearer already enforces on any mutating method — no new trust
boundary). New originPost in client.ts pins the mutation to the console's OWN
origin so a split-origin NEXT_PUBLIC_CLOUD_URL can't route around the gate.

Client: typed ProviderAdminApi (list/toggle/setPrimary) + optional-safe
normalizers + a pure deriveHealth. keyPresent is strict-true (fail-closed — a
provider key is never modeled or surfaced).

Integration flag: disabling OpenRouter here also gates it out of the pricing
catalog (the DO-first ENABLE_OPENROUTER sync reads provider-enabled state).

Tests: provider-admin.test.ts (deriveHealth matrix, normalizer key-leak
safety, same-origin /v1/admin/providers URL shape for GET+POST, never the
cloud host) + admin-aggregate providers head. tsc --noEmit clean; vitest
1179/1179 (97 files); next build clean (17/17, /admin/aggregate + /[...slug]
registered). Authenticated visual e2e is post-deploy.

* fix(console): admin-aggregate proxy targets cloud /v1/admin/* (integration-path fix)

The console admin-aggregate proxy (app/admin/aggregate/[...path]/route.ts) rebuilt
the upstream path as `admin/<head>` and forwarded it verbatim to CLOUD_API_URL (an
origin, no /v1), so a browser call to /v1/admin/providers hit
cloud-api.hanzo.svc:8000/admin/providers. But cloud serves EVERY admin route under
/v1/admin/* (hanzoai/ai's `/v1/*` beego glob for /v1/admin/providers{,/toggle,/primary};
cloud's own clients/admin `app.Get("/v1/admin/{overview,finance,compute,...}")`). There
is no bare /admin/* route → the provider dashboard's list/toggle/primary all 404'd.
(The overview/finance/compute boards masked the same mis-path behind LivingOverview's
honest usage-ledger fallback; provider-admin has no fallback, so it was visibly broken.)

Fix (server-side upstream path only; the browser-facing clean /v1/admin/* is unchanged):
- route.ts: build `v1/admin/<head>` (was `admin/<head>`) so the verbatim forward lands
  on cloud's real /v1/admin/* route. The rewrite destination (/admin/aggregate/<head>)
  is the internal Next route and correctly carries no /v1/ — this handler adds it.
- admin-aggregate.ts allowAdminSurface: validate the exact forwarded shape
  `v1/admin/<allowed-head>` (segs[0]==='v1' && segs[1]==='admin' && ALLOWED.has(segs[2])),
  refusing v1/admin/iam, v1/admin/kms, bare v1/admin, the pre-fix bare admin/<head>, and
  every traversal. The two-layer pathIsClean + allow-list defense (raw AND WHATWG-normalized
  path) is intact on the new shape: v1/admin/providers/../iam is refused at layer 1 (literal
  ..) and its normalized form v1/admin/iam at layer 2 (iam not allowed).

Beneficial side-effect: overview/finance/compute/orgs/audit/products/usage now also target
/v1/admin/* correctly (all shared this one proxy). next.config.mjs is untouched — its rewrite
already fires for every ADMIN_V1_HEAD incl providers, GET and POST.

Also (RED LOW-1): ProviderAdminModule toggle no longer flips the row optimistically before
the server confirms — a slow 403 never briefly renders an unauthorized 'on'; the enabled
state changes ONLY on a 2xx (the switch is disabled via `busy` in flight).

Tests: admin-aggregate.test.ts rewritten to the v1/admin/<head> shape (+ refuses the
pre-fix bare admin/<head>); bearer-proxy.test.ts +7 end-to-end forward tests proving the
upstream URL is cloud/v1/admin/providers (not /admin/providers), GET+POST forward, and
traversal / iam / kms 404 without ever fetching. tsc --noEmit clean; vitest 1187/1187
(97 files); next build ✓ (/admin/aggregate/[...path] registered).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 01:07:44 -07:00
75d7df5073 feat(console): admin AI-Providers control board — enable/disable/set-primary over gated /v1/admin/providers (#67)
* feat(console): admin AI-provider control board — enable/disable + set-primary over gated /v1/admin/providers

Adds the platform-wide provider MANAGEMENT dashboard (admin.hanzo.ai) for the
shared-gateway upstream providers (do-ai, openrouter, fireworks, openai-direct,
zen): one row per provider with a working Enabled toggle, a Primary badge +
Make-primary action, model count, a key present/missing pill (NEVER the key),
and an honest DERIVED health verdict (enabled+keyPresent=Ready, enabled+no-key=
No key, disabled=Off — labeled derived, not a live probe). DISTINCT from the
customer 'providers' catalog entry (model catalog + BYOK per-org CRUD).

Server: 'providers' added to the global-admin-gated admin-aggregate heads
(ADMIN_AGGREGATE_HEADS + next.config ADMIN_V1_HEADS); the aggregate route now
exports POST (same getAdminGate fail-closed 403 + same-origin CSRF the shared
forwardWithUserBearer already enforces on any mutating method — no new trust
boundary). New originPost in client.ts pins the mutation to the console's OWN
origin so a split-origin NEXT_PUBLIC_CLOUD_URL can't route around the gate.

Client: typed ProviderAdminApi (list/toggle/setPrimary) + optional-safe
normalizers + a pure deriveHealth. keyPresent is strict-true (fail-closed — a
provider key is never modeled or surfaced).

Integration flag: disabling OpenRouter here also gates it out of the pricing
catalog (the DO-first ENABLE_OPENROUTER sync reads provider-enabled state).

Tests: provider-admin.test.ts (deriveHealth matrix, normalizer key-leak
safety, same-origin /v1/admin/providers URL shape for GET+POST, never the
cloud host) + admin-aggregate providers head. tsc --noEmit clean; vitest
1179/1179 (97 files); next build clean (17/17, /admin/aggregate + /[...slug]
registered). Authenticated visual e2e is post-deploy.

* fix(console): admin-aggregate proxy targets cloud /v1/admin/* (integration-path fix)

The console admin-aggregate proxy (app/admin/aggregate/[...path]/route.ts) rebuilt
the upstream path as `admin/<head>` and forwarded it verbatim to CLOUD_API_URL (an
origin, no /v1), so a browser call to /v1/admin/providers hit
cloud-api.hanzo.svc:8000/admin/providers. But cloud serves EVERY admin route under
/v1/admin/* (hanzoai/ai's `/v1/*` beego glob for /v1/admin/providers{,/toggle,/primary};
cloud's own clients/admin `app.Get("/v1/admin/{overview,finance,compute,...}")`). There
is no bare /admin/* route → the provider dashboard's list/toggle/primary all 404'd.
(The overview/finance/compute boards masked the same mis-path behind LivingOverview's
honest usage-ledger fallback; provider-admin has no fallback, so it was visibly broken.)

Fix (server-side upstream path only; the browser-facing clean /v1/admin/* is unchanged):
- route.ts: build `v1/admin/<head>` (was `admin/<head>`) so the verbatim forward lands
  on cloud's real /v1/admin/* route. The rewrite destination (/admin/aggregate/<head>)
  is the internal Next route and correctly carries no /v1/ — this handler adds it.
- admin-aggregate.ts allowAdminSurface: validate the exact forwarded shape
  `v1/admin/<allowed-head>` (segs[0]==='v1' && segs[1]==='admin' && ALLOWED.has(segs[2])),
  refusing v1/admin/iam, v1/admin/kms, bare v1/admin, the pre-fix bare admin/<head>, and
  every traversal. The two-layer pathIsClean + allow-list defense (raw AND WHATWG-normalized
  path) is intact on the new shape: v1/admin/providers/../iam is refused at layer 1 (literal
  ..) and its normalized form v1/admin/iam at layer 2 (iam not allowed).

Beneficial side-effect: overview/finance/compute/orgs/audit/products/usage now also target
/v1/admin/* correctly (all shared this one proxy). next.config.mjs is untouched — its rewrite
already fires for every ADMIN_V1_HEAD incl providers, GET and POST.

Also (RED LOW-1): ProviderAdminModule toggle no longer flips the row optimistically before
the server confirms — a slow 403 never briefly renders an unauthorized 'on'; the enabled
state changes ONLY on a 2xx (the switch is disabled via `busy` in flight).

Tests: admin-aggregate.test.ts rewritten to the v1/admin/<head> shape (+ refuses the
pre-fix bare admin/<head>); bearer-proxy.test.ts +7 end-to-end forward tests proving the
upstream URL is cloud/v1/admin/providers (not /admin/providers), GET+POST forward, and
traversal / iam / kms 404 without ever fetching. tsc --noEmit clean; vitest 1187/1187
(97 files); next build ✓ (/admin/aggregate/[...path] registered).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-03 01:07:44 -07:00
zeekayandClaude Opus 4.8 f7d8d118c0 chore(console): v8.4.51 — true-black theme release
Bump past the live v8.4.50 so the true-black change ships as a clean immutable
semver tag (SEMVER-only build; a branch build without a bump collides with an
existing tag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:58:33 -07:00
zeekayandhanzo-dev 529d171a72 chore(console): v8.4.51 — true-black theme release
Bump past the live v8.4.50 so the true-black change ships as a clean immutable
semver tag (SEMVER-only build; a branch build without a bump collides with an
existing tag).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 00:58:33 -07:00
hanzo-dev c8a4e2db16 Merge remote-tracking branch 'origin/main' into feat/obs-ui-modules 2026-07-03 00:57:48 -07:00
hanzo-dev afd6a3dfe4 Merge remote-tracking branch 'origin/main' into feat/obs-ui-modules 2026-07-03 00:57:48 -07:00
hanzo-dev 9f54156829 feat(console): APM Service Map — per-service RED metrics + dependency graph
New 'Service Map' product (Observe) over the real o11y/SigNoz APM controllers
via the same-origin /cloud user-bearer proxy: /v1/o11y/v1/services (RED per
service), /dependency_graph (call edges), /service/top_operations. Every KPI/
row/edge folds over what the runtime returned — honest empty/RuntimeNotice on
503/404/403/401, never fabricated APM data. Tap a service row → detail rail
(RED overview, top operations, up/downstream deps).

- lib/api/apm.ts: ApmApi client + normalizers (services/deps/ops/hosts/pods/
  nodes/exceptions/dashboards) with a stable ApmWindow.
- observability/apm-format.ts: pure RED/number/duration formatters.
- ServiceMapModule.tsx: the mounted module; wired into registry as 'service-map'.
- index.ts: barrel exports (apm NodeRow aliased ApmNodeRow — distinct from the
  blockchain nodes.ts NodeRow).

Verified: 35/35 tests pass, tsc --noEmit clean (0 errors).
2026-07-03 00:56:53 -07:00
hanzo-dev e577f660ee feat(console): APM Service Map — per-service RED metrics + dependency graph
New 'Service Map' product (Observe) over the real o11y/SigNoz APM controllers
via the same-origin /cloud user-bearer proxy: /v1/o11y/v1/services (RED per
service), /dependency_graph (call edges), /service/top_operations. Every KPI/
row/edge folds over what the runtime returned — honest empty/RuntimeNotice on
503/404/403/401, never fabricated APM data. Tap a service row → detail rail
(RED overview, top operations, up/downstream deps).

- lib/api/apm.ts: ApmApi client + normalizers (services/deps/ops/hosts/pods/
  nodes/exceptions/dashboards) with a stable ApmWindow.
- observability/apm-format.ts: pure RED/number/duration formatters.
- ServiceMapModule.tsx: the mounted module; wired into registry as 'service-map'.
- index.ts: barrel exports (apm NodeRow aliased ApmNodeRow — distinct from the
  blockchain nodes.ts NodeRow).

Verified: 35/35 tests pass, tsc --noEmit clean (0 errors).
2026-07-03 00:56:53 -07:00
zeekayandClaude Opus 4.8 3d93e81f73 style(console): true-black dark theme to match hanzo.ai + hanzo.chat
Override the @hanzo/gui (Tamagui) .t_dark base --background to pure #000 so the
console reads as ONE black brand with the marketing site (--background:#000)
and hanzo.chat's OLED .dark theme. Panels sit a hair above pure black (#050505
press / #171717 hover-elevated) for depth. defaultTheme was already dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:48:04 -07:00
zeekayandhanzo-dev a579376aa5 style(console): true-black dark theme to match hanzo.ai + hanzo.chat
Override the @hanzo/gui (Tamagui) .t_dark base --background to pure #000 so the
console reads as ONE black brand with the marketing site (--background:#000)
and hanzo.chat's OLED .dark theme. Panels sit a hair above pure black (#050505
press / #171717 hover-elevated) for depth. defaultTheme was already dark.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 00:48:04 -07:00
hanzo-devandGitHub e970fa4828 Merge pull request #68 from hanzoai/feat/console2-calm-shell
console: calm, spacious design language + responsive love (v8.4.50)
2026-07-03 00:00:51 -07:00
hanzo-devandGitHub 014a0931dc Merge pull request #68 from hanzoai/feat/console2-calm-shell
console: calm, spacious design language + responsive love (v8.4.50)
2026-07-03 00:00:51 -07:00
hanzo-dev c5ea2daa53 console: calm, spacious design language + responsive love (v8.4.50)
Refine the console toward a quiet, spacious, low-glare feel that's healthy to
work in for a full day (Linear-esque FEEL, our own @hanzo/gui tokens).

Calm tokens (app/globals.css, scoped html:root.t_dark/.t_light so they win over
the generated @hanzo/gui theme on specificity — DRY, whole app calms at once):
- softer cool-charcoal surfaces with gentle elevation STEPS so cards read as
  calm panels, not flat voids on pure black
- off-white primary text (no pure-#fff glare) + muted secondary steps
- low-contrast hairline borders; comfortable body line-height + antialiasing

Sidebar (DashboardShell):
- calm neutral section headers (Linear-style) instead of a saturated rainbow;
  per-product COLOR now lives only on the icons (restrained accent)
- softer active state ($color4, not the loud $color5 block)
- more breathing room: taller category headers, larger row + section gaps,
  responsive rail padding (collapsed vs expanded)

Big desktop: content is a centered, capped column (max 1680) with padding that
scales up at xl — wide screens read comfortably, not stretched full-bleed.

Responsive:
- PageHeader wraps (flex + minW) so long subtitles wrap on mobile instead of
  running off-screen; actions drop below the title when narrow (DRY, every page)
- Inference "Usage Overview" overlap fixed: MetricStat no longer flex-collapses
  (flex-basis:0 in an auto-height column made the label overlap the value)

typecheck 0 errors · 1167 tests · next build green.
2026-07-02 23:58:28 -07:00
hanzo-dev d047540abe console: calm, spacious design language + responsive love (v8.4.50)
Refine the console toward a quiet, spacious, low-glare feel that's healthy to
work in for a full day (Linear-esque FEEL, our own @hanzo/gui tokens).

Calm tokens (app/globals.css, scoped html:root.t_dark/.t_light so they win over
the generated @hanzo/gui theme on specificity — DRY, whole app calms at once):
- softer cool-charcoal surfaces with gentle elevation STEPS so cards read as
  calm panels, not flat voids on pure black
- off-white primary text (no pure-#fff glare) + muted secondary steps
- low-contrast hairline borders; comfortable body line-height + antialiasing

Sidebar (DashboardShell):
- calm neutral section headers (Linear-style) instead of a saturated rainbow;
  per-product COLOR now lives only on the icons (restrained accent)
- softer active state ($color4, not the loud $color5 block)
- more breathing room: taller category headers, larger row + section gaps,
  responsive rail padding (collapsed vs expanded)

Big desktop: content is a centered, capped column (max 1680) with padding that
scales up at xl — wide screens read comfortably, not stretched full-bleed.

Responsive:
- PageHeader wraps (flex + minW) so long subtitles wrap on mobile instead of
  running off-screen; actions drop below the title when narrow (DRY, every page)
- Inference "Usage Overview" overlap fixed: MetricStat no longer flex-collapses
  (flex-basis:0 in an auto-height column made the label overlap the value)

typecheck 0 errors · 1167 tests · next build green.
2026-07-02 23:58:28 -07:00
hanzo-dev b39a3ae95c feat(models): model detail 'Try in Playground' preselects the model (deep-link ?p=)
The Model Catalog rows already open a rich detail pane, but the detail's primary
action pushed a bare /playground and DROPPED the model the user was looking at — it
looked wired but lost the selection. Now it deep-links /playground?p=<share> carrying
that model, so the Playground opens PRESELECTED on it (the composer restores the model
from the ?p= share state it already reads on mount). Same fix applied to the
Marketplace 'Try' CTA — both reuse ONE pure helper.

- New pure playgroundPathForModel(modelId) in playground/share.ts — the ONE way to
  deep-link the Playground onto a model (URI-safe; empty prompt, default settings).
- ModelCatalogModule detail: 'Open in Playground' → prominent 'Try in Playground'
  (theme=light) via the helper; routing/copy reuse the derived modelId.
- MarketplaceModule.open(): available model → playgroundPathForModel(id) (was bare
  /playground); catalog-only → /models. DRY, one deep-link path.

tsc clean; +2 share tests (round-trip + slash/space id), marketplace 16/16.
2026-07-02 23:56:32 -07:00
hanzo-dev f13113eb8b feat(models): model detail 'Try in Playground' preselects the model (deep-link ?p=)
The Model Catalog rows already open a rich detail pane, but the detail's primary
action pushed a bare /playground and DROPPED the model the user was looking at — it
looked wired but lost the selection. Now it deep-links /playground?p=<share> carrying
that model, so the Playground opens PRESELECTED on it (the composer restores the model
from the ?p= share state it already reads on mount). Same fix applied to the
Marketplace 'Try' CTA — both reuse ONE pure helper.

- New pure playgroundPathForModel(modelId) in playground/share.ts — the ONE way to
  deep-link the Playground onto a model (URI-safe; empty prompt, default settings).
- ModelCatalogModule detail: 'Open in Playground' → prominent 'Try in Playground'
  (theme=light) via the helper; routing/copy reuse the derived modelId.
- MarketplaceModule.open(): available model → playgroundPathForModel(id) (was bare
  /playground); catalog-only → /models. DRY, one deep-link path.

tsc clean; +2 share tests (round-trip + slash/space id), marketplace 16/16.
2026-07-02 23:56:32 -07:00
hanzo-dev d94e62e53d feat(base): Twenty-grade records surface (@hanzo/data 1.2.0); CRM = Base views
Base records (RecordsModule) now render the @hanzo/data RecordsView — table <-> board,
filter/sort/group, inline cell edit, kanban drag — over REAL per-org Base data via the
/superbase proxy (new base-data/CollectionView; inline edits + board moves persist through
BaseDataApi.updateRecord, honest error banner). CollectionTable superseded (one way).
RecordDetailView gains a titled detail panel; DnsModule onRowPress -> onOpen (new DataTable API).

CRM = Base views: CrmModule renders companies / contacts / opportunities through the SAME
RecordsView — CRM entities expressed as @hanzo/data FieldDefinition schemas + pure record
mappers (crm/collections: money->currency, epoch-s->ms, companyId->named relation chip);
opportunities get a stage PIPELINE board; company relation options injected for filter.
Live create stays; delete preserved + upgraded to bulk-delete via row selection. Read-only
views over live data (no /v1/crm update endpoint yet) — honest by construction.

@hanzo/data 1.1.0 -> ^1.2.0. Rebased on latest main (8.4.49 -> 8.4.50).
tsc clean; vitest 1198 green (+ crm/collections); next build green.
2026-07-02 23:56:07 -07:00
hanzo-dev 0adb7ded43 feat(base): Twenty-grade records surface (@hanzo/data 1.2.0); CRM = Base views
Base records (RecordsModule) now render the @hanzo/data RecordsView — table <-> board,
filter/sort/group, inline cell edit, kanban drag — over REAL per-org Base data via the
/superbase proxy (new base-data/CollectionView; inline edits + board moves persist through
BaseDataApi.updateRecord, honest error banner). CollectionTable superseded (one way).
RecordDetailView gains a titled detail panel; DnsModule onRowPress -> onOpen (new DataTable API).

CRM = Base views: CrmModule renders companies / contacts / opportunities through the SAME
RecordsView — CRM entities expressed as @hanzo/data FieldDefinition schemas + pure record
mappers (crm/collections: money->currency, epoch-s->ms, companyId->named relation chip);
opportunities get a stage PIPELINE board; company relation options injected for filter.
Live create stays; delete preserved + upgraded to bulk-delete via row selection. Read-only
views over live data (no /v1/crm update endpoint yet) — honest by construction.

@hanzo/data 1.1.0 -> ^1.2.0. Rebased on latest main (8.4.49 -> 8.4.50).
tsc clean; vitest 1198 green (+ crm/collections); next build green.
2026-07-02 23:56:07 -07:00
hanzo-dev 21a72fdd0f feat(gpus): tap-to-launch customer GPU catalog — every accelerator row opens the launch drawer preselected
The customer GPU catalog rendered as non-clickable rate rows (Overview 'Popular
accelerators', the GPUs-tab 'GPU catalog', and the Pricing tab) — real live visor
data, but it read like a static brochure. Now every accelerator row is TAP-TO-ACT:
tapping one opens the shared LaunchDrawer (kind=gpu) preselected on that accelerator's
size slug (the drawer already supports initialSize), so the price you see is the price
you launch. Same pattern as machines PR#57's MachineCatalog → onLaunch.

- launch() now takes an optional initialSize (memoized); launchGpu(row) = launch(row.slug).
- onRowPress wired on all three catalog DataTables + the Pricing tab (new optional onLaunch prop).
- Header / empty-state / settings 'Launch' buttons fixed to () => launch() (no event-as-size).
- Honest copy: 'tap to launch' hints; no fabricated data (empty catalog still honest).

tsc clean; gpus vitest 10/10.
2026-07-02 23:52:42 -07:00
hanzo-dev 3bad348688 feat(gpus): tap-to-launch customer GPU catalog — every accelerator row opens the launch drawer preselected
The customer GPU catalog rendered as non-clickable rate rows (Overview 'Popular
accelerators', the GPUs-tab 'GPU catalog', and the Pricing tab) — real live visor
data, but it read like a static brochure. Now every accelerator row is TAP-TO-ACT:
tapping one opens the shared LaunchDrawer (kind=gpu) preselected on that accelerator's
size slug (the drawer already supports initialSize), so the price you see is the price
you launch. Same pattern as machines PR#57's MachineCatalog → onLaunch.

- launch() now takes an optional initialSize (memoized); launchGpu(row) = launch(row.slug).
- onRowPress wired on all three catalog DataTables + the Pricing tab (new optional onLaunch prop).
- Header / empty-state / settings 'Launch' buttons fixed to () => launch() (no event-as-size).
- Honest copy: 'tap to launch' hints; no fabricated data (empty catalog still honest).

tsc clean; gpus vitest 10/10.
2026-07-02 23:52:42 -07:00
bf8bd89f1e feat(console): Overlord admin god-view + Web Search/Crawl product panel (v8.4.49) (#66)
Two surfaces, both over the ONE /v1 surface with real data + honest states,
reusing the existing LivingOverview + design system (DRY, no new UI systems).

Surface 1 — admin.hanzo.ai "Overlord" overview (god-view of EVERYTHING):
- New living-overview config `overlord` + pure adapter `fromOverlord` composing
  THREE real sources: the operator inventory (PlatformApi.apps → the platform-wide
  PRODUCT HEALTH board + product/healthy/needs-attention counts + distinct-org
  count — the centerpiece), the all-orgs `/v1/admin/overview` aggregate
  (usage/spend/top-models/activity/alerts) when routed, and the real commerce
  usage ledger (all-orgs) as the honest fallback so the board is never blank.
- New `overlord` catalog entry (Observe, admin:true) rendered by the ONE
  LivingOverview. GLOBAL-ADMIN ONLY: hidden from every customer's nav/launcher/
  palette (visibleCatalog filters admin entries), the catch-all shows the managed
  notice for a non-admin, and `/v1/admin/overview` is server-gated by getAdminGate.
- Pure health-tally helpers (`healthTally`, `orgsFromApps`) — every product-count
  KPI is derived from the real inventory, never fabricated (empty → honest em-dash).

Surface 2 — Web Search + Crawl product panel (SearXNG + Crawl4AI, LIVE):
- New `WebSearchApi` (lib/api/websearch.ts) over cloud `/v1/websearch/*`; search
  wired same-origin prefix-free `/v1/websearch/search` → the hardened `/cloud`
  user-bearer proxy (added `websearch` to CLOUD_HEADS + CLOUD_V1_HEADS — minimal,
  additive; distinct arrays from the concurrent providers lane).
- New tabbed `SearchModule` (Overview · Try Search · API · Engines · Config) —
  a REAL live search box, honest live-probe health (no health endpoint exists),
  the two endpoints + copy-paste curl, the deployed engine set (read-only), and
  the honest deployed config. HONEST GAPS surfaced, not hidden: usage is not
  metered yet (no cloud_usage rows for websearch), and scrape is documented but
  NOT a live try-it (it needs the shared WEBSEARCH_API_KEY, not a user session —
  so the console can never drive a scrape; no secret is ever exposed).
- The `websearch` + `crawl` catalog entries render the one module (crawl upgraded
  from a native-overview stub — one product, cross-linked, no duplicate surface).
  Tab slugs are non-base (search/api/engines/config) so they never collide with
  the shared Settings/Status/Logs/Metrics per-product sub-pages.

Verification: tsc --noEmit clean; vitest 1191/1191 (98 files; +28: 7 Overlord
adapter, 8 websearch normalizers, 9 search logic, 1 websearch allow-list, +3
registry-consistency now covering the new configs); next build ✓ (all routes;
/overlord + /websearch/* + /crawl all resolve 200 on the dev server, catch-all
compiles clean). Live authenticated visual e2e (admin session) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 23:39:47 -07:00
024be40f68 feat(console): Overlord admin god-view + Web Search/Crawl product panel (v8.4.49) (#66)
Two surfaces, both over the ONE /v1 surface with real data + honest states,
reusing the existing LivingOverview + design system (DRY, no new UI systems).

Surface 1 — admin.hanzo.ai "Overlord" overview (god-view of EVERYTHING):
- New living-overview config `overlord` + pure adapter `fromOverlord` composing
  THREE real sources: the operator inventory (PlatformApi.apps → the platform-wide
  PRODUCT HEALTH board + product/healthy/needs-attention counts + distinct-org
  count — the centerpiece), the all-orgs `/v1/admin/overview` aggregate
  (usage/spend/top-models/activity/alerts) when routed, and the real commerce
  usage ledger (all-orgs) as the honest fallback so the board is never blank.
- New `overlord` catalog entry (Observe, admin:true) rendered by the ONE
  LivingOverview. GLOBAL-ADMIN ONLY: hidden from every customer's nav/launcher/
  palette (visibleCatalog filters admin entries), the catch-all shows the managed
  notice for a non-admin, and `/v1/admin/overview` is server-gated by getAdminGate.
- Pure health-tally helpers (`healthTally`, `orgsFromApps`) — every product-count
  KPI is derived from the real inventory, never fabricated (empty → honest em-dash).

Surface 2 — Web Search + Crawl product panel (SearXNG + Crawl4AI, LIVE):
- New `WebSearchApi` (lib/api/websearch.ts) over cloud `/v1/websearch/*`; search
  wired same-origin prefix-free `/v1/websearch/search` → the hardened `/cloud`
  user-bearer proxy (added `websearch` to CLOUD_HEADS + CLOUD_V1_HEADS — minimal,
  additive; distinct arrays from the concurrent providers lane).
- New tabbed `SearchModule` (Overview · Try Search · API · Engines · Config) —
  a REAL live search box, honest live-probe health (no health endpoint exists),
  the two endpoints + copy-paste curl, the deployed engine set (read-only), and
  the honest deployed config. HONEST GAPS surfaced, not hidden: usage is not
  metered yet (no cloud_usage rows for websearch), and scrape is documented but
  NOT a live try-it (it needs the shared WEBSEARCH_API_KEY, not a user session —
  so the console can never drive a scrape; no secret is ever exposed).
- The `websearch` + `crawl` catalog entries render the one module (crawl upgraded
  from a native-overview stub — one product, cross-linked, no duplicate surface).
  Tab slugs are non-base (search/api/engines/config) so they never collide with
  the shared Settings/Status/Logs/Metrics per-product sub-pages.

Verification: tsc --noEmit clean; vitest 1191/1191 (98 files; +28: 7 Overlord
adapter, 8 websearch normalizers, 9 search logic, 1 websearch allow-list, +3
registry-consistency now covering the new configs); next build ✓ (all routes;
/overlord + /websearch/* + /crawl all resolve 200 on the dev server, catch-all
compiles clean). Live authenticated visual e2e (admin session) is post-deploy.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 23:39:47 -07:00
zandGitHub 11b622d454 Merge pull request #65 from hanzoai/fix/broken-links
fix: repair broken links (deep-audit workflow)
2026-07-02 23:02:28 -07:00
zandGitHub 6e866fcdc8 Merge pull request #65 from hanzoai/fix/broken-links
fix: repair broken links (deep-audit workflow)
2026-07-02 23:02:28 -07:00
hanzo-dev 87c3df0de3 fix: correct 3 broken links (cost→billing route, embeddings docs path)
- SidebarWallet balance row: router.push('/cost') → '/billing' (no 'cost'
  module in the registry; billing is the canonical balance/spend surface).
- HomeSummary 'View cost' button: same /cost → /billing fix.
- Embeddings SettingsView docs button: docsUrl/embeddings → docsUrl/docs/embeddings
  (Fumadocs serves only under /docs/*; matches the docsUrl() /docs convention).

Also updated the stale SidebarWallet docstring that referenced the removed /cost.
2026-07-02 22:49:23 -07:00
hanzo-dev e0adc0ee82 fix: correct 3 broken links (cost→billing route, embeddings docs path)
- SidebarWallet balance row: router.push('/cost') → '/billing' (no 'cost'
  module in the registry; billing is the canonical balance/spend surface).
- HomeSummary 'View cost' button: same /cost → /billing fix.
- Embeddings SettingsView docs button: docsUrl/embeddings → docsUrl/docs/embeddings
  (Fumadocs serves only under /docs/*; matches the docsUrl() /docs convention).

Also updated the stale SidebarWallet docstring that referenced the removed /cost.
2026-07-02 22:49:23 -07:00
d7d142fc04 fix(console): per-host brand in SSR <title> (white-label) + release 8.4.48 (#64)
The document <title> is SSR metadata resolved from the build-time default host,
so console.lux.cloud / console.zoo.cloud tabs read 'Hanzo Cloud Console' — a
white-label violation (Hanzo name on a Lux/Zoo surface). Read the request Host
header in generateMetadata and resolve the brand per host, so the tab title is
'Lux Cloud Console' / 'Zoo Cloud Console'. The visible shell was already correct
(client resolves brand from window.location); only the SSR title leaked.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:29:01 -07:00
a89f941426 fix(console): per-host brand in SSR <title> (white-label) + release 8.4.48 (#64)
The document <title> is SSR metadata resolved from the build-time default host,
so console.lux.cloud / console.zoo.cloud tabs read 'Hanzo Cloud Console' — a
white-label violation (Hanzo name on a Lux/Zoo surface). Read the request Host
header in generateMetadata and resolve the brand per host, so the tab title is
'Lux Cloud Console' / 'Zoo Cloud Console'. The visible shell was already correct
(client resolves brand from window.location); only the SSR title leaked.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-02 22:29:01 -07:00
zandGitHub b7ecab0c2f Merge pull request #63 from hanzoai/chore/release-8.4.47
chore(release): console 8.4.47 — customer Domains panel + Apps view
2026-07-02 22:02:45 -07:00
zandGitHub 8f464e24a6 Merge pull request #63 from hanzoai/chore/release-8.4.47
chore(release): console 8.4.47 — customer Domains panel + Apps view
2026-07-02 22:02:45 -07:00
hanzo-dev daa24fa7a7 chore(release): console 8.4.47 — ship customer Domains panel + BYO custom-domain verify (#62) 2026-07-02 22:02:38 -07:00
hanzo-dev 8c5e85b081 chore(release): console 8.4.47 — ship customer Domains panel + BYO custom-domain verify (#62) 2026-07-02 22:02:38 -07:00
zandGitHub 406aaa54bc Merge pull request #62 from hanzoai/feat/paas-domains
feat(paas): customer Domains panel — add/verify BYO custom domains from console
2026-07-02 22:01:45 -07:00
zandGitHub 2b183e22c3 Merge pull request #62 from hanzoai/feat/paas-domains
feat(paas): customer Domains panel — add/verify BYO custom domains from console
2026-07-02 22:01:45 -07:00
hanzo-dev 35e9d66247 feat(paas): customer Domains panel — add / verify BYO custom domains from console
Gap 1 (console self-serve domains UI) over the live /v1/platform/.../domains
surface (via the /cloud bearer proxy, org from the Bearer owner):

- lib/api/paas.ts: PaasDomain + listDomains/addDomain/verifyDomain/removeDomain.
- DomainsPanel.tsx: a Domains section on the app detail — lists the default host,
  org-subtree hosts, and BYO custom domains with an HONEST per-domain status from
  the operator CR (live / provisioning / awaiting deploy / unverified, never
  fabricated); an Add-domain form; per-domain Remove; and — for a pending custom
  domain — the exact DNS records to publish (TXT + CNAME, with copy) plus a Verify
  action that flips it live with TLS. Default host is non-removable.
- paas/logic.ts (+tests): pure isPendingCustom / canRemoveDomain / domainStatusLabel
  / orderDomains. Wired into PaasApplications AppDetail.

Drive-by: fix a pre-existing origin/main tsc error in overview/living/
open-edition.test.ts (vi.fn<[Args],Return> → single-function-type form the
installed vitest requires) so `tsc --noEmit` is green. Not caused by / related to
this feature; next build (which excludes tests) never surfaced it.

Verify: tsc --noEmit clean; vitest 1167/1167 (+12 domain logic); next build ✓.
2026-07-02 21:58:24 -07:00
hanzo-dev dd2bcb1117 feat(paas): customer Domains panel — add / verify BYO custom domains from console
Gap 1 (console self-serve domains UI) over the live /v1/platform/.../domains
surface (via the /cloud bearer proxy, org from the Bearer owner):

- lib/api/paas.ts: PaasDomain + listDomains/addDomain/verifyDomain/removeDomain.
- DomainsPanel.tsx: a Domains section on the app detail — lists the default host,
  org-subtree hosts, and BYO custom domains with an HONEST per-domain status from
  the operator CR (live / provisioning / awaiting deploy / unverified, never
  fabricated); an Add-domain form; per-domain Remove; and — for a pending custom
  domain — the exact DNS records to publish (TXT + CNAME, with copy) plus a Verify
  action that flips it live with TLS. Default host is non-removable.
- paas/logic.ts (+tests): pure isPendingCustom / canRemoveDomain / domainStatusLabel
  / orderDomains. Wired into PaasApplications AppDetail.

Drive-by: fix a pre-existing origin/main tsc error in overview/living/
open-edition.test.ts (vi.fn<[Args],Return> → single-function-type form the
installed vitest requires) so `tsc --noEmit` is green. Not caused by / related to
this feature; next build (which excludes tests) never surfaced it.

Verify: tsc --noEmit clean; vitest 1167/1167 (+12 domain logic); next build ✓.
2026-07-02 21:58:24 -07:00
hanzo-devandGitHub b4e3996a8c feat(embed): build:embed static export for the cloud one-binary (task #41) (#61)
The hanzoai/cloud Go binary go:embeds the console SPA and serves it at its own
web root; the Dockerfile already looks for `npm run build:embed` to produce the
static bundle. This adds that target — the last piece of 'True 1-binary FE'.

- next.config.mjs: CONSOLE_EMBED=1 → output:'export' + images.unoptimized and
  DROPS the aiSurfaceRewrites (a static export cannot run rewrites, and does not
  need them: the clean /v1/<head> the SPA already builds terminates directly at
  the embedded cloud's mounted /v1 subsystems, which is what the rewrites used to
  forward to via the Next BFF). The normal `npm run build` (server build) is
  UNCHANGED, so the standalone console deployment never regresses.
- scripts/build-embed.mjs: prepares the app for output:'export' for ONE build and
  ALWAYS restores it (finally block; main stays byte-identical):
    (1) stashes every app/**route.ts — a static export has no runtime for a
        server route handler; the BFF proxies collapse to cloud /v1/* and the two
        standalone routes (keys/onboard) are ported to cloud /v1/console/*.
    (2) overlays the two dynamic client pages ([...slug], discover/[id]) with a
        force-static server wrapper (generateStaticParams + dynamicParams=false)
        so they satisfy output:'export'; the real client body still ships in the
        JS bundle (client nav hydrates it) and deep links hit the host's SPA
        fallback (cloud/webui.go serveIndex → index.html) which re-resolves them.
- build:embed npm script.

Proven: `npm run build:embed` → out/ (47 files, 6 html incl. discover/ + the
SPA-shell index.html at 376 KB); working tree restored clean after.
2026-07-02 21:52:15 -07:00
hanzo-devandGitHub 8ec1db49f9 feat(embed): build:embed static export for the cloud one-binary (task #41) (#61)
The hanzoai/cloud Go binary go:embeds the console SPA and serves it at its own
web root; the Dockerfile already looks for `npm run build:embed` to produce the
static bundle. This adds that target — the last piece of 'True 1-binary FE'.

- next.config.mjs: CONSOLE_EMBED=1 → output:'export' + images.unoptimized and
  DROPS the aiSurfaceRewrites (a static export cannot run rewrites, and does not
  need them: the clean /v1/<head> the SPA already builds terminates directly at
  the embedded cloud's mounted /v1 subsystems, which is what the rewrites used to
  forward to via the Next BFF). The normal `npm run build` (server build) is
  UNCHANGED, so the standalone console deployment never regresses.
- scripts/build-embed.mjs: prepares the app for output:'export' for ONE build and
  ALWAYS restores it (finally block; main stays byte-identical):
    (1) stashes every app/**route.ts — a static export has no runtime for a
        server route handler; the BFF proxies collapse to cloud /v1/* and the two
        standalone routes (keys/onboard) are ported to cloud /v1/console/*.
    (2) overlays the two dynamic client pages ([...slug], discover/[id]) with a
        force-static server wrapper (generateStaticParams + dynamicParams=false)
        so they satisfy output:'export'; the real client body still ships in the
        JS bundle (client nav hydrates it) and deep links hit the host's SPA
        fallback (cloud/webui.go serveIndex → index.html) which re-resolves them.
- build:embed npm script.

Proven: `npm run build:embed` → out/ (47 files, 6 html incl. discover/ + the
SPA-shell index.html at 376 KB); working tree restored clean after.
2026-07-02 21:52:15 -07:00
hanzo-dev 872d5179df fix(base): drop the last competitor name from the assistant prompt + green main
The whole-repo sweep after v8.4.42 caught the one remaining user-facing Base
competitor reference: the built-in assistant's system prompt described Base as a
'Firebase-style backend' (from the v8.4.43 grounded-assistant lane). Rewrote it in
Hanzo's own voice — 'a realtime backend — spin up per-org Bases with content types,
records, and auth'. The console is now free of Supabase/Firebase user-facing copy.

Drive-by: open-edition.test.ts (#60) used the old vitest vi.fn<[Args],Return>() form
that vitest v3.2.4 rejects — main's tsc was RED. Migrated to the v3 single-fn-type
form. tsc clean, vitest 1163/1163, next build green.
2026-07-02 21:24:22 -07:00
hanzo-dev e83eb2405f fix(base): drop the last competitor name from the assistant prompt + green main
The whole-repo sweep after v8.4.42 caught the one remaining user-facing Base
competitor reference: the built-in assistant's system prompt described Base as a
'Firebase-style backend' (from the v8.4.43 grounded-assistant lane). Rewrote it in
Hanzo's own voice — 'a realtime backend — spin up per-org Bases with content types,
records, and auth'. The console is now free of Supabase/Firebase user-facing copy.

Drive-by: open-edition.test.ts (#60) used the old vitest vi.fn<[Args],Return>() form
that vitest v3.2.4 rejects — main's tsc was RED. Migrated to the v3 single-fn-type
form. tsc clean, vitest 1163/1163, next build green.
2026-07-02 21:24:22 -07:00
hanzo-devandGitHub 77ad2ec7af feat(overview): Open Edition (run-for-pay) living overview + visual gate (#60)
Add the Open Edition / run-for-pay overview as a living-overview config
(add-a-product = 1 config) that renders from the REAL commerce usage
ledger scoped to the `open-edition` product tag. Spend billed = the
served-revenue figure R (cost + 25% resell margin) per the pricing spec.

- living/registry.ts: `openEditionOverview` config + `open-edition` map
  key. Reuses UsageApi.overview({ product: 'open-edition' }) + the
  existing fromCloudUsage adapter (DRY) — honest-empty when no run-for-pay
  usage, never a mock.
- products/registry.tsx: `livingOverviewModule('open-edition')` + a
  catalog entry (Observe category) → the board lives at /open-edition.
- living/open-edition.test.ts: behavioral proof the loader forwards the
  `open-edition` product scope, maps real data through fromCloudUsage,
  and rolls up honest-empty (4 tests).
- e2e/open-edition.spec.ts: dedicated Playwright visual gate asserting the
  run-for-pay header, the cost+25% Spend KPI caption, and the spend/tokens
  KPIs render; captures a full-page screenshot.
- e2e/pages.spec.ts: add `open-edition` to the all-pages screenshot sweep.

Canonical model: universe docs/architecture/run-for-pay-pricing.md (price)
+ run-for-pay-and-contributor-revenue.md (settlement).

Additive only (no deletions); does not touch the concurrent session's
staged adapters.ts / registry.test.ts.
2026-07-02 21:17:39 -07:00
hanzo-devandGitHub 6a8f51a51f feat(overview): Open Edition (run-for-pay) living overview + visual gate (#60)
Add the Open Edition / run-for-pay overview as a living-overview config
(add-a-product = 1 config) that renders from the REAL commerce usage
ledger scoped to the `open-edition` product tag. Spend billed = the
served-revenue figure R (cost + 25% resell margin) per the pricing spec.

- living/registry.ts: `openEditionOverview` config + `open-edition` map
  key. Reuses UsageApi.overview({ product: 'open-edition' }) + the
  existing fromCloudUsage adapter (DRY) — honest-empty when no run-for-pay
  usage, never a mock.
- products/registry.tsx: `livingOverviewModule('open-edition')` + a
  catalog entry (Observe category) → the board lives at /open-edition.
- living/open-edition.test.ts: behavioral proof the loader forwards the
  `open-edition` product scope, maps real data through fromCloudUsage,
  and rolls up honest-empty (4 tests).
- e2e/open-edition.spec.ts: dedicated Playwright visual gate asserting the
  run-for-pay header, the cost+25% Spend KPI caption, and the spend/tokens
  KPIs render; captures a full-page screenshot.
- e2e/pages.spec.ts: add `open-edition` to the all-pages screenshot sweep.

Canonical model: universe docs/architecture/run-for-pay-pricing.md (price)
+ run-for-pay-and-contributor-revenue.md (settlement).

Additive only (no deletions); does not touch the concurrent session's
staged adapters.ts / registry.test.ts.
2026-07-02 21:17:39 -07:00
hanzo-dev 2fdd72162c console: real per-product Status/Logs/Metrics/Settings — one metadata-driven system (v8.4.45)
Make every product's shared base sub-pages real, correct, and per-product via ONE
metadata source, no fabrication.

- Metrics: consolidate the ledger-scope decision to sources.ts metricsScopeFor (kill
  the dead MetricsFeed/O11Y_METRICS_PRODUCTS dup); every product filters by
  metadata.product===id (honest-empty, never the org total); the inference surface
  (inference/models/api/gateway) reads the whole ledger with an explicit honest
  'org-wide inference' banner + scope-aware subtitle.
- Status/Logs: verified SERVICE_OVERRIDE (models, bot->bot-gateway, helpdesk->help)
  grounded in the live /v1/apps inventory; fix console spec service console2->console;
  neutral+honest 'no operator row' copy; Logs-specific honest managed card.
- Settings: settingsConfigFor surfaces real product-specific config (reuse the overview
  spec facts+actions, else category default) — a real Configuration card, not a dead form.
- Overview: console spec health repointed; defaultSpec stays honest.

Only subpage/*, overview/*, overview/living/* + per-product metadata. Did NOT touch
AgentsModule/agents/* or assistant/*. tsc clean; vitest 1140/1140; next build green.
2026-07-02 21:16:26 -07:00
hanzo-dev a1a3cc0500 console: real per-product Status/Logs/Metrics/Settings — one metadata-driven system (v8.4.45)
Make every product's shared base sub-pages real, correct, and per-product via ONE
metadata source, no fabrication.

- Metrics: consolidate the ledger-scope decision to sources.ts metricsScopeFor (kill
  the dead MetricsFeed/O11Y_METRICS_PRODUCTS dup); every product filters by
  metadata.product===id (honest-empty, never the org total); the inference surface
  (inference/models/api/gateway) reads the whole ledger with an explicit honest
  'org-wide inference' banner + scope-aware subtitle.
- Status/Logs: verified SERVICE_OVERRIDE (models, bot->bot-gateway, helpdesk->help)
  grounded in the live /v1/apps inventory; fix console spec service console2->console;
  neutral+honest 'no operator row' copy; Logs-specific honest managed card.
- Settings: settingsConfigFor surfaces real product-specific config (reuse the overview
  spec facts+actions, else category default) — a real Configuration card, not a dead form.
- Overview: console spec health repointed; defaultSpec stays honest.

Only subpage/*, overview/*, overview/living/* + per-product metadata. Did NOT touch
AgentsModule/agents/* or assistant/*. tsc clean; vitest 1140/1140; next build green.
2026-07-02 21:16:26 -07:00
hanzo-dev 9daefa19bf fix(agents): key single-agent detail + delete on NAME not the display id (v8.4.44) 2026-07-02 21:15:03 -07:00
hanzo-dev b7c35f1c0c fix(agents): key single-agent detail + delete on NAME not the display id (v8.4.44) 2026-07-02 21:15:03 -07:00
zandGitHub acdecfbef2 Merge pull request #59 from hanzoai/feat/compute-admin-clusters-functions
feat(compute): Clusters + Functions admin boards (kind spectrum)
2026-07-02 21:13:27 -07:00
zandGitHub 9c9f12fb3d Merge pull request #59 from hanzoai/feat/compute-admin-clusters-functions
feat(compute): Clusters + Functions admin boards (kind spectrum)
2026-07-02 21:13:27 -07:00
hanzo-dev 0825f9b328 release(console): v8.4.43 — built-in assistant is a grounded Hanzo-suite expert
Make the console's built-in chat assistant a genuine expert on the whole Hanzo
suite, GROUNDED in real sources (the product registry + docs RAG), never
hallucinated — ONE shared system prompt across all three chat surfaces.

- src/lib/assistant/: decomplected into a PURE builder (prompt-content.ts,
  unit-tested) + a thin registry-bound wrapper (system-prompt.ts). Catalog
  section generated FROM the live registry (visibleCatalogByCategory) — all
  products, per-category, name+description+GCP-analog+deep-link; complete,
  current, brand-white-labeled, admin-gated for customers. Plus a curated
  accurate 'what Hanzo is' overview + an honest behavior contract (never invent;
  say so when Hanzo lacks a thing).
- Wired DRY into ChatConversation (=> FloatingChat bubble + full /chat page) via
  AiApi.ragChat, and CommandPalette >/? via commandBarSystemPrompt (base + NAV
  contract) — replaces the old nav-only prompt.
- AiApi.ragChat gains optional history (backward-compatible) for grounded
  multi-turn; X-Retrieval store 'docs' shared, degrades gracefully.

tsc clean; npm test 1142/1142 (+10); next build green. Rebased on v8.4.42.
2026-07-02 21:11:48 -07:00
hanzo-dev 42e96cd01d release(console): v8.4.43 — built-in assistant is a grounded Hanzo-suite expert
Make the console's built-in chat assistant a genuine expert on the whole Hanzo
suite, GROUNDED in real sources (the product registry + docs RAG), never
hallucinated — ONE shared system prompt across all three chat surfaces.

- src/lib/assistant/: decomplected into a PURE builder (prompt-content.ts,
  unit-tested) + a thin registry-bound wrapper (system-prompt.ts). Catalog
  section generated FROM the live registry (visibleCatalogByCategory) — all
  products, per-category, name+description+GCP-analog+deep-link; complete,
  current, brand-white-labeled, admin-gated for customers. Plus a curated
  accurate 'what Hanzo is' overview + an honest behavior contract (never invent;
  say so when Hanzo lacks a thing).
- Wired DRY into ChatConversation (=> FloatingChat bubble + full /chat page) via
  AiApi.ragChat, and CommandPalette >/? via commandBarSystemPrompt (base + NAV
  contract) — replaces the old nav-only prompt.
- AiApi.ragChat gains optional history (backward-compatible) for grounded
  multi-turn; X-Retrieval store 'docs' shared, degrades gracefully.

tsc clean; npm test 1142/1142 (+10); next build green. Rebased on v8.4.42.
2026-07-02 21:11:48 -07:00
hanzo-dev 9e7600529e feat(base): Bases manager — create/configure per-org Base instances, drop competitor copy
Base was a content-type dashboard over the SuperBase orchestrator's own
collections (contacts/tenants/users), so the org saw one shared Base and had no
way to make another — 'stuck with a single base'. Rework it into the Base
INSTANCES manager over the real tenants API (each row = a Base on its own
<slug>.base.hanzo.ai), so the user can SEE all their Bases, CREATE a new one, and
CONFIGURE one (name/size/status/delete). One Base binding — the /superbase proxy
(user bearer + X-Org-Id from the JWT owner, derive-once). Clean split from
Records (which browses a Base's collections + records).

- NEW lib/base-data/tenants.ts (BaseTenantsApi over the tenants collection)
- NEW base/bases-logic.ts (+test): slug/validate/size presets/status
- NEW base/BasesManager.tsx: list + New Base + configure
- BaseModule -> BasesManager; registry routes ''|new|:base
- Drop the Supabase/Firebase copy + gcp:'Firebase' from the Base entry
- Remove the superseded content-type dashboard (BaseDashboard/CollectionBuilder/logic)

Copy: no competitor names in the Base UI or docstrings.
2026-07-02 21:04:25 -07:00
hanzo-dev 2ab1e09a46 feat(base): Bases manager — create/configure per-org Base instances, drop competitor copy
Base was a content-type dashboard over the SuperBase orchestrator's own
collections (contacts/tenants/users), so the org saw one shared Base and had no
way to make another — 'stuck with a single base'. Rework it into the Base
INSTANCES manager over the real tenants API (each row = a Base on its own
<slug>.base.hanzo.ai), so the user can SEE all their Bases, CREATE a new one, and
CONFIGURE one (name/size/status/delete). One Base binding — the /superbase proxy
(user bearer + X-Org-Id from the JWT owner, derive-once). Clean split from
Records (which browses a Base's collections + records).

- NEW lib/base-data/tenants.ts (BaseTenantsApi over the tenants collection)
- NEW base/bases-logic.ts (+test): slug/validate/size presets/status
- NEW base/BasesManager.tsx: list + New Base + configure
- BaseModule -> BasesManager; registry routes ''|new|:base
- Drop the Supabase/Firebase copy + gcp:'Firebase' from the Base entry
- Remove the superseded content-type dashboard (BaseDashboard/CollectionBuilder/logic)

Copy: no competitor names in the Base UI or docstrings.
2026-07-02 21:04:25 -07:00
hanzo-dev dbd42ef40b feat(compute): Clusters + Functions admin boards (kind spectrum)
Extend the admin compute-analytics boards from bot/machine to the full
kind spectrum so DOKS clusters, node pools and Functions surface in
admin.hanzo.ai like Bots/Machines.

- admin-compute.ts: widen ComputeKind to bot|machine|cluster|nodepool|
  function; asKind now canonicalizes over the whole spectrum (fallback
  machine, mirror of visor CanonicalKind) so a cluster/nodepool/function
  row never pollutes a sibling board via keepKind.
- ComputeModule.tsx: KIND_UI entries for cluster/nodepool/function;
  ClustersModule (kind=cluster) + FunctionsModule (kind=function) thin
  wrappers over the ONE ComputeBoard, mirroring Bots/Machines.
- registry.tsx: register cluster-fleet + function-fleet (admin:true,
  category Observe), distinct from the customer clusters/functions
  products (same split as vms vs machines).
- admin-compute.test.ts: cover the widened spectrum (cluster/nodepool/
  function fold + kind-filtering, unknown-kind fallback).

Honest-empty until the emitters + cloud read land. DRY: one ComputeBoard,
one datastore aggregate, one kind canonicalizer.
2026-07-02 20:39:24 -07:00
hanzo-dev 38de67b54a feat(compute): Clusters + Functions admin boards (kind spectrum)
Extend the admin compute-analytics boards from bot/machine to the full
kind spectrum so DOKS clusters, node pools and Functions surface in
admin.hanzo.ai like Bots/Machines.

- admin-compute.ts: widen ComputeKind to bot|machine|cluster|nodepool|
  function; asKind now canonicalizes over the whole spectrum (fallback
  machine, mirror of visor CanonicalKind) so a cluster/nodepool/function
  row never pollutes a sibling board via keepKind.
- ComputeModule.tsx: KIND_UI entries for cluster/nodepool/function;
  ClustersModule (kind=cluster) + FunctionsModule (kind=function) thin
  wrappers over the ONE ComputeBoard, mirroring Bots/Machines.
- registry.tsx: register cluster-fleet + function-fleet (admin:true,
  category Observe), distinct from the customer clusters/functions
  products (same split as vms vs machines).
- admin-compute.test.ts: cover the widened spectrum (cluster/nodepool/
  function fold + kind-filtering, unknown-kind fallback).

Honest-empty until the emitters + cloud read land. DRY: one ComputeBoard,
one datastore aggregate, one kind canonicalizer.
2026-07-02 20:39:24 -07:00
hanzo-dev f5a82a152d console: bump 8.4.41 (Authz + Settlement repoint) 2026-07-02 20:31:27 -07:00
hanzo-dev 5e85363093 console: bump 8.4.41 (Authz + Settlement repoint) 2026-07-02 20:31:27 -07:00
hanzo-dev d30108ec04 console: repoint Authz→cloud /v1/authz/policies (real Casbin rules) + Settlement→commerce /v1/billing/payouts (real payout ledger)
- AuthzModule: off dead /paas onto /cloud user-bearer proxy → cloud authz
  subsystem (hanzoai/authz) GET /v1/authz/policies; per-org enforcer picked
  from the Bearer-derived X-Org-Id. Match FE type to the Casbin [sub,obj,act]
  tuple (effect always allow); honest-empty + PlatformStateCard on failure.
- SettlementModule: off dead /paas onto the existing /billing/v1 commerce
  proxy → GET /v1/billing/payouts (ListPayouts), org-scoped server-side.
  Match FE type to commerce payoutResponse (amount cents/currency, status,
  destinationType/Id, created). 501 when COMMERCE_TOKEN unset → honest card.
- authz head already admitted in proxy-allow CLOUD_HEADS + next.config
  CLOUD_V1_HEADS.
2026-07-02 20:30:53 -07:00
hanzo-dev 622ecba85c console: repoint Authz→cloud /v1/authz/policies (real Casbin rules) + Settlement→commerce /v1/billing/payouts (real payout ledger)
- AuthzModule: off dead /paas onto /cloud user-bearer proxy → cloud authz
  subsystem (hanzoai/authz) GET /v1/authz/policies; per-org enforcer picked
  from the Bearer-derived X-Org-Id. Match FE type to the Casbin [sub,obj,act]
  tuple (effect always allow); honest-empty + PlatformStateCard on failure.
- SettlementModule: off dead /paas onto the existing /billing/v1 commerce
  proxy → GET /v1/billing/payouts (ListPayouts), org-scoped server-side.
  Match FE type to commerce payoutResponse (amount cents/currency, status,
  destinationType/Id, created). 501 when COMMERCE_TOKEN unset → honest card.
- authz head already admitted in proxy-allow CLOUD_HEADS + next.config
  CLOUD_V1_HEADS.
2026-07-02 20:30:53 -07:00
hanzo-dev cb6d3418ae feat(console): connect Indexer + Oracles pages to cloud /v1/*
Repoint the last two "not connected" chain-data pages off the admin
/paas proxy onto the native cloud user-bearer proxy, backed by the new
cloud clients/graph subsystem:

- IndexerModule: restGet(paas('indexers')) -> cloudProxyV1Url('indexers')
- OraclesModule: restGet(paas('oracles'))  -> cloudProxyV1Url('oracles')

Admit both heads on the /cloud proxy: add indexers,oracles to
next.config.mjs CLOUD_V1_HEADS and proxy-allow.ts CLOUD_HEADS. Bump
8.4.39 -> 8.4.40 so a fresh image builds.
2026-07-02 20:29:56 -07:00
hanzo-dev d39eb53a73 feat(console): connect Indexer + Oracles pages to cloud /v1/*
Repoint the last two "not connected" chain-data pages off the admin
/paas proxy onto the native cloud user-bearer proxy, backed by the new
cloud clients/graph subsystem:

- IndexerModule: restGet(paas('indexers')) -> cloudProxyV1Url('indexers')
- OraclesModule: restGet(paas('oracles'))  -> cloudProxyV1Url('oracles')

Admit both heads on the /cloud proxy: add indexers,oracles to
next.config.mjs CLOUD_V1_HEADS and proxy-allow.ts CLOUD_HEADS. Bump
8.4.39 -> 8.4.40 so a fresh image builds.
2026-07-02 20:29:56 -07:00
hanzo-dev cb4be5be13 console: repoint Alerts to native o11y /v1/rules; honest Logs; bump 8.4.39
Alerts: restGet('/paas/alerts') -> cloudProxyV1Url('o11y/v1/rules'), the real
hanzoai/o11y alert-rule-states route (cloud mounts /v1/o11y/*, reverse-proxies to
the o11y Deployment which rewrites /v1/o11y/* -> /api/v1/rules; listRules ->
ListRuleStates). Normalize the flattened GettableRule envelope
{status,data:{rules:[…]}} to the flat Alert row: name<-alert, severity<-labels.
severity, status<-state (or 'disabled'), condition<-description. lastFired left
empty honestly (rule-list carries no last-fired timestamp).

Logs: kept honest. o11y's GET /v1/o11y/v1/logs is a hardcoded empty stub and the
only real log read is a composite POST query_range — no clean logs-list route to
repoint to. Copy now names the true gap (needs a real logs-list route) instead of
the stale "VictoriaLogs not deployed"; nothing fabricated.

Status: unchanged — already on native VictoriaMetrics up{} via /telemetry (real
data). Repointing to platform apps would regress to a different, empty concern.

Heads: add 'o11y' to next.config CLOUD_V1_HEADS + server/proxy-allow CLOUD_HEADS
so /cloud admits the o11y sub-surface.
2026-07-02 20:28:39 -07:00
hanzo-dev 8130c334c4 console: repoint Alerts to native o11y /v1/rules; honest Logs; bump 8.4.39
Alerts: restGet('/paas/alerts') -> cloudProxyV1Url('o11y/v1/rules'), the real
hanzoai/o11y alert-rule-states route (cloud mounts /v1/o11y/*, reverse-proxies to
the o11y Deployment which rewrites /v1/o11y/* -> /api/v1/rules; listRules ->
ListRuleStates). Normalize the flattened GettableRule envelope
{status,data:{rules:[…]}} to the flat Alert row: name<-alert, severity<-labels.
severity, status<-state (or 'disabled'), condition<-description. lastFired left
empty honestly (rule-list carries no last-fired timestamp).

Logs: kept honest. o11y's GET /v1/o11y/v1/logs is a hardcoded empty stub and the
only real log read is a composite POST query_range — no clean logs-list route to
repoint to. Copy now names the true gap (needs a real logs-list route) instead of
the stale "VictoriaLogs not deployed"; nothing fabricated.

Status: unchanged — already on native VictoriaMetrics up{} via /telemetry (real
data). Repointing to platform apps would regress to a different, empty concern.

Heads: add 'o11y' to next.config CLOUD_V1_HEADS + server/proxy-allow CLOUD_HEADS
so /cloud admits the o11y sub-surface.
2026-07-02 20:28:39 -07:00
hanzo-dev 773d1a6522 fix(theme+gpus): apply org accent via Tamagui props (className not forwarded on Button) + Pools honest-empty
Theme (the real apply): v8.4.36's accent used a CSS class (hz-accent-fill) on the
accent surfaces, but Tamagui does NOT forward className to a Button's DOM node
(only to Stacks), so the org's saved brand color never recolored anything. Rebuilt
the mechanism to be Tamagui-native and verified LIVE: src/lib/theme/accent.ts now
holds the resolved accent in a tiny external store (setOrgAccent, called on load by
OrgAccentProvider AND on save by SettingsModule) exposed via useAccent(); the genuine
accent surfaces (PrimaryButton, the active GPU/Settings tabs, the active sidebar nav
item) recolor with real @hanzo/gui props — inline bg + readable contrast text (light
accent -> black text, dark -> white), the nav via an accent left-bar — reverting to
the default monochrome when the org disables its theme or the hex is invalid.
Verified in a browser: enable green -> primary button green + white text; yellow ->
black text; disable -> reverts (no inline style). Dead globals.css accent block +
classNames removed.

GPUs Pools tab: now honest-empty 'No GPU node pools yet' whenever the org has no
reachable GPU clusters (none provisioned, or the native /cloud/v1/clusters endpoint
isn't live) — a pools-specific state distinct from the Clusters tab, never a
fabricated pool. (Pools are derived from the org's real clusters.)

Rebased on v8.4.37 (Inference dashboard) — strict superset. tsc clean; vitest
1132/1132 (+2 accentFor); next build green.
2026-07-02 19:55:49 -07:00
hanzo-dev bbc38cba8f fix(theme+gpus): apply org accent via Tamagui props (className not forwarded on Button) + Pools honest-empty
Theme (the real apply): v8.4.36's accent used a CSS class (hz-accent-fill) on the
accent surfaces, but Tamagui does NOT forward className to a Button's DOM node
(only to Stacks), so the org's saved brand color never recolored anything. Rebuilt
the mechanism to be Tamagui-native and verified LIVE: src/lib/theme/accent.ts now
holds the resolved accent in a tiny external store (setOrgAccent, called on load by
OrgAccentProvider AND on save by SettingsModule) exposed via useAccent(); the genuine
accent surfaces (PrimaryButton, the active GPU/Settings tabs, the active sidebar nav
item) recolor with real @hanzo/gui props — inline bg + readable contrast text (light
accent -> black text, dark -> white), the nav via an accent left-bar — reverting to
the default monochrome when the org disables its theme or the hex is invalid.
Verified in a browser: enable green -> primary button green + white text; yellow ->
black text; disable -> reverts (no inline style). Dead globals.css accent block +
classNames removed.

GPUs Pools tab: now honest-empty 'No GPU node pools yet' whenever the org has no
reachable GPU clusters (none provisioned, or the native /cloud/v1/clusters endpoint
isn't live) — a pools-specific state distinct from the Clusters tab, never a
fabricated pool. (Pools are derived from the org's real clusters.)

Rebased on v8.4.37 (Inference dashboard) — strict superset. tsc clean; vitest
1132/1132 (+2 accentFor); next build green.
2026-07-02 19:55:49 -07:00
hanzo-dev 6307b49d3d feat(inference): rich endpoints dashboard + Status/Logs + shared per-product Metrics — all real data (v8.4.37)
Redesign the Inference page to the endpoints-dashboard mockup, wired to REAL sources
(honest '—'/empty where unexposed — never the mockup's placeholder numbers). Sidebar +
topbar untouched; only the Inference module content + the shared Metrics sub-page changed.

- Endpoints = managed model catalog (/v1/models) merged with the org's deployed KServe
  InferenceServices (cloud /v1/ml/models; new 'ml' /cloud head in proxy-allow + rewrite).
  Per-endpoint Requests(24h) + trend sparkline = REAL usage ledger by model id; KServe phase
  from live status.conditions; P95/uptime honest '—'.
- Hero 'Connected to Hanzo Cloud' (honest managed copy + purple SVG accent), purple Deploy
  Endpoint CTA (real POST /v1/ml/models), right rail Usage Overview (real ledger window +
  prior-period deltas) + Quick Actions + Need help (real routes/links).
- Inference OWNS Status + Logs as :tab views (health board + real recorded inference
  activity), declared as specific subpages so the router renders them.
- Shared per-product Metrics -> product-parameterized LivingOverview over the real usage
  ledger scoped by metadata.product; new byStatus + tokens breakdowns. P95 honest '—'.

tsc clean; vitest 1130/1130; next build green. Rebased on origin/main (v8.4.36) -> v8.4.37.
2026-07-02 19:25:56 -07:00
hanzo-dev c1a53dadc8 feat(inference): rich endpoints dashboard + Status/Logs + shared per-product Metrics — all real data (v8.4.37)
Redesign the Inference page to the endpoints-dashboard mockup, wired to REAL sources
(honest '—'/empty where unexposed — never the mockup's placeholder numbers). Sidebar +
topbar untouched; only the Inference module content + the shared Metrics sub-page changed.

- Endpoints = managed model catalog (/v1/models) merged with the org's deployed KServe
  InferenceServices (cloud /v1/ml/models; new 'ml' /cloud head in proxy-allow + rewrite).
  Per-endpoint Requests(24h) + trend sparkline = REAL usage ledger by model id; KServe phase
  from live status.conditions; P95/uptime honest '—'.
- Hero 'Connected to Hanzo Cloud' (honest managed copy + purple SVG accent), purple Deploy
  Endpoint CTA (real POST /v1/ml/models), right rail Usage Overview (real ledger window +
  prior-period deltas) + Quick Actions + Need help (real routes/links).
- Inference OWNS Status + Logs as :tab views (health board + real recorded inference
  activity), declared as specific subpages so the router renders them.
- Shared per-product Metrics -> product-parameterized LivingOverview over the real usage
  ledger scoped by metadata.product; new byStatus + tokens breakdowns. P95 honest '—'.

tsc clean; vitest 1130/1130; next build green. Rebased on origin/main (v8.4.36) -> v8.4.37.
2026-07-02 19:25:56 -07:00
hanzo-dev d47424a5a7 fix(gpus): real tabbed customer GPU view + wire org accent theme
GPU tabs (the customer bug): a non-admin's GPU page rendered the same static
catalog for every sub-tab (Clusters/Pools/Pricing/Alerts all showed the GPU
catalog) because CustomerGpus was one static component with no tab bar. It is now
TABBED like AdminGpus — the shared GpuTabBar + GPU_TABS (one source of truth,
router.push navigation), active tab from params.tab — with DISTINCT, customer-
scoped, per-org content per tab:
 - Overview/GPUs: visor catalog (/vm /v1/gpus) + the org's own GPU machines.
 - Clusters: the org's own clusters (PlatformApi.listClusters -> user-bearer
   /cloud/v1/clusters), honest-empty; reuses ClustersTab (DRY).
 - Pools: GPU node pools derived from the org's real clusters (honest-empty).
 - Pricing: the REAL per-accelerator price list from the live visor catalog.
 - Alerts: real alerts derived from the org's own GPU machines' health; reuses
   AlertsTab (DRY), honest-empty when healthy.
 - Settings: honest per-org GPU settings + real counts.
AdminGpus behavior is unchanged (now shares GpuTabBar/GPU_TABS). Pure derivations
(gpuPoolsFromClusters/gpuAlertsFromMachines/catalog stats) are unit-tested — real
per-org data or honest-empty, never fabricated.

Theme accent (folded in): the org's saved brand color (themeData.colorPrimary +
isEnabled) was persisted but never applied. Added src/lib/theme/accent.ts
(applyOrgAccent -> one root --hz-accent CSS var + data-hz-accent) applied on load
(OrgAccentProvider) AND immediately on save (SettingsModule). Genuine accent
surfaces read the one var via hz-accent-fill/hz-accent-bar (PrimaryButton, active
sidebar nav, active tabs) — DRY, reverts to default when disabled/invalid.

tsc clean; vitest 1092/1092 (+16 new); next build green.
2026-07-02 19:20:58 -07:00
hanzo-dev 858fd7c6c2 fix(gpus): real tabbed customer GPU view + wire org accent theme
GPU tabs (the customer bug): a non-admin's GPU page rendered the same static
catalog for every sub-tab (Clusters/Pools/Pricing/Alerts all showed the GPU
catalog) because CustomerGpus was one static component with no tab bar. It is now
TABBED like AdminGpus — the shared GpuTabBar + GPU_TABS (one source of truth,
router.push navigation), active tab from params.tab — with DISTINCT, customer-
scoped, per-org content per tab:
 - Overview/GPUs: visor catalog (/vm /v1/gpus) + the org's own GPU machines.
 - Clusters: the org's own clusters (PlatformApi.listClusters -> user-bearer
   /cloud/v1/clusters), honest-empty; reuses ClustersTab (DRY).
 - Pools: GPU node pools derived from the org's real clusters (honest-empty).
 - Pricing: the REAL per-accelerator price list from the live visor catalog.
 - Alerts: real alerts derived from the org's own GPU machines' health; reuses
   AlertsTab (DRY), honest-empty when healthy.
 - Settings: honest per-org GPU settings + real counts.
AdminGpus behavior is unchanged (now shares GpuTabBar/GPU_TABS). Pure derivations
(gpuPoolsFromClusters/gpuAlertsFromMachines/catalog stats) are unit-tested — real
per-org data or honest-empty, never fabricated.

Theme accent (folded in): the org's saved brand color (themeData.colorPrimary +
isEnabled) was persisted but never applied. Added src/lib/theme/accent.ts
(applyOrgAccent -> one root --hz-accent CSS var + data-hz-accent) applied on load
(OrgAccentProvider) AND immediately on save (SettingsModule). Genuine accent
surfaces read the one var via hz-accent-fill/hz-accent-bar (PrimaryButton, active
sidebar nav, active tabs) — DRY, reverts to default when disabled/invalid.

tsc clean; vitest 1092/1092 (+16 new); next build green.
2026-07-02 19:20:58 -07:00
hanzo-dev 7821fda0f1 feat(nav): collapsible category accordion in the sidebar product nav (v8.4.35)
Each level-1 CATEGORY is now a collapsible section: the header is a clickable
button with an obvious rotating chevron (▸ collapsed, ▾ expanded), keyboard-
toggleable + aria-expanded, over its product rows revealed in order when open.
The ~120-item flat list condenses to 13 tidy topic headers so the nav stops
overwhelming the user.

- Pure model: src/lib/products/nav-accordion.ts (categoryIsOpen/toggleCategory,
  10 unit tests). Default COLLAPSED for most; the active route's category is
  always open (navigating reveals it, even if collapsed); filtering opens every
  matching group so search is never hidden; clearing search restores collapse.
- Persisted per-user via usePreferences (account-backed + localStorage cache,
  the existing sidebarCollapsed idiom) under navCategoriesOpen — survives reloads
  + navigations, never clobbers other choices.
- CategorySection in DashboardShell; body animates height (grid-rows 0fr<->1fr) +
  opacity via .hz-acc, chevron rotates via .hz-chevron, both reduced-motion-
  guarded; collapsed body is inert (out of tab order). Shared by the desktop
  sidebar AND the mobile drawer (one SidebarNav). Items/icons/colors/routes/
  active-state unchanged; only the grouping is now collapsible.

tsc 0 errors, vitest 1086/1086 (+10 nav-accordion), next build green.
2026-07-02 19:18:31 -07:00
hanzo-dev 9fa848bc68 feat(nav): collapsible category accordion in the sidebar product nav (v8.4.35)
Each level-1 CATEGORY is now a collapsible section: the header is a clickable
button with an obvious rotating chevron (▸ collapsed, ▾ expanded), keyboard-
toggleable + aria-expanded, over its product rows revealed in order when open.
The ~120-item flat list condenses to 13 tidy topic headers so the nav stops
overwhelming the user.

- Pure model: src/lib/products/nav-accordion.ts (categoryIsOpen/toggleCategory,
  10 unit tests). Default COLLAPSED for most; the active route's category is
  always open (navigating reveals it, even if collapsed); filtering opens every
  matching group so search is never hidden; clearing search restores collapse.
- Persisted per-user via usePreferences (account-backed + localStorage cache,
  the existing sidebarCollapsed idiom) under navCategoriesOpen — survives reloads
  + navigations, never clobbers other choices.
- CategorySection in DashboardShell; body animates height (grid-rows 0fr<->1fr) +
  opacity via .hz-acc, chevron rotates via .hz-chevron, both reduced-motion-
  guarded; collapsed body is inert (out of tab order). Shared by the desktop
  sidebar AND the mobile drawer (one SidebarNav). Items/icons/colors/routes/
  active-state unchanged; only the grouping is now collapsible.

tsc 0 errors, vitest 1086/1086 (+10 nav-accordion), next build green.
2026-07-02 19:18:31 -07:00
hanzo-dev 84bebb264e fix: P0 fetch-binding regression + live-shape corrections + Playground multi-image/image-only (v8.4.34)
P0 [CRITICAL]: v8.4.33 resilientFetch called the global fetch as a METHOD
(deps.doFetch(url,init) → this=deps) → the browser threw 'Failed to execute fetch
on Window: Illegal invocation' on EVERY cloud/BFF call — the whole API layer broke
(Analytics/Models/CRM/CMS/... all 'Could not reach the backend'). The client-retry
unit tests passed because they injected a MOCK doFetch (no this requirement) — the
exact class a mock hides. Fixed: destructure const doFetch = deps.doFetch and call it
BARE (this=undefined) — works for a raw global fetch AND a wrapped one. New regression
test simulates a global-only fetch (throws unless this is the global) and asserts the
bare invocation. Live console was rolled back to v8.4.31 on detection; this is the fix.
LESSON: verify a shared-fetch refactor by RENDERING a live data page, not just unit tests.

live-shape (caught by the same live pass):
- CMS: Payload SQLite INTEGER ids ({id:3}) were read as strings → id='' (rowKey
  collisions); number-aware idStr. Media bytes url carries ?prefix=<tenant> that the
  filename-reconstruction dropped → cmsMediaSrc proxies the doc's real url through /cms.
- Commerce: /v1/store/current wraps the record as {store:{}} → currentStore unwraps .store.

RED LOW-1: /erp allow-list pinned to EXACTLY {Account, Item, Sales Order} (was any
DocType) so an entitled brand member can't over-read User/Salary Slip/etc. through the
shared ERP_API_TOKEN. RED verdict on v8.4.33: 0 crit/high/med, cross-tenant isolation
SOUND across CMS/ERP/Help/Analytics — SHIP.

Playground (coordinator, real user bugs):
- Multi-image upload: composer attachment (single) → attachments[] (append; multi-select
  dialog OR successive uploads/drag-drop accumulate); file input 'multiple'; thumbnail
  strip with count + per-image remove; buildRunMessages pushes one image_url part per image.
- 'Run does nothing' (image-only): validateRun now counts an attached image as user
  content — an image-only vision prompt is valid and Run proceeds; blocks ONLY a
  genuinely-empty message, and the reason renders PROMINENTLY right above the Run button.

tsc clean; vitest 1076/1076 (89 files); next build green.
2026-07-02 18:34:43 -07:00
hanzo-dev 672524e51d fix: P0 fetch-binding regression + live-shape corrections + Playground multi-image/image-only (v8.4.34)
P0 [CRITICAL]: v8.4.33 resilientFetch called the global fetch as a METHOD
(deps.doFetch(url,init) → this=deps) → the browser threw 'Failed to execute fetch
on Window: Illegal invocation' on EVERY cloud/BFF call — the whole API layer broke
(Analytics/Models/CRM/CMS/... all 'Could not reach the backend'). The client-retry
unit tests passed because they injected a MOCK doFetch (no this requirement) — the
exact class a mock hides. Fixed: destructure const doFetch = deps.doFetch and call it
BARE (this=undefined) — works for a raw global fetch AND a wrapped one. New regression
test simulates a global-only fetch (throws unless this is the global) and asserts the
bare invocation. Live console was rolled back to v8.4.31 on detection; this is the fix.
LESSON: verify a shared-fetch refactor by RENDERING a live data page, not just unit tests.

live-shape (caught by the same live pass):
- CMS: Payload SQLite INTEGER ids ({id:3}) were read as strings → id='' (rowKey
  collisions); number-aware idStr. Media bytes url carries ?prefix=<tenant> that the
  filename-reconstruction dropped → cmsMediaSrc proxies the doc's real url through /cms.
- Commerce: /v1/store/current wraps the record as {store:{}} → currentStore unwraps .store.

RED LOW-1: /erp allow-list pinned to EXACTLY {Account, Item, Sales Order} (was any
DocType) so an entitled brand member can't over-read User/Salary Slip/etc. through the
shared ERP_API_TOKEN. RED verdict on v8.4.33: 0 crit/high/med, cross-tenant isolation
SOUND across CMS/ERP/Help/Analytics — SHIP.

Playground (coordinator, real user bugs):
- Multi-image upload: composer attachment (single) → attachments[] (append; multi-select
  dialog OR successive uploads/drag-drop accumulate); file input 'multiple'; thumbnail
  strip with count + per-image remove; buildRunMessages pushes one image_url part per image.
- 'Run does nothing' (image-only): validateRun now counts an attached image as user
  content — an image-only vision prompt is valid and Run proceeds; blocks ONLY a
  genuinely-empty message, and the reason renders PROMINENTLY right above the Run button.

tsc clean; vitest 1076/1076 (89 files); next build green.
2026-07-02 18:34:43 -07:00
hanzo-devandGitHub 4ad6691a4b fix(machines): interactive live catalog (tap-to-launch) + native cloud /v1 wiring (#57)
Customer Machines showed a real but NON-interactive catalog (region chips + every size row were static text) under the "launch your first machine" state, so a data-rich priced catalog you couldn't click read as "not clickable / fake." Make it a real launch surface; finish the last /paas->/cloud data wiring in the cluster path.

- MachineCatalog: every size row is now clickable -> opens the real LaunchDrawer preselected on that size (and region); region chips are selectable filters (filter the size list via pure filterSizes + preset the launch region). Rows reflow to a mobile-friendly 2-column layout (no fixed 4-column table). No fabricated data: with no onLaunch the rows stay informational and an empty catalog still renders nothing.

- LaunchDrawer: accepts initialSize/initialRegion to open preselected.

- CustomerMachines: one openLaunch(preset) opener feeds the header button, empty-state CTA, and the catalog.

- visor: capture the per-size regions[] visor already returns (was dropped in normalizeSize) + pure filterSizes(sizes, query, region), unit-tested.

- platform: repoint provisionCluster from the /paas service-token proxy to native cloud /v1 (/cloud/v1/org/{org}/cluster, user IAM session) - the last /paas call in the cluster path; cluster reads already use /cloud/v1/clusters.

tsc --noEmit clean; visor.test.ts 16/16 (added regions + filterSizes cases). Verified locally headless: tap a size -> drawer opens with the correct $24/mo quote; mobile 0px overflow. Live data lights up once the console redeploys (this branch's /cloud allow-list already admits machines/clusters/gpus) against cloud v1.786.26 which now serves GET /v1/machines.
2026-07-02 18:03:50 -07:00
hanzo-devandGitHub 8a1a0b46bc fix(machines): interactive live catalog (tap-to-launch) + native cloud /v1 wiring (#57)
Customer Machines showed a real but NON-interactive catalog (region chips + every size row were static text) under the "launch your first machine" state, so a data-rich priced catalog you couldn't click read as "not clickable / fake." Make it a real launch surface; finish the last /paas->/cloud data wiring in the cluster path.

- MachineCatalog: every size row is now clickable -> opens the real LaunchDrawer preselected on that size (and region); region chips are selectable filters (filter the size list via pure filterSizes + preset the launch region). Rows reflow to a mobile-friendly 2-column layout (no fixed 4-column table). No fabricated data: with no onLaunch the rows stay informational and an empty catalog still renders nothing.

- LaunchDrawer: accepts initialSize/initialRegion to open preselected.

- CustomerMachines: one openLaunch(preset) opener feeds the header button, empty-state CTA, and the catalog.

- visor: capture the per-size regions[] visor already returns (was dropped in normalizeSize) + pure filterSizes(sizes, query, region), unit-tested.

- platform: repoint provisionCluster from the /paas service-token proxy to native cloud /v1 (/cloud/v1/org/{org}/cluster, user IAM session) - the last /paas call in the cluster path; cluster reads already use /cloud/v1/clusters.

tsc --noEmit clean; visor.test.ts 16/16 (added regions + filterSizes cases). Verified locally headless: tap a size -> drawer opens with the correct $24/mo quote; mobile 0px overflow. Live data lights up once the console redeploys (this branch's /cloud allow-list already admits machines/clusters/gpus) against cloud v1.786.26 which now serves GET /v1/machines.
2026-07-02 18:03:50 -07:00
hanzo-dev c5c72cd8e9 fix(api): resilient shared fetch — transient upstream errors auto-retry (v8.4.33)
Backend rolls invisible to customers. Root cause of a real 'Could not load — Upstream
service is unavailable' (Dave/maxpower, Models catalog): cloud is single-replica
Recreate, so a deploy-roll has a brief downtime window; a read landing in it got a
502/503/504 and the console showed a scary manual-Retry card.

Fixed in the ONE shared fetch (client.ts authedFetch → the pure, injectable
resilientFetch) that BOTH the casibase-envelope (request) and plain-REST (restRequest)
paths flow through — covers EVERY client fetch (Models, Overview, Billing, CRM, CMS,
ERP, commerce, analytics, agents, prompts, …), DRY.

- Transient upstream (502/503/504 or a network connection error) on an IDEMPOTENT read
  (GET/HEAD) → auto-retry with exponential backoff (300→900→2000ms, up to 3) BEFORE the
  honest 'Could not load' card, so a momentary roll self-heals; the card shows ONLY on a
  persistent outage (after retries exhaust).
- Genuine 4xx (401/403/404/402) → NOT retried (honest state immediately).
- Mutation (POST/PUT/PATCH/DELETE) → NOT auto-retried (a 5xx'd write may have applied —
  re-sending could double-create; the user retries manually).
- Caller-aborted request → honored, never retried.
- The 401 silent-refresh (v8.4.29) is preserved as the second orthogonal resilience,
  guarded against a refresh loop.

+13 tests (client-retry.test.ts): the exact Models-catalog 503→200 self-heal, budget
exhaust → honest error, network retry, 4xx/mutation no-retry, abort honored, 401 refresh
no-loop, + classification helpers.

v8.4.33 = the deployed superset (v8.4.32 native-apps set + this). tsc clean; vitest
1061/1061; next build green.
2026-07-02 17:57:04 -07:00
hanzo-dev c4f8851a90 fix(api): resilient shared fetch — transient upstream errors auto-retry (v8.4.33)
Backend rolls invisible to customers. Root cause of a real 'Could not load — Upstream
service is unavailable' (Dave/maxpower, Models catalog): cloud is single-replica
Recreate, so a deploy-roll has a brief downtime window; a read landing in it got a
502/503/504 and the console showed a scary manual-Retry card.

Fixed in the ONE shared fetch (client.ts authedFetch → the pure, injectable
resilientFetch) that BOTH the casibase-envelope (request) and plain-REST (restRequest)
paths flow through — covers EVERY client fetch (Models, Overview, Billing, CRM, CMS,
ERP, commerce, analytics, agents, prompts, …), DRY.

- Transient upstream (502/503/504 or a network connection error) on an IDEMPOTENT read
  (GET/HEAD) → auto-retry with exponential backoff (300→900→2000ms, up to 3) BEFORE the
  honest 'Could not load' card, so a momentary roll self-heals; the card shows ONLY on a
  persistent outage (after retries exhaust).
- Genuine 4xx (401/403/404/402) → NOT retried (honest state immediately).
- Mutation (POST/PUT/PATCH/DELETE) → NOT auto-retried (a 5xx'd write may have applied —
  re-sending could double-create; the user retries manually).
- Caller-aborted request → honored, never retried.
- The 401 silent-refresh (v8.4.29) is preserved as the second orthogonal resilience,
  guarded against a refresh loop.

+13 tests (client-retry.test.ts): the exact Models-catalog 503→200 self-heal, budget
exhaust → honest error, network retry, 4xx/mutation no-retry, abort honored, 401 refresh
no-loop, + classification helpers.

v8.4.33 = the deployed superset (v8.4.32 native-apps set + this). tsc clean; vitest
1061/1061; next build green.
2026-07-02 17:57:04 -07:00
hanzo-dev b647d9832d feat(apps): native ERP/CMS/Analytics + real commerce over canonical backends (v8.4.32)
Maximize native app coverage in the console — bind ERP, Content (CMS), Analytics,
and Commerce to their canonical backends per-org / entitlement-gated, one canonical
way, no fabricated data. Contracts verified against source repos + live probes.

Analytics — rebound to the FOUR real cloud clients/analytics routes (overview/
timeseries/top/health; the module had called 5 non-existent endpoints with wrong
shapes). LLM lens is REAL live per-org data (hanzo.cloud_usage, prod ClickHouse);
web/commerce lenses honest-empty via the backend 'available' flag. Dropped the
fabricated Real-Time tab (no backend). Tabs: Overview + LLM (top models).

Content (CMS) — tabbed: NATIVE Collections + Media/DAM read live over Payload REST
through a new /cms user-bearer proxy. Payload's multi-tenant plugin isolates rows by
the IAM owner claim → each org reads ONLY its own (per-org, backend-enforced);
allow-list admits only the two tenant-scoped collections + media bytes, never the
users/tenants registry. Studio tab keeps the entitlement-gated admin embed.

ERP — tabbed + entitlement-gated (Frappe is single-tenant → brand-org/global-admin
only). Overview drives a REAL /v1/platform deploy of the ERPNext app (idempotent
create-project+app+deploy, live status). Accounting/Items/Sales are NATIVE Frappe
REST summary views (real erpnext-v15 DocType fields) over a new /erp proxy (Frappe
token auth, SSRF-clamped, read-only resource lists) — honest 'deploy ERP' until an
instance is live. Desk embeds the real desk once reachable.

Commerce — Products full CRUD (create+delete over /v1/product; validator needs
name+sku+slug); Store settings reads the org's real storefront (/v1/store/current).
Orders/Customers/Inventory/Promotions stay real per-org reads. Via the /commerce
bearer proxy (org from token owner). hanzoai/commerce is the ONE authority — NOT Medusa.

GPUs (drive-by) — KPI reconciled: was distinct-model count (6) vs the catalog table +
Launch drawer configs (9); now shows launchable configs with model count in the sub.

tsc clean; vitest 1050/1050 (+22); next build green (/cms + /erp routes registered).
2026-07-02 17:47:32 -07:00
hanzo-dev 4e39fd0708 feat(apps): native ERP/CMS/Analytics + real commerce over canonical backends (v8.4.32)
Maximize native app coverage in the console — bind ERP, Content (CMS), Analytics,
and Commerce to their canonical backends per-org / entitlement-gated, one canonical
way, no fabricated data. Contracts verified against source repos + live probes.

Analytics — rebound to the FOUR real cloud clients/analytics routes (overview/
timeseries/top/health; the module had called 5 non-existent endpoints with wrong
shapes). LLM lens is REAL live per-org data (hanzo.cloud_usage, prod ClickHouse);
web/commerce lenses honest-empty via the backend 'available' flag. Dropped the
fabricated Real-Time tab (no backend). Tabs: Overview + LLM (top models).

Content (CMS) — tabbed: NATIVE Collections + Media/DAM read live over Payload REST
through a new /cms user-bearer proxy. Payload's multi-tenant plugin isolates rows by
the IAM owner claim → each org reads ONLY its own (per-org, backend-enforced);
allow-list admits only the two tenant-scoped collections + media bytes, never the
users/tenants registry. Studio tab keeps the entitlement-gated admin embed.

ERP — tabbed + entitlement-gated (Frappe is single-tenant → brand-org/global-admin
only). Overview drives a REAL /v1/platform deploy of the ERPNext app (idempotent
create-project+app+deploy, live status). Accounting/Items/Sales are NATIVE Frappe
REST summary views (real erpnext-v15 DocType fields) over a new /erp proxy (Frappe
token auth, SSRF-clamped, read-only resource lists) — honest 'deploy ERP' until an
instance is live. Desk embeds the real desk once reachable.

Commerce — Products full CRUD (create+delete over /v1/product; validator needs
name+sku+slug); Store settings reads the org's real storefront (/v1/store/current).
Orders/Customers/Inventory/Promotions stay real per-org reads. Via the /commerce
bearer proxy (org from token owner). hanzoai/commerce is the ONE authority — NOT Medusa.

GPUs (drive-by) — KPI reconciled: was distinct-model count (6) vs the catalog table +
Launch drawer configs (9); now shows launchable configs with model count in the sub.

tsc clean; vitest 1050/1050 (+22); next build green (/cms + /erp routes registered).
2026-07-02 17:47:32 -07:00
hanzo-dev 9cff39e59f console: repoint ServiceMesh + Edge to native /v1 (close zt loose end)
The zt cloud client bound /v1/mesh/services + /v1/edge/nodes and the heads +
proxy-allow landed, but the two module fetches were left on the /paas proxy.
Switch both to cloudProxyV1Url (the user-bearer /cloud proxy) like the other
repointed modules. Networks already used /v1/networks. Now all 12 infra pages
(compute/DO/platform/zt) read the native cloud /v1 gateway.
2026-07-02 17:43:07 -07:00
hanzo-dev 82ca08c848 console: repoint ServiceMesh + Edge to native /v1 (close zt loose end)
The zt cloud client bound /v1/mesh/services + /v1/edge/nodes and the heads +
proxy-allow landed, but the two module fetches were left on the /paas proxy.
Switch both to cloudProxyV1Url (the user-bearer /cloud proxy) like the other
repointed modules. Networks already used /v1/networks. Now all 12 infra pages
(compute/DO/platform/zt) read the native cloud /v1 gateway.
2026-07-02 17:43:07 -07:00
hanzo-dev 0e20f0322e feat(console): wire 9 cloud modules to native /v1 (repoint /paas → /cloud)
The unified cloud binary now serves these surfaces per-org at /v1/*; repoint
each module from the /paas control-plane proxy to the native cloud /v1 gateway
via the user-bearer /cloud proxy (org resolved from the Bearer owner).

Compute (visor-backed):
- Machines: VisorApi.machines/quote/launch → /cloud/v1/machines[/launch];
  add terminate (DELETE /v1/machines/:id) + a Terminate action on the customer
  view. Catalog (regions/sizes/gpus) stays on visor /vm.
- GPUs: ComputeApi.gpus/alerts/pools → /cloud/v1/gpus[/alerts|/pools].
- Clusters: PlatformApi.listClusters/getCluster → /v1/clusters (org-scoped by
  the Bearer owner; drop the org arg — 5 callers updated); add node-pool
  add/scale/delete (POST/DELETE /v1/clusters/:cid/pools[/:pid[/scale]]) + a
  Node pools management UI. apps() + provisionCluster() stay on /paas.

DO-native (full CRUD):
- VPC: list + create + delete (GET/POST/DELETE /v1/vpcs[/:id]).
- Load Balancers: list + create + delete (GET/POST/DELETE /v1/load-balancers[/:id]).

Platform aggregates (list-only, read-only):
- Environments / Pipelines / Builds / Releases: restGet(cloudProxyV1Url(...)).

Heads: add machines,gpus,clusters,vpcs,load-balancers,environments,pipelines,
builds,releases to proxy-allow CLOUD_HEADS; add vpcs,load-balancers to
next.config CLOUD_V1_HEADS.

tsc --noEmit clean; compute/visor/logic/proxy-allow tests pass.
2026-07-02 17:39:07 -07:00
hanzo-dev 62925485ec feat(console): wire 9 cloud modules to native /v1 (repoint /paas → /cloud)
The unified cloud binary now serves these surfaces per-org at /v1/*; repoint
each module from the /paas control-plane proxy to the native cloud /v1 gateway
via the user-bearer /cloud proxy (org resolved from the Bearer owner).

Compute (visor-backed):
- Machines: VisorApi.machines/quote/launch → /cloud/v1/machines[/launch];
  add terminate (DELETE /v1/machines/:id) + a Terminate action on the customer
  view. Catalog (regions/sizes/gpus) stays on visor /vm.
- GPUs: ComputeApi.gpus/alerts/pools → /cloud/v1/gpus[/alerts|/pools].
- Clusters: PlatformApi.listClusters/getCluster → /v1/clusters (org-scoped by
  the Bearer owner; drop the org arg — 5 callers updated); add node-pool
  add/scale/delete (POST/DELETE /v1/clusters/:cid/pools[/:pid[/scale]]) + a
  Node pools management UI. apps() + provisionCluster() stay on /paas.

DO-native (full CRUD):
- VPC: list + create + delete (GET/POST/DELETE /v1/vpcs[/:id]).
- Load Balancers: list + create + delete (GET/POST/DELETE /v1/load-balancers[/:id]).

Platform aggregates (list-only, read-only):
- Environments / Pipelines / Builds / Releases: restGet(cloudProxyV1Url(...)).

Heads: add machines,gpus,clusters,vpcs,load-balancers,environments,pipelines,
builds,releases to proxy-allow CLOUD_HEADS; add vpcs,load-balancers to
next.config CLOUD_V1_HEADS.

tsc --noEmit clean; compute/visor/logic/proxy-allow tests pass.
2026-07-02 17:39:07 -07:00
zandGitHub e19aea466d Merge pull request #56 from hanzoai/feat/console-apps
feat(console): Apps — hanzo.app buildable-sites round-trip over /v1/projects
2026-07-02 17:37:56 -07:00
zandGitHub 9ccfbc0995 Merge pull request #56 from hanzoai/feat/console-apps
feat(console): Apps — hanzo.app buildable-sites round-trip over /v1/projects
2026-07-02 17:37:56 -07:00
hanzo-dev 897a0986c7 feat(web3): brand-scoped launch tiles for the deployed Lux/Zoo chain-app suite
Surface the standalone, already-deployed Lux/Zoo web3 apps (Explorer,
Exchange, Bridge, Faucet, Safe, DEX, Wallet) as launch tiles in the Web3
category — not rebuilt in-console, opened at their own domains.

Modeled as a restored `kind: 'external'; href` CatalogEntry member (the
honest sum type for a standalone app that owns no in-console route) — it
slots into the `kind !== 'module'` fail-closed guards the module-only
collapse deliberately preserved, so productSubpages/resolveProductView/
destinationsFor/productModules never manufacture a dead route for it.
`openProduct` becomes the ONE opener: a module navigates to `/<id>`, an
external opens `href` in a new tab. Every card seam (nav, launcher, ⌘K,
category page, level-2 siblings) routes through it, so no external tile
can 404.

Per-entry brand scope (`brands?: BrandId[]` + pure `entryInBrandScope`,
mirroring `nodeNetworksForBrand`) keeps the two suites from cross-leaking
inside the shared Web3 category: Lux tiles show only on lux, Zoo only on
zoo. Every href is a real, verified deployment — no fabricated URLs.

Tests: per-entry brand-scope predicate (no cross-leak); tsc clean; full
suite 1019 passing.
2026-07-02 17:37:08 -07:00
hanzo-dev 4862544457 feat(web3): brand-scoped launch tiles for the deployed Lux/Zoo chain-app suite
Surface the standalone, already-deployed Lux/Zoo web3 apps (Explorer,
Exchange, Bridge, Faucet, Safe, DEX, Wallet) as launch tiles in the Web3
category — not rebuilt in-console, opened at their own domains.

Modeled as a restored `kind: 'external'; href` CatalogEntry member (the
honest sum type for a standalone app that owns no in-console route) — it
slots into the `kind !== 'module'` fail-closed guards the module-only
collapse deliberately preserved, so productSubpages/resolveProductView/
destinationsFor/productModules never manufacture a dead route for it.
`openProduct` becomes the ONE opener: a module navigates to `/<id>`, an
external opens `href` in a new tab. Every card seam (nav, launcher, ⌘K,
category page, level-2 siblings) routes through it, so no external tile
can 404.

Per-entry brand scope (`brands?: BrandId[]` + pure `entryInBrandScope`,
mirroring `nodeNetworksForBrand`) keeps the two suites from cross-leaking
inside the shared Web3 category: Lux tiles show only on lux, Zoo only on
zoo. Every href is a real, verified deployment — no fabricated URLs.

Tests: per-entry brand-scope predicate (no cross-leak); tsc clean; full
suite 1019 passing.
2026-07-02 17:37:08 -07:00
hanzo-dev 095c2250a6 feat(console): Apps — the org's hanzo.app buildable-sites over /v1/projects, with Edit-in-hanzo.app deep-links
Closes the console→app round-trip: a Platform › Apps module lists the org's
buildable/deployed sites from the shared org-scoped cloud clients/projectsvc
store (/v1/projects, same-origin user-bearer /cloud proxy — the exact per-tenant
path Agents/CRM use). Per row: Open site (liveUrl) + Edit in hanzo.app
(/dev?project=<slug>). Honest loading/empty/BackendState; never fabricates rows.

- src/lib/api/apps.ts        AppsApi (list/get/deployments) + defensive projectView/
                             deploymentView normalizers + injection-safe builderEditUrl
- src/lib/api/apps.test.ts   normalizers + /v1/projects route contract + deep-link (9)
- AppsModule.tsx             org-scoped list (Site/Framework/Status/Updated/actions) +
                             per-site deploy-history detail rail (:slug route)
- registry.tsx               ONE Platform entry id:apps (distinct from IAM Projects
                             scope + Compute Applications PaaS)

tsc --noEmit clean; vitest 1023/1023 (85 files); next build ✓ (17/17).
2026-07-02 17:36:29 -07:00
hanzo-dev 32139d19a6 feat(console): Apps — the org's hanzo.app buildable-sites over /v1/projects, with Edit-in-hanzo.app deep-links
Closes the console→app round-trip: a Platform › Apps module lists the org's
buildable/deployed sites from the shared org-scoped cloud clients/projectsvc
store (/v1/projects, same-origin user-bearer /cloud proxy — the exact per-tenant
path Agents/CRM use). Per row: Open site (liveUrl) + Edit in hanzo.app
(/dev?project=<slug>). Honest loading/empty/BackendState; never fabricates rows.

- src/lib/api/apps.ts        AppsApi (list/get/deployments) + defensive projectView/
                             deploymentView normalizers + injection-safe builderEditUrl
- src/lib/api/apps.test.ts   normalizers + /v1/projects route contract + deep-link (9)
- AppsModule.tsx             org-scoped list (Site/Framework/Status/Updated/actions) +
                             per-site deploy-history detail rail (:slug route)
- registry.tsx               ONE Platform entry id:apps (distinct from IAM Projects
                             scope + Compute Applications PaaS)

tsc --noEmit clean; vitest 1023/1023 (85 files); next build ✓ (17/17).
2026-07-02 17:36:29 -07:00
zandGitHub 99e600d5fd Merge pull request #55 from hanzoai/feat/console2-admin-fleets
feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
2026-07-02 17:21:19 -07:00
zandGitHub 1578c8c3aa Merge pull request #55 from hanzoai/feat/console2-admin-fleets
feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
2026-07-02 17:21:19 -07:00
hanzo-dev 592bf16a03 feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
Two GLOBAL-ADMIN operator boards on admin.hanzo.ai (Observe, beside Business +
Finance), two lenses over ONE datastore table split on `kind`: Bots (kind=bot —
@hanzo/bot agents booted, gateway-connected) and Machines (kind=machine — raw VMs
visor opens). Each surfaces per-org/app/project count, active, and spend, grouped
org -> app -> project, sourced from the unified datastore (ClickHouse) via a new
`compute` admin-aggregate head. `/v1/admin/compute?kind=` is server-gated by
getAdminGate (the RED-H1 gate) and rewritten to app/admin/aggregate — no new proxy
or trust boundary.

- lib/api/admin-compute.ts: kind-parameterized, optional-safe client + pure
  foldEvents/buildTree over both pre-aggregated {leaves} and raw {events} (9-col
  datastore schema: org, app, project, kind, event, machine_id, size, price_cents,
  ts). Rollup {count,active,spendCents}; normalizeCompute(raw, kind) filters to kind.
- components/products/ComputeModule.tsx: ONE ComputeBoard({kind}); BotsModule /
  MachinesModule are thin wrappers (collapsible org->app->project tree + KPIs,
  honest loading/403/404/empty states — honest-empty until the emitter lands).
- registry: `bots` + `vms` (Machines) entries, admin:true; the admin machines module
  is aliased to avoid the clash with the per-org customer Machines (visor).
- admin-aggregate.ts + next.config.mjs: `compute` added to the admin read heads.
- Pairs with cloud GET /v1/admin/compute (hanzoai/cloud#62).

tsc clean; npm test 1012/1012; next build green.
2026-07-02 17:07:37 -07:00
hanzo-dev 8d6774b614 feat(console): Bots + Machines — per-org/app/project compute analytics from the datastore
Two GLOBAL-ADMIN operator boards on admin.hanzo.ai (Observe, beside Business +
Finance), two lenses over ONE datastore table split on `kind`: Bots (kind=bot —
@hanzo/bot agents booted, gateway-connected) and Machines (kind=machine — raw VMs
visor opens). Each surfaces per-org/app/project count, active, and spend, grouped
org -> app -> project, sourced from the unified datastore (ClickHouse) via a new
`compute` admin-aggregate head. `/v1/admin/compute?kind=` is server-gated by
getAdminGate (the RED-H1 gate) and rewritten to app/admin/aggregate — no new proxy
or trust boundary.

- lib/api/admin-compute.ts: kind-parameterized, optional-safe client + pure
  foldEvents/buildTree over both pre-aggregated {leaves} and raw {events} (9-col
  datastore schema: org, app, project, kind, event, machine_id, size, price_cents,
  ts). Rollup {count,active,spendCents}; normalizeCompute(raw, kind) filters to kind.
- components/products/ComputeModule.tsx: ONE ComputeBoard({kind}); BotsModule /
  MachinesModule are thin wrappers (collapsible org->app->project tree + KPIs,
  honest loading/403/404/empty states — honest-empty until the emitter lands).
- registry: `bots` + `vms` (Machines) entries, admin:true; the admin machines module
  is aliased to avoid the clash with the per-org customer Machines (visor).
- admin-aggregate.ts + next.config.mjs: `compute` added to the admin read heads.
- Pairs with cloud GET /v1/admin/compute (hanzoai/cloud#62).

tsc clean; npm test 1012/1012; next build green.
2026-07-02 17:07:37 -07:00
hanzo-dev 896d6584f5 fix(auth): two cookies — small identity (Path=/) + chunked refresh (Path=/auth) — browser-safe (v8.4.31)
Casdoor refresh tokens are ALSO ~3.6KB full-user JWTs, so v8.4.30 sealed cookie
was still 5560 bytes (> browser 4KB cap → a real browser would reject it). Split:
- hz_session (Path=/, sealed {access-exp, projected claims}, ~1KB): resolveUser/BFF.
- hz_rt (Path=/auth, sealed refresh token, chunked hz_rt0/hz_rt1): sent ONLY to
  /auth — never to /v1 or the BFF, so no header bloat / gateway-431 risk.
Both sealed (integrity). Live-verified: establish/GET/refresh-rotate all 200.
2026-07-02 17:05:57 -07:00
hanzo-dev 88a2730871 fix(auth): two cookies — small identity (Path=/) + chunked refresh (Path=/auth) — browser-safe (v8.4.31)
Casdoor refresh tokens are ALSO ~3.6KB full-user JWTs, so v8.4.30 sealed cookie
was still 5560 bytes (> browser 4KB cap → a real browser would reject it). Split:
- hz_session (Path=/, sealed {access-exp, projected claims}, ~1KB): resolveUser/BFF.
- hz_rt (Path=/auth, sealed refresh token, chunked hz_rt0/hz_rt1): sent ONLY to
  /auth — never to /v1 or the BFF, so no header bloat / gateway-431 risk.
Both sealed (integrity). Live-verified: establish/GET/refresh-rotate all 200.
2026-07-02 17:05:57 -07:00
hanzo-dev 0fbcdad02b fix(auth): seal PROJECTED claims + refresh (not the ~10KB access JWT) — browser-safe hz_session cookie (v8.4.30)
The v8.4.29 sealed cookie held the raw Casdoor access token (whole user object,
~9.8 KB) which exceeds the browser 4 KB per-cookie limit → a real browser would
reject it (curl does not). sealSession now projects to the small display/authz
claim set + refresh token + exp → a bounded ~1 KB cookie. Live-verified.
2026-07-02 16:50:17 -07:00
hanzo-dev 7be50d4345 fix(auth): seal PROJECTED claims + refresh (not the ~10KB access JWT) — browser-safe hz_session cookie (v8.4.30)
The v8.4.29 sealed cookie held the raw Casdoor access token (whole user object,
~9.8 KB) which exceeds the browser 4 KB per-cookie limit → a real browser would
reject it (curl does not). sealSession now projects to the small display/authz
claim set + refresh token + exp → a bounded ~1 KB cookie. Live-verified.
2026-07-02 16:50:17 -07:00
hanzo-dev d758305ee3 feat(auth): silent token-refresh — durable console OAuth session, no mid-task logout (v8.4.29)
Adds a console-owned hanzo-console OAuth session (access + rotating refresh, sealed
AES-256-GCM in httpOnly hz_session) as the preferred identity source for the AuthGate
and the /cloud bearer-proxy, silently refreshed via grant_type=refresh_token
(proactive timer + reactive single-flight on 401 + self-heal-on-load). Casibase
session kept as the graceful fallback; strictly additive, zero regression.

- src/lib/server/session.ts: token manager (password/refresh grants, AEAD seal, claims)
- app/auth/session/route.ts: establish (gated, MFA-safe) / current / signout
- app/auth/refresh/route.ts: rotation-aware refresh, no-clear-on-fail (multi-tab safe)
- resolveUser prefers the console session; client proactive+reactive refresh
- +27 tests; tsc + next build green
2026-07-02 16:35:22 -07:00
hanzo-dev 022c5194fb feat(auth): silent token-refresh — durable console OAuth session, no mid-task logout (v8.4.29)
Adds a console-owned hanzo-console OAuth session (access + rotating refresh, sealed
AES-256-GCM in httpOnly hz_session) as the preferred identity source for the AuthGate
and the /cloud bearer-proxy, silently refreshed via grant_type=refresh_token
(proactive timer + reactive single-flight on 401 + self-heal-on-load). Casibase
session kept as the graceful fallback; strictly additive, zero regression.

- src/lib/server/session.ts: token manager (password/refresh grants, AEAD seal, claims)
- app/auth/session/route.ts: establish (gated, MFA-safe) / current / signout
- app/auth/refresh/route.ts: rotation-aware refresh, no-clear-on-fail (multi-tab safe)
- resolveUser prefers the console session; client proactive+reactive refresh
- +27 tests; tsc + next build green
2026-07-02 16:35:22 -07:00
hanzo-dev 089a58cba2 feat(kubeflow): real ML Pipelines module over the live Kubeflow bridge
The last ComingSoon stub is now a real module. The cloud mlsvc
(hanzoai/cloud clients/ml) fronts the Kubeflow-family CRDs as REST, so
KubeflowModule is the read-only orchestration + control-plane lens over
that live surface (distinct from Fine-tuning's train-my-model wizard):

- Control-plane health strip from a REAL probe (GET /v1/train/health) —
  which Kubeflow operators/CRDs (Trainer/trainjobs, Katib/experiments)
  are actually served; honest connected/degraded/not-reporting states.
- Pipelines = Katib Experiments (GET /v1/train/experiments).
- Runs = trainer TrainJobs (GET /v1/train/jobs).

Pipelines/runs REUSE TrainApi (one client, no duplication); the new
KubeflowApi adds only the control-plane probe the console lacked (a
tolerant fetch that reads the 503 body restGet would discard). The
/training proxy allowlist gains train/health (additive, read-only).
Registry: kubeflow flips soon -> enabled, routes -> KubeflowModule.
Honest states only, no fabricated data. tsc clean; 84 registry tests pass.
2026-07-02 16:16:30 -07:00
hanzo-dev c55547495c feat(kubeflow): real ML Pipelines module over the live Kubeflow bridge
The last ComingSoon stub is now a real module. The cloud mlsvc
(hanzoai/cloud clients/ml) fronts the Kubeflow-family CRDs as REST, so
KubeflowModule is the read-only orchestration + control-plane lens over
that live surface (distinct from Fine-tuning's train-my-model wizard):

- Control-plane health strip from a REAL probe (GET /v1/train/health) —
  which Kubeflow operators/CRDs (Trainer/trainjobs, Katib/experiments)
  are actually served; honest connected/degraded/not-reporting states.
- Pipelines = Katib Experiments (GET /v1/train/experiments).
- Runs = trainer TrainJobs (GET /v1/train/jobs).

Pipelines/runs REUSE TrainApi (one client, no duplication); the new
KubeflowApi adds only the control-plane probe the console lacked (a
tolerant fetch that reads the 503 body restGet would discard). The
/training proxy allowlist gains train/health (additive, read-only).
Registry: kubeflow flips soon -> enabled, routes -> KubeflowModule.
Honest states only, no fabricated data. tsc clean; 84 registry tests pass.
2026-07-02 16:16:30 -07:00
hanzo-dev 62644a072a test(e2e): pages pass signs in ONCE, reuses session (was 94 logins → rate-limit)
The 94-page screenshot sweep signed in per test (beforeEach), so ~94 logins as
z@hanzo.ai tripped IAM's 'too many login attempts' rate-limit around page 35 —
that's the security feature working, not a page failure. Switch to a shared
serial context: one signIn in beforeAll, every page reuses the cookie. Faster
(~1 login vs 94) and no rate-limit, so the full sweep completes.
2026-07-02 15:19:45 -07:00
hanzo-dev c689487ef8 test(e2e): pages pass signs in ONCE, reuses session (was 94 logins → rate-limit)
The 94-page screenshot sweep signed in per test (beforeEach), so ~94 logins as
z@hanzo.ai tripped IAM's 'too many login attempts' rate-limit around page 35 —
that's the security feature working, not a page failure. Switch to a shared
serial context: one signIn in beforeAll, every page reuses the cookie. Faster
(~1 login vs 94) and no rate-limit, so the full sweep completes.
2026-07-02 15:19:45 -07:00
hanzo-dev a409e8a71b test(e2e): fix 3 stale assertions surfaced by the live prod run
- off-list secrets path: accept 401 OR 404 (both block the tunnel; prod hits the
  auth gate → 401 before the 404 allow-list check). A 2xx would be the real bug.
- API key extraction: match only a FULL hk- token (16+ chars, no ellipsis) so it
  never grabs the masked 'hk-2f18…' account-card display as a credential.
- /v1/messages: pick a model that is ACTUALLY in /v1/models right now instead of
  hardcoding claude-sonnet-4-6 (not provisioned on DO → correct 'not available').
All verified against live prod; the surfaces themselves (proxy gating, key mint,
Anthropic-compat inference) already work — these were test-data/expectation drift.
2026-07-02 15:16:45 -07:00
hanzo-dev a1ba6b7d98 test(e2e): fix 3 stale assertions surfaced by the live prod run
- off-list secrets path: accept 401 OR 404 (both block the tunnel; prod hits the
  auth gate → 401 before the 404 allow-list check). A 2xx would be the real bug.
- API key extraction: match only a FULL hk- token (16+ chars, no ellipsis) so it
  never grabs the masked 'hk-2f18…' account-card display as a credential.
- /v1/messages: pick a model that is ACTUALLY in /v1/models right now instead of
  hardcoding claude-sonnet-4-6 (not provisioned on DO → correct 'not available').
All verified against live prod; the surfaces themselves (proxy gating, key mint,
Anthropic-compat inference) already work — these were test-data/expectation drift.
2026-07-02 15:16:45 -07:00
hanzo-dev 7983dfb24c chore(release): console 8.4.28 — per-vendor COGS donut on the finance board 2026-07-02 14:47:26 -07:00
hanzo-dev 7bddcba544 chore(release): console 8.4.28 — per-vendor COGS donut on the finance board 2026-07-02 14:47:26 -07:00
hanzo-dev b1096575f5 feat(finance): per-vendor COGS donut on the finance board (v8.4.27)
Extends the EXISTING finance living-overview to read the now-multi-vendor
/v1/admin/finance (cloud enriches its cost side from commerce /v1/costs):

- FinanceCost gains {configured, totalCents, vendors[], period}; margin/spend now
  reflect the whole-platform COGS (DO compute + LLM providers), not DO MTD alone.
- fromFinance projects cost.vendors onto a 'vendorCogs' distribution (donut) and
  makes the headline spend + margin the commerce COGS — decoupled from DO, so a
  missing DO_API_TOKEN no longer blanks COGS/margin (DO stays the credit/runway
  treasury view + burn-down series only).
- registry: 'COGS (all vendors)' headline + a 'COGS by vendor' donut beside the
  burn-down; profitability verdict now gates on commerce COGS, not DO.
- reads ONLY /v1/admin/finance — no console-side /costs proxy (the admin-cogs
  duplicate is superseded and dropped).
- tests: vendor donut, zero-line pruning, DO-off-COGS-still-flows, COGS-off honest
  empty. 380 unit tests + tsc + next build green.
2026-07-02 14:46:27 -07:00
hanzo-dev 85d63058ef feat(finance): per-vendor COGS donut on the finance board (v8.4.27)
Extends the EXISTING finance living-overview to read the now-multi-vendor
/v1/admin/finance (cloud enriches its cost side from commerce /v1/costs):

- FinanceCost gains {configured, totalCents, vendors[], period}; margin/spend now
  reflect the whole-platform COGS (DO compute + LLM providers), not DO MTD alone.
- fromFinance projects cost.vendors onto a 'vendorCogs' distribution (donut) and
  makes the headline spend + margin the commerce COGS — decoupled from DO, so a
  missing DO_API_TOKEN no longer blanks COGS/margin (DO stays the credit/runway
  treasury view + burn-down series only).
- registry: 'COGS (all vendors)' headline + a 'COGS by vendor' donut beside the
  burn-down; profitability verdict now gates on commerce COGS, not DO.
- reads ONLY /v1/admin/finance — no console-side /costs proxy (the admin-cogs
  duplicate is superseded and dropped).
- tests: vendor donut, zero-line pruning, DO-off-COGS-still-flows, COGS-off honest
  empty. 380 unit tests + tsc + next build green.
2026-07-02 14:46:27 -07:00
hanzo-devandGitHub 3c6a3dddd4 Merge pull request #54 from hanzoai/feat/console2-record-form-refresh
Record-form data-loss fix + Memory/Datasets delete + 5-min-logout diagnosis (v8.4.27)
2026-07-02 14:26:07 -07:00
hanzo-devandGitHub 068418cbae Merge pull request #54 from hanzoai/feat/console2-record-form-refresh
Record-form data-loss fix + Memory/Datasets delete + 5-min-logout diagnosis (v8.4.27)
2026-07-02 14:26:07 -07:00
hanzo-dev a7a50312e9 fix(console): record-form data-loss (registerDefaultFields) + Memory/Datasets delete key fixes; flag 5-min session as backend TTL (v8.4.27) 2026-07-02 14:25:37 -07:00
hanzo-dev d60ad811f0 fix(console): record-form data-loss (registerDefaultFields) + Memory/Datasets delete key fixes; flag 5-min session as backend TTL (v8.4.27) 2026-07-02 14:25:37 -07:00
zandGitHub 618c7339ff Merge pull request #53 from hanzoai/fix/console-qa
fix(console): wire Status/Metrics to VictoriaMetrics + fix all dropdowns (native select) + mobile
2026-07-02 14:21:24 -07:00
zandGitHub 85b5eeb788 Merge pull request #53 from hanzoai/fix/console-qa
fix(console): wire Status/Metrics to VictoriaMetrics + fix all dropdowns (native select) + mobile
2026-07-02 14:21:24 -07:00
hanzo-dev abb385b461 fix(console): wire Status/Metrics to live VictoriaMetrics, fix broken FieldSelect dropdowns, mobile-scroll tables
Issue 1 — Status/Logs/Metrics not wired (o11y):
- /v1/o11y is NOT 503 (stale) — it's a 403 (auth) reverse-proxy to SigNoz, whose
  runtime is un-set-up (setupCompleted:false, no data). Status read /paas/apps which
  reports ZERO apps; Logs read /paas/logs which 401s (no such endpoint). Neither ever
  showed data. The live signal is VictoriaMetrics (up{job=*-health}, ~29 targets).
- New read-only same-origin proxy app/telemetry/[...path] -> VictoriaMetrics query API
  (authenticated, GET-only, allow-listed to /api/v1/query|query_range|series|labels|
  label/*/values, traversal-hardened, honest 501 when VM_URL unset).
- lib/api/telemetry.ts (pure parse + service-health helpers, unit-tested).
- StatusModule: real up{} service-health board (down-first, healthy/down counts).
- MetricsModule: real VM infra dashboard (KPIs, healthy/targets-over-time, health
  donut, down-now) — replaces the unwired 'metrics' NativeOverview; distinct from
  AI Metrics. LogsModule: honest 'no log store deployed' state (no fabricated grid).

Issue 2 — launch-machine form: FieldSelect used @hanzo/gui <Select native>, which in
gui 7.3.0 emits bare <option> with NO <select> wrapper — every dropdown app-wide (27
usages) rendered as a non-interactive flat list. In the launch drawer this buried the
Quote + Launch button. FieldSelect now renders a real native <select> (theme-var
styled, native mobile picker), fixing the region picker and all other dropdowns.

Issue 3 — mobile: the FieldSelect fix repairs every form's pickers; DataTable now
scrolls horizontally on overflow instead of clipping wide tables (cut-off columns).

Verified live (VictoriaMetrics + real z@hanzo.ai session): Status 29 services/22
healthy/7 down, Metrics dashboard, Logs honest state, launch drawer native region
select with Launch visible, mobile table scroll. tsc clean; 964 vitest pass.

Also: dev-only next.config rewrite (DEV_CLOUD_ORIGIN, inert in prod) to run the
console locally against a real backend; removed orphaned observability/metrics.ts.
2026-07-02 14:17:42 -07:00
hanzo-dev 6e13fe23c4 fix(console): wire Status/Metrics to live VictoriaMetrics, fix broken FieldSelect dropdowns, mobile-scroll tables
Issue 1 — Status/Logs/Metrics not wired (o11y):
- /v1/o11y is NOT 503 (stale) — it's a 403 (auth) reverse-proxy to SigNoz, whose
  runtime is un-set-up (setupCompleted:false, no data). Status read /paas/apps which
  reports ZERO apps; Logs read /paas/logs which 401s (no such endpoint). Neither ever
  showed data. The live signal is VictoriaMetrics (up{job=*-health}, ~29 targets).
- New read-only same-origin proxy app/telemetry/[...path] -> VictoriaMetrics query API
  (authenticated, GET-only, allow-listed to /api/v1/query|query_range|series|labels|
  label/*/values, traversal-hardened, honest 501 when VM_URL unset).
- lib/api/telemetry.ts (pure parse + service-health helpers, unit-tested).
- StatusModule: real up{} service-health board (down-first, healthy/down counts).
- MetricsModule: real VM infra dashboard (KPIs, healthy/targets-over-time, health
  donut, down-now) — replaces the unwired 'metrics' NativeOverview; distinct from
  AI Metrics. LogsModule: honest 'no log store deployed' state (no fabricated grid).

Issue 2 — launch-machine form: FieldSelect used @hanzo/gui <Select native>, which in
gui 7.3.0 emits bare <option> with NO <select> wrapper — every dropdown app-wide (27
usages) rendered as a non-interactive flat list. In the launch drawer this buried the
Quote + Launch button. FieldSelect now renders a real native <select> (theme-var
styled, native mobile picker), fixing the region picker and all other dropdowns.

Issue 3 — mobile: the FieldSelect fix repairs every form's pickers; DataTable now
scrolls horizontally on overflow instead of clipping wide tables (cut-off columns).

Verified live (VictoriaMetrics + real z@hanzo.ai session): Status 29 services/22
healthy/7 down, Metrics dashboard, Logs honest state, launch drawer native region
select with Launch visible, mobile table scroll. tsc clean; 964 vitest pass.

Also: dev-only next.config rewrite (DEV_CLOUD_ORIGIN, inert in prod) to run the
console locally against a real backend; removed orphaned observability/metrics.ts.
2026-07-02 14:17:42 -07:00
11ae61d541 feat(templates): visual preview banners + a clear one-click deploy flow (#46)
The Templates gallery cards were text-only and dead-ended after fork
("Draft — deploy it to go live" with no action). Two fixes:

1. Preview banner: render the gallery screenshot (t.preview) with a branded
   gradient fallback (stable per category + framework glyph) when it's absent
   or 404s — cards are visual immediately and auto-upgrade to the real shot
   once gallery.hanzo.ai serves it. No broken images, no fabricated screenshots.
2. Deploy flow: fork→draft now shows a clear "Deploy" button that ships the
   project live via projectsvc git deploy (POST /v1/projects/:slug/deploy
   {source:git}); building → "Check status" → "Open site" (liveUrl). Each
   phase shows exactly one next step, so "how to deploy" is never ambiguous.

TemplatesApi gains deploy()/status()/isLive()/normalizeDeployResult over the
existing same-origin /v1 surface. 18 vitest tests, next build green (14/14).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 13:26:46 -07:00
13c6bef6b3 feat(templates): visual preview banners + a clear one-click deploy flow (#46)
The Templates gallery cards were text-only and dead-ended after fork
("Draft — deploy it to go live" with no action). Two fixes:

1. Preview banner: render the gallery screenshot (t.preview) with a branded
   gradient fallback (stable per category + framework glyph) when it's absent
   or 404s — cards are visual immediately and auto-upgrade to the real shot
   once gallery.hanzo.ai serves it. No broken images, no fabricated screenshots.
2. Deploy flow: fork→draft now shows a clear "Deploy" button that ships the
   project live via projectsvc git deploy (POST /v1/projects/:slug/deploy
   {source:git}); building → "Check status" → "Open site" (liveUrl). Each
   phase shows exactly one next step, so "how to deploy" is never ambiguous.

TemplatesApi gains deploy()/status()/isLive()/normalizeDeployResult over the
existing same-origin /v1 surface. 18 vitest tests, next build green (14/14).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 13:26:46 -07:00
zandGitHub 25550dd595 Merge pull request #51 from hanzoai/feat/fork-to-builder
feat(fork): Open-in-builder — fork a starter → customize by prompt → talk-and-edit
2026-07-02 13:06:09 -07:00
zandGitHub 82988951c1 Merge pull request #51 from hanzoai/feat/fork-to-builder
feat(fork): Open-in-builder — fork a starter → customize by prompt → talk-and-edit
2026-07-02 13:06:09 -07:00
hanzo-dev 85ef4cdfe6 feat(templates): fork → 'Open in builder' loop (customize by prompt)
Add an 'Open in builder' primary CTA to each starter card that deep-links to
the hanzo.app builder pre-seeded to customize this template by prompt:
<app>/dev?template=<source>&prompt=<seed>&action=edit. A small inline input
takes an optional free-text customization; the seed prompt carries the template
context (title/framework/description) so the builder auto-starts the first
generation.

- buildBuilderUrl(template, userText, appBase) + customizePrompt(): pure,
  injection-safe (single URL-encoded query params), unit-tested (+7 tests).
- config.appUrl (NEXT_PUBLIC_APP_URL, default https://hanzo.app).
- 'Fork / deploy' kept as the secondary action + honest 404 gallery fallback.

tsc --noEmit clean; vitest 881/881.
2026-07-02 13:04:39 -07:00
hanzo-dev 1f66cab288 feat(templates): fork → 'Open in builder' loop (customize by prompt)
Add an 'Open in builder' primary CTA to each starter card that deep-links to
the hanzo.app builder pre-seeded to customize this template by prompt:
<app>/dev?template=<source>&prompt=<seed>&action=edit. A small inline input
takes an optional free-text customization; the seed prompt carries the template
context (title/framework/description) so the builder auto-starts the first
generation.

- buildBuilderUrl(template, userText, appBase) + customizePrompt(): pure,
  injection-safe (single URL-encoded query params), unit-tested (+7 tests).
- config.appUrl (NEXT_PUBLIC_APP_URL, default https://hanzo.app).
- 'Fork / deploy' kept as the secondary action + honest 404 gallery fallback.

tsc --noEmit clean; vitest 881/881.
2026-07-02 13:04:39 -07:00
hanzo-devandGitHub a7d1938fd6 Merge pull request #50 from hanzoai/feat/console2-honest-states
Honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25)
2026-07-02 12:45:17 -07:00
hanzo-devandGitHub 52d1ec9290 Merge pull request #50 from hanzoai/feat/console2-honest-states
Honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25)
2026-07-02 12:45:17 -07:00
hanzo-dev 7f3ddfd709 fix(console): honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25) 2026-07-02 12:44:51 -07:00
hanzo-dev bfda9ff1af fix(console): honest-state punch-list — signed-in 403≠'sign in', read-402≠paywall, graceful re-auth, chunk self-heal (v8.4.25) 2026-07-02 12:44:51 -07:00
hanzo-devandGitHub 647eeb6eee harden(console): server-side entitlement gate for CMS/ERP/Help embeds (v8.4.24) — RED (#49)
RED reviewed v8.4.22's embeds (0 critical; SSRF clamp, iframe SOP, no-credential-
injection, honest normalizers all REFUTED). Fixes the console residuals RED flagged:

- Server-side entitlement gate: /embed-status resolves the caller's org (token owner)
  and returns `entitled` per app. cms/erp/help are all brand-owned single instances,
  so a non-owning (customer) org gets entitled:false + NO embed URL + no probe -> the
  module shows the provision panel. Only a brand-org member / global admin embeds.
  This is now the AUTHORITATIVE gate (the client check was cosmetic). Client normalizer
  fails closed (entitled strict-true; stale server -> provision panel, never a frame).
- Help is brand-owned too (was embed-for-all) -> a customer never frames the shared
  Frappe Helpdesk (removes unverified cross-org ticket-visibility risk).
- Dropped the false 'org==tenant enforced server-side' claims in EmbeddedApp/
  embed-hosts/module docstrings (the console gates WHO it frames; the shared app still
  owes its own per-org isolation -> separate CMS-side fix).
- Trimmed the iframe sandbox (dropped allow-top-navigation-by-user-activation,
  allow-popups-to-escape-sandbox, clipboard-read).
- /waitlist: bind recorded email to the session account; stop forwarding forgeable XFF.

tsc clean; vitest 952/952 (+13 entitlement/brand-org/fail-closed); next build green.
2026-07-02 12:30:04 -07:00
hanzo-devandGitHub 855a7af633 harden(console): server-side entitlement gate for CMS/ERP/Help embeds (v8.4.24) — RED (#49)
RED reviewed v8.4.22's embeds (0 critical; SSRF clamp, iframe SOP, no-credential-
injection, honest normalizers all REFUTED). Fixes the console residuals RED flagged:

- Server-side entitlement gate: /embed-status resolves the caller's org (token owner)
  and returns `entitled` per app. cms/erp/help are all brand-owned single instances,
  so a non-owning (customer) org gets entitled:false + NO embed URL + no probe -> the
  module shows the provision panel. Only a brand-org member / global admin embeds.
  This is now the AUTHORITATIVE gate (the client check was cosmetic). Client normalizer
  fails closed (entitled strict-true; stale server -> provision panel, never a frame).
- Help is brand-owned too (was embed-for-all) -> a customer never frames the shared
  Frappe Helpdesk (removes unverified cross-org ticket-visibility risk).
- Dropped the false 'org==tenant enforced server-side' claims in EmbeddedApp/
  embed-hosts/module docstrings (the console gates WHO it frames; the shared app still
  owes its own per-org isolation -> separate CMS-side fix).
- Trimmed the iframe sandbox (dropped allow-top-navigation-by-user-activation,
  allow-popups-to-escape-sandbox, clipboard-read).
- /waitlist: bind recorded email to the session account; stop forwarding forgeable XFF.

tsc clean; vitest 952/952 (+13 entitlement/brand-org/fail-closed); next build green.
2026-07-02 12:30:04 -07:00
hanzo-devandGitHub 9bb582d8cf Merge pull request #48 from hanzoai/feat/console2-subpages-base
Real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23)
2026-07-02 12:05:40 -07:00
hanzo-devandGitHub 8efa85eb02 Merge pull request #48 from hanzoai/feat/console2-subpages-base
Real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23)
2026-07-02 12:05:40 -07:00
hanzo-dev 69a9af6d51 feat(console): real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23) 2026-07-02 12:05:25 -07:00
hanzo-dev f43b5d5441 feat(console): real per-product Status/Logs/Metrics/Settings + Base content-type builder + live PaaS Applications (v8.4.23) 2026-07-02 12:05:25 -07:00
hanzo-devandGitHub 638baf7fb5 feat(console): de-link-out Content + native ERP/Help Center — embedded Business-OS apps (v8.4.22) (#47)
CMS was a window.open link-out; ERP/Help were 'soon' placeholders. Port all three
into the console as EMBEDDED (SSO iframe) or HONEST-provision surfaces, binding to
the canonical Payload/Frappe backends (not reimplemented). CRM stays the native
/v1/crm reference; these are the embed half of the Business-OS.

- EmbeddedApp: the one way to frame a canonical app IN the console shell (full-height
  iframe, scoped sandbox, real loading + honest 'Open full screen' fallback — never a
  fabricated load verdict). ProvisionPanel: DRY honest pre-provision surface over the
  real /waitlist intake.
- embed-hosts.ts (PURE): white-label cms|erp|help.<brand> host derivation.
- /embed-status BFF + pure embed-probe.ts: session-gated reachability probe, NO
  god-mode, SSRF-clamped to known brand domains, AbortSignal-bounded.
- CmsModule: embed the Studio for a brand-org member/global admin ONLY (customer org
  gets an honest provision panel — no cross-tenant framing of the shared instance).
- ErpModule: erp.<brand> is 502 -> honest 'Deploy ERP' panel; SAME gate embeds the
  real desk once live. HelpModule: embeds the live shared brand support desk (Frappe
  scopes tickets per-user via SSO).
- registry: erp + helpdesk soon -> enabled native modules.

Verified against live cluster + repos (single shared HANZO_ORG=hanzo instances; no
per-customer-org isolation yet) so nothing claims tenancy it doesn't have.
typecheck 0 errors; vitest 914/914 (+21); next build green (/embed-status registered).
2026-07-02 11:53:45 -07:00
hanzo-devandGitHub a2c78f8fe1 feat(console): de-link-out Content + native ERP/Help Center — embedded Business-OS apps (v8.4.22) (#47)
CMS was a window.open link-out; ERP/Help were 'soon' placeholders. Port all three
into the console as EMBEDDED (SSO iframe) or HONEST-provision surfaces, binding to
the canonical Payload/Frappe backends (not reimplemented). CRM stays the native
/v1/crm reference; these are the embed half of the Business-OS.

- EmbeddedApp: the one way to frame a canonical app IN the console shell (full-height
  iframe, scoped sandbox, real loading + honest 'Open full screen' fallback — never a
  fabricated load verdict). ProvisionPanel: DRY honest pre-provision surface over the
  real /waitlist intake.
- embed-hosts.ts (PURE): white-label cms|erp|help.<brand> host derivation.
- /embed-status BFF + pure embed-probe.ts: session-gated reachability probe, NO
  god-mode, SSRF-clamped to known brand domains, AbortSignal-bounded.
- CmsModule: embed the Studio for a brand-org member/global admin ONLY (customer org
  gets an honest provision panel — no cross-tenant framing of the shared instance).
- ErpModule: erp.<brand> is 502 -> honest 'Deploy ERP' panel; SAME gate embeds the
  real desk once live. HelpModule: embeds the live shared brand support desk (Frappe
  scopes tickets per-user via SSO).
- registry: erp + helpdesk soon -> enabled native modules.

Verified against live cluster + repos (single shared HANZO_ORG=hanzo instances; no
per-customer-org isolation yet) so nothing claims tenancy it doesn't have.
typecheck 0 errors; vitest 914/914 (+21); next build green (/embed-status registered).
2026-07-02 11:53:45 -07:00
zeekayandClaude Opus 4.8 69eef199e7 harden(console): bound every request-time server fetch (no upstream can hang) — v8.4.21
Investigated the "brand host (cloud.lux.network) hangs during render, while
console.hanzo.ai is fast" report. It does NOT reproduce in the app and cannot by
design: the page-render path (app/layout.tsx + (dashboard)/layout.tsx, the only
server components) does ZERO per-brand network fetch — no next/headers, cookies(),
generateMetadata, or server-only render fetch. Brand resolves from window.location
in the browser; SSR uses the build-time NEXT_PUBLIC_DEFAULT_HOST, so the server
HTML is byte-identical for every brand host (verified: cloud.lux.network and
console.hanzo.ai return the SAME md5, <title>Hanzo Cloud Console</title>, HTTP 200
in ~4-18ms for lux/zoo/pars/hanzo). The prod origin difference is an ingress/
routing artifact, not app SSR (out of scope; app code only).

The real in-code "no timeout → the route wedges" hazard (the described failure
class) IS fixed: every request-time server fetch — the /v1/* BFF proxies plus IAM/
cloud identity resolution — had NO upstream timeout, so a reachable-but-silent
backend blocked the route until the client gave up.

Fix, DRY: one src/lib/server/fetch-timeout.ts (fetchWithTimeout) — a bounded
AbortSignal COMPOSED with any caller signal (init.signal / req.signal), so a
request aborts on EITHER a client disconnect OR the timeout. Default 10_000ms, env
HANZO_UPSTREAM_TIMEOUT_MS. On timeout it rejects like an aborted fetch, so every
existing catch keeps its honest fallback (resolveUser → null → 401; proxies → 502).
Threaded through identity.ts (all 5 IAM/cloud calls), bearer-proxy.ts (the shared
proxy engine → cloud/ai/vm/tasksd/commerce/superbase), iam-proxy.ts, and the
custom proxies (/paas, /training, /admin/kms, /billing, /billing/topup/wallet,
/waitlist). The /nodes per-brand luxd RPC probe was already bounded — left as-is.

Verification: npm run typecheck 0 errors; npm test 823/823 (69 files, +6 new
fetch-timeout tests); next build green (all routes); Host-header curl test returns
200 fast for BOTH cloud.lux.network and console.hanzo.ai.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:01:43 -07:00
zeekayandhanzo-dev e88fe3f730 harden(console): bound every request-time server fetch (no upstream can hang) — v8.4.21
Investigated the "brand host (cloud.lux.network) hangs during render, while
console.hanzo.ai is fast" report. It does NOT reproduce in the app and cannot by
design: the page-render path (app/layout.tsx + (dashboard)/layout.tsx, the only
server components) does ZERO per-brand network fetch — no next/headers, cookies(),
generateMetadata, or server-only render fetch. Brand resolves from window.location
in the browser; SSR uses the build-time NEXT_PUBLIC_DEFAULT_HOST, so the server
HTML is byte-identical for every brand host (verified: cloud.lux.network and
console.hanzo.ai return the SAME md5, <title>Hanzo Cloud Console</title>, HTTP 200
in ~4-18ms for lux/zoo/pars/hanzo). The prod origin difference is an ingress/
routing artifact, not app SSR (out of scope; app code only).

The real in-code "no timeout → the route wedges" hazard (the described failure
class) IS fixed: every request-time server fetch — the /v1/* BFF proxies plus IAM/
cloud identity resolution — had NO upstream timeout, so a reachable-but-silent
backend blocked the route until the client gave up.

Fix, DRY: one src/lib/server/fetch-timeout.ts (fetchWithTimeout) — a bounded
AbortSignal COMPOSED with any caller signal (init.signal / req.signal), so a
request aborts on EITHER a client disconnect OR the timeout. Default 10_000ms, env
HANZO_UPSTREAM_TIMEOUT_MS. On timeout it rejects like an aborted fetch, so every
existing catch keeps its honest fallback (resolveUser → null → 401; proxies → 502).
Threaded through identity.ts (all 5 IAM/cloud calls), bearer-proxy.ts (the shared
proxy engine → cloud/ai/vm/tasksd/commerce/superbase), iam-proxy.ts, and the
custom proxies (/paas, /training, /admin/kms, /billing, /billing/topup/wallet,
/waitlist). The /nodes per-brand luxd RPC probe was already bounded — left as-is.

Verification: npm run typecheck 0 errors; npm test 823/823 (69 files, +6 new
fetch-timeout tests); next build green (all routes); Host-header curl test returns
200 fast for BOTH cloud.lux.network and console.hanzo.ai.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-02 09:01:43 -07:00
16246565b7 fix(console): pin non-global admins to their own org, dropping stale cross-tenant scope (v8.4.20) (#45)
A tenant (non-global) admin like `hanzo/z` can only ever act in their OWN org —
the server pins them there and every cross-tenant call 403s. But `OrgGate`
restored a persisted "last org" (`hz_last_org` / `hanzo.console.org`) on sign-in
WITHOUT checking the user can access it. So a leftover `adnexus` scope (from a
prior global-admin switch) made every org-scoped call — e.g.
`/org/iam/get-organization-projects?organization=adnexus` — 403 for z.

Fix: in the OrgGate seed effect, a non-global admin is ALWAYS hard-pinned to
`owner`. If a stale cross-tenant scope is active it's reset and the page reloads
once (guarded on `currentOrg() !== owner`, so it can't loop) — self-healing
existing stale state and preventing recurrence. Global admins keep the
switch-and-restore behavior. Complements the v8.4.18/19 gates on the admin
aggregates; this closes the org-SCOPED 403s.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:53:11 -07:00
9ab7427967 fix(console): pin non-global admins to their own org, dropping stale cross-tenant scope (v8.4.20) (#45)
A tenant (non-global) admin like `hanzo/z` can only ever act in their OWN org —
the server pins them there and every cross-tenant call 403s. But `OrgGate`
restored a persisted "last org" (`hz_last_org` / `hanzo.console.org`) on sign-in
WITHOUT checking the user can access it. So a leftover `adnexus` scope (from a
prior global-admin switch) made every org-scoped call — e.g.
`/org/iam/get-organization-projects?organization=adnexus` — 403 for z.

Fix: in the OrgGate seed effect, a non-global admin is ALWAYS hard-pinned to
`owner`. If a stale cross-tenant scope is active it's reset and the page reloads
once (guarded on `currentOrg() !== owner`, so it can't loop) — self-healing
existing stale state and preventing recurrence. Global admins keep the
switch-and-restore behavior. Complements the v8.4.18/19 gates on the admin
aggregates; this closes the org-SCOPED 403s.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:53:11 -07:00
Darkhorse7stars a24a14e1ae chore(console): v8.4.19 — complete the tenant admin 403 gate (get-organizations + org logo)
Follows v8.4.18 (#44), which gated only the overview aggregate + /paas probe.
This release also gates the OrgSwitcher/CommandPalette org list and BrandLogo's
org-logo lookup, so a per-org (non-global) admin's dashboard load fires ZERO
cross-tenant /admin/iam calls — no more 403 noise in the browser console.
2026-07-02 08:28:21 -05:00
Darkhorse7stars 3ab450dd1e chore(console): v8.4.19 — complete the tenant admin 403 gate (get-organizations + org logo)
Follows v8.4.18 (#44), which gated only the overview aggregate + /paas probe.
This release also gates the OrgSwitcher/CommandPalette org list and BrandLogo's
org-logo lookup, so a per-org (non-global) admin's dashboard load fires ZERO
cross-tenant /admin/iam calls — no more 403 noise in the browser console.
2026-07-02 08:28:21 -05:00
Darkhorse7stars c61e422c86 fix(console): gate get-organizations + get-organization on isGlobalAdmin (complete the tenant 403 gate)
v8.4.18 (#44) gated the platform-overview loader (`/v1/admin/overview` + the
`/paas/apps` health probe) on `isGlobalAdmin`, but the dashboard chrome still
fired two more CROSS-TENANT admin calls for every user on every load:

  - `/admin/iam/get-organizations?owner=admin` — the OrgSwitcher + CommandPalette
    org list, server-gated to global (admin-org) admins.
  - `/admin/iam/get-organization?id=admin/<tenant>` — BrandLogo's org-logo lookup.

For a per-ORG admin who is NOT a global admin (e.g. `hanzo/z`) both 403, so a
tenant still saw a wall of red 403s in the browser console on every dashboard
load even after v8.4.18.

Gate them with the SAME `useIsGlobalAdmin` signal the overview loader + nav use:

  - OrgSwitcher / CommandPalette: skip `get-organizations` for a non-global-admin
    (they still see their current org + "Create organization"; just no cross-tenant
    switch list).
  - BrandLogo: a tenant reads its OWN org logo via the org-scoped `/org/iam` proxy
    (`TeamApi.organization`, which authorizes any member and pins to the caller's
    own org) instead of the admin-gated `/admin/iam` proxy — the logo still works,
    minus the 403.

Global admins are unchanged. No permission or server change; server gates still
enforce. typecheck + 887 tests + next build all green.
2026-07-02 08:28:13 -05:00
Darkhorse7stars 630e754831 fix(console): gate get-organizations + get-organization on isGlobalAdmin (complete the tenant 403 gate)
v8.4.18 (#44) gated the platform-overview loader (`/v1/admin/overview` + the
`/paas/apps` health probe) on `isGlobalAdmin`, but the dashboard chrome still
fired two more CROSS-TENANT admin calls for every user on every load:

  - `/admin/iam/get-organizations?owner=admin` — the OrgSwitcher + CommandPalette
    org list, server-gated to global (admin-org) admins.
  - `/admin/iam/get-organization?id=admin/<tenant>` — BrandLogo's org-logo lookup.

For a per-ORG admin who is NOT a global admin (e.g. `hanzo/z`) both 403, so a
tenant still saw a wall of red 403s in the browser console on every dashboard
load even after v8.4.18.

Gate them with the SAME `useIsGlobalAdmin` signal the overview loader + nav use:

  - OrgSwitcher / CommandPalette: skip `get-organizations` for a non-global-admin
    (they still see their current org + "Create organization"; just no cross-tenant
    switch list).
  - BrandLogo: a tenant reads its OWN org logo via the org-scoped `/org/iam` proxy
    (`TeamApi.organization`, which authorizes any member and pins to the caller's
    own org) instead of the admin-gated `/admin/iam` proxy — the logo still works,
    minus the 403.

Global admins are unchanged. No permission or server change; server gates still
enforce. typecheck + 887 tests + next build all green.
2026-07-02 08:28:13 -05:00
hanzo-dev 5339d28657 chore(console): v8.4.18 — release the tenant admin-overview 403 gate (#44) 2026-07-02 08:14:17 -05:00
hanzo-dev f59dee0615 chore(console): v8.4.18 — release the tenant admin-overview 403 gate (#44) 2026-07-02 08:14:17 -05:00
d4a87ddfee fix(console): gate admin-overview + paas probe on isGlobalAdmin so tenant admins don't 403 (#44)
The platform overview home (`livingOverviewModule('overview')`) is the default
landing for every signed-in user, but its loader fired the CROSS-TENANT
`/v1/admin/overview` aggregate (and the `/paas/apps` health probe) for everyone.
Those are server-gated to global (admin-org) admins, so a tenant user — even an
org's OWN admin, e.g. `hanzo/z` — got a wall of repeated `403 (Forbidden)` in the
browser console on every dashboard load.

It "worked" only because the loader caught the 403 and fell back to the org-scoped
usage ledger; the board rendered, but spammed the console with doomed requests.

Fix: thread `isGlobalAdmin` (already resolved by `useIsGlobalAdmin`, the same
signal the nav/launcher use to hide admin surfaces) through the ONE loader call
site into `OverviewContext`, and in the platform-overview loader skip the admin
aggregate + apps probe for non-global-admins — going straight to the org-scoped
usage ledger (the exact source the catch-fallback already used). Global admins are
unchanged. `withHealth` gains an optional `probeApps` (default true) so the four
other, already admin-gated overviews are untouched.

Net: a tenant admin's overview renders identically, minus the 403 console noise;
no permission or server change.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:08:48 -07:00
11baf27fc1 fix(console): gate admin-overview + paas probe on isGlobalAdmin so tenant admins don't 403 (#44)
The platform overview home (`livingOverviewModule('overview')`) is the default
landing for every signed-in user, but its loader fired the CROSS-TENANT
`/v1/admin/overview` aggregate (and the `/paas/apps` health probe) for everyone.
Those are server-gated to global (admin-org) admins, so a tenant user — even an
org's OWN admin, e.g. `hanzo/z` — got a wall of repeated `403 (Forbidden)` in the
browser console on every dashboard load.

It "worked" only because the loader caught the 403 and fell back to the org-scoped
usage ledger; the board rendered, but spammed the console with doomed requests.

Fix: thread `isGlobalAdmin` (already resolved by `useIsGlobalAdmin`, the same
signal the nav/launcher use to hide admin surfaces) through the ONE loader call
site into `OverviewContext`, and in the platform-overview loader skip the admin
aggregate + apps probe for non-global-admins — going straight to the org-scoped
usage ledger (the exact source the catch-fallback already used). Global admins are
unchanged. `withHealth` gains an optional `probeApps` (default true) so the four
other, already admin-gated overviews are untouched.

Net: a tenant admin's overview renders identically, minus the 403 console noise;
no permission or server change.

Co-authored-by: dev <dev@hanzo.ai>
2026-07-02 06:08:48 -07:00
fddf5f3aec feat(console): Business-OS suite — CRM + Content + ERP/Help Center + Accessibility over native /v1 (v8.4.17) (#43)
* feat(console): consolidate CRM + Accessibility Business-OS modules over native /v1

CRM — the first Business-OS brick — as ONE canonical module over the native-Go
cloud /v1/crm surface (cloud clients/crm on Base/SQLite: companies, contacts,
opportunities; a port of Twenty's core model), per-org via the user-bearer /cloud
proxy. Consolidates the two competing CRM PRs (#38 feat/console2-crm and #39
crm-work): keeps #39's richer routed-tab views + defensive normalizers + tests,
re-paths its API from the explicit /cloud/v1 form to the canonical same-origin
originV1Url('crm') (matching Agents/Evals/Prompts — the one way), and grafts #38's
next.config `crm` head rewrite + per-row delete. crm.ts is the single typed mirror
of the /v1/crm contract (one method per route); every row is org-scoped SERVER-SIDE
from the token owner claim, with honest loading/empty/error states in @hanzo/gui.

Accessibility — a Wix-style WCAG scanner for the site being built. Runs Deque's
axe-core against the current page 100% client-side (engine lazy-loaded into its own
chunk, never the main bundle); pure sort/summarize/WCAG-label logic in
~/lib/a11y/scan is unit-tested without a browser or engine.

- next.config.mjs / proxy-allow.ts: allow-list the `crm` head on both the rewrite
  and the bearer proxy (same least-privilege path as the 5 existing surfaces).
- registry: CRM + Accessibility in the Apps catalog.
- version 8.4.16 -> 8.4.17; axe-core 4.12.1 (lazy import, own chunk).

typecheck clean; 887 unit tests pass (13 new: 9 crm + 4 a11y); next build green.

* feat(console): fold Content Studio + ERP/Help Center into the Business-OS suite (v8.4.17)

Consolidates the three overlapping Business-OS PRs into ONE canonical superset.
Onto the #39 base (canonical CRM over originV1Url → /v1/crm direct + per-row
delete, and the client-side axe-core Accessibility scanner), fold in #42's:
- CmsModule (Content Studio: honest in-console home for the live Payload CMS)
- registry entries cms (Content), erp + helpdesk (honest soon → ComingSoon)

Reconciliation: kept #39's originV1Url CRM (hits /v1/crm directly, the majority
agents/prompts/evals pattern) over #42's /cloud-prefixed cloudProxyV1Url; kept
#39's CrmModule (superset — adds delete); dropped #42's stray gcp:'Content';
regenerated package-lock.json for axe-core 4.12.1 (was package.json-only).

Supersedes #38, #39, #42.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 05:32:16 -07:00
5f1d550940 feat(console): Business-OS suite — CRM + Content + ERP/Help Center + Accessibility over native /v1 (v8.4.17) (#43)
* feat(console): consolidate CRM + Accessibility Business-OS modules over native /v1

CRM — the first Business-OS brick — as ONE canonical module over the native-Go
cloud /v1/crm surface (cloud clients/crm on Base/SQLite: companies, contacts,
opportunities; a port of Twenty's core model), per-org via the user-bearer /cloud
proxy. Consolidates the two competing CRM PRs (#38 feat/console2-crm and #39
crm-work): keeps #39's richer routed-tab views + defensive normalizers + tests,
re-paths its API from the explicit /cloud/v1 form to the canonical same-origin
originV1Url('crm') (matching Agents/Evals/Prompts — the one way), and grafts #38's
next.config `crm` head rewrite + per-row delete. crm.ts is the single typed mirror
of the /v1/crm contract (one method per route); every row is org-scoped SERVER-SIDE
from the token owner claim, with honest loading/empty/error states in @hanzo/gui.

Accessibility — a Wix-style WCAG scanner for the site being built. Runs Deque's
axe-core against the current page 100% client-side (engine lazy-loaded into its own
chunk, never the main bundle); pure sort/summarize/WCAG-label logic in
~/lib/a11y/scan is unit-tested without a browser or engine.

- next.config.mjs / proxy-allow.ts: allow-list the `crm` head on both the rewrite
  and the bearer proxy (same least-privilege path as the 5 existing surfaces).
- registry: CRM + Accessibility in the Apps catalog.
- version 8.4.16 -> 8.4.17; axe-core 4.12.1 (lazy import, own chunk).

typecheck clean; 887 unit tests pass (13 new: 9 crm + 4 a11y); next build green.

* feat(console): fold Content Studio + ERP/Help Center into the Business-OS suite (v8.4.17)

Consolidates the three overlapping Business-OS PRs into ONE canonical superset.
Onto the #39 base (canonical CRM over originV1Url → /v1/crm direct + per-row
delete, and the client-side axe-core Accessibility scanner), fold in #42's:
- CmsModule (Content Studio: honest in-console home for the live Payload CMS)
- registry entries cms (Content), erp + helpdesk (honest soon → ComingSoon)

Reconciliation: kept #39's originV1Url CRM (hits /v1/crm directly, the majority
agents/prompts/evals pattern) over #42's /cloud-prefixed cloudProxyV1Url; kept
#39's CrmModule (superset — adds delete); dropped #42's stray gcp:'Content';
regenerated package-lock.json for axe-core 4.12.1 (was package.json-only).

Supersedes #38, #39, #42.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-02 05:32:16 -07:00
hanzo-dev 509daa10e9 test(e2e): finalize live billing-admin confirmation spec for v8.4.16
Deep-links /billing/reports (now unshadowed) and asserts the god-view gate matches
the account grant (z is a hanzo org-admin → honest 403 + managed fallback; an
admin-org member → 200 KPI board). No app change.
2026-07-02 01:14:15 -07:00
hanzo-dev fbb322f656 test(e2e): finalize live billing-admin confirmation spec for v8.4.16
Deep-links /billing/reports (now unshadowed) and asserts the god-view gate matches
the account grant (z is a hanzo org-admin → honest 403 + managed fallback; an
admin-org member → 200 KPI board). No app change.
2026-07-02 01:14:15 -07:00
hanzo-dev 54f8182467 fix(billing): namespace data proxy under /billing/v1/* so tab URLs reach the SPA (v8.4.16)
The per-tenant commerce DATA proxy (app/billing/[...path]/route.ts) claimed the
whole /billing/* URL space; a Next route handler wins over the catch-all page for a
matching segment, so every billing tab except Overview (/billing/reports, budgets,
invoices, subscriptions, payment-methods, credits) resolved to the proxy and
returned commerce {"error":"not found"} / raw JSON instead of the UI. This
blocked the v8.4.15 Reports cost-dimension (product/agent) surface in production.

Namespace the data proxy under /billing/v1/* (the 'always /v1/' convention) so it
can never collide with a UI tab slug; tab URLs fall through to app/(dashboard)/[...slug].
- move app/billing/[...path] -> app/billing/v1/[...path]; topup/wallet likewise
  (forward target commerce/v1/billing/<path> unchanged — v1 is now a static segment)
- billingUrl() (billing.ts + aimetrics.ts) and wallet.ts appUrl() prepend v1/
- tests: aimetrics.test.ts asserts /billing/v1/usage; billing-isolation e2e fetches /billing/v1/<p>

tsc clean; 874/874 tests; next build green (routes /billing/v1/[...path],
/billing/v1/topup/wallet, /[...slug] catch-all serves the tabs).
2026-07-02 01:02:24 -07:00
hanzo-dev 3e79f2d8ad fix(billing): namespace data proxy under /billing/v1/* so tab URLs reach the SPA (v8.4.16)
The per-tenant commerce DATA proxy (app/billing/[...path]/route.ts) claimed the
whole /billing/* URL space; a Next route handler wins over the catch-all page for a
matching segment, so every billing tab except Overview (/billing/reports, budgets,
invoices, subscriptions, payment-methods, credits) resolved to the proxy and
returned commerce {"error":"not found"} / raw JSON instead of the UI. This
blocked the v8.4.15 Reports cost-dimension (product/agent) surface in production.

Namespace the data proxy under /billing/v1/* (the 'always /v1/' convention) so it
can never collide with a UI tab slug; tab URLs fall through to app/(dashboard)/[...slug].
- move app/billing/[...path] -> app/billing/v1/[...path]; topup/wallet likewise
  (forward target commerce/v1/billing/<path> unchanged — v1 is now a static segment)
- billingUrl() (billing.ts + aimetrics.ts) and wallet.ts appUrl() prepend v1/
- tests: aimetrics.test.ts asserts /billing/v1/usage; billing-isolation e2e fetches /billing/v1/<p>

tsc clean; 874/874 tests; next build green (routes /billing/v1/[...path],
/billing/v1/topup/wallet, /[...slug] catch-all serves the tabs).
2026-07-02 01:02:24 -07:00
zandGitHub 23f12107e0 Merge pull request #41 from hanzoai/feat/saas-finance
feat(admin): SaaS finance dashboard — DO burn-down + revenue + margin/runway (console2)
2026-07-02 01:00:37 -07:00
zandGitHub e725a0c9f8 Merge pull request #41 from hanzoai/feat/saas-finance
feat(admin): SaaS finance dashboard — DO burn-down + revenue + margin/runway (console2)
2026-07-02 01:00:37 -07:00
hanzo-dev e23b9722a4 feat(finance): admin-only SaaS profitability dashboard (Finance)
Add a global-admin-only Finance board to admin.hanzo.ai over the ONE
LivingOverview system — DigitalOcean credit burn-down (our primary ~$40k
venue), month-to-date spend, MRR, total revenue, gross margin %, runway,
and a profitability health verdict. TRUE by construction: every tile reads
the real `/v1/admin/finance` aggregate; no fallback fabricates numbers.

- lib/api/finance.ts: FinanceApi.finance() + normalizeFinance — optional-safe
  map of `/v1/admin/finance` onto Finance. Reads via originGet (same-origin
  `<origin>/v1/admin/finance`), so the request rides the global-admin-gated
  aggregate proxy. runwayDays preserves NULL (never coerced to 0 = fake alarm).
- overview/living/adapters.ts: fromFinance (payload → OverviewData, honest-empty
  on unconfigured DO / commerce) + pure financeHealth verdict (green profitable /
  yellow thin-margin <20% / red burning-faster / '' unknown when DO off).
- overview/living/registry.ts: `finance` living config — 6 KPI tiles + credit
  burn-down bar series + health/alerts. No try/catch fallback (finance is
  true-by-construction; a denied/not-routed backend renders the honest error).
- products/registry.tsx: `finance` catalog entry, `admin: true` (Observe) —
  hidden from every customer's nav/launcher/palette; the catch-all renders a
  managed notice for a non-admin. Reuses livingOverviewModule; no new UI.
- SECURITY: financial data is Hanzo-internal. Routed through the `/admin/
  aggregate` bearer proxy (getAdminGate: verified @hanzo.ai + IAM global-admin,
  fail-closed 403) — NOT the generic /cloud product proxy. `finance` added to
  ADMIN_AGGREGATE_HEADS (admin-aggregate.ts) + ADMIN_V1_HEADS (next.config.mjs);
  the allow-list still REFUSES admin/iam + admin/kms. A non-admin can never see
  or reach the board or the data.

tsc --noEmit: 0 errors. vitest: 874/874 (incl. +8 finance: normalizeFinance,
fromFinance, financeHealth, allowAdminSurface finance head). next build: ✓
compiled, 14/14 pages.
2026-07-02 00:57:59 -07:00
hanzo-dev 616d2704f0 feat(finance): admin-only SaaS profitability dashboard (Finance)
Add a global-admin-only Finance board to admin.hanzo.ai over the ONE
LivingOverview system — DigitalOcean credit burn-down (our primary ~$40k
venue), month-to-date spend, MRR, total revenue, gross margin %, runway,
and a profitability health verdict. TRUE by construction: every tile reads
the real `/v1/admin/finance` aggregate; no fallback fabricates numbers.

- lib/api/finance.ts: FinanceApi.finance() + normalizeFinance — optional-safe
  map of `/v1/admin/finance` onto Finance. Reads via originGet (same-origin
  `<origin>/v1/admin/finance`), so the request rides the global-admin-gated
  aggregate proxy. runwayDays preserves NULL (never coerced to 0 = fake alarm).
- overview/living/adapters.ts: fromFinance (payload → OverviewData, honest-empty
  on unconfigured DO / commerce) + pure financeHealth verdict (green profitable /
  yellow thin-margin <20% / red burning-faster / '' unknown when DO off).
- overview/living/registry.ts: `finance` living config — 6 KPI tiles + credit
  burn-down bar series + health/alerts. No try/catch fallback (finance is
  true-by-construction; a denied/not-routed backend renders the honest error).
- products/registry.tsx: `finance` catalog entry, `admin: true` (Observe) —
  hidden from every customer's nav/launcher/palette; the catch-all renders a
  managed notice for a non-admin. Reuses livingOverviewModule; no new UI.
- SECURITY: financial data is Hanzo-internal. Routed through the `/admin/
  aggregate` bearer proxy (getAdminGate: verified @hanzo.ai + IAM global-admin,
  fail-closed 403) — NOT the generic /cloud product proxy. `finance` added to
  ADMIN_AGGREGATE_HEADS (admin-aggregate.ts) + ADMIN_V1_HEADS (next.config.mjs);
  the allow-list still REFUSES admin/iam + admin/kms. A non-admin can never see
  or reach the board or the data.

tsc --noEmit: 0 errors. vitest: 874/874 (incl. +8 finance: normalizeFinance,
fromFinance, financeHealth, allowAdminSurface finance head). next build: ✓
compiled, 14/14 pages.
2026-07-02 00:57:59 -07:00
hanzo-dev aa507916ad fix(admin,billing): RED fixes — god-view server gate + attribution + row cap (v8.4.15)
H1 (HIGH): the admin business god-view had NO console-side server gate — /v1/admin/*
was not rewritten and rested solely on an unverified cloud-side gate. Add
app/admin/aggregate/[...path] behind getAdminGate (global-admin only, fail-closed
403) → forwardWithUserBearer; rewrite /v1/admin/{overview,usage,orgs,audit,products}
to it (iam/kms untouched). New originGet pins AdminApi to the console origin so a
split-origin NEXT_PUBLIC_CLOUD_URL can't bypass the gate. Least-privilege surface is
the pure, tested lib/server/admin-aggregate.ts (refuses admin/iam, admin/kms).

L1 (LOW, proven): agentUsageFor id-OR-name Set union conflated two agents within an
org. Prefer exact id, fall back to name only when id matched nothing. +collision tests.

L2 (LOW, proven): unbounded cost table under high agent/product cardinality. New pure
capRows (COST_ROW_CAP=100) bounds the DOM to top-by-spend with an honest Show-all
affordance (never hides real data). +tests.

RED-refuted vectors verified safe (fallback scope, metadata forgery, DoS, honest
state, client gating). typecheck clean; 853/853 tests; next build green (new route
registered).
2026-07-02 00:32:23 -07:00
hanzo-dev c70b0735ab fix(admin,billing): RED fixes — god-view server gate + attribution + row cap (v8.4.15)
H1 (HIGH): the admin business god-view had NO console-side server gate — /v1/admin/*
was not rewritten and rested solely on an unverified cloud-side gate. Add
app/admin/aggregate/[...path] behind getAdminGate (global-admin only, fail-closed
403) → forwardWithUserBearer; rewrite /v1/admin/{overview,usage,orgs,audit,products}
to it (iam/kms untouched). New originGet pins AdminApi to the console origin so a
split-origin NEXT_PUBLIC_CLOUD_URL can't bypass the gate. Least-privilege surface is
the pure, tested lib/server/admin-aggregate.ts (refuses admin/iam, admin/kms).

L1 (LOW, proven): agentUsageFor id-OR-name Set union conflated two agents within an
org. Prefer exact id, fall back to name only when id matched nothing. +collision tests.

L2 (LOW, proven): unbounded cost table under high agent/product cardinality. New pure
capRows (COST_ROW_CAP=100) bounds the DOM to top-by-spend with an honest Show-all
affordance (never hides real data). +tests.

RED-refuted vectors verified safe (fallback scope, metadata forgery, DoS, honest
state, client gating). typecheck clean; 853/853 tests; next build green (new route
registered).
2026-07-02 00:32:23 -07:00
hanzo-dev 738ebee6e6 feat(billing,admin): per-agent/product cost dimension + admin business board (v8.4.14)
Usage/billing visibility:
- aimetrics UsageRecord extracts metadata.{product,agent} (canonical contract);
  perAgent + agentUsageFor rollups over the SAME charged commerce ledger.
- Cost Reports add product + agent breakdowns (presentDimensions gates each on
  real data — honest until spend is tagged); BillingReports renders them.
- Agents detail pane shows per-agent cost from the ledger (agentUsageFor), not a
  hardcoded/registry metric; honest '—' until attributed.

admin.hanzo.ai business dashboard (global-admin only):
- New admin-business living overview (MRR/revenue/usage-cost/orgs/customers,
  revenue-by-product + plan-mix + top-agents-by-cost donuts, alerts, activity,
  fleet health) over /v1/admin/overview allOrgs, honest usage-ledger+operator
  fallback; reuses the ONE LivingOverview system.
- admin-overview gains an optional named-distributions map (revenue/plans/
  topAgents), present only when the backend sends it; fromAdminOverview projects
  each into distribution[key].
- Registry catalog entry 'business' (Observe, admin:true) — gated by getAdminGate
  + useIsGlobalAdmin; the aggregate is server-gated.

Mobile-responsive by construction (gui v5 shorthands + flexWrap rows, no fixed
grids). typecheck clean; 841/841 tests; next build green. /v1 only.
2026-07-02 00:32:23 -07:00
hanzo-dev 87bf6f13af feat(billing,admin): per-agent/product cost dimension + admin business board (v8.4.14)
Usage/billing visibility:
- aimetrics UsageRecord extracts metadata.{product,agent} (canonical contract);
  perAgent + agentUsageFor rollups over the SAME charged commerce ledger.
- Cost Reports add product + agent breakdowns (presentDimensions gates each on
  real data — honest until spend is tagged); BillingReports renders them.
- Agents detail pane shows per-agent cost from the ledger (agentUsageFor), not a
  hardcoded/registry metric; honest '—' until attributed.

admin.hanzo.ai business dashboard (global-admin only):
- New admin-business living overview (MRR/revenue/usage-cost/orgs/customers,
  revenue-by-product + plan-mix + top-agents-by-cost donuts, alerts, activity,
  fleet health) over /v1/admin/overview allOrgs, honest usage-ledger+operator
  fallback; reuses the ONE LivingOverview system.
- admin-overview gains an optional named-distributions map (revenue/plans/
  topAgents), present only when the backend sends it; fromAdminOverview projects
  each into distribution[key].
- Registry catalog entry 'business' (Observe, admin:true) — gated by getAdminGate
  + useIsGlobalAdmin; the aggregate is server-gated.

Mobile-responsive by construction (gui v5 shorthands + flexWrap rows, no fixed
grids). typecheck clean; 841/841 tests; next build green. /v1 only.
2026-07-02 00:32:23 -07:00
zandGitHub 29b9587914 Merge pull request #40 from hanzoai/feat/template-fork
feat(fork): template → project fork (console2)
2026-07-01 23:48:04 -07:00
zandGitHub 6dece0376e Merge pull request #40 from hanzoai/feat/template-fork
feat(fork): template → project fork (console2)
2026-07-01 23:48:04 -07:00
hanzo-dev 7d6b2c8e25 feat(templates): Fork/deploy creates a real project from a template
The gallery "Fork / deploy" button now creates a REAL project in-console via
POST /v1/projects/fork (cloud projectsvc) instead of only opening the gallery
source URL — the ONE way to start a project from a template.

- lib/api/templates.ts: TemplatesApi.fork(slug, {name?}) POSTs to the
  same-origin /v1/projects/fork (originV1Url, no prefix) and normalizes the
  returned projectsvc Project (normalizeForkedProject, pure/tested).
- TemplatesModule: per-card idle -> forking -> created state; on success shows
  the new project (Open site when a liveUrl exists, else honest draft). On a 404
  (older backend without the fork route) it falls back to opening the gallery
  source, so the button is never dead.
- next.config.mjs + proxy-allow.ts: add the `projects` head so /v1/projects*
  (incl. /fork) routes through the hardened /cloud bearer proxy, org-scoped from
  the Bearer owner.

Tests: normalizeForkedProject + TemplatesApi.fork (exact same-origin URL, body
with/without name override, 404 ApiError for the fallback); proxy-allow admits
the projects fork subtree. tsc clean; vitest 834/834.
2026-07-01 23:42:03 -07:00
hanzo-dev b0135fb98e feat(templates): Fork/deploy creates a real project from a template
The gallery "Fork / deploy" button now creates a REAL project in-console via
POST /v1/projects/fork (cloud projectsvc) instead of only opening the gallery
source URL — the ONE way to start a project from a template.

- lib/api/templates.ts: TemplatesApi.fork(slug, {name?}) POSTs to the
  same-origin /v1/projects/fork (originV1Url, no prefix) and normalizes the
  returned projectsvc Project (normalizeForkedProject, pure/tested).
- TemplatesModule: per-card idle -> forking -> created state; on success shows
  the new project (Open site when a liveUrl exists, else honest draft). On a 404
  (older backend without the fork route) it falls back to opening the gallery
  source, so the button is never dead.
- next.config.mjs + proxy-allow.ts: add the `projects` head so /v1/projects*
  (incl. /fork) routes through the hardened /cloud bearer proxy, org-scoped from
  the Bearer owner.

Tests: normalizeForkedProject + TemplatesApi.fork (exact same-origin URL, body
with/without name override, 404 ApiError for the fallback); proxy-allow admits
the projects fork subtree. tsc clean; vitest 834/834.
2026-07-01 23:42:03 -07:00
hanzo-dev c72ee8d5f0 feat(agent-builder): superset builder — hanzo.chat advanced config folded into the ONE builder
console2's canonical, decoupled agent builder becomes the true superset: its
host-injected loader seam (unchanged) + hanzo.chat (@hanzo/ai)'s advanced
generation config. One component over the ONE /v1/agents backend — hanzo.chat
and console v8 render the identical builder.

Contract (types.ts):
- AgentConfig: temperature/topP/topK/stream/thinking/useTools/webSearch +
  reasoningEffort, all with defaults; folded onto AgentSpec as OPTIONAL config +
  knowledge, so a simple agent is unchanged.
- ReasoningEffort = the full Hanzo/Claude ladder low·medium·high·xhigh·max·
  ultracode (ultracode = xhigh+workflows, top tier, use sparingly) — supersedes
  @hanzo/ai's old 3-level enum.
- AgentCreateBody: the pruned wire value the builder emits; createAgent now
  takes the body (no more 'as AgentSpec' cast at the call site).

Pure logic (logic.ts): defaultConfig/clampConfig/pruneConfig +
normalizeList(→tools,knowledge, DRY). toCreateBody prunes every default knob, so
opening Advanced never changes what a simple agent posts. clampNum fixed: NaN→min
but ±∞ clamp to the nearest bound (slider-to-top lands on max, not min).

UI (AgentBuilder.tsx): hidden-by-default Advanced section (sliders/switches/select
over the existing Field primitives). loaders.ts + NewAgentBody pass config+knowledge
through to POST /v1/agents.

Verified: all pure transforms proven via a standalone node harness (22/22 incl.
backward-compat, clamp edge cases, prune, ultracode). tsc/vitest run in CI.
2026-07-01 23:03:53 -07:00
hanzo-dev e2ab8c15ac feat(agent-builder): superset builder — hanzo.chat advanced config folded into the ONE builder
console2's canonical, decoupled agent builder becomes the true superset: its
host-injected loader seam (unchanged) + hanzo.chat (@hanzo/ai)'s advanced
generation config. One component over the ONE /v1/agents backend — hanzo.chat
and console v8 render the identical builder.

Contract (types.ts):
- AgentConfig: temperature/topP/topK/stream/thinking/useTools/webSearch +
  reasoningEffort, all with defaults; folded onto AgentSpec as OPTIONAL config +
  knowledge, so a simple agent is unchanged.
- ReasoningEffort = the full Hanzo/Claude ladder low·medium·high·xhigh·max·
  ultracode (ultracode = xhigh+workflows, top tier, use sparingly) — supersedes
  @hanzo/ai's old 3-level enum.
- AgentCreateBody: the pruned wire value the builder emits; createAgent now
  takes the body (no more 'as AgentSpec' cast at the call site).

Pure logic (logic.ts): defaultConfig/clampConfig/pruneConfig +
normalizeList(→tools,knowledge, DRY). toCreateBody prunes every default knob, so
opening Advanced never changes what a simple agent posts. clampNum fixed: NaN→min
but ±∞ clamp to the nearest bound (slider-to-top lands on max, not min).

UI (AgentBuilder.tsx): hidden-by-default Advanced section (sliders/switches/select
over the existing Field primitives). loaders.ts + NewAgentBody pass config+knowledge
through to POST /v1/agents.

Verified: all pure transforms proven via a standalone node harness (22/22 incl.
backward-compat, clamp edge cases, prune, ultracode). tsc/vitest run in CI.
2026-07-01 23:03:53 -07:00
hanzo-dev 9ce9a78f14 console: publish ghcr.io/hanzoai/console (drop the 2) — repo renamed console2→console
CI workflow now builds+pushes ghcr.io/hanzoai/console:v<version> directly
(no more console2→console retag hack). registry product source-links and the
build/deploy doc updated to the renamed repo. Image name == repo name, one way.
2026-07-01 19:58:18 -07:00
hanzo-dev 98ac20f87d console: publish ghcr.io/hanzoai/console (drop the 2) — repo renamed console2→console
CI workflow now builds+pushes ghcr.io/hanzoai/console:v<version> directly
(no more console2→console retag hack). registry product source-links and the
build/deploy doc updated to the renamed repo. Image name == repo name, one way.
2026-07-01 19:58:18 -07:00
hanzo-dev f9e6c28e91 Merge remote-tracking branch 'origin/main' into feat/console2-observe-langfuse-port
# Conflicts:
#	next.config.mjs
#	package.json
2026-07-01 19:42:31 -07:00
hanzo-dev b246a1f01c Merge remote-tracking branch 'origin/main' into feat/console2-observe-langfuse-port
# Conflicts:
#	next.config.mjs
#	package.json
2026-07-01 19:42:31 -07:00
hanzo-dev f3e32fa480 console: shared "Hanzo Cloud 8.4" product-release label (v8.4.12)
One product, two build lineages: console ships app-semver 8.4.x, cloud
ships its own Go-module v1.786.x (never above v1 — Go module semantics +
the standing rule). Unify only the STORY under a "Hanzo Cloud <MAJOR.MINOR>"
umbrella, single-sourced from the console app version (no second place holds
it): next.config injects NEXT_PUBLIC_APP_VERSION from package.json;
config derives branding.release ("8.4") + branding.productLine
("Hanzo Cloud 8.4"), shown on the sign-in screen. Convention documented in
LLM.md. Nothing in Go changes. typecheck clean, vitest 793/793.
2026-07-01 19:21:25 -07:00
hanzo-dev fdff533ac8 console: shared "Hanzo Cloud 8.4" product-release label (v8.4.12)
One product, two build lineages: console ships app-semver 8.4.x, cloud
ships its own Go-module v1.786.x (never above v1 — Go module semantics +
the standing rule). Unify only the STORY under a "Hanzo Cloud <MAJOR.MINOR>"
umbrella, single-sourced from the console app version (no second place holds
it): next.config injects NEXT_PUBLIC_APP_VERSION from package.json;
config derives branding.release ("8.4") + branding.productLine
("Hanzo Cloud 8.4"), shown on the sign-in screen. Convention documented in
LLM.md. Nothing in Go changes. typecheck clean, vitest 793/793.
2026-07-01 19:21:25 -07:00
hanzo-dev 10c9dad2cd templates: Gallery starter-kit browser (Apps › Templates) over /v1/templates — category filter + search + fork/deploy handoff 2026-07-01 19:18:23 -07:00
hanzo-dev b84143e2a8 templates: Gallery starter-kit browser (Apps › Templates) over /v1/templates — category filter + search + fork/deploy handoff 2026-07-01 19:18:23 -07:00
zeekayandClaude Opus 4.8 7e0683b4c1 release: v8.4.11 — verified-green build (typecheck 0 errors, 793 tests, next build)
Cut a fresh semver from a verified-clean main HEAD so CI publishes
ghcr.io/hanzoai/console2:v8.4.11. Tree already type-checks + builds
clean (concurrent lanes resolved the earlier RecordsModule maxW /
metrics.test null-type breakages); this bump is the publish trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:17:33 -07:00
zeekayandhanzo-dev 75ee49b6d4 release: v8.4.11 — verified-green build (typecheck 0 errors, 793 tests, next build)
Cut a fresh semver from a verified-clean main HEAD so CI publishes
ghcr.io/hanzoai/console2:v8.4.11. Tree already type-checks + builds
clean (concurrent lanes resolved the earlier RecordsModule maxW /
metrics.test null-type breakages); this bump is the publish trigger.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 19:17:33 -07:00
hanzo-dev 6143c61c2f fix(observe): close RED honesty findings — non-finite/negative enrichment + tag coercion
RED review of v8.4.9 (0 critical/high/medium): 2 LOW honesty one-liners + 1 INFO, all closed at the DRY adapter layer.

1. Non-finite/negative enrichment: toTrace/toObservation coerce latency/cost/tokens
   via nonNeg (Number.isFinite && >= 0 → null, else 0 for usage members), so a
   malformed row (1e999→Infinity, -5, NaN) degrades to an em dash and never skews
   metric folds. fmtLatency/fmtCost mirror the guard. toScore drops non-finite
   values (keeps legit negatives).
2. Tags: toTrace filters non-string tags (a non-string tag crashed TraceDetailView).
3. INFO: getTrace/session throw ApiError(404) on a hollow 200-empty instead of
   synthesizing a trace/session — honest not-found → BackendStateCard.

+6 pure-logic tests (1e999 / -5 / NaN / negative). tsc clean · vitest 795/795 ·
next build green.
2026-07-01 19:14:46 -07:00
hanzo-dev c350a925a7 fix(observe): close RED honesty findings — non-finite/negative enrichment + tag coercion
RED review of v8.4.9 (0 critical/high/medium): 2 LOW honesty one-liners + 1 INFO, all closed at the DRY adapter layer.

1. Non-finite/negative enrichment: toTrace/toObservation coerce latency/cost/tokens
   via nonNeg (Number.isFinite && >= 0 → null, else 0 for usage members), so a
   malformed row (1e999→Infinity, -5, NaN) degrades to an em dash and never skews
   metric folds. fmtLatency/fmtCost mirror the guard. toScore drops non-finite
   values (keeps legit negatives).
2. Tags: toTrace filters non-string tags (a non-string tag crashed TraceDetailView).
3. INFO: getTrace/session throw ApiError(404) on a hollow 200-empty instead of
   synthesizing a trace/session — honest not-found → BackendStateCard.

+6 pure-logic tests (1e999 / -5 / NaN / negative). tsc clean · vitest 795/795 ·
next build green.
2026-07-01 19:14:46 -07:00
hanzo-dev a2ac5244d5 prompts: Starter library browse+import over /v1/prompts/catalog (107 curated starters; honest org-empty until imported) 2026-07-01 18:56:20 -07:00
hanzo-dev 6b4aa6bce9 prompts: Starter library browse+import over /v1/prompts/catalog (107 curated starters; honest org-empty until imported) 2026-07-01 18:56:20 -07:00
hanzo-dev 4af523b87b console2: rename the 'Object Storage' product to 'S3' (v8.4.10)
The user asked to call it just 'S3'. Rename the user-visible product
name everywhere it surfaces — the catalog entry (registry.tsx label +
description), the StorageModule page header title, and the Zero Trust
data-plane posture row — plus the two resource-console doc comments that
list the kinds. The provisioning kind id stays 's3' and the file-manager
backend (/v1/s3) is unchanged; this is a label rename only.

typecheck clean, vitest 778/778 green.
2026-07-01 18:51:16 -07:00
hanzo-dev 788dc23116 console2: rename the 'Object Storage' product to 'S3' (v8.4.10)
The user asked to call it just 'S3'. Rename the user-visible product
name everywhere it surfaces — the catalog entry (registry.tsx label +
description), the StorageModule page header title, and the Zero Trust
data-plane posture row — plus the two resource-console doc comments that
list the kinds. The provisioning kind id stays 's3' and the file-manager
backend (/v1/s3) is unchanged; this is a label rename only.

typecheck clean, vitest 778/778 green.
2026-07-01 18:51:16 -07:00
6dabff3e77 feat(records): browse + edit any Base collection as a CRM/CMS (click-through) (#37)
Makes Base usable BY CLICKING, not just via the API — the gap the live Playwright
check found (the base admin UI is read-only). A 'Records' product (Data category):
browse a collection, open a record, edit it, create new ones — all rendered from
the collection's OWN field schema through @hanzo/data (DataTable for the list,
RecordDetail/RecordForm for detail; every field type is now editable per
hanzoai/ui#232). Data flows through console2's per-user /superbase proxy (IAM
bearer minted server-side; the proxy allow-list gains the collections + records
paths). Routes: /records (index) · /records/:collection · /records/:collection/:id
(:id=new → create).

base-data/{api,fields} map Base schema → @hanzo/data FieldDefinition[] and do the
list/get/create/update/delete; CollectionTable + RecordDetailView are the views.
Registered next to Base (the backend) — Base is the store, Records is the app on
top. 19 base-data tests pass; RecordsModule + registry typecheck clean (the 4
remaining tsc errors are pre-existing: @hanzo/dash local-only + metrics.test).

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:51:12 -07:00
dfe0b54205 feat(records): browse + edit any Base collection as a CRM/CMS (click-through) (#37)
Makes Base usable BY CLICKING, not just via the API — the gap the live Playwright
check found (the base admin UI is read-only). A 'Records' product (Data category):
browse a collection, open a record, edit it, create new ones — all rendered from
the collection's OWN field schema through @hanzo/data (DataTable for the list,
RecordDetail/RecordForm for detail; every field type is now editable per
hanzoai/ui#232). Data flows through console2's per-user /superbase proxy (IAM
bearer minted server-side; the proxy allow-list gains the collections + records
paths). Routes: /records (index) · /records/:collection · /records/:collection/:id
(:id=new → create).

base-data/{api,fields} map Base schema → @hanzo/data FieldDefinition[] and do the
list/get/create/update/delete; CollectionTable + RecordDetailView are the views.
Registered next to Base (the backend) — Base is the store, Records is the app on
top. 19 base-data tests pass; RecordsModule + registry typecheck clean (the 4
remaining tsc errors are pre-existing: @hanzo/dash local-only + metrics.test).

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 18:51:12 -07:00
hanzo-dev 493dba9d0f feat(observe): retarget the Langfuse Observe surface to native /v1/evals (v8.4.9)
The whole Observe surface (Traces, trace-detail span-tree/waterfall, Observations,
Sessions, Scores, Score Configs, Datasets, Dataset Items, Dataset Runs, Metrics
dashboard) now reads the NATIVE cloud clients/eval /v1/evals contract instead of
the retired /v1/o11y proxy. One-way, no duplicate modules.

- evals.ts: the ONE native client — datasets/items/evaluators/score-configs/scores/
  traces(+detail)/observations/sessions/runs, mapping wire rows into the canonical
  Langfuse view-models (toTrace/toObservation/toScore) so SpanTree + metrics render
  unchanged. Missing enrichment → null (em dash); unbound endpoint → typed ApiError
  → honest BackendStateCard. Never a fabricated row.
- o11y.ts: O11yApi retargeted to delegate to EvalsApi (traces/sessions/observations/
  scores/score-configs native); annotation-queues/users stay on /v1/o11y. TraceDetail
  fixed to Omit observations/scores from Trace before intersecting. Trace gains
  totalTokens (Langfuse Usage column).
- DatasetsModule: wired to the real native endpoints (listDatasets/listDatasetItems
  per-dataset/listRuns) — no longer forward-compatible stubs.
- TracesModule: adds the Tokens column; format.ts adds shared fmtTokens.
- evals.test.ts: 11 pure-logic adapter tests (null-safety, ms→s, token/usage fold).
- NOTICE: MIT attribution for the Langfuse-derived screen layout/flows.

Static-export-safe (data via same-origin /v1 client fetches, no new server routes).
tsc clean · vitest 789/789 · next build 14/14.
2026-07-01 18:44:05 -07:00
hanzo-dev 1c50d0f6b5 feat(observe): retarget the Langfuse Observe surface to native /v1/evals (v8.4.9)
The whole Observe surface (Traces, trace-detail span-tree/waterfall, Observations,
Sessions, Scores, Score Configs, Datasets, Dataset Items, Dataset Runs, Metrics
dashboard) now reads the NATIVE cloud clients/eval /v1/evals contract instead of
the retired /v1/o11y proxy. One-way, no duplicate modules.

- evals.ts: the ONE native client — datasets/items/evaluators/score-configs/scores/
  traces(+detail)/observations/sessions/runs, mapping wire rows into the canonical
  Langfuse view-models (toTrace/toObservation/toScore) so SpanTree + metrics render
  unchanged. Missing enrichment → null (em dash); unbound endpoint → typed ApiError
  → honest BackendStateCard. Never a fabricated row.
- o11y.ts: O11yApi retargeted to delegate to EvalsApi (traces/sessions/observations/
  scores/score-configs native); annotation-queues/users stay on /v1/o11y. TraceDetail
  fixed to Omit observations/scores from Trace before intersecting. Trace gains
  totalTokens (Langfuse Usage column).
- DatasetsModule: wired to the real native endpoints (listDatasets/listDatasetItems
  per-dataset/listRuns) — no longer forward-compatible stubs.
- TracesModule: adds the Tokens column; format.ts adds shared fmtTokens.
- evals.test.ts: 11 pure-logic adapter tests (null-safety, ms→s, token/usage fold).
- NOTICE: MIT attribution for the Langfuse-derived screen layout/flows.

Static-export-safe (data via same-origin /v1 client fetches, no new server routes).
tsc clean · vitest 789/789 · next build 14/14.
2026-07-01 18:44:05 -07:00
hanzo-dev 2da0f024f4 revert: drop console dual-tag — unbreak the release build
The CI token can push the existing console2 ghcr package but lacks
write_package to CREATE a new ghcr.io/hanzoai/console package, so the atomic
buildx --push of both tags failed and v8.4.9 published NEITHER image. Back to
single console2 tag so releases build again. The console2 → console image
rename needs the ghcr  package created + repo-linked first (an
org/settings step), then the tag can be added.
2026-07-01 18:24:00 -07:00
hanzo-dev 6a30592a0f revert: drop console dual-tag — unbreak the release build
The CI token can push the existing console2 ghcr package but lacks
write_package to CREATE a new ghcr.io/hanzoai/console package, so the atomic
buildx --push of both tags failed and v8.4.9 published NEITHER image. Back to
single console2 tag so releases build again. The console2 → console image
rename needs the ghcr  package created + repo-linked first (an
org/settings step), then the tag can be added.
2026-07-01 18:24:00 -07:00
hanzo-dev e135c81422 feat(analytics): native /v1/analytics Observe module (web+commerce+LLM over datastore)
Per-org Analytics product over cloud clients/analytics: Overview (hanzo.events +
events_daily), Real-Time (live sessions/feed), LLM (cloud_usage + observations).
Same-origin /v1/analytics/* via the /cloud bearer proxy; analytics head added to
proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS. Registered under Observe.
Every metric a real warehouse query; honest-empty otherwise.
2026-07-01 18:20:05 -07:00
hanzo-dev 94331b208e feat(analytics): native /v1/analytics Observe module (web+commerce+LLM over datastore)
Per-org Analytics product over cloud clients/analytics: Overview (hanzo.events +
events_daily), Real-Time (live sessions/feed), LLM (cloud_usage + observations).
Same-origin /v1/analytics/* via the /cloud bearer proxy; analytics head added to
proxy-allow CLOUD_HEADS + next.config CLOUD_V1_HEADS. Registered under Observe.
Every metric a real warehouse query; honest-empty otherwise.
2026-07-01 18:20:05 -07:00
hanzo-dev f9ced604dd release: v8.4.9 — publish ghcr.io/hanzoai/console:v8.4.9 (rename transition)
Dual-tag release so the console image exists at this version before the operator
CR + gateway cut over from console2 → console in one coordinated deploy.
2026-07-01 18:06:01 -07:00
hanzo-dev decc802f03 release: v8.4.9 — publish ghcr.io/hanzoai/console:v8.4.9 (rename transition)
Dual-tag release so the console image exists at this version before the operator
CR + gateway cut over from console2 → console in one coordinated deploy.
2026-07-01 18:06:01 -07:00
hanzo-dev 373385b0b0 build: dual-tag ghcr console + console2 (rename step 1, additive)
'console2' was only ever a working name — the package is already @hanzo/console
and it's v8. Start the rename by publishing ghcr.io/hanzoai/console:<ver>
ALONGSIDE console2:<ver>. Purely additive — nothing consumes the console image
yet, so no rollout can break. The cutover (operator CR + gateway k8s Service
console2→console, in ONE deploy) and dropping the console2 tag follow once the
new image has published on a release.
2026-07-01 18:04:10 -07:00
hanzo-dev 12f4e87834 build: dual-tag ghcr console + console2 (rename step 1, additive)
'console2' was only ever a working name — the package is already @hanzo/console
and it's v8. Start the rename by publishing ghcr.io/hanzoai/console:<ver>
ALONGSIDE console2:<ver>. Purely additive — nothing consumes the console image
yet, so no rollout can break. The cutover (operator CR + gateway k8s Service
console2→console, in ONE deploy) and dropping the console2 tag follow once the
new image has published on a release.
2026-07-01 18:04:10 -07:00
hanzo-dev 3cff611498 feat(ai): console2 AI surface LIVE over same-origin /v1/* + canonical agent builder (v8.4.8)
Rebased onto main v8.4.7 (Prompts/Agents/Evals live via next.config /v1 rewrites,
dynamic model+prompt agent builder, ComboBox). Was fe3ee26 (v8.4.5).
Cross-tenant eval fix ships in cloud v1.786.10.
2026-07-01 16:49:19 -07:00
hanzo-dev 55f7f45f75 feat(ai): console2 AI surface LIVE over same-origin /v1/* + canonical agent builder (v8.4.8)
Rebased onto main v8.4.7 (Prompts/Agents/Evals live via next.config /v1 rewrites,
dynamic model+prompt agent builder, ComboBox). Was fe3ee26 (v8.4.5).
Cross-tenant eval fix ships in cloud v1.786.10.
2026-07-01 16:49:19 -07:00
hanzo-dev 40001fb502 feat(compute): real Launch drawer (GPUs+Machines) + full live catalog + Hanzo-brand Tasks/Functions (v8.4.7)
Launch GPU/Machine opened docs — now opens a REAL launch flow (POST /vm/v1/machines/launch,
per-org, metered; proven live vs vm:0.1.10). ONE shared LaunchDrawer (kind cpu|gpu) in the
DetailPane from both pages: complete live catalog (172 sizes / 9 GPUs, searchable) + regions,
OUR market price ($/hr+$/mo == the dryRun quote == what launch charges, visor HanzoPrice one
source), 402→'add credits'. Docs demoted to secondary.

Pricing: fixed $/mo (was priceHourly×730=693.5, now authoritative priceMonthly=706.8).
Catalog: MachineCatalog shows ALL sizes (search+scroll) not top-6; GPUs shows all 9.
Branding: Tasks drops 'Temporal'→Hanzo Tasks; Functions drops 'Fission'→Serverless/Hanzo.

VisorApi.quote/launch added (casibase-envelope unwrap). tsc clean; vitest green; build 14/14.
2026-07-01 16:34:55 -07:00
hanzo-dev 500f56cfb8 feat(compute): real Launch drawer (GPUs+Machines) + full live catalog + Hanzo-brand Tasks/Functions (v8.4.7)
Launch GPU/Machine opened docs — now opens a REAL launch flow (POST /vm/v1/machines/launch,
per-org, metered; proven live vs vm:0.1.10). ONE shared LaunchDrawer (kind cpu|gpu) in the
DetailPane from both pages: complete live catalog (172 sizes / 9 GPUs, searchable) + regions,
OUR market price ($/hr+$/mo == the dryRun quote == what launch charges, visor HanzoPrice one
source), 402→'add credits'. Docs demoted to secondary.

Pricing: fixed $/mo (was priceHourly×730=693.5, now authoritative priceMonthly=706.8).
Catalog: MachineCatalog shows ALL sizes (search+scroll) not top-6; GPUs shows all 9.
Branding: Tasks drops 'Temporal'→Hanzo Tasks; Functions drops 'Fission'→Serverless/Hanzo.

VisorApi.quote/launch added (casibase-envelope unwrap). tsc clean; vitest green; build 14/14.
2026-07-01 16:34:55 -07:00
hanzo-dev dbae61c06e feat(nav): per-category color-coding + category-overview color wiring (v8.4.6)
Products inherit a per-category color family by default (one color per category:
AI/Compute/Data/Security/…), user override still wins, no-category callers keep
their legacy curated pick. colorOf/keyOf thread the entry's category so the sidebar
icons, collapsed rail, and L2 header recolor per category in one place. Category
landing (/category/<slug>, CategoryOverview — already built) tiles + header glyph +
the L1/L2 category labels are tinted the category color. colors.ts pure + tested.

tsc clean, vitest 711/711, next build green.
2026-07-01 16:20:33 -07:00
hanzo-dev 3de8bc6bf1 feat(nav): per-category color-coding + category-overview color wiring (v8.4.6)
Products inherit a per-category color family by default (one color per category:
AI/Compute/Data/Security/…), user override still wins, no-category callers keep
their legacy curated pick. colorOf/keyOf thread the entry's category so the sidebar
icons, collapsed rail, and L2 header recolor per category in one place. Category
landing (/category/<slug>, CategoryOverview — already built) tiles + header glyph +
the L1/L2 category labels are tinted the category color. colors.ts pure + tested.

tsc clean, vitest 711/711, next build green.
2026-07-01 16:20:33 -07:00
hanzo-dev 0cb0763e9b feat(nav): persistent product filter at level 2 — quick-jump across products from any level (v8.4.5)
The sidebar product filter is hoisted out of the level-1 slide panel into a
persistent header above the two-level slide, so a user deep in a product's
sub-pages can filter + jump straight to another product without going Back.
showLevel2 yields to the product list while filtering; selecting a result
clears the filter and slides to that product's level-2 sub-nav. One input,
one predicate (entryMatches), one list. Mobile drawer inherits it.

tsc clean, vitest 706/706, next build green.
2026-07-01 16:01:09 -07:00
hanzo-dev 1b508d7e9b feat(nav): persistent product filter at level 2 — quick-jump across products from any level (v8.4.5)
The sidebar product filter is hoisted out of the level-1 slide panel into a
persistent header above the two-level slide, so a user deep in a product's
sub-pages can filter + jump straight to another product without going Back.
showLevel2 yields to the product list while filtering; selecting a result
clears the filter and slides to that product's level-2 sub-nav. One input,
one predicate (entryMatches), one list. Mobile drawer inherits it.

tsc clean, vitest 706/706, next build green.
2026-07-01 16:01:09 -07:00
hanzo-dev 4e6ba0fbce test(billing): fix groupSpend provider assertion — openai(400)>hanzo(350) sorts first
groupSpend sorts cents-desc (see the by-model test); the provider test
asserted the reverse and had been red on main. No logic change.
2026-07-01 15:46:31 -07:00
hanzo-dev 0674b5c0cf test(billing): fix groupSpend provider assertion — openai(400)>hanzo(350) sorts first
groupSpend sorts cents-desc (see the by-model test); the provider test
asserted the reverse and had been red on main. No logic change.
2026-07-01 15:46:31 -07:00
hanzo-dev 5817ff5c92 feat(storage): native S3 file manager + KMS repoint to embedded cloud KMS (v8.4.2)
PRIORITY 1 — S3 file manager. Upgrades the 'Object Storage' catalog entry from
the generic provisioning resource card to a REAL S3 file manager over the
org-scoped /v1/s3 control plane in the unified cloud binary (hanzoai/cloud
clients/s3). One console, one backend — no external s3.hanzo.ai UI.

- src/lib/api/storage.ts (StorageApi): buckets list/create/delete, object list
  (folder-style via prefix), delete, and presigned upload/download. Metadata ops
  go through the same-origin /cloud user-bearer proxy (cloudProxyV1Url — s3 is
  already allow-listed in proxy-allow.ts). Upload/download use PRESIGNED URLs:
  the backend mints a time-boxed URL scoped to the exact bucket+key and the
  browser transfers bytes DIRECTLY to S3 — bypassing the proxy (which buffers the
  body as text + forces JSON content-type, corrupting binary) and never exposing
  the admin credential. Defensive normalizers (billing.ts style); org is
  server-authoritative (never a browser claim).
- src/components/products/StorageModule.tsx: bucket list (create/delete) plus an
  object browser with breadcrumb folder navigation, upload (file picker to
  presigned direct-to-S3), download, delete. Honest loading/empty(first-run)/
  BackendState (503/404/403) states — never a fabricated bucket/object. hanzo/gui
  v5 shorthands; mirrors FunctionsModule.
- registry.tsx: s3 entry now routes to StorageModule (repo hanzoai/s3).

PRIORITY 2 — KMS repoint. src/lib/server/identity.ts KMS_URL default
http://kms.hanzo.svc (legacy Infisical fork) to http://cloud.hanzo.svc:8000
(embedded cloud KMS serving /v1/kms/orgs/{org}/secrets natively, HIP-0106). The
/admin/kms proxy forwards to kmsBaseUrl + /v1/kms/orgs/{org}/secrets — matches.
CAVEAT (documented, not a blocker): the embedded KMS is health-only (secret ops
503) until CLOUD_KMS_MASTER_KEY_REF is provisioned + secrets migrated; the KMS
module shows its honest 'not initialized' state until then.

Tests: vitest storage.test.ts 15/15 (normalizers, folder detection, presign,
traversal-safe key encoding, /cloud-proxy URL construction, direct-to-S3 upload).
tsc --noEmit clean. next build green (14/14 pages). Version 8.4.1 to 8.4.2.
2026-07-01 15:42:31 -07:00
hanzo-dev d43beb21fc feat(storage): native S3 file manager + KMS repoint to embedded cloud KMS (v8.4.2)
PRIORITY 1 — S3 file manager. Upgrades the 'Object Storage' catalog entry from
the generic provisioning resource card to a REAL S3 file manager over the
org-scoped /v1/s3 control plane in the unified cloud binary (hanzoai/cloud
clients/s3). One console, one backend — no external s3.hanzo.ai UI.

- src/lib/api/storage.ts (StorageApi): buckets list/create/delete, object list
  (folder-style via prefix), delete, and presigned upload/download. Metadata ops
  go through the same-origin /cloud user-bearer proxy (cloudProxyV1Url — s3 is
  already allow-listed in proxy-allow.ts). Upload/download use PRESIGNED URLs:
  the backend mints a time-boxed URL scoped to the exact bucket+key and the
  browser transfers bytes DIRECTLY to S3 — bypassing the proxy (which buffers the
  body as text + forces JSON content-type, corrupting binary) and never exposing
  the admin credential. Defensive normalizers (billing.ts style); org is
  server-authoritative (never a browser claim).
- src/components/products/StorageModule.tsx: bucket list (create/delete) plus an
  object browser with breadcrumb folder navigation, upload (file picker to
  presigned direct-to-S3), download, delete. Honest loading/empty(first-run)/
  BackendState (503/404/403) states — never a fabricated bucket/object. hanzo/gui
  v5 shorthands; mirrors FunctionsModule.
- registry.tsx: s3 entry now routes to StorageModule (repo hanzoai/s3).

PRIORITY 2 — KMS repoint. src/lib/server/identity.ts KMS_URL default
http://kms.hanzo.svc (legacy Infisical fork) to http://cloud.hanzo.svc:8000
(embedded cloud KMS serving /v1/kms/orgs/{org}/secrets natively, HIP-0106). The
/admin/kms proxy forwards to kmsBaseUrl + /v1/kms/orgs/{org}/secrets — matches.
CAVEAT (documented, not a blocker): the embedded KMS is health-only (secret ops
503) until CLOUD_KMS_MASTER_KEY_REF is provisioned + secrets migrated; the KMS
module shows its honest 'not initialized' state until then.

Tests: vitest storage.test.ts 15/15 (normalizers, folder detection, presign,
traversal-safe key encoding, /cloud-proxy URL construction, direct-to-S3 upload).
tsc --noEmit clean. next build green (14/14 pages). Version 8.4.1 to 8.4.2.
2026-07-01 15:42:31 -07:00
hanzo-dev 6adacc7e4e feat(commerce): Commerce store dashboard in the console — Products/Orders/Customers/Inventory/Promotions/Store, per-org (v8.4.3)
A new Commerce category surfaces the hanzoai/commerce merchant store natively
inside the console (no admin.commerce.hanzo.ai subdomain). Six pages —
Products, Orders, Customers, Inventory, Promotions, Store settings — each a
native module over the real commerce backend, scoped to the signed-in org.

- BFF: app/commerce/[...path]/route.ts forwards to commerce.hanzo.svc:8001 via
  forwardWithUserBearer (mints a short-lived user IAM token; commerce EdgeAuth
  resolves the org from the owner claim). Least-privilege allow-list
  (allowCommerceSurface / COMMERCE_HEADS): only the merchant REST heads
  (product/order/user/variant/discount/collection/store/…) — /billing, /checkout,
  /_/commerce/tenants are NOT reachable (money stays on the /billing proxy).
- Client: src/lib/api/commerce.ts (CommerceApi) — defensive normalizers over the
  real {count,models,facets} envelope; empty store → honest empty, never faked.
- UI: one CommerceResource list (fetch → loading/empty/error via BackendStateCard)
  + six thin pages; Store settings notes Square/Billing. @hanzo/gui v5 shorthands.
- Category: 'Commerce' added to brand-scope (hanzo shows it; web3 brands don't).
- No Stripe, no new DB, no billing-engine change. Payments remain Square via
  hanzoai/commerce /v1/billing.

Verification: tsc --noEmit clean; vitest 52 green (commerce/logic/proxy-allow/
brand-scope); next build ✓ (/commerce/[...path] compiled). The one failing test
(billing/logic.ts groupSpend ordering) is pre-existing on origin/main, unrelated.
2026-07-01 14:56:23 -07:00
hanzo-dev a07eeb78bf feat(commerce): Commerce store dashboard in the console — Products/Orders/Customers/Inventory/Promotions/Store, per-org (v8.4.3)
A new Commerce category surfaces the hanzoai/commerce merchant store natively
inside the console (no admin.commerce.hanzo.ai subdomain). Six pages —
Products, Orders, Customers, Inventory, Promotions, Store settings — each a
native module over the real commerce backend, scoped to the signed-in org.

- BFF: app/commerce/[...path]/route.ts forwards to commerce.hanzo.svc:8001 via
  forwardWithUserBearer (mints a short-lived user IAM token; commerce EdgeAuth
  resolves the org from the owner claim). Least-privilege allow-list
  (allowCommerceSurface / COMMERCE_HEADS): only the merchant REST heads
  (product/order/user/variant/discount/collection/store/…) — /billing, /checkout,
  /_/commerce/tenants are NOT reachable (money stays on the /billing proxy).
- Client: src/lib/api/commerce.ts (CommerceApi) — defensive normalizers over the
  real {count,models,facets} envelope; empty store → honest empty, never faked.
- UI: one CommerceResource list (fetch → loading/empty/error via BackendStateCard)
  + six thin pages; Store settings notes Square/Billing. @hanzo/gui v5 shorthands.
- Category: 'Commerce' added to brand-scope (hanzo shows it; web3 brands don't).
- No Stripe, no new DB, no billing-engine change. Payments remain Square via
  hanzoai/commerce /v1/billing.

Verification: tsc --noEmit clean; vitest 52 green (commerce/logic/proxy-allow/
brand-scope); next build ✓ (/commerce/[...path] compiled). The one failing test
(billing/logic.ts groupSpend ordering) is pre-existing on origin/main, unrelated.
2026-07-01 14:56:23 -07:00
hanzo-dev d13dc49e69 fix(compute): make every Compute page read CONNECTED as a customer (v8.4.2)
Live browser pass as Dave (maxpower) found pages that were connected-but-read-as-broken:
- Machines: /vm/v1/machines 403s for a signed-in customer (visor authorizes the
  public catalog but denies the per-org list) → the page said 'Sign in to view your
  machines' next to the real region/size catalog. interpretVisorError now maps 403→
  connected-managed (only 401 = sign-in); CustomerMachines shows 'Launch your first
  machine' + the live catalog, never a sign-in wall.
- platform/state forbidden: reframed to 'Connected · managed by Hanzo' (green check, no
  warning triangle, no Retry) so Containers/Edge/Applications read connected, not error.
- Applications: repointed from casibase IAM OAuth apps (get-applications) to the DEPLOYED
  app services (/v1/apps) — connected/managed/empty states + deploy-via-Functions/Agents.
- Agents: 'Connected · no agents yet' banner on the live 200-empty state.
- Proxy defaults hardened (vm/cloud/tasksd): '|| default' (not '??') so an env
  reconciled to an EMPTY string still resolves the in-cluster service.

tsc clean; vitest green (visor test updated for 403→connected).
2026-07-01 14:42:53 -07:00
hanzo-dev 1e61c43363 fix(compute): make every Compute page read CONNECTED as a customer (v8.4.2)
Live browser pass as Dave (maxpower) found pages that were connected-but-read-as-broken:
- Machines: /vm/v1/machines 403s for a signed-in customer (visor authorizes the
  public catalog but denies the per-org list) → the page said 'Sign in to view your
  machines' next to the real region/size catalog. interpretVisorError now maps 403→
  connected-managed (only 401 = sign-in); CustomerMachines shows 'Launch your first
  machine' + the live catalog, never a sign-in wall.
- platform/state forbidden: reframed to 'Connected · managed by Hanzo' (green check, no
  warning triangle, no Retry) so Containers/Edge/Applications read connected, not error.
- Applications: repointed from casibase IAM OAuth apps (get-applications) to the DEPLOYED
  app services (/v1/apps) — connected/managed/empty states + deploy-via-Functions/Agents.
- Agents: 'Connected · no agents yet' banner on the live 200-empty state.
- Proxy defaults hardened (vm/cloud/tasksd): '|| default' (not '??') so an env
  reconciled to an EMPTY string still resolves the in-cluster service.

tsc clean; vitest green (visor test updated for 403→connected).
2026-07-01 14:42:53 -07:00
hanzo-dev ec75af5859 feat(compute): wire all Compute pages per-org to real backends + rich Agents dashboard (v8.3.2)
Agents: rebuilt AgentsModule into a rich dashboard over /cloud/v1/agents (was
/paas): 5 stat cards, invocations area chart, health donut, agents table with
status tabs + pagination + version badges, recent-activity feed, top-agents bar
list, 30d resource-usage panel. Every number real/derived; polished
create-first empty state + real New-Agent flow. New lib/api/agents.ts (+22 tests)
+ agents/{parts,forms}.tsx.

Machines: customer branch shows the real visor region/size catalog + pricing
(MachineCatalog) under the launch state — never blank.

GPUs: role-routed like Machines — customer sees the real visor GPU catalog +
their GPU machines (CustomerGpus); admin keeps the /paas fleet. Overview route
is role-aware (GpusOverview). +4 visor catalog normalizer tests.

Containers: surface the apps 403 as a graceful 'Managed control plane' card
(was a masked bare-empty table). Edge: honest coming-soon/managed state.

platform/state.tsx: split 401/403 (forbidden → 'Managed control plane') from
501 (not-configured → admin token hint) so customers never see the false
PAAS_SERVICE_TOKEN message across every /paas module.

visor.ts: add regions()/sizes()/gpus() catalog + normalizers.

tsc clean; vitest 639+ green; next build 14/14.
2026-07-01 13:47:15 -07:00
hanzo-dev 98fb6e8a2d feat(compute): wire all Compute pages per-org to real backends + rich Agents dashboard (v8.3.2)
Agents: rebuilt AgentsModule into a rich dashboard over /cloud/v1/agents (was
/paas): 5 stat cards, invocations area chart, health donut, agents table with
status tabs + pagination + version badges, recent-activity feed, top-agents bar
list, 30d resource-usage panel. Every number real/derived; polished
create-first empty state + real New-Agent flow. New lib/api/agents.ts (+22 tests)
+ agents/{parts,forms}.tsx.

Machines: customer branch shows the real visor region/size catalog + pricing
(MachineCatalog) under the launch state — never blank.

GPUs: role-routed like Machines — customer sees the real visor GPU catalog +
their GPU machines (CustomerGpus); admin keeps the /paas fleet. Overview route
is role-aware (GpusOverview). +4 visor catalog normalizer tests.

Containers: surface the apps 403 as a graceful 'Managed control plane' card
(was a masked bare-empty table). Edge: honest coming-soon/managed state.

platform/state.tsx: split 401/403 (forbidden → 'Managed control plane') from
501 (not-configured → admin token hint) so customers never see the false
PAAS_SERVICE_TOKEN message across every /paas module.

visor.ts: add regions()/sizes()/gpus() catalog + normalizers.

tsc clean; vitest 639+ green; next build 14/14.
2026-07-01 13:47:15 -07:00
hanzo-dev 8cc9baa514 release: billing center — GCP-grade unified billing (Overview/Reports/Budgets/Invoices/Subscriptions/Payments/Credits) + billing-only shell mode for billing.hanzo.ai 2026-07-01 13:30:51 -07:00
hanzo-dev 261515d9a6 release: billing center — GCP-grade unified billing (Overview/Reports/Budgets/Invoices/Subscriptions/Payments/Credits) + billing-only shell mode for billing.hanzo.ai 2026-07-01 13:30:51 -07:00
hanzo-dev 10fef57400 Merge remote-tracking branch 'origin/feat/billing-center' into integrate/billing-center
# Conflicts:
#	LLM.md
2026-07-01 13:30:51 -07:00
hanzo-dev 87fb228b10 Merge remote-tracking branch 'origin/feat/billing-center' into integrate/billing-center
# Conflicts:
#	LLM.md
2026-07-01 13:30:51 -07:00
hanzo-dev bfad0b8bf6 feat(billing): unified GCP-grade Billing Center + billing-only shell mode
Part A — Billing Center (one `billing` catalog entry, tabbed):
- Consolidate the scattered Cost/Subscriptions/Payment-methods entries into ONE
  `BillingModule` (registry ''+:tab) under Observe. Delete superseded CostModule.
- Overview: balance/credits + month-to-date spend + clearly-labelled linear
  projection + daily-spend trend (real /v1/billing/usage ledger, pure logic.ts).
- Reports: cost breakdown by real ledger dimension (model/provider — no invented
  project/SKU), filterable table + BarChart + spend-share Donut over a range.
- Budgets: REAL create+list over commerce spend-alerts (GET/POST /v1/billing/
  spend-alerts). Edit/delete withheld pending commerce per-alert ownership check.
- Invoices: GET /v1/billing/invoices + download; honest empty.
- Subscriptions/Payment methods/Credits: reuse existing modules verbatim as tabs.
- Add `scopedBillingBody` (billing-scope) so a write body's subject is pinned
  server-side — create-budget works without the browser knowing its subject and a
  forged body subject can't widen scope. Wired into the /billing proxy write path.

Part B — billing-only shell (billing.<brand> = same image, filtered):
- config.billingOnly (host billing.<brand> OR NEXT_PUBLIC_BILLING_ONLY=1).
- visibleCatalog / DashboardShell nav filter to the Billing Center sub-pages,
  full chrome kept; default route redirects / -> /billing.
- cmd+K + AppLauncher source from visibleCatalog so scoping is consistent.

Honest states everywhere; no fabricated data. New unit tests: billing/logic,
scopedBillingBody, config billing-only host. e2e/pages.spec updated for the
consolidation. Docs in LLM.md.
2026-07-01 13:25:45 -07:00
hanzo-dev 675abb0301 feat(billing): unified GCP-grade Billing Center + billing-only shell mode
Part A — Billing Center (one `billing` catalog entry, tabbed):
- Consolidate the scattered Cost/Subscriptions/Payment-methods entries into ONE
  `BillingModule` (registry ''+:tab) under Observe. Delete superseded CostModule.
- Overview: balance/credits + month-to-date spend + clearly-labelled linear
  projection + daily-spend trend (real /v1/billing/usage ledger, pure logic.ts).
- Reports: cost breakdown by real ledger dimension (model/provider — no invented
  project/SKU), filterable table + BarChart + spend-share Donut over a range.
- Budgets: REAL create+list over commerce spend-alerts (GET/POST /v1/billing/
  spend-alerts). Edit/delete withheld pending commerce per-alert ownership check.
- Invoices: GET /v1/billing/invoices + download; honest empty.
- Subscriptions/Payment methods/Credits: reuse existing modules verbatim as tabs.
- Add `scopedBillingBody` (billing-scope) so a write body's subject is pinned
  server-side — create-budget works without the browser knowing its subject and a
  forged body subject can't widen scope. Wired into the /billing proxy write path.

Part B — billing-only shell (billing.<brand> = same image, filtered):
- config.billingOnly (host billing.<brand> OR NEXT_PUBLIC_BILLING_ONLY=1).
- visibleCatalog / DashboardShell nav filter to the Billing Center sub-pages,
  full chrome kept; default route redirects / -> /billing.
- cmd+K + AppLauncher source from visibleCatalog so scoping is consistent.

Honest states everywhere; no fabricated data. New unit tests: billing/logic,
scopedBillingBody, config billing-only host. e2e/pages.spec updated for the
consolidation. Docs in LLM.md.
2026-07-01 13:25:45 -07:00
hanzo-dev b27f7165a0 release: console2 v8.3.1 — Nodes + DNS Network modules consolidated onto main
Build Docker Image / docker (push) Successful in 2m59s
One authoritative build ending the deploy-war. Two genuinely-unmerged Network
modules land (nodes: per-node luxd validators/peers; dns: per-org managed DNS);
all other session branches were already on main (git cherry-verified) and are
pruned, not re-merged. The api.hanzo.ai-gateway default change was rejected (CR
documents in-cluster CF-403 on public hosts; safe default is cloud.hanzo.svc).
Fixed pre-existing observability/metrics.test.ts null-override types so
tsc --noEmit is fully green.

tsc --noEmit clean; next build is the authoritative Node-24 gate (on-cluster
Kaniko, no GitHub builders).
2026-07-01 13:22:39 -07:00
hanzo-dev 1e587a3c92 release: console2 v8.3.1 — Nodes + DNS Network modules consolidated onto main
One authoritative build ending the deploy-war. Two genuinely-unmerged Network
modules land (nodes: per-node luxd validators/peers; dns: per-org managed DNS);
all other session branches were already on main (git cherry-verified) and are
pruned, not re-merged. The api.hanzo.ai-gateway default change was rejected (CR
documents in-cluster CF-403 on public hosts; safe default is cloud.hanzo.svc).
Fixed pre-existing observability/metrics.test.ts null-override types so
tsc --noEmit is fully green.

tsc --noEmit clean; next build is the authoritative Node-24 gate (on-cluster
Kaniko, no GitHub builders).
2026-07-01 13:22:39 -07:00
hanzo-dev 4038efd556 fix(registry): dedupe DNS — render real DnsModule from the Network-cluster entry, drop the duplicate
The DNS module cherry-pick (bee05edc) added a second id:'dns' catalog entry while
main already carried a DNS overview stub in the Network cluster. Upgrade that
well-placed stub to render the real DnsModule (zones/records over /v1/dns) and
remove the duplicate — one id, one entry, one way.
2026-07-01 13:19:16 -07:00
hanzo-dev 2a72c63ee8 fix(registry): dedupe DNS — render real DnsModule from the Network-cluster entry, drop the duplicate
The DNS module cherry-pick (bee05edc) added a second id:'dns' catalog entry while
main already carried a DNS overview stub in the Network cluster. Upgrade that
well-placed stub to render the real DnsModule (zones/records over /v1/dns) and
remove the duplicate — one id, one entry, one way.
2026-07-01 13:19:16 -07:00
1ca63e1836 feat(nodes): per-node blockchain infrastructure module (validators + peers)
Add a real `nodes` catalog entry (Network category) surfacing individual luxd
node infrastructure — validators (P-chain platform.getCurrentValidators) + peers
(info.peers) — across networks, wired to LIVE luxd RPC. Complements the Bootnode
`networks` module (network-level counts) with per-NODE inventory. REAL data only;
honest "not reporting" per unreachable network, honest empty otherwise.

- app/nodes/[...path]/route.ts: same-origin proxy mirroring the /bootnode security
  pattern — session-gated, brand/org-aware (brandFromHost), least-privilege. Only
  path is v1/inventory; only the four read methods getCurrentValidators/peers/
  getNodeVersion/getHeight are called server-side. Per-network RPC hosts in a small
  env-overridable map; unreachable host -> honest not-reporting, never fake rows.
- lib/api/nodes.ts (extended; cluster-capacity logic untouched): pure
  normalizeValidators/normalizePeers/combineInventory -> uniform NodeRow, dedupe by
  nodeID (validator wins, version enriched from peer), parseUptimePct/parseHeight/
  fmtWeight; NodesApi browser client over the proxy.
- lib/products/brand-scope.ts: nodeNetworksForBrand DATA scope — hanzo=all networks
  (super-admin/infra view), lux/zoo/pars scoped to their own chain. Nodes lives in
  Network so category scope admits it on every brand.
- components/products/NodesModule.tsx: per-network summary cards + network filter +
  DataTable (Network/Role/Node ID/Version/Status/Uptime/Height); BackendStateCard/
  EmptyState honest states.
- Confirmed live (2026-07-01): lux mainnet/testnet/devnet, pars-mainnet. zoo has no
  confirmed public host yet -> honest not-reporting.

Tests: nodes normalizers (real captured wire shapes) + brand->network scoping.
typecheck clean, vitest 404/404 (37 files), next build green. package 8.2.1->8.2.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:17:15 -07:00
zeekayandhanzo-dev 4ebdad3f72 feat(nodes): per-node blockchain infrastructure module (validators + peers)
Add a real `nodes` catalog entry (Network category) surfacing individual luxd
node infrastructure — validators (P-chain platform.getCurrentValidators) + peers
(info.peers) — across networks, wired to LIVE luxd RPC. Complements the Bootnode
`networks` module (network-level counts) with per-NODE inventory. REAL data only;
honest "not reporting" per unreachable network, honest empty otherwise.

- app/nodes/[...path]/route.ts: same-origin proxy mirroring the /bootnode security
  pattern — session-gated, brand/org-aware (brandFromHost), least-privilege. Only
  path is v1/inventory; only the four read methods getCurrentValidators/peers/
  getNodeVersion/getHeight are called server-side. Per-network RPC hosts in a small
  env-overridable map; unreachable host -> honest not-reporting, never fake rows.
- lib/api/nodes.ts (extended; cluster-capacity logic untouched): pure
  normalizeValidators/normalizePeers/combineInventory -> uniform NodeRow, dedupe by
  nodeID (validator wins, version enriched from peer), parseUptimePct/parseHeight/
  fmtWeight; NodesApi browser client over the proxy.
- lib/products/brand-scope.ts: nodeNetworksForBrand DATA scope — hanzo=all networks
  (super-admin/infra view), lux/zoo/pars scoped to their own chain. Nodes lives in
  Network so category scope admits it on every brand.
- components/products/NodesModule.tsx: per-network summary cards + network filter +
  DataTable (Network/Role/Node ID/Version/Status/Uptime/Height); BackendStateCard/
  EmptyState honest states.
- Confirmed live (2026-07-01): lux mainnet/testnet/devnet, pars-mainnet. zoo has no
  confirmed public host yet -> honest not-reporting.

Tests: nodes normalizers (real captured wire shapes) + brand->network scoping.
typecheck clean, vitest 404/404 (37 files), next build green. package 8.2.1->8.2.2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 13:17:15 -07:00
865833e049 feat(dns): per-org DNS module — zones + records over /v1/dns (hanzodns)
A Network-category console module: lists org-scoped DNS zones and their records
on the unified /v1/dns surface (api.hanzo.ai gateway → hanzodns → CoreDNS +
Cloudflare sync). Honest BackendStateCard states (401/404/503) until the route
is bound — never fabricates a zone/record. Mirrors the Networks module; uses
@hanzo/data DataTable + the X-Org-Id the cloud client stamps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:14:11 -07:00
zeekayandhanzo-dev f3559ebbc8 feat(dns): per-org DNS module — zones + records over /v1/dns (hanzodns)
A Network-category console module: lists org-scoped DNS zones and their records
on the unified /v1/dns surface (api.hanzo.ai gateway → hanzodns → CoreDNS +
Cloudflare sync). Honest BackendStateCard states (401/404/503) until the route
is bound — never fabricates a zone/record. Mirrors the Networks module; uses
@hanzo/data DataTable + the X-Org-Id the cloud client stamps.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 13:14:11 -07:00
hanzo-dev cbfe1c090b release: console UX batch — model catalog logos+providers, cmd+K scroll, docs links, editable branding, Linear icons, category overviews, org-wide observability
Consolidates 5 reviewed branches (PRs #24 #25 #28 #29 #35):
- Model catalog: real Zen ensō + Hanzo H marks; Providers link
- cmd+K: scrolls (mouse + keyboard-follow); docs deep links resolve (/docs)
- Settings: editable org branding (name/logo/colors/theme) with real save
- Linear-style ProductIcon tiles across cmd+K, sidebar, overview headers
- Per-category overview pages (12) + the AI hub
- Observability org-wide on login (Langfuse metrics, no per-project gate)
2026-07-01 12:05:21 -07:00
hanzo-dev 42b53565f7 release: console UX batch — model catalog logos+providers, cmd+K scroll, docs links, editable branding, Linear icons, category overviews, org-wide observability
Consolidates 5 reviewed branches (PRs #24 #25 #28 #29 #35):
- Model catalog: real Zen ensō + Hanzo H marks; Providers link
- cmd+K: scrolls (mouse + keyboard-follow); docs deep links resolve (/docs)
- Settings: editable org branding (name/logo/colors/theme) with real save
- Linear-style ProductIcon tiles across cmd+K, sidebar, overview headers
- Per-category overview pages (12) + the AI hub
- Observability org-wide on login (Langfuse metrics, no per-project gate)
2026-07-01 12:05:21 -07:00
hanzo-dev 3e7db2c7e1 Merge remote-tracking branch 'origin/fix/console-observability' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 653f1315f2 Merge remote-tracking branch 'origin/fix/console-observability' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 23e9df90aa Merge remote-tracking branch 'origin/feat/console-category-overviews' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev f437ceeff9 Merge remote-tracking branch 'origin/feat/console-category-overviews' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev c18cada283 Merge remote-tracking branch 'origin/fix/console-branding-and-icons' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev c5d526a3f0 Merge remote-tracking branch 'origin/fix/console-branding-and-icons' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 087f35d0d0 Merge remote-tracking branch 'origin/fix/cmdk-scroll-and-docs-links' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 591ce5804d Merge remote-tracking branch 'origin/fix/cmdk-scroll-and-docs-links' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev c654b714b2 Merge remote-tracking branch 'origin/fix/console-model-catalog' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev d002a0cc61 Merge remote-tracking branch 'origin/fix/console-model-catalog' into integrate/console-ux-batch 2026-07-01 12:03:58 -07:00
hanzo-dev 4a9ba2af8f fix(console): observability is org-wide on login (Langfuse metrics, no per-project gate)
The Langfuse-derived views (metrics/logs/traces/sessions/scores/observations)
share the o11y REST client. Document + lock the tenancy contract: every call
stamps X-Org-Id (currentOrg(), always set on login) and adds X-Project-Id ONLY
when a project is selected — so these views are ORG-WIDE by default and narrow to
a project only when one is picked. No project required to 'just log in and see
metrics'. Adds a real MetricsModule (org rollups over /v1/o11y + /v1/metrics),
pure metrics logic, and its test. Honest states preserved (loading / not-
initialized 503 / empty) — no fabricated data.
2026-07-01 12:02:18 -07:00
hanzo-dev ab61feb25a fix(console): observability is org-wide on login (Langfuse metrics, no per-project gate)
The Langfuse-derived views (metrics/logs/traces/sessions/scores/observations)
share the o11y REST client. Document + lock the tenancy contract: every call
stamps X-Org-Id (currentOrg(), always set on login) and adds X-Project-Id ONLY
when a project is selected — so these views are ORG-WIDE by default and narrow to
a project only when one is picked. No project required to 'just log in and see
metrics'. Adds a real MetricsModule (org rollups over /v1/o11y + /v1/metrics),
pure metrics logic, and its test. Honest states preserved (loading / not-
initialized 503 / empty) — no fabricated data.
2026-07-01 12:02:18 -07:00
098b04b75d fix(base): transpile @hanzo/dash (was @hanzo/dashboard) — unbreak build; lockfile + v8.2.13 (#33)
next.config transpilePackages still listed the OLD package name, so Next parsed
@hanzo/dash's shipped TSX source as plain JS → 'Unexpected token' on export type.
Rename the transpile entry, regenerate the lockfile onto @hanzo/dash@0.3.0.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:54:24 -07:00
503d87c98e fix(base): transpile @hanzo/dash (was @hanzo/dashboard) — unbreak build; lockfile + v8.2.13 (#33)
next.config transpilePackages still listed the OLD package name, so Next parsed
@hanzo/dash's shipped TSX source as plain JS → 'Unexpected token' on export type.
Rename the transpile entry, regenerate the lockfile onto @hanzo/dash@0.3.0.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:54:24 -07:00
hanzo-devandGitHub 9ea65311a5 Merge pull request #32 from hanzoai/claude/wallet-live-balance
fix(wallet): live cloud-credit balance — refetch on focus + after completion/top-up (v8.2.12)
2026-07-01 11:50:09 -07:00
hanzo-devandGitHub 5c0ee0c7c0 Merge pull request #32 from hanzoai/claude/wallet-live-balance
fix(wallet): live cloud-credit balance — refetch on focus + after completion/top-up (v8.2.12)
2026-07-01 11:50:09 -07:00
987dee1e08 Base: show 'Bases' scoped to Org → Project (IAM-native) via @hanzo/dash@0.3.0 (#31)
* feat(base): render Bases under Org → Project (drop the 'tenant' noun)

A superbase tenant IS a full Hanzo Base instance → label the page 'Bases' + show
the Org → Project scope (top-bar ScopeSwitcher) as a breadcrumb, consistent with
every resource module. Passes labels + context to the shared @hanzo/dashboard
screens (props added in superbase feat/base-labels-context). Needs that package
republished + a version bump here to build.

* chore: @hanzo/dashboard → @hanzo/dash@0.3.0 (coherent SDK name) + Base labels/context

Renames the dashboard SDK dep + imports to the canonical @hanzo/dash (was the
incoherent @hanzo/dashboard). 0.3.0 carries the labels/context props so the Base
page renders 'Bases — <org> / <project>' (Org → Project scope) instead of the
'Tenants' noun.

* chore: v8.2.12 — Bases-under-Org via @hanzo/dash@0.3.0 (merge main)

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:49:27 -07:00
d1b5556e5a Base: show 'Bases' scoped to Org → Project (IAM-native) via @hanzo/dash@0.3.0 (#31)
* feat(base): render Bases under Org → Project (drop the 'tenant' noun)

A superbase tenant IS a full Hanzo Base instance → label the page 'Bases' + show
the Org → Project scope (top-bar ScopeSwitcher) as a breadcrumb, consistent with
every resource module. Passes labels + context to the shared @hanzo/dashboard
screens (props added in superbase feat/base-labels-context). Needs that package
republished + a version bump here to build.

* chore: @hanzo/dashboard → @hanzo/dash@0.3.0 (coherent SDK name) + Base labels/context

Renames the dashboard SDK dep + imports to the canonical @hanzo/dash (was the
incoherent @hanzo/dashboard). 0.3.0 carries the labels/context props so the Base
page renders 'Bases — <org> / <project>' (Org → Project scope) instead of the
'Tenants' noun.

* chore: v8.2.12 — Bases-under-Org via @hanzo/dash@0.3.0 (merge main)

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-01 11:49:27 -07:00
hanzo-dev bcc6d41bba fix(wallet): live cloud-credit balance — one shared store, refetch on focus + after completion/top-up (v8.2.12)
Dave (maxpower) saw his real $99.74 on mount but it never CHANGED without a
reload — "not seeing increase in the wallet view". The balance READ path was
correct (every surface hits /billing/balance, server-scoped to the caller's
commerce subject = maxpower, the exact subject the gateway debits), but liveness
was missing: SidebarWallet only polled 30s; WalletModule, CostModule fetched on
mount only; nothing refetched on window focus or after a balance-affecting
action. So a completion (debit) or an external billing.hanzo.ai top-up (credit)
was invisible until a manual refresh/reload.

Fix — ONE shared reactive balance store (src/lib/billing/live-balance.ts):
- owns the single /billing/balance fetch (in-flight de-dupe + freshness window
  so N mounted consumers cause ONE call);
- refetches on mount, on window focus + tab-visibility (returning from the Square
  top-up portal now shows the new balance with no reload), and on ONE ref-counted
  30s poll (paused when hidden);
- invalidateBalance() forces an immediate refetch after any balance-affecting action.
useCloudBalance() (useSyncExternalStore) is consumed by SidebarWallet, the Wallet
page cloud-credit card, and the Cost page balance card — every money surface now
shows the SAME live number.

Wire the completion seam: PlaygroundApi.chat (Chat/Playground/cmd-K) and the
streaming runner both invalidateBalance() on a finished completion, and the wallet
top-up success does too — so spend/credit reflects immediately.

Also: /billing proxy responses now send Cache-Control: no-store (a per-tenant
money response must never be cached). And drop the broken self-referential
node_modules 120000 symlink that origin/main re-tracked (it breaks
npm install / vitest / next build locally).

Tests: new live-balance.test.ts (10 — dedupe, freshness, phase mapping,
invalidate, no-flicker). tsc clean, vitest 573/573, next build green (14 pages).
2026-07-01 11:48:17 -07:00
hanzo-dev 4371e47a12 fix(wallet): live cloud-credit balance — one shared store, refetch on focus + after completion/top-up (v8.2.12)
Dave (maxpower) saw his real $99.74 on mount but it never CHANGED without a
reload — "not seeing increase in the wallet view". The balance READ path was
correct (every surface hits /billing/balance, server-scoped to the caller's
commerce subject = maxpower, the exact subject the gateway debits), but liveness
was missing: SidebarWallet only polled 30s; WalletModule, CostModule fetched on
mount only; nothing refetched on window focus or after a balance-affecting
action. So a completion (debit) or an external billing.hanzo.ai top-up (credit)
was invisible until a manual refresh/reload.

Fix — ONE shared reactive balance store (src/lib/billing/live-balance.ts):
- owns the single /billing/balance fetch (in-flight de-dupe + freshness window
  so N mounted consumers cause ONE call);
- refetches on mount, on window focus + tab-visibility (returning from the Square
  top-up portal now shows the new balance with no reload), and on ONE ref-counted
  30s poll (paused when hidden);
- invalidateBalance() forces an immediate refetch after any balance-affecting action.
useCloudBalance() (useSyncExternalStore) is consumed by SidebarWallet, the Wallet
page cloud-credit card, and the Cost page balance card — every money surface now
shows the SAME live number.

Wire the completion seam: PlaygroundApi.chat (Chat/Playground/cmd-K) and the
streaming runner both invalidateBalance() on a finished completion, and the wallet
top-up success does too — so spend/credit reflects immediately.

Also: /billing proxy responses now send Cache-Control: no-store (a per-tenant
money response must never be cached). And drop the broken self-referential
node_modules 120000 symlink that origin/main re-tracked (it breaks
npm install / vitest / next build locally).

Tests: new live-balance.test.ts (10 — dedupe, freshness, phase mapping,
invalidate, no-flicker). tsc clean, vitest 573/573, next build green (14 pages).
2026-07-01 11:48:17 -07:00
hanzo-devandGitHub 19536e947a harden(security): pathIsClean rejects any surviving %XX + matrix-param (RED final, defense-in-depth, v8.2.11) (#30)
RED's final re-review: double-encoded traversal CLOSED, recommends ship. Two LOW
residuals remained — both inert against the Go backend fleet, but Blue closes them
so the boundary is robust INDEPENDENT of any downstream decode behavior (don't rely
on 'the upstream is Go, single-decodes'):
- R1: N>=3 encoding (%25252e) / overlong UTF-8 (%c0%ae) — pathIsClean now rejects
  ANY surviving percent-escape /%[0-9a-f]{2}/i (a legit segment carries none after
  Next's single decode; a residual %XX is a multi-encoding/overlong tell). Does NOT
  over-reject literal-% names (50%off -> %of, 'o' not hex -> allowed).
- R2: '..;' matrix-param traversal — reject any ';' segment (matrix params unused by
  these REST APIs).

The authoritative post-normalization new URL() gate (v8.2.10) is unchanged; this is
the fast defense-in-depth first gate. Non-blocking per RED. RED INFO (/paas + /billing
bypass bearer-proxy) is by-design — they have their own gates (admin+service-token /
billing-scope.ts).

NOTE: local tsc/vitest are down (env upgraded to Node v26 mid-session + shared
node_modules symlink loop); change is a monotonic regex broadening over the
563-test-passing v8.2.10 guard. Validated via CI next build (Node 24) + live (%252e->404,
clean->401 no over-reject).
2026-07-01 11:26:55 -07:00
hanzo-devandGitHub bb1846008a harden(security): pathIsClean rejects any surviving %XX + matrix-param (RED final, defense-in-depth, v8.2.11) (#30)
RED's final re-review: double-encoded traversal CLOSED, recommends ship. Two LOW
residuals remained — both inert against the Go backend fleet, but Blue closes them
so the boundary is robust INDEPENDENT of any downstream decode behavior (don't rely
on 'the upstream is Go, single-decodes'):
- R1: N>=3 encoding (%25252e) / overlong UTF-8 (%c0%ae) — pathIsClean now rejects
  ANY surviving percent-escape /%[0-9a-f]{2}/i (a legit segment carries none after
  Next's single decode; a residual %XX is a multi-encoding/overlong tell). Does NOT
  over-reject literal-% names (50%off -> %of, 'o' not hex -> allowed).
- R2: '..;' matrix-param traversal — reject any ';' segment (matrix params unused by
  these REST APIs).

The authoritative post-normalization new URL() gate (v8.2.10) is unchanged; this is
the fast defense-in-depth first gate. Non-blocking per RED. RED INFO (/paas + /billing
bypass bearer-proxy) is by-design — they have their own gates (admin+service-token /
billing-scope.ts).

NOTE: local tsc/vitest are down (env upgraded to Node v26 mid-session + shared
node_modules symlink loop); change is a monotonic regex broadening over the
563-test-passing v8.2.10 guard. Validated via CI next build (Node 24) + live (%252e->404,
clean->401 no over-reject).
2026-07-01 11:26:55 -07:00
hanzo-dev 078cebdcf1 feat(console): per-category overview pages + an AI hub
Each product CATEGORY now has its own landing page at a stable
`/category/<slug>` route (AI, Compute, Data, Network, Security, …) — the
category-level twin of the native product overview. It shows the category name,
an honest one-line description of what the category is, and a grid of every
product in it (each card opening the product's native route). `soon` products
are shown with the existing SOON affordance — never hidden, never faked.

The "AI" overview is the hub for all things AI: it groups Models, Providers,
Inference, Agents, Embeddings, Playground, and Prompts, and leads with prominent
shortcuts to the Model Catalog and Providers.

- Routing follows the SAME pattern as products: one `ProductModule` with a
  `:slug` `ProductRoute` (`categoryRouteModule`) in `productModules`, resolved by
  the same `resolveRoute` and rendered by the same catch-all. It is deliberately
  NOT a `catalog` entry — a category is a grouping of products, not a product, so
  it never appears as a card in the nav / home / launcher. Unknown or
  out-of-brand slugs `notFound()` (propagated cleanly by ProductErrorBoundary).
- All content is derived from the registry (`visibleCatalogByCategory`, brand-
  and admin-scoped) — zero fabricated data. A new product appears on its
  category page for free.
- Nav wiring: the sidebar level-1 category headers, the sidebar level-2
  "More in <category>", and the catalog-home category headers all link to the
  category overview. Breadcrumbs render `Home / Category / <Name>`.
- Pure taxonomy helpers `categorySlug` / `categoryFromSlug` / `CATEGORY_SUMMARY`
  in brand-scope.ts (dependency-free), re-exported from the registry; unit-tested
  in registry-brand.test.ts (round-trip, unique slugs, complete summaries).

No version bump.
2026-07-01 11:07:28 -07:00
hanzo-dev ed5bacf2ff feat(console): per-category overview pages + an AI hub
Each product CATEGORY now has its own landing page at a stable
`/category/<slug>` route (AI, Compute, Data, Network, Security, …) — the
category-level twin of the native product overview. It shows the category name,
an honest one-line description of what the category is, and a grid of every
product in it (each card opening the product's native route). `soon` products
are shown with the existing SOON affordance — never hidden, never faked.

The "AI" overview is the hub for all things AI: it groups Models, Providers,
Inference, Agents, Embeddings, Playground, and Prompts, and leads with prominent
shortcuts to the Model Catalog and Providers.

- Routing follows the SAME pattern as products: one `ProductModule` with a
  `:slug` `ProductRoute` (`categoryRouteModule`) in `productModules`, resolved by
  the same `resolveRoute` and rendered by the same catch-all. It is deliberately
  NOT a `catalog` entry — a category is a grouping of products, not a product, so
  it never appears as a card in the nav / home / launcher. Unknown or
  out-of-brand slugs `notFound()` (propagated cleanly by ProductErrorBoundary).
- All content is derived from the registry (`visibleCatalogByCategory`, brand-
  and admin-scoped) — zero fabricated data. A new product appears on its
  category page for free.
- Nav wiring: the sidebar level-1 category headers, the sidebar level-2
  "More in <category>", and the catalog-home category headers all link to the
  category overview. Breadcrumbs render `Home / Category / <Name>`.
- Pure taxonomy helpers `categorySlug` / `categoryFromSlug` / `CATEGORY_SUMMARY`
  in brand-scope.ts (dependency-free), re-exported from the registry; unit-tested
  in registry-brand.test.ts (round-trip, unique slugs, complete summaries).

No version bump.
2026-07-01 11:07:28 -07:00
hanzo-dev 755389b935 fix(console): editable org branding + Linear-style product icon tiles
Branding (Settings → Branding was read-only host config):
- BrandingTab is now an editable org-branding form (display name, website,
  logo URL, favicon URL, primary color + "apply custom theme") with a real
  Save that round-trips the FULL org record to Hanzo IAM via
  TeamApi.updateOrganization → the /org/iam self-service proxy. Honest states:
  saving / saved / error; a non-admin sees the fields read-only with a gated
  notice (the server proxy 403 surfaced as the same honest access message).
  No fake success. The prior host-runtime values stay as a read-only
  "Runtime (resolved per host)" section.
- Server: /org/iam proxy now allows update-organization (org-admin-only via
  requireAdminForWrite), pinned to the caller's OWN org by BOTH the ?id name
  and the record's body name (forwardIam), so a brand admin can't retarget
  another tenant. admin.ts adds a ThemeData type + Organization.themeData.

Icons (Linear-style):
- New src/components/ui/ProductIcon.tsx — a rounded-square tile filled with the
  product's accent color and the glyph knocked out in near-white (the same
  raw-hex fill + #fff glyph technique as SwatchButton / ProviderLogo); neutral
  $color12/$color1 chip when no color.
- Swapped into CatalogRow (CommandPalette), the sidebar NavRow collapsed +
  expanded and its L2 detail header (DashboardShell), and the NativeOverview
  product header — each preserving the product's colorOf() accent.

Tests: iam-proxy.test adds org-name body extraction (the parsing the new
update-organization write guard depends on).
2026-07-01 11:07:22 -07:00
hanzo-dev e890b37970 fix(console): editable org branding + Linear-style product icon tiles
Branding (Settings → Branding was read-only host config):
- BrandingTab is now an editable org-branding form (display name, website,
  logo URL, favicon URL, primary color + "apply custom theme") with a real
  Save that round-trips the FULL org record to Hanzo IAM via
  TeamApi.updateOrganization → the /org/iam self-service proxy. Honest states:
  saving / saved / error; a non-admin sees the fields read-only with a gated
  notice (the server proxy 403 surfaced as the same honest access message).
  No fake success. The prior host-runtime values stay as a read-only
  "Runtime (resolved per host)" section.
- Server: /org/iam proxy now allows update-organization (org-admin-only via
  requireAdminForWrite), pinned to the caller's OWN org by BOTH the ?id name
  and the record's body name (forwardIam), so a brand admin can't retarget
  another tenant. admin.ts adds a ThemeData type + Organization.themeData.

Icons (Linear-style):
- New src/components/ui/ProductIcon.tsx — a rounded-square tile filled with the
  product's accent color and the glyph knocked out in near-white (the same
  raw-hex fill + #fff glyph technique as SwatchButton / ProviderLogo); neutral
  $color12/$color1 chip when no color.
- Swapped into CatalogRow (CommandPalette), the sidebar NavRow collapsed +
  expanded and its L2 detail header (DashboardShell), and the NativeOverview
  product header — each preserving the product's colorOf() accent.

Tests: iam-proxy.test adds org-name body extraction (the parsing the new
update-organization write guard depends on).
2026-07-01 11:07:22 -07:00
hanzo-devandGitHub f44b575c45 fix(security): close double-encoded (%252e) path-traversal bypass — RED re-review HIGH (v8.2.10) (#27)
RED's re-review confirmed 4/5 fixes closed but found a residual HIGH: pathIsClean
validated the PRE-normalization string, so a double-encoded %252e%252e (Next decodes
once -> %2e%2e, survives the guard) then normalized to real ../ inside undici's URL
parser at fetch time -> reached /v1/get-account, Base _superusers, escaped CLOUD_HEADS
on /cloud /vm /superbase. Live-proven (401 = passed the guard).

Robust fix (validate what fetch ACTUALLY sends):
- forwardWithUserBearer now re-parses the built target URL with new URL() and runs the
  AUTHORITATIVE pathIsClean + allow() gate on the NORMALIZED pathname (relative to the
  target base), then fetches that normalized dest — so %2e, double-encoding, and any
  future encoding are all gated on the exact path undici will request. One-way, DRY:
  fixes every helper proxy (/cloud, /vm, /superbase, /tasksd) at once.
- pathIsClean also now rejects %2e (not just %2f) as a fast defense-in-depth first gate
  (catches the Next-single-decoded %2e%2e before URL construction).

New tests: 4 double-encoded pathIsClean cases (red today, green now). typecheck clean,
vitest 563/563, next build green. RED re-review of bearer-proxy path validation requested.
2026-07-01 10:59:43 -07:00
hanzo-devandGitHub 6910f4fa49 fix(security): close double-encoded (%252e) path-traversal bypass — RED re-review HIGH (v8.2.10) (#27)
RED's re-review confirmed 4/5 fixes closed but found a residual HIGH: pathIsClean
validated the PRE-normalization string, so a double-encoded %252e%252e (Next decodes
once -> %2e%2e, survives the guard) then normalized to real ../ inside undici's URL
parser at fetch time -> reached /v1/get-account, Base _superusers, escaped CLOUD_HEADS
on /cloud /vm /superbase. Live-proven (401 = passed the guard).

Robust fix (validate what fetch ACTUALLY sends):
- forwardWithUserBearer now re-parses the built target URL with new URL() and runs the
  AUTHORITATIVE pathIsClean + allow() gate on the NORMALIZED pathname (relative to the
  target base), then fetches that normalized dest — so %2e, double-encoding, and any
  future encoding are all gated on the exact path undici will request. One-way, DRY:
  fixes every helper proxy (/cloud, /vm, /superbase, /tasksd) at once.
- pathIsClean also now rejects %2e (not just %2f) as a fast defense-in-depth first gate
  (catches the Next-single-decoded %2e%2e before URL construction).

New tests: 4 double-encoded pathIsClean cases (red today, green now). typecheck clean,
vitest 563/563, next build green. RED re-review of bearer-proxy path validation requested.
2026-07-01 10:59:43 -07:00
hanzo-dev bc5b35c0b8 fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:52:55 -07:00
hanzo-dev f8fb0b0e5b fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:52:55 -07:00
hanzo-devandGitHub a1e0f8e792 Merge pull request #26 from hanzoai/claude/console2-route-error-boundary
fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
2026-07-01 10:49:30 -07:00
hanzo-devandGitHub 7a2703dcf1 Merge pull request #26 from hanzoai/claude/console2-route-error-boundary
fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
2026-07-01 10:49:30 -07:00
hanzo-dev 1608de5fd9 fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
Product modules mount CLIENT-ONLY under the catch-all route (the authed shell
renders a loader during SSR, so the /playground server HTML carries no module
markup — verified). With NO error boundary anywhere in the app, a throw in one
module's first client render bubbled to Next's root fallback and white-screened
the whole console with "Application error: a client-side exception has occurred"
— but only on a DIRECT load / REFRESH; in-app nav re-renders fresh and hid it.
That matches the reported /playground, /prompts, /gpus crashes exactly.

Fix (one place, closes the class for every product route — DRY):
- ProductErrorBoundary wraps the resolved module in the catch-all page. A module
  throw now keeps the shell + nav and shows an honest, retryable card instead of
  a white screen. Re-throws Next control flow (notFound/redirect/CSR bailout) so
  routing still works; auto-recovers a ChunkLoadError (rolling-deploy skew) with
  ONE guarded reload (no loop).
- app/(dashboard)/error.tsx: Next-native backstop for throws above the module
  (the resolver), rendered inside the shell.
- boundary-logic.ts: pure decisions (chunk detect / control-flow detect / reload
  gate), 11 unit tests. Proven end-to-end against a production build: a real
  throw renders the card with zero uncaught pageerror (screenshots).

e2e/deeplink-refresh.spec.ts locks the reported scenario: /playground, /prompts,
/gpus (+ controls) must render on direct load AND refresh with no white-screen.

tsc clean · vitest 562/562 · next build green.
2026-07-01 10:48:48 -07:00
hanzo-dev 7f232777b7 fix(console): product-route error boundary — direct-load/refresh never white-screens (v8.2.9)
Product modules mount CLIENT-ONLY under the catch-all route (the authed shell
renders a loader during SSR, so the /playground server HTML carries no module
markup — verified). With NO error boundary anywhere in the app, a throw in one
module's first client render bubbled to Next's root fallback and white-screened
the whole console with "Application error: a client-side exception has occurred"
— but only on a DIRECT load / REFRESH; in-app nav re-renders fresh and hid it.
That matches the reported /playground, /prompts, /gpus crashes exactly.

Fix (one place, closes the class for every product route — DRY):
- ProductErrorBoundary wraps the resolved module in the catch-all page. A module
  throw now keeps the shell + nav and shows an honest, retryable card instead of
  a white screen. Re-throws Next control flow (notFound/redirect/CSR bailout) so
  routing still works; auto-recovers a ChunkLoadError (rolling-deploy skew) with
  ONE guarded reload (no loop).
- app/(dashboard)/error.tsx: Next-native backstop for throws above the module
  (the resolver), rendered inside the shell.
- boundary-logic.ts: pure decisions (chunk detect / control-flow detect / reload
  gate), 11 unit tests. Proven end-to-end against a production build: a real
  throw renders the card with zero uncaught pageerror (screenshots).

e2e/deeplink-refresh.spec.ts locks the reported scenario: /playground, /prompts,
/gpus (+ controls) must render on direct load AND refresh with no white-screen.

tsc clean · vitest 562/562 · next build green.
2026-07-01 10:48:48 -07:00
hanzo-dev 8f60abb1a6 fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:47:20 -07:00
hanzo-dev 32c8ab3ee7 fix(console): real Zen/Hanzo marks in model catalog + Providers link
- ProviderLogo rendered first-party (Zen/Hanzo) models with a generic Sparkles
  glyph. Now render the REAL marks knocked out of a filled rounded tile: the Zen
  ensō (identical geometry to @zenlm/logo) and the Hanzo block-H (@hanzo/logo) —
  so zen models show the proper logo and read on-brand (Linear-style cut-out).
- Add a 'Providers' button to the Model Catalog header → /providers. Models and
  providers are one AI surface; this makes it easy to get back to providers.
2026-07-01 10:47:20 -07:00
3318ca32a9 refactor(auth): call the account surface under /v1/iam/* (#20)
* refactor(auth): call the account surface under /v1/iam/*

Pairs with hanzoai/ai serving signin/signout/get-account/update-preferences
under the organized /v1/iam/ namespace. The client account calls (account.ts)
and the server-side session resolve (identity.ts resolveUser) now target
/v1/iam/*; all remaining references (doc comments) updated for accuracy.

Deploy order: cloud-api (with V1IamRewriteFilter) MUST ship before this, so
/v1/iam/* resolves. No top-level fallback is kept — forward-perfect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cloud): default to cloud.hanzo.svc, drop the cloud-api alias name

The API binary Service is canonically `cloud` (universe renamed cloud-api →
cloud; the cloud-api ClusterIP is a transitional alias). The console2 CR already
sets CLOUD_API_URL=cloud.hanzo.svc, but the server-route code DEFAULTS still
named the dead alias — so any deployment without the explicit env (local dev)
would dial a name slated for removal. Point both defaults (identity.ts,
training proxy) at cloud.hanzo.svc — one name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:35:14 -07:00
d31f022c5a refactor(auth): call the account surface under /v1/iam/* (#20)
* refactor(auth): call the account surface under /v1/iam/*

Pairs with hanzoai/ai serving signin/signout/get-account/update-preferences
under the organized /v1/iam/ namespace. The client account calls (account.ts)
and the server-side session resolve (identity.ts resolveUser) now target
/v1/iam/*; all remaining references (doc comments) updated for accuracy.

Deploy order: cloud-api (with V1IamRewriteFilter) MUST ship before this, so
/v1/iam/* resolves. No top-level fallback is kept — forward-perfect.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* refactor(cloud): default to cloud.hanzo.svc, drop the cloud-api alias name

The API binary Service is canonically `cloud` (universe renamed cloud-api →
cloud; the cloud-api ClusterIP is a transitional alias). The console2 CR already
sets CLOUD_API_URL=cloud.hanzo.svc, but the server-route code DEFAULTS still
named the dead alias — so any deployment without the explicit env (local dev)
would dial a name slated for removal. Point both defaults (identity.ts,
training proxy) at cloud.hanzo.svc — one name.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

---------

Co-authored-by: zeekay <z@zeekay.io>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 10:35:14 -07:00
hanzo-dev 03523a317c fix(console): cmd+K scrolls + keyboard-follows; docs deep links resolve
Command palette (cmd+K):
- The results list clipped instead of scrolling: the body had maxH:420 but the
  inner ScrollView had no flex bound. Add overflow:hidden on the body + flex:1
  on the ScrollView so the list scrolls (mouse/trackpad) past the fold, and show
  the scroll indicator.
- Keyboard ↑/↓ moved selection off-screen with no follow. Tag the active row
  (id=cmdk-active) and scrollIntoView({block:'nearest'}) on selection change, so
  arrowing always keeps the highlighted item visible. All 50 results are now
  reachable and discoverable.

Docs deep links:
- docs.hanzo.ai serves the Fumadocs site under the /docs base path
  (docs.hanzo.ai/docs/<slug>), but the console linked bare docs.hanzo.ai/<slug>
  → every 'Full docs' / resource docs link 404'd. Point DOCS (registry) and
  docsUrl(kind) (resource/logic) at .../docs. Matches the created product pages.
2026-07-01 10:33:33 -07:00
hanzo-dev b74c48ab74 fix(console): cmd+K scrolls + keyboard-follows; docs deep links resolve
Command palette (cmd+K):
- The results list clipped instead of scrolling: the body had maxH:420 but the
  inner ScrollView had no flex bound. Add overflow:hidden on the body + flex:1
  on the ScrollView so the list scrolls (mouse/trackpad) past the fold, and show
  the scroll indicator.
- Keyboard ↑/↓ moved selection off-screen with no follow. Tag the active row
  (id=cmdk-active) and scrollIntoView({block:'nearest'}) on selection change, so
  arrowing always keeps the highlighted item visible. All 50 results are now
  reachable and discoverable.

Docs deep links:
- docs.hanzo.ai serves the Fumadocs site under the /docs base path
  (docs.hanzo.ai/docs/<slug>), but the console linked bare docs.hanzo.ai/<slug>
  → every 'Full docs' / resource docs link 404'd. Point DOCS (registry) and
  docsUrl(kind) (resource/logic) at .../docs. Matches the created product pages.
2026-07-01 10:33:33 -07:00
hanzo-devandGitHub 289d51f37f fix(security): RED findings — cross-tenant projects + %2f allow-list bypass (v8.2.8) (#23)
* fix(vm proxy): default VISOR_URL to visor.hanzo.svc:19000 (was :80)

Cosmetic source fix — the live CR + universe already set VISOR_URL to :19000, which
governs runtime. This corrects the fallback constant so an env-less run also targets
the port visor actually serves (its Service exposes :19000 only; :80 → 502 upstream
unreachable). No version bump; rides the next build (the compute session owns /vm).

* fix(security): RED findings — cross-tenant projects, %2f allow-list bypass, scope headers, error DNS (v8.2.7)

Adversarial review (RED, PR#18/#19) found live holes in the BFF auth path. Fixes:

- [CRITICAL] Cross-tenant project enumeration. /org/iam/get-organization-projects
  with an OMITTED/empty ?organization passed the 'validate-if-present' check
  (ownerOk(null)=true), and IAM drops its WHERE on empty -> returned EVERY org's
  projects (IAM bypasses Casbin for project routes, so the proxy is the only gate).
  CONFIRMED LIVE (Dave/maxpower saw dhd, 7stars-dev projects). Fix: forwardIam now
  PINS ?organization to the caller's orgScope for a non-global admin on org-keyed
  segments (pinnedSearch, server-authoritative like X-Org-Id); global admins
  unrestricted. Org-keyed WRITES must carry owner+organization == own org (no
  owner='' junk rows). New orgParamSegments={get-organization-projects,add/delete-project}.
- [HIGH] %2f + .. allow-list bypass on /cloud, /superbase, /tasksd. Next decodes
  %2f into segments without re-normalizing dot-segments, and fetch() collapses '..'
  AFTER the allow-list -> 'functions%2f..%2f..%2fiam' slipped a foreign head past
  allowCloudSurface. Fix: forwardWithUserBearer now rejects any '', '.', '..' or
  surviving %2f segment (pathIsClean) BEFORE the allow-list, and trims trailing
  slashes. One guard fixes every helper-based proxy; /ai was already immune (exact-match Set).
- [MEDIUM] Dropped forwardScope on /cloud + /vm — no longer forward browser-controlled
  X-Project-Id/X-Environment (org is authoritative via the Bearer owner; the resources
  are org-keyed). A project-scoped feature must validate membership first.
- [LOW] Redacted 502 bodies (were leaking internal svc host/port) -> generic message +
  server-side console.error. Both bearer-proxy and iam-proxy.
- [bundled] /vm default VISOR_URL -> visor.hanzo.svc:19000 (was :80; visor serves :19000).

New pure tests: pinnedSearch (4), pathIsClean (3). typecheck clean, vitest 551/551,
next build green. Re-review by RED requested.
2026-07-01 10:31:40 -07:00
hanzo-devandGitHub 00597815e0 fix(security): RED findings — cross-tenant projects + %2f allow-list bypass (v8.2.8) (#23)
* fix(vm proxy): default VISOR_URL to visor.hanzo.svc:19000 (was :80)

Cosmetic source fix — the live CR + universe already set VISOR_URL to :19000, which
governs runtime. This corrects the fallback constant so an env-less run also targets
the port visor actually serves (its Service exposes :19000 only; :80 → 502 upstream
unreachable). No version bump; rides the next build (the compute session owns /vm).

* fix(security): RED findings — cross-tenant projects, %2f allow-list bypass, scope headers, error DNS (v8.2.7)

Adversarial review (RED, PR#18/#19) found live holes in the BFF auth path. Fixes:

- [CRITICAL] Cross-tenant project enumeration. /org/iam/get-organization-projects
  with an OMITTED/empty ?organization passed the 'validate-if-present' check
  (ownerOk(null)=true), and IAM drops its WHERE on empty -> returned EVERY org's
  projects (IAM bypasses Casbin for project routes, so the proxy is the only gate).
  CONFIRMED LIVE (Dave/maxpower saw dhd, 7stars-dev projects). Fix: forwardIam now
  PINS ?organization to the caller's orgScope for a non-global admin on org-keyed
  segments (pinnedSearch, server-authoritative like X-Org-Id); global admins
  unrestricted. Org-keyed WRITES must carry owner+organization == own org (no
  owner='' junk rows). New orgParamSegments={get-organization-projects,add/delete-project}.
- [HIGH] %2f + .. allow-list bypass on /cloud, /superbase, /tasksd. Next decodes
  %2f into segments without re-normalizing dot-segments, and fetch() collapses '..'
  AFTER the allow-list -> 'functions%2f..%2f..%2fiam' slipped a foreign head past
  allowCloudSurface. Fix: forwardWithUserBearer now rejects any '', '.', '..' or
  surviving %2f segment (pathIsClean) BEFORE the allow-list, and trims trailing
  slashes. One guard fixes every helper-based proxy; /ai was already immune (exact-match Set).
- [MEDIUM] Dropped forwardScope on /cloud + /vm — no longer forward browser-controlled
  X-Project-Id/X-Environment (org is authoritative via the Bearer owner; the resources
  are org-keyed). A project-scoped feature must validate membership first.
- [LOW] Redacted 502 bodies (were leaking internal svc host/port) -> generic message +
  server-side console.error. Both bearer-proxy and iam-proxy.
- [bundled] /vm default VISOR_URL -> visor.hanzo.svc:19000 (was :80; visor serves :19000).

New pure tests: pinnedSearch (4), pathIsClean (3). typecheck clean, vitest 551/551,
next build green. Re-review by RED requested.
2026-07-01 10:31:40 -07:00
hanzo-dev b736e97595 release: console2 v8.2.7 (KMS + IAM Users/Roles full CRUD live)
Advance prod (operator-pinned v8.1.1) to current main: KMS secret CRUD, IAM
Users CRUD, IAM Roles CRUD, 89-page e2e harness. Build publishes
ghcr.io/hanzoai/console2:v8.2.7; operator CR bump follows once the image lands.
2026-07-01 10:27:12 -07:00
hanzo-dev 746c0fd868 release: console2 v8.2.7 (KMS + IAM Users/Roles full CRUD live)
Advance prod (operator-pinned v8.1.1) to current main: KMS secret CRUD, IAM
Users CRUD, IAM Roles CRUD, 89-page e2e harness. Build publishes
ghcr.io/hanzoai/console2:v8.2.7; operator CR bump follows once the image lands.
2026-07-01 10:27:12 -07:00
hanzo-dev 5e1ea787fe Revert "release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console"
This reverts commit 63acec3982.
2026-07-01 10:25:44 -07:00
hanzo-dev 9c739a79d8 Revert "release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console"
This reverts commit ad7004f5b6.
2026-07-01 10:25:44 -07:00
hanzo-dev 63acec3982 release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console
Cuts a release so prod (operator-pinned) can advance from v8.1.1 to include:
- KMS secret management (create/reveal/delete)
- IAM Users CRUD (create/promote/delete)
- IAM Roles CRUD (create/delete)
- 89-page screenshot e2e harness
All auth via /v1/iam/* + cookie-session /v1/*. Build publishes
ghcr.io/hanzoai/console2:v8.2.7.
2026-07-01 10:22:44 -07:00
hanzo-dev ad7004f5b6 release: console2 v8.2.7 — KMS + IAM Users/Roles full CRUD in-console
Cuts a release so prod (operator-pinned) can advance from v8.1.1 to include:
- KMS secret management (create/reveal/delete)
- IAM Users CRUD (create/promote/delete)
- IAM Roles CRUD (create/delete)
- 89-page screenshot e2e harness
All auth via /v1/iam/* + cookie-session /v1/*. Build publishes
ghcr.io/hanzoai/console2:v8.2.7.
2026-07-01 10:22:44 -07:00
hanzo-dev bd2641feb4 chore: gitignore test-results artifacts 2026-07-01 10:21:02 -07:00
hanzo-dev cb62f66c4f chore: gitignore test-results artifacts 2026-07-01 10:21:02 -07:00
hanzo-dev cc19ebb8db console2(iam): Roles full CRUD — completes the IAM management surface
IAM already serves /v1/iam/{add,update,delete}-role (iam controllers/role.go) —
I was wrong that it needed a backend. Wire it: allowlist the 3 role mutations in
the /admin/iam proxy, add addRole/updateRole/deleteRole to IamAdminApi, and give
the Roles tab a create/delete view (RolesAdminView, mirrors UsersAdminView).
All auth flows through /v1/iam/* per the one-path rule. Typecheck clean.

IAM is now fully CRUD in-console: Orgs (list), Users (create/promote/delete),
Roles (create/delete), Applications + Providers (add/edit/delete) — no link-out
for the common lifecycle.
2026-07-01 10:20:13 -07:00
hanzo-dev 64ce24a070 console2(iam): Roles full CRUD — completes the IAM management surface
IAM already serves /v1/iam/{add,update,delete}-role (iam controllers/role.go) —
I was wrong that it needed a backend. Wire it: allowlist the 3 role mutations in
the /admin/iam proxy, add addRole/updateRole/deleteRole to IamAdminApi, and give
the Roles tab a create/delete view (RolesAdminView, mirrors UsersAdminView).
All auth flows through /v1/iam/* per the one-path rule. Typecheck clean.

IAM is now fully CRUD in-console: Orgs (list), Users (create/promote/delete),
Roles (create/delete), Applications + Providers (add/edit/delete) — no link-out
for the common lifecycle.
2026-07-01 10:20:13 -07:00
hanzo-devandGitHub 5eed0f5a2f feat(shell): delightful mobile + customizable sidebar — right-side SlideOver drawer/DetailPane, colorful per-product icons, grouped drag-reorder pins, full-screen mobile ⌘K, L2 category links, customer compute via visor (v8.2.6) (#21)
- SlideOver: ONE transform-driven right-side overlay (drawer + DetailPane + account menu); enter+exit animate, backdrop cross-fade, Escape, scroll-lock, focus return, reduced-motion. Full-screen <lg, fixed-width lg+.
- DetailPane: descriptor-driven item detail/edit pane (products write a descriptor, not their own pane).
- Colorful Linear-style icons: pure colors.ts palette (override > curated > hash), per-user overridable; applied in sidebar/palette/launcher.
- Pins: pure pins-core model (groups + order); usePins over account prefs; grouped display + drag-reorder (pointer DnD, no deps) + groups in the Manage/Customize panes.
- Mobile: nav drawer now RIGHT with ⌘K/AI-search + Apps at top; palette full-screen on mobile.
- L2 sub-nav: category breadcrumb + 'More in <category>' sibling jumps.
- #9: customer Machines via user-scoped /vm visor (real machines or graceful 'launch one') — infra 'PAAS_SERVICE_TOKEN' message gated to global admin only.
- favorites reimplemented over usePins (one store). +39 tests (colors/pins-core/Reorder/visor). tsc+vitest(540)+next build green.
2026-07-01 10:17:05 -07:00
hanzo-devandGitHub 18349e389f feat(shell): delightful mobile + customizable sidebar — right-side SlideOver drawer/DetailPane, colorful per-product icons, grouped drag-reorder pins, full-screen mobile ⌘K, L2 category links, customer compute via visor (v8.2.6) (#21)
- SlideOver: ONE transform-driven right-side overlay (drawer + DetailPane + account menu); enter+exit animate, backdrop cross-fade, Escape, scroll-lock, focus return, reduced-motion. Full-screen <lg, fixed-width lg+.
- DetailPane: descriptor-driven item detail/edit pane (products write a descriptor, not their own pane).
- Colorful Linear-style icons: pure colors.ts palette (override > curated > hash), per-user overridable; applied in sidebar/palette/launcher.
- Pins: pure pins-core model (groups + order); usePins over account prefs; grouped display + drag-reorder (pointer DnD, no deps) + groups in the Manage/Customize panes.
- Mobile: nav drawer now RIGHT with ⌘K/AI-search + Apps at top; palette full-screen on mobile.
- L2 sub-nav: category breadcrumb + 'More in <category>' sibling jumps.
- #9: customer Machines via user-scoped /vm visor (real machines or graceful 'launch one') — infra 'PAAS_SERVICE_TOKEN' message gated to global admin only.
- favorites reimplemented over usePins (one store). +39 tests (colors/pins-core/Reorder/visor). tsc+vitest(540)+next build green.
2026-07-01 10:17:05 -07:00
hanzo-devandGitHub e50efd1f43 fix(projects): route the Projects page through the /org/iam Bearer proxy (v8.2.4) (#19)
Projects rendered 'not routed' because ProjectApi hit the cloud /v1 cookie path
for IAM endpoints: console.hanzo.ai/v1/iam/get-organization-projects → the gateway
sends /v1/* to the CLOUD binary, which does NOT serve IAM → 404. Proven live:
that exact URL returns 404, while IAM serves the endpoint.

Fix (same BFF-Bearer pattern, existing infra — no new mega-router):
- projects.ts now calls the same-origin /org/iam proxy via makeIamClient, which
  mints a user-bound Bearer server-side and forwards to iam.hanzo.svc. Org resolves
  from the token owner claim (per-tenant), the member-roster pattern.
- /org/iam allow-list gains get-organization-projects (GET) + add-project,
  delete-project (POST, org-admin only via requireAdminForWrite).
- SECURITY: forwardIam now also pins the ?organization param AND the body
  organization field to the caller's org (ownerOk), closing the cross-tenant gap
  for the projects lister/CRUD (ownerOk(null) is a no-op for segments without it,
  so no regression to get-users/get-roles). bodyOwner generalized to bodyField.

typecheck clean, vitest 505/505 (+4 new: projects.test.ts, iam-proxy.test.ts),
next build green. Auth-mint/proxy path touched → hand to RED.
2026-07-01 09:56:13 -07:00
hanzo-devandGitHub ff0f302f9d fix(projects): route the Projects page through the /org/iam Bearer proxy (v8.2.4) (#19)
Projects rendered 'not routed' because ProjectApi hit the cloud /v1 cookie path
for IAM endpoints: console.hanzo.ai/v1/iam/get-organization-projects → the gateway
sends /v1/* to the CLOUD binary, which does NOT serve IAM → 404. Proven live:
that exact URL returns 404, while IAM serves the endpoint.

Fix (same BFF-Bearer pattern, existing infra — no new mega-router):
- projects.ts now calls the same-origin /org/iam proxy via makeIamClient, which
  mints a user-bound Bearer server-side and forwards to iam.hanzo.svc. Org resolves
  from the token owner claim (per-tenant), the member-roster pattern.
- /org/iam allow-list gains get-organization-projects (GET) + add-project,
  delete-project (POST, org-admin only via requireAdminForWrite).
- SECURITY: forwardIam now also pins the ?organization param AND the body
  organization field to the caller's org (ownerOk), closing the cross-tenant gap
  for the projects lister/CRUD (ownerOk(null) is a no-op for segments without it,
  so no regression to get-users/get-roles). bodyOwner generalized to bodyField.

typecheck clean, vitest 505/505 (+4 new: projects.test.ts, iam-proxy.test.ts),
next build green. Auth-mint/proxy path touched → hand to RED.
2026-07-01 09:56:13 -07:00
hanzo-dev d5e94b9a7a fix(api): drop dead X-IAM-Org-Id stamp; X-Org-Id is the one canonical org header
The ai data-scoping filters (GetEffectiveOrg, controllers/org_resolver.go)
and the provisioning sub-service both read `X-Org-Id` — NOT `X-IAM-Org-Id`.
cloud mints X-IAM-Org-Id OUTBOUND toward commerce from the validated
principal, so the browser stamp was inert dead weight (and the old comment
claiming GetEffectiveOrg reads X-IAM-Org-Id was drift). Keep the required
X-Org-Id stamp; drop X-IAM-Org-Id; note X-Project-Id is the canonical
project sub-scope evalsvc now reads.
2026-07-01 09:53:19 -07:00
hanzo-dev 15433f4f3e fix(api): drop dead X-IAM-Org-Id stamp; X-Org-Id is the one canonical org header
The ai data-scoping filters (GetEffectiveOrg, controllers/org_resolver.go)
and the provisioning sub-service both read `X-Org-Id` — NOT `X-IAM-Org-Id`.
cloud mints X-IAM-Org-Id OUTBOUND toward commerce from the validated
principal, so the browser stamp was inert dead weight (and the old comment
claiming GetEffectiveOrg reads X-IAM-Org-Id was drift). Keep the required
X-Org-Id stamp; drop X-IAM-Org-Id; note X-Project-Id is the canonical
project sub-scope evalsvc now reads.
2026-07-01 09:53:19 -07:00
2c352a373b feat(bff): user-bound Bearer for every service proxy — cookie→BFF→Bearer (v8.2.3) (#18)
The data + serverless surfaces (vector/sql/kv/s3/docdb/datastore/search,
functions/prompts/agents) and visor compute now resolve org from the Bearer
JWT owner claim; a cookie-only browser call 403s ('X-Org-Id required'). The
console BFF now mints a short-lived user-bound IAM token server-side (the
proven /ai + /keys pattern) and forwards it, so the browser path works
end-to-end, per-org — no token in the browser, org never browser-supplied.

- src/lib/server/bearer-proxy.ts — ONE shared forwardWithUserBearer(req, opts):
  resolveUser (session cookie) -> adminBearer (shared per-user token cache in
  identity.ts) -> forward with Authorization: Bearer + X-Org-Id=owner, cookie
  NEVER forwarded (dodges the public-gateway 431), response STREAMED (SSE/JSON/
  204). Pure errorBody/upstreamHeaders + proxy-allow.ts allow-lists, unit-tested.
- app/cloud/[...path] — user-bearer proxy to cloud-api for the data + serverless
  heads (allowCloudSurface); provisioning.ts + functions.ts repointed to
  <origin>/cloud/v1/* (was the cookie-only direct path that 403s).
- app/vm/[...path] — user-bearer proxy to visor (allowVisorSurface) for the
  compute surface (regions/gpus/machines); ready for the compute UI.
- DRY: /ai, /tasksd, /superbase refactored onto the shared helper — 3 duplicate
  issue-user-token caches deleted, one adminBearer cache for every proxy.

typecheck clean, vitest 464/464 (+14 new), next build green (/cloud + /vm compiled).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 09:36:17 -07:00
03bfa52b21 feat(bff): user-bound Bearer for every service proxy — cookie→BFF→Bearer (v8.2.3) (#18)
The data + serverless surfaces (vector/sql/kv/s3/docdb/datastore/search,
functions/prompts/agents) and visor compute now resolve org from the Bearer
JWT owner claim; a cookie-only browser call 403s ('X-Org-Id required'). The
console BFF now mints a short-lived user-bound IAM token server-side (the
proven /ai + /keys pattern) and forwards it, so the browser path works
end-to-end, per-org — no token in the browser, org never browser-supplied.

- src/lib/server/bearer-proxy.ts — ONE shared forwardWithUserBearer(req, opts):
  resolveUser (session cookie) -> adminBearer (shared per-user token cache in
  identity.ts) -> forward with Authorization: Bearer + X-Org-Id=owner, cookie
  NEVER forwarded (dodges the public-gateway 431), response STREAMED (SSE/JSON/
  204). Pure errorBody/upstreamHeaders + proxy-allow.ts allow-lists, unit-tested.
- app/cloud/[...path] — user-bearer proxy to cloud-api for the data + serverless
  heads (allowCloudSurface); provisioning.ts + functions.ts repointed to
  <origin>/cloud/v1/* (was the cookie-only direct path that 403s).
- app/vm/[...path] — user-bearer proxy to visor (allowVisorSurface) for the
  compute surface (regions/gpus/machines); ready for the compute UI.
- DRY: /ai, /tasksd, /superbase refactored onto the shared helper — 3 duplicate
  issue-user-token caches deleted, one adminBearer cache for every proxy.

typecheck clean, vitest 464/464 (+14 new), next build green (/cloud + /vm compiled).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 09:36:17 -07:00
hanzo-dev 03a6167613 chore(release): v8.2.2 — immutable tag for all-pages + living-overview HEAD 2026-07-01 09:29:40 -07:00
hanzo-dev 00d512baea chore(release): v8.2.2 — immutable tag for all-pages + living-overview HEAD 2026-07-01 09:29:40 -07:00
8ceffc76e8 console2: all-pages production build — native control planes, no external link-outs (#16)
* feat(console2): make all 14 external products native in-console overviews

The registry declared 14 products with kind:'external' (gateway, dns, cdn,
mpc, cli, sdks, api, ide, desktop, registry, metrics, crawl, studio, console),
but open.ts / match-core / NativeOverview / overviewFor were already collapsed
to a no-external world. Result: those 14 pushed /${id} which resolveProductView
returned notfound → HARD 404 dead links from the overview grid + app launcher.

Convert each external entry to kind:'module' routes:overviewRoutes(id), rendering
the existing NativeOverview (bespoke OVERVIEW_SPECS already merged — real header,
live platform-app health, key facts, native actions, INLINE docs, zero link-out).
Collapse CatalogEntry union to module-only and ProductStatus to enabled|soon;
remove the dead external branches in DashboardShell + OverviewModule and the stale
ext/href config. One way to open anything: a native route.

resolve.test.ts already pins all 14 specs + native-route actions; match-core.test
updated to assert the kind-guard fails closed for a non-module entry.
typecheck 0 errors, 381 vitest pass.

* feat(console2): Subscriptions + Payment Methods + Marketplace pages (real feeds)

Three new production-complete control-plane pages, all backed by REAL /v1 data,
matching the console taxonomy — no new backend needed (they ride existing feeds).

Billing sub-pages (category Observe, alongside cost/plans):
- SubscriptionsModule → GET /v1/billing/subscriptions (commerce, via the /billing
  per-tenant proxy: server-injected COMMERCE_TOKEN, org-scoped, client can't widen).
  Plan / status / seats / price / renewal. Read-only; manage links to the portal.
- PaymentMethodsModule → GET /v1/billing/payment-methods. Card-data MASKED by
  construction: normalizer extracts only brand+last4+expMonth/Year+isDefault — a
  PAN/CVV/token in the payload is dropped and never reaches the display object
  (dedicated leak test asserts it). Renders '••••  last4'. Read-only; add links to portal.
- billing.ts: Subscription+PaymentMethod types + normalizeSubscriptions/
  normalizePaymentMethods (handle Stripe snake_case AND camelCase, nested card obj,
  Unix seconds/ms dates). billing.test.ts: +9 tests incl the PAN/CVV/token leak guard.

Marketplace (category Apps, alongside chat/bot/search):
- MarketplaceModule → the storefront over the REAL model catalog (aicatalog.
  fetchCatalog → GET /v1/pricing/models via the authed /ai proxy). Category tiles,
  featured shelf (real catalog flag), filterable listings w/ real per-Mtok pricing,
  Try-it→Playground CTA. Reuses the existing aicatalog client + ProviderLogo — a
  distinct storefront view over the SAME catalog, not a duplicate of Model Catalog.
- marketplace/logic.ts (categorize/featured/applyFilters/marketStats) + 16 tests
  incl a regex-injection safety test (search is a literal substring filter).

Hardening across all three: read-only, org-scoped (IDOR-proof), no secrets in the
bundle, XSS-safe (plain <Text>, no dangerouslySetInnerHTML), honest loading/empty/
error states (BackendStateCard/ErrorState), every number a real field or '—' —
nothing fabricated. Tamagui shorthand only, dark design language.

typecheck 0 errors · 406 vitest pass (+25 from the new suites).

* docs(console2): document the all-pages build in LLM.md (external→native + billing/marketplace + honest scope)

* fix(console2): billing-proxy tenant isolation — X-Org-Id + full subject-key pinning (RED HIGH)

RED found the /billing proxy's tenant scoping was INERT — a cross-tenant IDOR:
1. It stamped X-Hanzo-Org, but commerce reads X-Org-Id on the service-token path
   (commerce/middleware/accesstoken.go) — the header silently fell back to the
   service org, so every tenant shared one commerce namespace.
2. It pinned only ?user=, but subscriptions filter ?userId=
   (commerce/api/billing/subscriptions.go) — with no userId the query returned
   EVERY subject's subscriptions in the namespace (cross-tenant read).

Fix, mirroring commerce's own edge-auth exactly:
- Send X-Org-Id (like the exemplary /ai proxy) so the namespace resolves per-tenant.
- Pin the FULL subject-key set {user,userId,customerId} to the server-resolved
  subject (= commerce/middleware/edgeauth.go billingSubjectKeys) so NO billing
  endpoint is left unfiltered whichever param it reads; ?org= is still dropped.
- Extract the scoping to a pure src/lib/server/billing-scope.ts (scopedBillingSearch
  + billingSubject) — testable without the Next runtime, same pattern as ai-proxy.ts.
- Defense-in-depth: normalizePaymentMethods clamps last4 to the last 4 digits even
  if commerce puts a full PAN there.

Tests: billing-scope.test.ts (11) — client-forged-subject overwrite, two-tenant
disjointness, non-subject passthrough; billing.test.ts +1 (last4 PAN clamp);
e2e/billing-isolation.spec.ts — live two-tenant disjoint subscription/payment sets;
the 3 new pages added to the 89-route render pass. typecheck 0, 487 vitest, next build ✓.

Rebased on latest main (living-overview #17).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 07:00:04 -07:00
715ad0ed00 console2: all-pages production build — native control planes, no external link-outs (#16)
* feat(console2): make all 14 external products native in-console overviews

The registry declared 14 products with kind:'external' (gateway, dns, cdn,
mpc, cli, sdks, api, ide, desktop, registry, metrics, crawl, studio, console),
but open.ts / match-core / NativeOverview / overviewFor were already collapsed
to a no-external world. Result: those 14 pushed /${id} which resolveProductView
returned notfound → HARD 404 dead links from the overview grid + app launcher.

Convert each external entry to kind:'module' routes:overviewRoutes(id), rendering
the existing NativeOverview (bespoke OVERVIEW_SPECS already merged — real header,
live platform-app health, key facts, native actions, INLINE docs, zero link-out).
Collapse CatalogEntry union to module-only and ProductStatus to enabled|soon;
remove the dead external branches in DashboardShell + OverviewModule and the stale
ext/href config. One way to open anything: a native route.

resolve.test.ts already pins all 14 specs + native-route actions; match-core.test
updated to assert the kind-guard fails closed for a non-module entry.
typecheck 0 errors, 381 vitest pass.

* feat(console2): Subscriptions + Payment Methods + Marketplace pages (real feeds)

Three new production-complete control-plane pages, all backed by REAL /v1 data,
matching the console taxonomy — no new backend needed (they ride existing feeds).

Billing sub-pages (category Observe, alongside cost/plans):
- SubscriptionsModule → GET /v1/billing/subscriptions (commerce, via the /billing
  per-tenant proxy: server-injected COMMERCE_TOKEN, org-scoped, client can't widen).
  Plan / status / seats / price / renewal. Read-only; manage links to the portal.
- PaymentMethodsModule → GET /v1/billing/payment-methods. Card-data MASKED by
  construction: normalizer extracts only brand+last4+expMonth/Year+isDefault — a
  PAN/CVV/token in the payload is dropped and never reaches the display object
  (dedicated leak test asserts it). Renders '••••  last4'. Read-only; add links to portal.
- billing.ts: Subscription+PaymentMethod types + normalizeSubscriptions/
  normalizePaymentMethods (handle Stripe snake_case AND camelCase, nested card obj,
  Unix seconds/ms dates). billing.test.ts: +9 tests incl the PAN/CVV/token leak guard.

Marketplace (category Apps, alongside chat/bot/search):
- MarketplaceModule → the storefront over the REAL model catalog (aicatalog.
  fetchCatalog → GET /v1/pricing/models via the authed /ai proxy). Category tiles,
  featured shelf (real catalog flag), filterable listings w/ real per-Mtok pricing,
  Try-it→Playground CTA. Reuses the existing aicatalog client + ProviderLogo — a
  distinct storefront view over the SAME catalog, not a duplicate of Model Catalog.
- marketplace/logic.ts (categorize/featured/applyFilters/marketStats) + 16 tests
  incl a regex-injection safety test (search is a literal substring filter).

Hardening across all three: read-only, org-scoped (IDOR-proof), no secrets in the
bundle, XSS-safe (plain <Text>, no dangerouslySetInnerHTML), honest loading/empty/
error states (BackendStateCard/ErrorState), every number a real field or '—' —
nothing fabricated. Tamagui shorthand only, dark design language.

typecheck 0 errors · 406 vitest pass (+25 from the new suites).

* docs(console2): document the all-pages build in LLM.md (external→native + billing/marketplace + honest scope)

* fix(console2): billing-proxy tenant isolation — X-Org-Id + full subject-key pinning (RED HIGH)

RED found the /billing proxy's tenant scoping was INERT — a cross-tenant IDOR:
1. It stamped X-Hanzo-Org, but commerce reads X-Org-Id on the service-token path
   (commerce/middleware/accesstoken.go) — the header silently fell back to the
   service org, so every tenant shared one commerce namespace.
2. It pinned only ?user=, but subscriptions filter ?userId=
   (commerce/api/billing/subscriptions.go) — with no userId the query returned
   EVERY subject's subscriptions in the namespace (cross-tenant read).

Fix, mirroring commerce's own edge-auth exactly:
- Send X-Org-Id (like the exemplary /ai proxy) so the namespace resolves per-tenant.
- Pin the FULL subject-key set {user,userId,customerId} to the server-resolved
  subject (= commerce/middleware/edgeauth.go billingSubjectKeys) so NO billing
  endpoint is left unfiltered whichever param it reads; ?org= is still dropped.
- Extract the scoping to a pure src/lib/server/billing-scope.ts (scopedBillingSearch
  + billingSubject) — testable without the Next runtime, same pattern as ai-proxy.ts.
- Defense-in-depth: normalizePaymentMethods clamps last4 to the last 4 digits even
  if commerce puts a full PAN there.

Tests: billing-scope.test.ts (11) — client-forged-subject overwrite, two-tenant
disjointness, non-subject passthrough; billing.test.ts +1 (last4 PAN clamp);
e2e/billing-isolation.spec.ts — live two-tenant disjoint subscription/payment sets;
the 3 new pages added to the 89-route render pass. typecheck 0, 487 vitest, next build ✓.

Rebased on latest main (living-overview #17).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 07:00:04 -07:00
zeekayandClaude Opus 4.8 4863c4f1e7 refactor(web3): drop /bootnode proxy prefix — Networks uses unified /v1/networks
Per the 'one /v1, no extraneous prefix' rule: the Networks module now calls
same-origin /v1/networks (gateway-routed to the bootnode control plane), not a
per-backend /bootnode/* proxy. Deletes app/bootnode. Honest states unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:19:05 -07:00
zeekayandhanzo-dev b5c93fbbd5 refactor(web3): drop /bootnode proxy prefix — Networks uses unified /v1/networks
Per the 'one /v1, no extraneous prefix' rule: the Networks module now calls
same-origin /v1/networks (gateway-routed to the bootnode control plane), not a
per-backend /bootnode/* proxy. Deletes app/bootnode. Honest states unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 06:19:05 -07:00
f472e6385c feat(overview): reusable "living overview" — animated, real-data, config-driven across products (#17)
The admin Platform Overview is now a REUSABLE `LivingOverview` component system
(one component, many configs), not a one-off. Videogame-like: count-up KPIs, live
sparklines, a streaming/virtualized activity feed, throttled polling — tasteful,
60fps, reduced-motion-guarded. Backed by REAL /v1 data, no mocks; every missing
feed renders its honest empty/skeleton/em-dash, never fabricated numbers.

System (src/components/products/overview/living/):
- config.ts   declarative LivingOverviewConfig: tiles in rows + one real-data
              load() => OverviewData + a live block (pollMs/countUp).
- motion.ts   pure count-up/sparkline-ring/poll-clock math (unit-tested).
- hooks.ts    thin rAF/interval drivers (useCountUp animates from the current
              on-screen value on retarget; usePoll/useReducedMotion/usePageHidden),
              all self-cleaning — no leaked frames/timers.
- logic.ts    pure tile decisions: unit-aware formatMetric, deltaOf ("—" w/o basis),
              hasTrend, status/health colors, mergeActivity (stream dedupe),
              windowRows (virtualization), worst/tally (unit-tested).
- tiles.tsx   the 6 animated tiles (reuse ui/Charts verbatim; skeleton/empty/error).
- LivingOverview.tsx  the driver: one throttled poll loop (5s floor, paused when
              hidden/errored), reqRef race guard, background refetch never blanks a
              board with real data. globals.css: hz-skeleton/hz-pulse/hz-row-in.

Real data (adapters.ts, pure + tested): fromCloudUsage (commerce usage ledger),
fromAdminOverview (new lib/api/admin-overview.ts — /v1/admin/overview, optional-safe,
degrades to honest empty on 404), fromFunctions, healthFromApps (operator inventory).

Wired (overview/living/registry.ts — declarative catalog): overview (platform
centerpiece at / and /overview; admin aggregate w/ honest fallback to usage+health),
ai-metrics, functions, gpus. Product route '' renders livingOverviewModule(id); tabbed
products keep :tab, reachable via the sidebar sub-nav (declared subpages) — no dead-end.
Adding a product overview = one config, no UI.

Deletes the superseded OverviewModule + AiMetricsModule (+ aimetrics/{StatTile,
UsageChart,format}) — one overview system, DRY.

typecheck clean (0 errors), 449/449 tests (42 files), next build green (14/14).
Visual proof via headless Playwright: full board renders w/ count-up + live sparklines
+ streaming + donut + health tally, values change across a 5s poll, reduced-motion
snaps to real values, functions/gpus render honest empty/error states w/o crashing.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 06:17:25 -07:00
8aa5294eb1 feat(overview): reusable "living overview" — animated, real-data, config-driven across products (#17)
The admin Platform Overview is now a REUSABLE `LivingOverview` component system
(one component, many configs), not a one-off. Videogame-like: count-up KPIs, live
sparklines, a streaming/virtualized activity feed, throttled polling — tasteful,
60fps, reduced-motion-guarded. Backed by REAL /v1 data, no mocks; every missing
feed renders its honest empty/skeleton/em-dash, never fabricated numbers.

System (src/components/products/overview/living/):
- config.ts   declarative LivingOverviewConfig: tiles in rows + one real-data
              load() => OverviewData + a live block (pollMs/countUp).
- motion.ts   pure count-up/sparkline-ring/poll-clock math (unit-tested).
- hooks.ts    thin rAF/interval drivers (useCountUp animates from the current
              on-screen value on retarget; usePoll/useReducedMotion/usePageHidden),
              all self-cleaning — no leaked frames/timers.
- logic.ts    pure tile decisions: unit-aware formatMetric, deltaOf ("—" w/o basis),
              hasTrend, status/health colors, mergeActivity (stream dedupe),
              windowRows (virtualization), worst/tally (unit-tested).
- tiles.tsx   the 6 animated tiles (reuse ui/Charts verbatim; skeleton/empty/error).
- LivingOverview.tsx  the driver: one throttled poll loop (5s floor, paused when
              hidden/errored), reqRef race guard, background refetch never blanks a
              board with real data. globals.css: hz-skeleton/hz-pulse/hz-row-in.

Real data (adapters.ts, pure + tested): fromCloudUsage (commerce usage ledger),
fromAdminOverview (new lib/api/admin-overview.ts — /v1/admin/overview, optional-safe,
degrades to honest empty on 404), fromFunctions, healthFromApps (operator inventory).

Wired (overview/living/registry.ts — declarative catalog): overview (platform
centerpiece at / and /overview; admin aggregate w/ honest fallback to usage+health),
ai-metrics, functions, gpus. Product route '' renders livingOverviewModule(id); tabbed
products keep :tab, reachable via the sidebar sub-nav (declared subpages) — no dead-end.
Adding a product overview = one config, no UI.

Deletes the superseded OverviewModule + AiMetricsModule (+ aimetrics/{StatTile,
UsageChart,format}) — one overview system, DRY.

typecheck clean (0 errors), 449/449 tests (42 files), next build green (14/14).
Visual proof via headless Playwright: full board renders w/ count-up + live sparklines
+ streaming + donut + health tally, values change across a 5s poll, reduced-motion
snaps to real values, functions/gpus render honest empty/error states w/o crashing.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-01 06:17:25 -07:00
hanzo-dev 355112b403 e2e: screenshot + render-check every console page (89 routes)
Drives a signed-in session over every registered product route, asserts each
mounts without a hard crash (no Next error overlay, non-blank body, <500), and
captures a full-page screenshot to e2e/screenshots/<id>.png. The 'screenshot it
all, verify every page is wired FE↔BE' pass. Gitignores screenshots/test-results.

Running it against live console.hanzo.ai surfaced a P0: the cloud /v1 backend is
returning 502/503, so sign-in (/v1/signin) fails for all users.
2026-07-01 06:13:07 -07:00
hanzo-dev 3d64ac216b e2e: screenshot + render-check every console page (89 routes)
Drives a signed-in session over every registered product route, asserts each
mounts without a hard crash (no Next error overlay, non-blank body, <500), and
captures a full-page screenshot to e2e/screenshots/<id>.png. The 'screenshot it
all, verify every page is wired FE↔BE' pass. Gitignores screenshots/test-results.

Running it against live console.hanzo.ai surfaced a P0: the cloud /v1 backend is
returning 502/503, so sign-in (/v1/signin) fails for all users.
2026-07-01 06:13:07 -07:00
hanzo-dev 864788b42f console2(iam): full user CRUD in-console (casdoor surface, no link-out)
IamModule's Users tab was read-only + linked out to the external casdoor console,
but IamAdminApi already has add/update/delete-user (and the /admin/iam proxy
allowlists those mutations). Surface them: create user (name/email/password,
argon2id-hashed by IAM), promote/demote global-admin (shield toggle), and delete
— all scoped to the active org through the server-gated proxy. Honest states
(loading / operator-required 403 / error / empty). Typecheck clean.
2026-07-01 06:06:32 -07:00
hanzo-dev b94b0ee27a console2(iam): full user CRUD in-console (casdoor surface, no link-out)
IamModule's Users tab was read-only + linked out to the external casdoor console,
but IamAdminApi already has add/update/delete-user (and the /admin/iam proxy
allowlists those mutations). Surface them: create user (name/email/password,
argon2id-hashed by IAM), promote/demote global-admin (shield toggle), and delete
— all scoped to the active org through the server-gated proxy. Honest states
(loading / operator-required 403 / error / empty). Typecheck clean.
2026-07-01 06:06:32 -07:00
hanzo-dev f6b8b51daa console2(kms): full secret management in-console (one FE for all)
The KmsModule listed metadata and linked OUT to the standalone kms.hanzo.ai
console for everything else. But the /admin/kms proxy + KmsAdminApi already
support full CRUD (list/reveal/create/rotate/remove) over /v1/kms/orgs/{org}/
secrets. Surface it: create/upsert form (path/name/env/value, secure), per-row
reveal (one value, shown once, audited, never cached/listed), and delete — all
through the server-gated admin proxy, scoped to the active org. Keeps the
zero-knowledge stance (no bulk value listing). Removes the external link-out, so
console2 is the single KMS management surface. Typecheck clean.
2026-07-01 05:57:57 -07:00
hanzo-dev dcaf97ea09 console2(kms): full secret management in-console (one FE for all)
The KmsModule listed metadata and linked OUT to the standalone kms.hanzo.ai
console for everything else. But the /admin/kms proxy + KmsAdminApi already
support full CRUD (list/reveal/create/rotate/remove) over /v1/kms/orgs/{org}/
secrets. Surface it: create/upsert form (path/name/env/value, secure), per-row
reveal (one value, shown once, audited, never cached/listed), and delete — all
through the server-gated admin proxy, scoped to the active org. Keeps the
zero-knowledge stance (no bulk value listing). Removes the external link-out, so
console2 is the single KMS management surface. Typecheck clean.
2026-07-01 05:57:57 -07:00
hanzo-dev 35627404fc fix(tasksd): default TASKS_URL to :7243 (tasks REST port; :80 doesn't exist) — Tasks page now shows real workflows/namespaces 2026-07-01 05:24:21 -07:00
hanzo-dev c63a040efa fix(tasksd): default TASKS_URL to :7243 (tasks REST port; :80 doesn't exist) — Tasks page now shows real workflows/namespaces 2026-07-01 05:24:21 -07:00
zeekayandClaude Opus 4.8 5a0db2c6dd test(e2e): expand public suite — proxy security gates + routes (no creds)
Adds 4 credential-free live tests proving production posture: root serves
(200/dark #0a0a0a) + /base resolves; server proxies (superbase/bootnode/keys)
reject unauth with 401; proxy allow-lists reject off-list paths with 404 (no
tunnel); unknown route never 5xxs. 5/5 green vs console.hanzo.ai — real CI signal
without the prod superuser password.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:02:21 -07:00
zeekayandhanzo-dev 504bfce54b test(e2e): expand public suite — proxy security gates + routes (no creds)
Adds 4 credential-free live tests proving production posture: root serves
(200/dark #0a0a0a) + /base resolves; server proxies (superbase/bootnode/keys)
reject unauth with 401; proxy allow-lists reject off-list paths with 404 (no
tunnel); unknown route never 5xxs. 5/5 green vs console.hanzo.ai — real CI signal
without the prod superuser password.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 23:02:21 -07:00
zeekayandClaude Opus 4.8 215354b47a test(brand): prove per-brand catalog scope + extract pure brand-scope (v8.2.1)
Extract the taxonomy + per-brand scope into dependency-free src/lib/products/
brand-scope.ts (pure, brand-passed-in) so it's unit-testable without hostname
mocking or loading the React-heavy registry. registry re-exports it (no API
change). New registry-brand.test.ts PROVES: hanzo=all 12 categories; lux/zoo/
pars=ONLY Web3/Network/Security/Dev/Settings (web3/bootnode), hiding every
AI-cloud category; Networks(Web3) surfaces on lux/zoo. 12 tests green, 381 total,
typecheck + next build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:43:53 -07:00
zeekayandhanzo-dev c5b2141b30 test(brand): prove per-brand catalog scope + extract pure brand-scope (v8.2.1)
Extract the taxonomy + per-brand scope into dependency-free src/lib/products/
brand-scope.ts (pure, brand-passed-in) so it's unit-testable without hostname
mocking or loading the React-heavy registry. registry re-exports it (no API
change). New registry-brand.test.ts PROVES: hanzo=all 12 categories; lux/zoo/
pars=ONLY Web3/Network/Security/Dev/Settings (web3/bootnode), hiding every
AI-cloud category; Networks(Web3) surfaces on lux/zoo. 12 tests green, 381 total,
typecheck + next build clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 22:43:53 -07:00
zeekayandClaude Opus 4.8 f0ad2e0a91 feat(web3): Bootnode Networks module — blockchain-network admin in console (v8.2.0)
The core bootnode ('Web3 Backend in a Box') primitive, ported into console2 so
the lux/zoo web3 consoles manage real blockchain networks (chain/nodes/status/
RPC). Reads the live bootnode control plane via a new per-user /bootnode proxy
(mints the user's IAM bearer; least-privilege to the networks surface + launch/
rpc/scale), rendered with @hanzo/data's DataTable. Honest states on 401/404/503.
Retires the old bootnode-admin app — one console (hanzoai/console), brand-scoped.
typecheck + build + 352 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:24:24 -07:00
zeekayandhanzo-dev 28eedc257a feat(web3): Bootnode Networks module — blockchain-network admin in console (v8.2.0)
The core bootnode ('Web3 Backend in a Box') primitive, ported into console2 so
the lux/zoo web3 consoles manage real blockchain networks (chain/nodes/status/
RPC). Reads the live bootnode control plane via a new per-user /bootnode proxy
(mints the user's IAM bearer; least-privilege to the networks surface + launch/
rpc/scale), rendered with @hanzo/data's DataTable. Honest states on 401/404/503.
Retires the old bootnode-admin app — one console (hanzoai/console), brand-scoped.
typecheck + build + 352 tests green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 22:24:24 -07:00
Hanzo AI 81af20b738 fix(console): Overview reads real commerce spend + API keys list minted key (8.1.1)
Two customer-facing bugs found in a live real-tenant e2e audit of
console.hanzo.ai, both making the "backend-aware FE" partially broken.

BUG 1 — Overview usage/spend widget showed no spend.
  The Overview called cloud `/v1/get-cloud-usages`, which returns 200 with
  {"status":"error","msg":"usage ledger unavailable: datastore peer not
  connected"} — the cloud usage-ledger's o11y datastore peer is down. Repoint
  `UsageApi.overview()` at the REAL commerce ledger `/v1/billing/usage` (via the
  existing per-tenant `/billing/*` proxy — the SAME source the Cost page and the
  gateway debit against, X-Hanzo-Org scoped). New pure `usage-adapter.ts` rolls
  the raw records up into the rich `CloudUsageOverview` the dashboard already
  renders (totals, prior-period deltas, dense time series, top-N + Other
  spend-by-model, paginated inference activity) — reusing the ONE canonical
  aimetrics parse, so Overview and Cost agree to the cent. Overview UI unchanged.
  Verified live: commerce returns 158 real records for hanzo/z; cloud ledger is
  dead. `UsageRecord` gains additive premium/stream/status/requestId (from the
  commerce metadata) for faithful activity rows.

BUG 2 — API Keys never listed a minted key (uncopyable/unrevocable, re-minted).
  `POST /keys` mints a real working hk- key (User.AccessKey in IAM), but
  `GET /keys` derived hasKey from the cloud `get-account` session claim, which
  returns accessKey='' for a freshly-minted key → the page reverted to the empty
  "Create" state on reload. Read the key AUTHORITATIVELY from IAM
  `get-user?id=<owner>/<name>` (new `getUserKey` server helper) instead. Verified
  live: IAM holds z's accessKey=hk-eeedb378-... while get-account returns ''. The
  key now lists (prefix + last created/rotated date) and revoke works.

- No mocks/fixtures; honest states preserved. Coordinated admin/per-host/ingress
  files untouched. tsc clean, 369 vitest pass (22 new adapter tests), next build green.
2026-06-30 20:48:00 -07:00
Hanzo AI 3a30412275 fix(console): Overview reads real commerce spend + API keys list minted key (8.1.1)
Two customer-facing bugs found in a live real-tenant e2e audit of
console.hanzo.ai, both making the "backend-aware FE" partially broken.

BUG 1 — Overview usage/spend widget showed no spend.
  The Overview called cloud `/v1/get-cloud-usages`, which returns 200 with
  {"status":"error","msg":"usage ledger unavailable: datastore peer not
  connected"} — the cloud usage-ledger's o11y datastore peer is down. Repoint
  `UsageApi.overview()` at the REAL commerce ledger `/v1/billing/usage` (via the
  existing per-tenant `/billing/*` proxy — the SAME source the Cost page and the
  gateway debit against, X-Hanzo-Org scoped). New pure `usage-adapter.ts` rolls
  the raw records up into the rich `CloudUsageOverview` the dashboard already
  renders (totals, prior-period deltas, dense time series, top-N + Other
  spend-by-model, paginated inference activity) — reusing the ONE canonical
  aimetrics parse, so Overview and Cost agree to the cent. Overview UI unchanged.
  Verified live: commerce returns 158 real records for hanzo/z; cloud ledger is
  dead. `UsageRecord` gains additive premium/stream/status/requestId (from the
  commerce metadata) for faithful activity rows.

BUG 2 — API Keys never listed a minted key (uncopyable/unrevocable, re-minted).
  `POST /keys` mints a real working hk- key (User.AccessKey in IAM), but
  `GET /keys` derived hasKey from the cloud `get-account` session claim, which
  returns accessKey='' for a freshly-minted key → the page reverted to the empty
  "Create" state on reload. Read the key AUTHORITATIVELY from IAM
  `get-user?id=<owner>/<name>` (new `getUserKey` server helper) instead. Verified
  live: IAM holds z's accessKey=hk-eeedb378-... while get-account returns ''. The
  key now lists (prefix + last created/rotated date) and revoke works.

- No mocks/fixtures; honest states preserved. Coordinated admin/per-host/ingress
  files untouched. tsc clean, 369 vitest pass (22 new adapter tests), next build green.
2026-06-30 20:48:00 -07:00
zeekayandClaude Opus 4.8 48efb20cfa feat(brand): per-brand catalog scope — lux/zoo/pars = web3/bootnode admin (v8.1.0)
console.hanzo.ai = full AI cloud; console.lux.cloud / console.zoo.cloud /
console.pars.* = web3/bootnode admin only (Web3 + Network + Security + Dev +
Settings — on-chain, networks/nodes/peering, keys/HSM/authz, dev keys, org).
ONE knob: BRAND_CATEGORIES in registry, filtered at the single catalog-consumption
point (visibleCatalog/catalogByCategory/visibleCatalogByCategory) via inBrand +
brandCategoryOrder. Settings already shows brand-resolved info (brand/name/IAM/
billing per host). Hanzo unchanged. typecheck + 345 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:17:42 -07:00
zeekayandhanzo-dev b04ef258d5 feat(brand): per-brand catalog scope — lux/zoo/pars = web3/bootnode admin (v8.1.0)
console.hanzo.ai = full AI cloud; console.lux.cloud / console.zoo.cloud /
console.pars.* = web3/bootnode admin only (Web3 + Network + Security + Dev +
Settings — on-chain, networks/nodes/peering, keys/HSM/authz, dev keys, org).
ONE knob: BRAND_CATEGORIES in registry, filtered at the single catalog-consumption
point (visibleCatalog/catalogByCategory/visibleCatalogByCategory) via inBrand +
brandCategoryOrder. Settings already shows brand-resolved info (brand/name/IAM/
billing per host). Hanzo unchanged. typecheck + 345 tests green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 20:17:42 -07:00
Hanzo AI cfef594484 chore(console2): 8.0.4 — per-host admin login client on admin.<brand> 2026-06-30 20:17:00 -07:00
Hanzo AI f00643a687 chore(console2): 8.0.4 — per-host admin login client on admin.<brand> 2026-06-30 20:17:00 -07:00
blueandHanzo AI 3b4a66999e feat(config): per-host admin login client (admin.<brand> → admin-console)
On an admin console host (admin.hanzo.ai) resolve the OAuth client to the
admin-org app `admin-console` so IAM login resolves the global-admin identity
(owner=admin); every normal host keeps the brand cloud client (hanzo-cloud).

Isolated to src/config: `adminApp` per brand (ONE global admin-console app for
the reserved admin org), `isAdminHost()`, and a HOST-keyed cache (admin.hanzo.ai
and cloud.hanzo.ai are the same brand but must resolve to different clients — a
brand-keyed cache would collide). iamAppName + iamClientId travel together;
NEXT_PUBLIC_* overrides still win. Composes with the admin-mode UI (untouched).

+7 vitest (admin/normal host resolution, cache isolation, strict admin. prefix);
tsc --noEmit clean.
2026-06-30 20:17:00 -07:00
blueandHanzo AI 974c63a2a7 feat(config): per-host admin login client (admin.<brand> → admin-console)
On an admin console host (admin.hanzo.ai) resolve the OAuth client to the
admin-org app `admin-console` so IAM login resolves the global-admin identity
(owner=admin); every normal host keeps the brand cloud client (hanzo-cloud).

Isolated to src/config: `adminApp` per brand (ONE global admin-console app for
the reserved admin org), `isAdminHost()`, and a HOST-keyed cache (admin.hanzo.ai
and cloud.hanzo.ai are the same brand but must resolve to different clients — a
brand-keyed cache would collide). iamAppName + iamClientId travel together;
NEXT_PUBLIC_* overrides still win. Composes with the admin-mode UI (untouched).

+7 vitest (admin/normal host resolution, cache isolation, strict admin. prefix);
tsc --noEmit clean.
2026-06-30 20:17:00 -07:00
zeekayandClaude Opus 4.8 41b5385f73 test(e2e): ungate public sign-in smoke (runs without HANZO_PASSWORD)
The whole suite gated on HANZO_PASSWORD, so CI got zero signal without the prod
superuser secret. Split the credential-free sign-in render check into its own
describe so it always runs — asserts email/password + GitHub/Google + passkey.
Verified green against live console.hanzo.ai. Authenticated flows still gate on
HANZO_PASSWORD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:01:43 -07:00
zeekayandhanzo-dev 45da453c0a test(e2e): ungate public sign-in smoke (runs without HANZO_PASSWORD)
The whole suite gated on HANZO_PASSWORD, so CI got zero signal without the prod
superuser secret. Split the credential-free sign-in render check into its own
describe so it always runs — asserts email/password + GitHub/Google + passkey.
Verified green against live console.hanzo.ai. Authenticated flows still gate on
HANZO_PASSWORD.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 20:01:43 -07:00
Hanzo AI bdd27ec829 fix(test): cast entry() helper to CatalogEntry (union-spread) — 8.0.3 build green 2026-06-30 18:19:56 -07:00
Hanzo AI fc5f5b4bf1 fix(test): cast entry() helper to CatalogEntry (union-spread) — 8.0.3 build green 2026-06-30 18:19:56 -07:00
Hanzo AI bd68c0ee23 fix: resolve package.json conflict → 8.0.3 2026-06-30 18:18:01 -07:00
Hanzo AI 69c8661fa2 fix: resolve package.json conflict → 8.0.3 2026-06-30 18:18:01 -07:00
Hanzo AI 20f1097810 merge(shell-ia): 2-level nav + Team/Settings + admin↔customer gating + /org/iam hardening → 8.0.3 2026-06-30 18:17:23 -07:00
Hanzo AI 5a2d0bc1f9 merge(shell-ia): 2-level nav + Team/Settings + admin↔customer gating + /org/iam hardening → 8.0.3 2026-06-30 18:17:23 -07:00
Hanzo AI f3cb76e35b feat(console): 2-level shell IA + uniform sub-page contract + Team/Settings/Profile + admin↔customer gating (v8.0.1)
Shell + information architecture (owns DashboardShell, nav, header, registry
category/sub-page contract, ⌘K, Team/Settings/Profile). No product content
modules touched (referenced by id).

1. Two-level sidebar nav (Linear-style). Level 1 = categorized product list;
   clicking a product slides into its sub-nav (level 2) with a Back affordance;
   the open product follows the route. CSS-transform slide (.hz-slide,
   reduced-motion aware).
2. Uniform sub-page contract. Every product gets Overview · Settings · Status ·
   Logs · Metrics plus its declared specifics (productSubpages, match-core).
   A sub-page with no backend renders an honest ProductSubpageStub — never a
   404, never fabricated.
3. ⌘K jumps to ANY level. searchDestinations indexes products + declared
   specifics ("queues" → Compute › Tasks › Queues).
4. Category restructure. Deploy→Platform; new Training (Fine-tuning + ML
   Pipelines) and Settings (Team/Settings/Profile); Compute gains
   Kubernetes/Clusters/Tasks; Async category + Jobs entry KILLED (Tasks
   replaces Jobs). Dev/Web3 retained (real products).
5. Sidebar chrome. H mark only (no wordmark), no in-sidebar collapse toggle
   (moved to header), animated collapse (.hz-collapse, 264↔64).
6. Header cleanup. Removed user name + Sign out from the header; Sign out now in
   the footer wallet under Top up; footer user row → Profile; kept org/project/
   network(env) switchers + theme + help + notifications(bell→/alerts).
7. Team (org member mgmt: list/invite/role/remove + read-only Roles), org
   Settings (General/Branding), Profile (Account/Security/API Keys). Member
   mgmt runs over a NEW org-scoped /org/iam proxy so an ORG admin (not only a
   global admin) manages their own org — tenant-isolated server-side, with the
   body-owner cross-tenant write gap closed in a shared forwardIam (also
   hardens /admin/iam). DRY IAM envelope client (iam-envelope).

Admin↔customer surface gating (systemic): admin-only products (Providers, IAM,
KMS, Secrets, Audit, Clusters, Kubernetes) + admin sub-pages (Models › Routing)
are hidden from a customer's nav/launcher/⌘K and render a graceful "Managed by
Hanzo" notice on direct access instead of a hostile 403. Signal = global-admin
(isGlobalAdminAccount, DRY with OrgGate). Customer surfaces (Models browse,
Playground, Chat, API keys, Cost, Team, Compute/data) fully work per-org.

Network×org×project scope was already first-class (client.ts stamps X-Org-Id +
X-IAM-Org-Id + X-Project-Id + X-Environment; ScopeSwitcher env picker) — kept
as-is.

Verify: tsc clean · vitest 308 (incl. new sub-page/routing/destinations/org-
policy tests) · next build green (/org/iam route compiled) · Playwright desktop
+ 390px mobile, both personas.
2026-06-30 17:57:23 -07:00
Hanzo AI 178aca78ba feat(console): 2-level shell IA + uniform sub-page contract + Team/Settings/Profile + admin↔customer gating (v8.0.1)
Shell + information architecture (owns DashboardShell, nav, header, registry
category/sub-page contract, ⌘K, Team/Settings/Profile). No product content
modules touched (referenced by id).

1. Two-level sidebar nav (Linear-style). Level 1 = categorized product list;
   clicking a product slides into its sub-nav (level 2) with a Back affordance;
   the open product follows the route. CSS-transform slide (.hz-slide,
   reduced-motion aware).
2. Uniform sub-page contract. Every product gets Overview · Settings · Status ·
   Logs · Metrics plus its declared specifics (productSubpages, match-core).
   A sub-page with no backend renders an honest ProductSubpageStub — never a
   404, never fabricated.
3. ⌘K jumps to ANY level. searchDestinations indexes products + declared
   specifics ("queues" → Compute › Tasks › Queues).
4. Category restructure. Deploy→Platform; new Training (Fine-tuning + ML
   Pipelines) and Settings (Team/Settings/Profile); Compute gains
   Kubernetes/Clusters/Tasks; Async category + Jobs entry KILLED (Tasks
   replaces Jobs). Dev/Web3 retained (real products).
5. Sidebar chrome. H mark only (no wordmark), no in-sidebar collapse toggle
   (moved to header), animated collapse (.hz-collapse, 264↔64).
6. Header cleanup. Removed user name + Sign out from the header; Sign out now in
   the footer wallet under Top up; footer user row → Profile; kept org/project/
   network(env) switchers + theme + help + notifications(bell→/alerts).
7. Team (org member mgmt: list/invite/role/remove + read-only Roles), org
   Settings (General/Branding), Profile (Account/Security/API Keys). Member
   mgmt runs over a NEW org-scoped /org/iam proxy so an ORG admin (not only a
   global admin) manages their own org — tenant-isolated server-side, with the
   body-owner cross-tenant write gap closed in a shared forwardIam (also
   hardens /admin/iam). DRY IAM envelope client (iam-envelope).

Admin↔customer surface gating (systemic): admin-only products (Providers, IAM,
KMS, Secrets, Audit, Clusters, Kubernetes) + admin sub-pages (Models › Routing)
are hidden from a customer's nav/launcher/⌘K and render a graceful "Managed by
Hanzo" notice on direct access instead of a hostile 403. Signal = global-admin
(isGlobalAdminAccount, DRY with OrgGate). Customer surfaces (Models browse,
Playground, Chat, API keys, Cost, Team, Compute/data) fully work per-org.

Network×org×project scope was already first-class (client.ts stamps X-Org-Id +
X-IAM-Org-Id + X-Project-Id + X-Environment; ScopeSwitcher env picker) — kept
as-is.

Verify: tsc clean · vitest 308 (incl. new sub-page/routing/destinations/org-
policy tests) · next build green (/org/iam route compiled) · Playwright desktop
+ 390px mobile, both personas.
2026-06-30 17:57:23 -07:00
Hanzo AI 1a8061bc34 chore: console 8.0.2 — Compute (K8s/Containers/Tasks/Training, no async/Jobs) + Playground UX 2026-06-30 17:53:29 -07:00
Hanzo AI 645ce65550 chore: console 8.0.2 — Compute (K8s/Containers/Tasks/Training, no async/Jobs) + Playground UX 2026-06-30 17:53:29 -07:00
Hanzo AI c91a641fa9 Merge remote-tracking branch 'origin/feat/playground-ux' into deploy/console-8.0.2 2026-06-30 17:53:06 -07:00
Hanzo AI cc8ec2f0fc Merge remote-tracking branch 'origin/feat/playground-ux' into deploy/console-8.0.2 2026-06-30 17:53:06 -07:00
Hanzo AI 0ca75cae70 Merge remote-tracking branch 'origin/feat/compute-pages' into deploy/console-8.0.2
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-30 17:53:06 -07:00
Hanzo AI 8f1abb3558 Merge remote-tracking branch 'origin/feat/compute-pages' into deploy/console-8.0.2
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-30 17:53:06 -07:00
Hanzo AI cbffe1dde4 feat(compute): Kubernetes, Containers, Training & Temporal Tasks consoles (v8.0.1)
Four resource consoles wired to REAL backends with honest states (never fabricated).

- Kubernetes (Compute): real DOKS clusters via PlatformApi (/paas → platform);
  stat cards + cluster table with provisioned CPU/RAM DERIVED from node-pool slugs
  (honest "—" for GPU/unknown); Import (honest) + Create → real provisionCluster.
- Containers (Compute): apps-inventory Workloads + Pods/Images/Namespaces/Events as
  :tab sub-routes over /paas (tolerant shapes, honest states); cluster-detail sidebar.
- Training (AI/Fine-tuning): live mlsvc /v1/train/{jobs,experiments} + /v1/ml/models
  via generalized /training proxy; stat cards, jobs table, training-loss chart,
  checkpoints, models, configs; New-job panel (base model from real aicatalog) →
  POST /v1/train/jobs (402 ResourceMeter surfaced honestly); Save-as-config.
- Tasks (Compute): Temporal console over hanzoai/tasks tasksd via NEW /tasksd
  minted-Bearer proxy; Workflows/Schedules/Queues/Workers/Activities :tab sub-routes;
  stat cards; workflow detail panel + step graph + 8 sub-tabs; engine-health /
  throughput / queue-util / recent rail. Public tasks TLS not live yet → honest
  states until the in-cluster engine is reachable.

Registry: removed the Async category + the Jobs entry; Tasks & Kubernetes → Compute.
Shared: promoted console primitives to ui/Metric.tsx (DRY; gpus/charts re-exports);
nodes.ts capacity derivation (+12 unit tests). tsc 0 / vitest 304 / next build green.
2026-06-30 17:44:21 -07:00
Hanzo AI 1d2331704e feat(compute): Kubernetes, Containers, Training & Temporal Tasks consoles (v8.0.1)
Four resource consoles wired to REAL backends with honest states (never fabricated).

- Kubernetes (Compute): real DOKS clusters via PlatformApi (/paas → platform);
  stat cards + cluster table with provisioned CPU/RAM DERIVED from node-pool slugs
  (honest "—" for GPU/unknown); Import (honest) + Create → real provisionCluster.
- Containers (Compute): apps-inventory Workloads + Pods/Images/Namespaces/Events as
  :tab sub-routes over /paas (tolerant shapes, honest states); cluster-detail sidebar.
- Training (AI/Fine-tuning): live mlsvc /v1/train/{jobs,experiments} + /v1/ml/models
  via generalized /training proxy; stat cards, jobs table, training-loss chart,
  checkpoints, models, configs; New-job panel (base model from real aicatalog) →
  POST /v1/train/jobs (402 ResourceMeter surfaced honestly); Save-as-config.
- Tasks (Compute): Temporal console over hanzoai/tasks tasksd via NEW /tasksd
  minted-Bearer proxy; Workflows/Schedules/Queues/Workers/Activities :tab sub-routes;
  stat cards; workflow detail panel + step graph + 8 sub-tabs; engine-health /
  throughput / queue-util / recent rail. Public tasks TLS not live yet → honest
  states until the in-cluster engine is reachable.

Registry: removed the Async category + the Jobs entry; Tasks & Kubernetes → Compute.
Shared: promoted console primitives to ui/Metric.tsx (DRY; gpus/charts re-exports);
nodes.ts capacity derivation (+12 unit tests). tsc 0 / vitest 304 / next build green.
2026-06-30 17:44:21 -07:00
Hanzo AI d1a537c32c feat(playground): UX fixes — provider→model cascade, stop, markdown, per-user history, mobile (v8.0.1)
Fixes Dave's live-Playground complaints on console.hanzo.ai/playground.

- ModelPicker: rebuilt as a keyboard-navigable provider→model CASCADE (Zen-first
  provider rail + model pane with real context badge + $/Mtok + live dot;
  searchable; free-text fallback). New pure providers.ts (Zen-first grouping) +tests.
- ResponsePanel: render the completion as real markdown (new pure markdown.ts
  tokenizer + MarkdownView: fenced code blocks w/ copy, inline code, bold/italic,
  lists, headings, links) instead of plaintext; add a clear Stop control in the
  panel header during streaming (AbortController was already wired end-to-end).
- history.ts: namespace per user (owner/name), auto-persist every completed run
  so History auto-populates; one account never sees another's runs (+tests).
- ModelSettings: consolidated into a collapsible side-pane ATTACHED to the prompt
  builder (desktop) and a bottom sheet on mobile (new SettingsSheet, reusing the
  shell Dialog drawer pattern); shrink the oversized slider thumb + thin the track.
- ChatPlayground/Composer: responsive 3-zone layout stacks cleanly at ~390px with
  no horizontal scroll; a settings toggle opens the desktop pane / mobile sheet.

Verified: tsc --noEmit clean, vitest 311/311, next build green; Playwright desktop
+ 390px (cascade, keyboard nav, stop, markdown, mobile stack, settings sheet).

Wallet "Top up" deliberately NOT rerouted to pay.hanzo.ai: it is ALSO broken
(GET /v1/commerce/tenant -> 404 "unknown tenant", so the Square Web Payments SDK
never initializes). Breakage not moved; root cause reported for a backend fix.
2026-06-30 17:25:14 -07:00
Hanzo AI 5a1ca53628 feat(playground): UX fixes — provider→model cascade, stop, markdown, per-user history, mobile (v8.0.1)
Fixes Dave's live-Playground complaints on console.hanzo.ai/playground.

- ModelPicker: rebuilt as a keyboard-navigable provider→model CASCADE (Zen-first
  provider rail + model pane with real context badge + $/Mtok + live dot;
  searchable; free-text fallback). New pure providers.ts (Zen-first grouping) +tests.
- ResponsePanel: render the completion as real markdown (new pure markdown.ts
  tokenizer + MarkdownView: fenced code blocks w/ copy, inline code, bold/italic,
  lists, headings, links) instead of plaintext; add a clear Stop control in the
  panel header during streaming (AbortController was already wired end-to-end).
- history.ts: namespace per user (owner/name), auto-persist every completed run
  so History auto-populates; one account never sees another's runs (+tests).
- ModelSettings: consolidated into a collapsible side-pane ATTACHED to the prompt
  builder (desktop) and a bottom sheet on mobile (new SettingsSheet, reusing the
  shell Dialog drawer pattern); shrink the oversized slider thumb + thin the track.
- ChatPlayground/Composer: responsive 3-zone layout stacks cleanly at ~390px with
  no horizontal scroll; a settings toggle opens the desktop pane / mobile sheet.

Verified: tsc --noEmit clean, vitest 311/311, next build green; Playwright desktop
+ 390px (cascade, keyboard nav, stop, markdown, mobile stack, settings sheet).

Wallet "Top up" deliberately NOT rerouted to pay.hanzo.ai: it is ALSO broken
(GET /v1/commerce/tenant -> 404 "unknown tenant", so the Square Web Payments SDK
never initializes). Breakage not moved; root cause reported for a backend fix.
2026-06-30 17:25:14 -07:00
2f1ae6b7c2 Native control planes (zero external link-outs) + Hanzo Functions dashboard (#15)
* feat(console2): native control planes (zero external link-outs) + Hanzo Functions dashboard

Three deliverables, one PR, all over the one /v1 surface.

1) No external link-outs (priority). The catalog's `external` kind is removed:
   CatalogEntry is module-only, ProductStatus is 'enabled' | 'soon'. The 14
   products that used to open another domain (Gateway, DNS, CDN, MPC, CLI, SDKs,
   API, IDE, Desktop, Registry, Metrics, Crawl, Studio, Console) are now native
   in-console routes rendering ONE shared NativeOverview (overviewFor(id) +
   overviewRoutes(id), the DRY twin of soonRoutes): header + summary, a REAL
   health band (probes PlatformApi.apps() with honest not-deployed/not-reporting
   states), key-fact cards, native-route actions, and INLINE docs. Content is a
   pure OverviewSpec per product (overview/spec.ts + resolve.ts, with a
   catalog-derived defaultSpec fallback). The external branches in open.ts,
   DashboardShell, AppLauncher, CommandPalette, ProductInterstitial, and
   OverviewModule are removed.

2) Hanzo Functions dashboard. FunctionsModule rebuilt into a tabbed product
   (Overview · Functions · Deployments · Triggers · Secrets · Settings) over the
   rich lib/api/functions.ts (GET /v1/functions*). Branded "Hanzo Functions" with
   the honest Fission engine badge. Overview: 6 KPI cards derived from real rows
   (deriveOverview, honest "—"), real-series sparklines + trendPct deltas, an
   "Invocations over time" LineChart with 1H/6H/24H/7D/30D toggles, an "Invocation
   status" Donut, and the shared FunctionsBrowser (table + DetailRail). Secrets is
   names-only. Reuses functions/{FunctionsTable,DetailRail,parts}.tsx unchanged.

3) Overview "Explore products" drops the enablement gate: no more
   Enabled/External/Soon badge; every product is open-for-all with Open (native) +
   a "Learn more" affordance to the native /discover/:id interstitial.

Idiom: strictly @hanzo/gui v5 shorthands. New tests: overview/resolve.test.ts.
npm run typecheck clean (0 errors); npm test 298/298 (31 files); every route
compiles + 200s on the dev server.

Drive-by: remove the bogus tracked node_modules self-symlink blob that broke
npm install/vitest (.gitignore already ignores node_modules/).

* docs(console2): fix overviewFor comment reference

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 17:06:23 -07:00
4f2beb0328 Native control planes (zero external link-outs) + Hanzo Functions dashboard (#15)
* feat(console2): native control planes (zero external link-outs) + Hanzo Functions dashboard

Three deliverables, one PR, all over the one /v1 surface.

1) No external link-outs (priority). The catalog's `external` kind is removed:
   CatalogEntry is module-only, ProductStatus is 'enabled' | 'soon'. The 14
   products that used to open another domain (Gateway, DNS, CDN, MPC, CLI, SDKs,
   API, IDE, Desktop, Registry, Metrics, Crawl, Studio, Console) are now native
   in-console routes rendering ONE shared NativeOverview (overviewFor(id) +
   overviewRoutes(id), the DRY twin of soonRoutes): header + summary, a REAL
   health band (probes PlatformApi.apps() with honest not-deployed/not-reporting
   states), key-fact cards, native-route actions, and INLINE docs. Content is a
   pure OverviewSpec per product (overview/spec.ts + resolve.ts, with a
   catalog-derived defaultSpec fallback). The external branches in open.ts,
   DashboardShell, AppLauncher, CommandPalette, ProductInterstitial, and
   OverviewModule are removed.

2) Hanzo Functions dashboard. FunctionsModule rebuilt into a tabbed product
   (Overview · Functions · Deployments · Triggers · Secrets · Settings) over the
   rich lib/api/functions.ts (GET /v1/functions*). Branded "Hanzo Functions" with
   the honest Fission engine badge. Overview: 6 KPI cards derived from real rows
   (deriveOverview, honest "—"), real-series sparklines + trendPct deltas, an
   "Invocations over time" LineChart with 1H/6H/24H/7D/30D toggles, an "Invocation
   status" Donut, and the shared FunctionsBrowser (table + DetailRail). Secrets is
   names-only. Reuses functions/{FunctionsTable,DetailRail,parts}.tsx unchanged.

3) Overview "Explore products" drops the enablement gate: no more
   Enabled/External/Soon badge; every product is open-for-all with Open (native) +
   a "Learn more" affordance to the native /discover/:id interstitial.

Idiom: strictly @hanzo/gui v5 shorthands. New tests: overview/resolve.test.ts.
npm run typecheck clean (0 errors); npm test 298/298 (31 files); every route
compiles + 200s on the dev server.

Drive-by: remove the bogus tracked node_modules self-symlink blob that broke
npm install/vitest (.gitignore already ignores node_modules/).

* docs(console2): fix overviewFor comment reference

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 17:06:23 -07:00
zeekayandClaude Opus 4.8 48f3c170a0 fix(ci): base image public.ecr node:24-alpine (ghcr hanzoai/nodejs 403'd the runner)
The arcd runner can't pull ghcr.io/hanzoai/nodejs:24-alpine (403) — broke every
build since v0.7.32. Use the public ECR Docker-library mirror (no auth, no rate
limit), the v0.7.9 pattern; align the workflow pre-pull to 24-alpine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:33:27 -07:00
zeekayandhanzo-dev 5561d0f0ed fix(ci): base image public.ecr node:24-alpine (ghcr hanzoai/nodejs 403'd the runner)
The arcd runner can't pull ghcr.io/hanzoai/nodejs:24-alpine (403) — broke every
build since v0.7.32. Use the public ECR Docker-library mirror (no auth, no rate
limit), the v0.7.9 pattern; align the workflow pre-pull to 24-alpine.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 16:33:27 -07:00
zeekay 9144dc6aba chore: sync lockfile for @hanzo/dashboard + @hanzo/data on 8.0.0 2026-06-30 16:31:09 -07:00
zeekay 9b407f75c1 chore: sync lockfile for @hanzo/dashboard + @hanzo/data on 8.0.0 2026-06-30 16:31:09 -07:00
zeekayandClaude Opus 4.8 277abb5e15 feat(base-data): render real Base records via @hanzo/data
Wire published @hanzo/data@1.1.0 (peer @hanzo/gui 7.3.0) into the Base UI
as a composable, honest collection viewer.

- next.config.mjs: transpile @hanzo/data (ships TSX source, like @hanzo/gui)
- src/lib/base-data/fields.ts: pure baseCollectionToFields() mapping a Base
  collection schema -> @hanzo/data FieldDefinition[]. Covers text/number/bool/
  email/url/editor/date/autodate/select+multiSelect/json/relation/file/geoPoint;
  skips hidden+system fields (keeps id); handles modern `fields` and legacy
  `schema`/nested `options`.
- src/lib/base-data/api.ts: tiny BaseDataApi over a Base /v1 (listCollections,
  listRecords) -- raw REST + optional bearer, shares the app's typed ApiError.
- src/components/base-data/CollectionTable.tsx: client component; schema ->
  fields -> records -> @hanzo/data DataTable with honest
  loading/empty/error/not-found states (no fabricated rows).
- src/lib/base-data/fields.test.ts: 10 vitest cases for the mapping.

Verify: `npm run typecheck` clean; `npm test` 58/58.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:27:18 -07:00
zeekayandhanzo-dev c505a9e49d feat(base-data): render real Base records via @hanzo/data
Wire published @hanzo/data@1.1.0 (peer @hanzo/gui 7.3.0) into the Base UI
as a composable, honest collection viewer.

- next.config.mjs: transpile @hanzo/data (ships TSX source, like @hanzo/gui)
- src/lib/base-data/fields.ts: pure baseCollectionToFields() mapping a Base
  collection schema -> @hanzo/data FieldDefinition[]. Covers text/number/bool/
  email/url/editor/date/autodate/select+multiSelect/json/relation/file/geoPoint;
  skips hidden+system fields (keeps id); handles modern `fields` and legacy
  `schema`/nested `options`.
- src/lib/base-data/api.ts: tiny BaseDataApi over a Base /v1 (listCollections,
  listRecords) -- raw REST + optional bearer, shares the app's typed ApiError.
- src/components/base-data/CollectionTable.tsx: client component; schema ->
  fields -> records -> @hanzo/data DataTable with honest
  loading/empty/error/not-found states (no fabricated rows).
- src/lib/base-data/fields.test.ts: 10 vitest cases for the mapping.

Verify: `npm run typecheck` clean; `npm test` 58/58.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 16:27:18 -07:00
zeekayandClaude Opus 4.8 b7595715ff feat(console2): embed Hanzo Base + Base look-and-feel (v0.7.11)
Base product module rendering the shared @hanzo/dashboard screens (published
on npm) via a per-user /superbase proxy (mints the user's IAM bearer); catalog
entry + page /base · /base/new. Plus the Base look: black #0a0a0a surface
aligning to the zinc-on-black identity. Consumes @hanzo/dashboard@0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:26:59 -07:00
zeekayandhanzo-dev 4ed4781f3e feat(console2): embed Hanzo Base + Base look-and-feel (v0.7.11)
Base product module rendering the shared @hanzo/dashboard screens (published
on npm) via a per-user /superbase proxy (mints the user's IAM bearer); catalog
entry + page /base · /base/new. Plus the Base look: black #0a0a0a surface
aligning to the zinc-on-black identity. Consumes @hanzo/dashboard@0.2.0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 16:26:59 -07:00
592e575ae3 feat(chat): demo-ready visual polish (presentation only) (#14)
Polish the Chat UI without touching any data wiring — Chat still calls the
real /v1/chat/completions through the keyless /ai proxy, and every state/API
call, prop, and honest empty/down state is preserved.

- Bubbles: assistant turns read as open text with a sparkle medallion +
  name + timestamp and comfortable line-height (dropped the heavy bordered
  card); the user turn is a refined right-aligned accent bubble. Both render
  light markdown — fenced code blocks (monospace, tinted card, optional lang),
  inline code chips, and bold.
- Markdown: new dependency-free renderer (chat/markdown.tsx) reusing the
  existing `fontFamily: 'monospace'` idiom — no heavy remark/rehype tree added
  (console2 ships no markdown lib).
- Welcome/empty state: sparkle avatar + "How can I help?" + 3–4 clickable
  suggested-prompt chips that fill the composer on click.
- Composer: one rounded, elevated input with a code-insert ({}) and a circular
  send affordance (hover/press states) over a subtle muted hint row.
- ChatView: read-only history thread matches the new bubble look + markdown.
- ChatListView: name cell reads as a link (weight + hover); table unchanged.

All Tamagui shorthands (bg/maxW/rounded/items/justify/self/p/px/py/gap) per
onlyShorthandStyleProps. tsc --noEmit clean; 245 vitest tests pass.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 16:04:25 -07:00
b4552ce0d2 feat(chat): demo-ready visual polish (presentation only) (#14)
Polish the Chat UI without touching any data wiring — Chat still calls the
real /v1/chat/completions through the keyless /ai proxy, and every state/API
call, prop, and honest empty/down state is preserved.

- Bubbles: assistant turns read as open text with a sparkle medallion +
  name + timestamp and comfortable line-height (dropped the heavy bordered
  card); the user turn is a refined right-aligned accent bubble. Both render
  light markdown — fenced code blocks (monospace, tinted card, optional lang),
  inline code chips, and bold.
- Markdown: new dependency-free renderer (chat/markdown.tsx) reusing the
  existing `fontFamily: 'monospace'` idiom — no heavy remark/rehype tree added
  (console2 ships no markdown lib).
- Welcome/empty state: sparkle avatar + "How can I help?" + 3–4 clickable
  suggested-prompt chips that fill the composer on click.
- Composer: one rounded, elevated input with a code-insert ({}) and a circular
  send affordance (hover/press states) over a subtle muted hint row.
- ChatView: read-only history thread matches the new bubble look + markdown.
- ChatListView: name cell reads as a link (weight + hover); table unchanged.

All Tamagui shorthands (bg/maxW/rounded/items/justify/self/p/px/py/gap) per
onlyShorthandStyleProps. tsc --noEmit clean; 245 vitest tests pass.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 16:04:25 -07:00
Hanzo AI 68cfbc1cd5 chore: console2 v0.7.32 — Playground parity (single-model 3-col compose) onto org-switcher main 2026-06-30 16:00:11 -07:00
Hanzo AI 53643c6cd5 chore: console2 v0.7.32 — Playground parity (single-model 3-col compose) onto org-switcher main 2026-06-30 16:00:11 -07:00
Hanzo AI fa52cc74e1 Merge remote-tracking branch 'origin/feat/playground-parity' into deploy/playground-0.7.32 2026-06-30 15:59:54 -07:00
Hanzo AI 3f35fe0fe1 Merge remote-tracking branch 'origin/feat/playground-parity' into deploy/playground-0.7.32 2026-06-30 15:59:54 -07:00
Hanzo AI 48e84ee58d feat(playground): single-model 3-column composer to mockup parity (v0.7.31)
Redesign the Playground from the multi-model compare board into the polished
3-zone surface in the mockup, reusing the existing AI binding (runner/stream/
PlaygroundApi) — nothing about model output, tokens or cost is fabricated.

Layout (per mockup):
- Header "Playground" + subtitle + Save prompt / </> code / Share actions.
- Tabs: Chat · Completions · Embeddings · Audio · Vision.
- Composer (left): model chip with a REAL "context" badge + searchable picker
  over the live catalog (aicatalog.fetchCatalog, 375 models), System prompt +
  user/assistant turns with char counts, and a footer of Add message / Upload
  (image→vision) / {} Variables / Run (⌘↵ + caret). Completions collapses to a
  single Prompt card.
- Examples (labelled starters w/ model chips) + History (real local prior runs,
  honest-empty) beneath the composer.
- Right rail: Response/Logs panel (real completion, USAGE tokens + COST from the
  model's real $/Mtok, "—" when absent) and Model settings (Temperature 0.7,
  Top P 0.9, Max tokens, Stop, Advanced: freq/presence penalty + seed).

Real behaviors, no backend: Save prompt (local library → Examples), Code (real
cURL/JSON request preview), Share (encode composer into ?p= link, restore on
load), {{variables}} substitution, ⌘↵ to run.

Wiring: Temperature/top-p/max-tokens/stop + advanced penalties/seed flow through
paramsOf into the request (sent only when set). Removes the compare-only files
(ComparePlayground/CompareColumn/AddModel/useCompare/SettingsControls).

Tests: +37 unit tests (params/variables/share/prompts/request-preview/compose/
relative); 282 total green. tsc clean, next build green.
2026-06-30 15:40:45 -07:00
Hanzo AI 5cb1568e95 feat(playground): single-model 3-column composer to mockup parity (v0.7.31)
Redesign the Playground from the multi-model compare board into the polished
3-zone surface in the mockup, reusing the existing AI binding (runner/stream/
PlaygroundApi) — nothing about model output, tokens or cost is fabricated.

Layout (per mockup):
- Header "Playground" + subtitle + Save prompt / </> code / Share actions.
- Tabs: Chat · Completions · Embeddings · Audio · Vision.
- Composer (left): model chip with a REAL "context" badge + searchable picker
  over the live catalog (aicatalog.fetchCatalog, 375 models), System prompt +
  user/assistant turns with char counts, and a footer of Add message / Upload
  (image→vision) / {} Variables / Run (⌘↵ + caret). Completions collapses to a
  single Prompt card.
- Examples (labelled starters w/ model chips) + History (real local prior runs,
  honest-empty) beneath the composer.
- Right rail: Response/Logs panel (real completion, USAGE tokens + COST from the
  model's real $/Mtok, "—" when absent) and Model settings (Temperature 0.7,
  Top P 0.9, Max tokens, Stop, Advanced: freq/presence penalty + seed).

Real behaviors, no backend: Save prompt (local library → Examples), Code (real
cURL/JSON request preview), Share (encode composer into ?p= link, restore on
load), {{variables}} substitution, ⌘↵ to run.

Wiring: Temperature/top-p/max-tokens/stop + advanced penalties/seed flow through
paramsOf into the request (sent only when set). Removes the compare-only files
(ComparePlayground/CompareColumn/AddModel/useCompare/SettingsControls).

Tests: +37 unit tests (params/variables/share/prompts/request-preview/compose/
relative); 282 total green. tsc clean, next build green.
2026-06-30 15:40:45 -07:00
Hanzo AI 11763882f8 fix(org): working org switcher + create-org (multi-tenant onboarding)
The OrgSwitcher collapsed to a STATIC non-clickable label whenever the admin-gated
org list returned empty (every tenant: /admin/iam is global-admin-only → 403), and
there was NO create-org affordance. Now: the trigger is ALWAYS an interactive
Popover (current org always shown + filter + switch), with a 'Create organization'
flow that posts to /onboard and scope-switches into the new org. /onboard relaxed:
an existing-org user can create an ADDITIONAL org (created WITHOUT moving them — a
move would strip a global admin's status + orphan their current org); zero-org
first-run still creates+joins. v0.7.31
2026-06-30 15:03:37 -07:00
Hanzo AI 4e7202a9d5 fix(org): working org switcher + create-org (multi-tenant onboarding)
The OrgSwitcher collapsed to a STATIC non-clickable label whenever the admin-gated
org list returned empty (every tenant: /admin/iam is global-admin-only → 403), and
there was NO create-org affordance. Now: the trigger is ALWAYS an interactive
Popover (current org always shown + filter + switch), with a 'Create organization'
flow that posts to /onboard and scope-switches into the new org. /onboard relaxed:
an existing-org user can create an ADDITIONAL org (created WITHOUT moving them — a
move would strip a global admin's status + orphan their current org); zero-org
first-run still creates+joins. v0.7.31
2026-06-30 15:03:37 -07:00
6a74c141d5 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#13)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:27 -07:00
b05f8b4835 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#13)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:27 -07:00
Hanzo AI df5bf34218 feat(data): KV/SQL/Datastore/Object Storage/Vector pages to mockup parity
Decomplect the shared resourceModule factory (backs all managed-data kinds)
into ONE spec-driven console, upgrading every data page at once:

- resource/logic.ts   pure per-kind ResourceSpec + fleet/status/endpoint/
  snippet helpers (RESOURCE_SPECS, 16 unit tests). The list API carries
  lifecycle facts only, so usage metrics are honest "absent" (null -> "—"),
  never fabricated.
- resource/parts.tsx  ResourceStat (honest "—" + "Awaiting metering"),
  StatusDonutCard, TabBar, SnippetBlock, CopyField, SectionCard.
- resource/ResourceListView.tsx  the polished home console: REAL fleet stat
  row + tabs (Overview / <instances> / Access / Metrics / [tool] / Settings)
  + right rail (status donut, quick actions, quick start) + the real create
  flow (POST /v1/<kind>, password shown once).
- resource/ResourceInstanceView.tsx  tabbed single-instance console
  (Overview / Access / Settings) with a real confirmed delete.
- ResourceModule.tsx  thin router; same resourceModule/resourceRoutes
  exports -> zero registry churn, all 5 graded kinds (+docdb/search) tuned.

Honest by construction: status breakdown from real lifecycle, create/delete
real, the data-plane tool tab (Query/Browser/Explore) is an explicit
"coming soon" with the real connect snippet — never a fake success.

Gates: tsc --noEmit clean, vitest 245 pass, next build 14/14. Bump 0.7.30.
2026-06-30 14:16:22 -07:00
Hanzo AI 518d30ade8 feat(data): KV/SQL/Datastore/Object Storage/Vector pages to mockup parity
Decomplect the shared resourceModule factory (backs all managed-data kinds)
into ONE spec-driven console, upgrading every data page at once:

- resource/logic.ts   pure per-kind ResourceSpec + fleet/status/endpoint/
  snippet helpers (RESOURCE_SPECS, 16 unit tests). The list API carries
  lifecycle facts only, so usage metrics are honest "absent" (null -> "—"),
  never fabricated.
- resource/parts.tsx  ResourceStat (honest "—" + "Awaiting metering"),
  StatusDonutCard, TabBar, SnippetBlock, CopyField, SectionCard.
- resource/ResourceListView.tsx  the polished home console: REAL fleet stat
  row + tabs (Overview / <instances> / Access / Metrics / [tool] / Settings)
  + right rail (status donut, quick actions, quick start) + the real create
  flow (POST /v1/<kind>, password shown once).
- resource/ResourceInstanceView.tsx  tabbed single-instance console
  (Overview / Access / Settings) with a real confirmed delete.
- ResourceModule.tsx  thin router; same resourceModule/resourceRoutes
  exports -> zero registry churn, all 5 graded kinds (+docdb/search) tuned.

Honest by construction: status breakdown from real lifecycle, create/delete
real, the data-plane tool tab (Query/Browser/Explore) is an explicit
"coming soon" with the real connect snippet — never a fake success.

Gates: tsc --noEmit clean, vitest 245 pass, next build 14/14. Bump 0.7.30.
2026-06-30 14:16:22 -07:00
Hanzo AI 3c54be90f7 feat(catalog): unify model/provider/plan client; Models & Providers parity
One spine for the Models, Providers, and (later) hanzo.ai / @hanzo/dev / desktop
surfaces, all read through the authenticated /ai proxy. Real data, honest states.

aicatalog (the spine):
- Joins the TWO real catalog record shapes — first-party Zen (`context`+`name`)
  and third-party (`contextWindow`+`id` like openai/gpt-5). Fixes context showing
  "—" on 339/375 models and cross-references availability by the stable id.
- Adds priceBucket/matchesQuery/modelTypes/CONTEXT_BUCKETS filters, the provider
  `verified` flag, modelDisplayName (strips "OpenAI: " row prefixes), and
  fetchPlans() reading the real /v1/plans subscription tiers (rpm/tpm/quota),
  honest-empty on 401/404.

Components (self-contained, prop-driven, liftable to @hanzo/ui):
- ProviderLogo — monochrome avatar resolved by provider name: first-party Sparkles
  mark, curated known-provider glyphs, initials chip otherwise. No external URLs.
- SelectMenu — reusable Popover dropdown for the Providers filter row.

Models page: ProviderLogo in rows + provider column, search box, collapsible
type/provider/pricing filters, stacked in/out pricing, real context via the join,
and subscription-plan badges in the detail (model tier -> /v1/plans limits).

Providers page: Explore / My Providers / Custom Models / BYO Weights tabs (the
latter three honest, pointing at the real add/deploy flow), search + type/context/
pricing/verified/sort row, ProviderLogo + Verified badge on cards and detail.

/ai proxy: allow v1/plans through the authenticated proxy (least-privilege add).
Overview: already at parity on the real usage ledger — verified, not rebuilt.

Gates: tsc --noEmit clean, 230/230 vitest (incl. 12 new aicatalog tests),
next build green (14/14). Bump 0.7.28 -> 0.7.29.
2026-06-30 13:21:49 -07:00
Hanzo AI b660328550 feat(catalog): unify model/provider/plan client; Models & Providers parity
One spine for the Models, Providers, and (later) hanzo.ai / @hanzo/dev / desktop
surfaces, all read through the authenticated /ai proxy. Real data, honest states.

aicatalog (the spine):
- Joins the TWO real catalog record shapes — first-party Zen (`context`+`name`)
  and third-party (`contextWindow`+`id` like openai/gpt-5). Fixes context showing
  "—" on 339/375 models and cross-references availability by the stable id.
- Adds priceBucket/matchesQuery/modelTypes/CONTEXT_BUCKETS filters, the provider
  `verified` flag, modelDisplayName (strips "OpenAI: " row prefixes), and
  fetchPlans() reading the real /v1/plans subscription tiers (rpm/tpm/quota),
  honest-empty on 401/404.

Components (self-contained, prop-driven, liftable to @hanzo/ui):
- ProviderLogo — monochrome avatar resolved by provider name: first-party Sparkles
  mark, curated known-provider glyphs, initials chip otherwise. No external URLs.
- SelectMenu — reusable Popover dropdown for the Providers filter row.

Models page: ProviderLogo in rows + provider column, search box, collapsible
type/provider/pricing filters, stacked in/out pricing, real context via the join,
and subscription-plan badges in the detail (model tier -> /v1/plans limits).

Providers page: Explore / My Providers / Custom Models / BYO Weights tabs (the
latter three honest, pointing at the real add/deploy flow), search + type/context/
pricing/verified/sort row, ProviderLogo + Verified badge on cards and detail.

/ai proxy: allow v1/plans through the authenticated proxy (least-privilege add).
Overview: already at parity on the real usage ledger — verified, not rebuilt.

Gates: tsc --noEmit clean, 230/230 vitest (incl. 12 new aicatalog tests),
next build green (14/14). Bump 0.7.28 -> 0.7.29.
2026-06-30 13:21:49 -07:00
Hanzo AI 38c035022f Merge remote-tracking branch 'origin/feat/mobile-responsive-chat'
# Conflicts:
#	package.json
2026-06-30 13:03:06 -07:00
Hanzo AI 97f7ec8be3 Merge remote-tracking branch 'origin/feat/mobile-responsive-chat'
# Conflicts:
#	package.json
2026-06-30 13:03:06 -07:00
Hanzo AI b9e200f58a Merge remote-tracking branch 'origin/feat/playground-multi-model' 2026-06-30 13:03:06 -07:00
Hanzo AI fb6775169d Merge remote-tracking branch 'origin/feat/playground-multi-model' 2026-06-30 13:03:06 -07:00
Hanzo AI 1552bb1874 Merge remote-tracking branch 'origin/feat/finetuning-unsloth-class'
# Conflicts:
#	src/components/products/FinetuningModule.tsx
2026-06-30 13:03:06 -07:00
Hanzo AI 54e6d0df44 Merge remote-tracking branch 'origin/feat/finetuning-unsloth-class'
# Conflicts:
#	src/components/products/FinetuningModule.tsx
2026-06-30 13:03:06 -07:00
Hanzo AI ca8017f958 fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine (ghcr-only policy)
Re-pin off public.ecr.aws (rate-limited builds + violates ghcr-only). nodejs is the
Node.js runtime mirror (hanzoai/node is the blockchain node). COPY-before-install
(the real Kaniko --single-snapshot fix) is preserved.
2026-06-30 13:02:43 -07:00
Hanzo AI 43fde5d77a fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine (ghcr-only policy)
Re-pin off public.ecr.aws (rate-limited builds + violates ghcr-only). nodejs is the
Node.js runtime mirror (hanzoai/node is the blockchain node). COPY-before-install
(the real Kaniko --single-snapshot fix) is preserved.
2026-06-30 13:02:43 -07:00
hanzo-dev 1d2b2b040d feat(routing): cloud.hanzo.ai is the canonical console host
- default SSR/build host -> cloud.hanzo.ai (was console.hanzo.ai); brand still
  resolves by hostname suffix, so console.hanzo.ai/console2.hanzo.ai (which now
  301 to cloud.hanzo.ai at the gateway) still map to brand hanzo.
- cloudUrl() stays SAME-ORIGIN: the SPA calls cloud.hanzo.ai/v1, and the gateway
  routes that /v1 to the cloud package (global IAM-JWT + rate-limit) — so
  "everything goes through the gateway" holds with the cookie first-party (no CORS).
- console product + experiments external links -> cloud.hanzo.ai.
2026-06-30 12:35:48 -07:00
hanzo-dev 9f6a3d269c feat(routing): cloud.hanzo.ai is the canonical console host
- default SSR/build host -> cloud.hanzo.ai (was console.hanzo.ai); brand still
  resolves by hostname suffix, so console.hanzo.ai/console2.hanzo.ai (which now
  301 to cloud.hanzo.ai at the gateway) still map to brand hanzo.
- cloudUrl() stays SAME-ORIGIN: the SPA calls cloud.hanzo.ai/v1, and the gateway
  routes that /v1 to the cloud package (global IAM-JWT + rate-limit) — so
  "everything goes through the gateway" holds with the cookie first-party (no CORS).
- console product + experiments external links -> cloud.hanzo.ai.
2026-06-30 12:35:48 -07:00
hanzo-devandGitHub e3a400b1ac Merge pull request #12 from hanzoai/fix/console2-base-image
fix(ci): console2 base image → public ECR node:22-alpine (unblock v0.7.28 build)
2026-06-30 12:28:40 -07:00
hanzo-devandGitHub 32da5701cd Merge pull request #12 from hanzoai/fix/console2-base-image
fix(ci): console2 base image → public ECR node:22-alpine (unblock v0.7.28 build)
2026-06-30 12:28:40 -07:00
Hanzo AI 62fad7cb48 fix(ci): base image → public ECR node:22-alpine (the one CI pre-pulls)
The v0.7.26/27/28 image builds 403'd: today's base-image thrashing pointed the
Dockerfile at ghcr.io/hanzoai/nodejs:24-alpine, which is PRIVATE — the host
builder's ghcr login is scoped to console2's own package and can't read the
separate nodejs package (403 Forbidden on the base pull).

build-image.yml still pre-pulls + caches public.ecr.aws/docker/library/node:
22-alpine (its comment even says the Dockerfile uses it). Re-align the Dockerfile
to that public base: no auth, no 403, and the warm ARC runner serves it straight
from the cache the pre-pull step populates. node 22 is the version v0.7.25
shipped on. The Kaniko COPY-before-install + retry-hardened npm install fixes are
untouched.
2026-06-30 12:28:32 -07:00
Hanzo AI 0794eda10e fix(ci): base image → public ECR node:22-alpine (the one CI pre-pulls)
The v0.7.26/27/28 image builds 403'd: today's base-image thrashing pointed the
Dockerfile at ghcr.io/hanzoai/nodejs:24-alpine, which is PRIVATE — the host
builder's ghcr login is scoped to console2's own package and can't read the
separate nodejs package (403 Forbidden on the base pull).

build-image.yml still pre-pulls + caches public.ecr.aws/docker/library/node:
22-alpine (its comment even says the Dockerfile uses it). Re-align the Dockerfile
to that public base: no auth, no 403, and the warm ARC runner serves it straight
from the cache the pre-pull step populates. node 22 is the version v0.7.25
shipped on. The Kaniko COPY-before-install + retry-hardened npm install fixes are
untouched.
2026-06-30 12:28:32 -07:00
hanzo-devandGitHub 6470f8e41b Merge pull request #11 from hanzoai/feat/zero-trust-landing
feat(zero-trust): operational landing — KPIs, post-quantum posture, mesh topology
2026-06-30 12:24:19 -07:00
hanzo-devandGitHub 0925d37289 Merge pull request #11 from hanzoai/feat/zero-trust-landing
feat(zero-trust): operational landing — KPIs, post-quantum posture, mesh topology
2026-06-30 12:24:19 -07:00
Hanzo AI 7a6e3f3f62 Merge remote-tracking branch 'origin/main' into feat/zero-trust-landing
# Conflicts:
#	package.json
2026-06-30 12:23:52 -07:00
Hanzo AI 9be1abef74 Merge remote-tracking branch 'origin/main' into feat/zero-trust-landing
# Conflicts:
#	package.json
2026-06-30 12:23:52 -07:00
Hanzo AI 890d98d32d feat(zero-trust): operational landing — KPIs, PQ posture, mesh topology
Upgrades the Zero Trust surface from five plain tabbed tables into a cloud-console
landing page (the Image #11 operational style), wired to the REAL /v1/zt/* mesh:

- Overview: KPI cards (routers/services/identities/sessions/policies — real
  counts or honest em-dash), a router→service→identity topology strip (only
  nodes that came back; status-coloured), and a post-quantum POSTURE card tying
  the data plane together: Object Storage (S3) ⇄ Zero Trust over Hanzo zap
  (ML-KEM-768 key exchange · ML-DSA-65 signatures). PQ-session % shows only when
  the backend reports a real cipher — never guessed.
- Honest by construction: each of the 5 sections loads independently; when the
  zt backend isn't mounted on this host every section degrades to ONE
  BackendStateCard while the (true) PQ posture still shows. No fabricated rows,
  trends, or telemetry.
- DRY: the five detail tabs reuse the SAME zeroTrustSurfaces + ForwardSurface
  (now exported from ConsoleFeatureModule) — one zero-trust surface, not two.
  ZeroTrustModule moved out of the generic table shell into its own rich module
  (mirrors Embeddings/Overview); registry repointed.
- Pure logic in zt/logic.ts (counts, topology, posture) with vitest coverage.

tsc --noEmit (strict) clean. v0.7.26.
2026-06-30 12:23:08 -07:00
Hanzo AI 1579cb5647 feat(zero-trust): operational landing — KPIs, PQ posture, mesh topology
Upgrades the Zero Trust surface from five plain tabbed tables into a cloud-console
landing page (the Image #11 operational style), wired to the REAL /v1/zt/* mesh:

- Overview: KPI cards (routers/services/identities/sessions/policies — real
  counts or honest em-dash), a router→service→identity topology strip (only
  nodes that came back; status-coloured), and a post-quantum POSTURE card tying
  the data plane together: Object Storage (S3) ⇄ Zero Trust over Hanzo zap
  (ML-KEM-768 key exchange · ML-DSA-65 signatures). PQ-session % shows only when
  the backend reports a real cipher — never guessed.
- Honest by construction: each of the 5 sections loads independently; when the
  zt backend isn't mounted on this host every section degrades to ONE
  BackendStateCard while the (true) PQ posture still shows. No fabricated rows,
  trends, or telemetry.
- DRY: the five detail tabs reuse the SAME zeroTrustSurfaces + ForwardSurface
  (now exported from ConsoleFeatureModule) — one zero-trust surface, not two.
  ZeroTrustModule moved out of the generic table shell into its own rich module
  (mirrors Embeddings/Overview); registry repointed.
- Pure logic in zt/logic.ts (counts, topology, posture) with vitest coverage.

tsc --noEmit (strict) clean. v0.7.26.
2026-06-30 12:23:08 -07:00
Hanzo AI ef8e8fbdf6 fix(ci): COPY before npm install — Kaniko --single-snapshot dropped node_modules
ROOT CAUSE (proven in the build log): the install's own 'test -f next/dist/bin/next'
PASSED, then 'COPY . .' ran, then the build RUN couldn't find next. Under Kaniko
--single-snapshot, a COPY that follows RUN npm install in the same stage drops the
RUN's freshly-created node_modules. The old multi-stage Dockerfile avoided this (no
COPY after install); my single-stage reorder reintroduced it. Fix: COPY all source
FIRST, then install, then build — node_modules is created by the last RUNs so nothing
clobbers it. This (not the base registry or the .bin symlink) was the real blocker.
2026-06-30 12:21:00 -07:00
Hanzo AI f52fae9420 fix(ci): COPY before npm install — Kaniko --single-snapshot dropped node_modules
ROOT CAUSE (proven in the build log): the install's own 'test -f next/dist/bin/next'
PASSED, then 'COPY . .' ran, then the build RUN couldn't find next. Under Kaniko
--single-snapshot, a COPY that follows RUN npm install in the same stage drops the
RUN's freshly-created node_modules. The old multi-stage Dockerfile avoided this (no
COPY after install); my single-stage reorder reintroduced it. Fix: COPY all source
FIRST, then install, then build — node_modules is created by the last RUNs so nothing
clobbers it. This (not the base registry or the .bin symlink) was the real blocker.
2026-06-30 12:21:00 -07:00
Hanzo AI d78dd1ff52 fix(ci): retry-hardened install + assert next is present (self-heal partial tree)
The full @hanzo/gui dep tree intermittently installs ~80 packages short (incl next)
— npm reports success but skips them, surfacing later as 'next not found'. Now:
retry-hardened fetch, an explicit 'npm install next' repair if its bin is missing,
and a hard 'test -f' assert so a partial install fails loudly at the install step
(with a clear cause) rather than at build.
2026-06-30 12:15:02 -07:00
Hanzo AI a2a1f56d43 fix(ci): retry-hardened install + assert next is present (self-heal partial tree)
The full @hanzo/gui dep tree intermittently installs ~80 packages short (incl next)
— npm reports success but skips them, surfacing later as 'next not found'. Now:
retry-hardened fetch, an explicit 'npm install next' repair if its bin is missing,
and a hard 'test -f' assert so a partial install fails loudly at the install step
(with a clear cause) rather than at build.
2026-06-30 12:15:02 -07:00
Hanzo AI cf4c66e977 fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine + invoke next directly
Two fixes for the broken builds:
1. Base image: use ghcr.io/hanzoai/nodejs:24-alpine (our mirror of node:24-alpine)
   per the ghcr-only policy. (public.ecr.aws rate-limited; mirror.gcr.io was a
   stopgap. hanzoai/node is the blockchain node — nodejs is the Node.js runtime.)
   Bumped 22→24.
2. Invoke next via node_modules/next/dist/bin/next, not the .bin/next symlink: the
   @hanzo/gui RN dep tree intermittently drops the symlink under the build npm
   ('sh: next: not found') even though the next package installs. Direct invocation
   is symlink-independent. Build + runner CMD both updated.
2026-06-30 12:07:14 -07:00
Hanzo AI 0055054ca7 fix(ci): base → ghcr.io/hanzoai/nodejs:24-alpine + invoke next directly
Two fixes for the broken builds:
1. Base image: use ghcr.io/hanzoai/nodejs:24-alpine (our mirror of node:24-alpine)
   per the ghcr-only policy. (public.ecr.aws rate-limited; mirror.gcr.io was a
   stopgap. hanzoai/node is the blockchain node — nodejs is the Node.js runtime.)
   Bumped 22→24.
2. Invoke next via node_modules/next/dist/bin/next, not the .bin/next symlink: the
   @hanzo/gui RN dep tree intermittently drops the symlink under the build npm
   ('sh: next: not found') even though the next package installs. Direct invocation
   is symlink-independent. Build + runner CMD both updated.
2026-06-30 12:07:14 -07:00
Hanzo AI 15b2dedd42 fix(ci): base image → mirror.gcr.io (ECR-public was rate-limiting builds)
public.ecr.aws/docker/library/node:22-alpine started returning TOOMANYREQUESTS on
the base-image pull (the real cause of the build failures — a partial pull also
surfaced as the misleading 'next: not found'). mirror.gcr.io/library/node:22-alpine
is Google's Docker Hub mirror — reliable, not rate-limited. Combined with the
single-stage install+build, the v0.7.27 admin-fix build is unblocked.
2026-06-30 11:58:13 -07:00
Hanzo AI f96f971d6f fix(ci): base image → mirror.gcr.io (ECR-public was rate-limiting builds)
public.ecr.aws/docker/library/node:22-alpine started returning TOOMANYREQUESTS on
the base-image pull (the real cause of the build failures — a partial pull also
surfaced as the misleading 'next: not found'). mirror.gcr.io/library/node:22-alpine
is Google's Docker Hub mirror — reliable, not rate-limited. Combined with the
single-stage install+build, the v0.7.27 admin-fix build is unblocked.
2026-06-30 11:58:13 -07:00
Hanzo AI 117f19e6cf fix(ci): single-stage install+build — stop losing node_modules/.bin/next in Kaniko
The deps→build cross-stage COPY of node_modules intermittently dropped
node_modules/.bin/next under Kaniko (sh: next: not found at 'npm run build',
despite a clean install adding ~850 packages). Reproduced: local install is fine,
so it's the cross-stage symlink handoff. Install + next build now run in ONE stage
on the same filesystem — the seam is gone. Runner stage unchanged (runtime next
start worked through its COPY all along). Unblocks the v0.7.27 admin-fix build.
2026-06-30 11:53:36 -07:00
Hanzo AI 1b6fa74980 fix(ci): single-stage install+build — stop losing node_modules/.bin/next in Kaniko
The deps→build cross-stage COPY of node_modules intermittently dropped
node_modules/.bin/next under Kaniko (sh: next: not found at 'npm run build',
despite a clean install adding ~850 packages). Reproduced: local install is fine,
so it's the cross-stage symlink handoff. Install + next build now run in ONE stage
on the same filesystem — the seam is gone. Runner stage unchanged (runtime next
start worked through its COPY all along). Unblocks the v0.7.27 admin-fix build.
2026-06-30 11:53:36 -07:00
Hanzo AI 69c2b3535d chore: v0.7.27 — admin redirect-loop fix 2026-06-30 09:47:18 -07:00
Hanzo AI cb841a3a64 chore: v0.7.27 — admin redirect-loop fix 2026-06-30 09:47:18 -07:00
Hanzo AI e703c25bc6 fix(console): P0 admin.hanzo.ai redirect-loop — admin-org membership = global admin
IAM never populates isGlobalAdmin AND admin-org members carry isAdmin=false, so the
gate's '&& isAdmin' made NO ONE a global admin → every legitimate admin was bounced
from admin.hanzo.ai to the console (redirect loop). Fix: membership in the reserved
'admin' org alone = global admin. Client OrgGate + server identity.ts. Tenants
(owner!=='admin') stay non-global → admin remains locked to them. v0.7.26
2026-06-30 09:46:54 -07:00
Hanzo AI a0f6b106ba fix(console): P0 admin.hanzo.ai redirect-loop — admin-org membership = global admin
IAM never populates isGlobalAdmin AND admin-org members carry isAdmin=false, so the
gate's '&& isAdmin' made NO ONE a global admin → every legitimate admin was bounced
from admin.hanzo.ai to the console (redirect loop). Fix: membership in the reserved
'admin' org alone = global admin. Client OrgGate + server identity.ts. Tenants
(owner!=='admin') stay non-global → admin remains locked to them. v0.7.26
2026-06-30 09:46:54 -07:00
z 8435eb6fa1 merge origin/main (metrics-led Overview) — lead home with the comprehensive OverviewDashboard
Resolve the home-lead fork to the full OverviewModule (Image-#3 vision: metric
cards + sparklines + tokens/spend/by-model charts + activity + quick actions +
wallet + system status) over the narrower AiMetricsModule lead. AiMetrics stays
its own route. One home dashboard, one way.
2026-06-30 00:34:18 -07:00
z e435350b41 merge origin/main (metrics-led Overview) — lead home with the comprehensive OverviewDashboard
Resolve the home-lead fork to the full OverviewModule (Image-#3 vision: metric
cards + sparklines + tokens/spend/by-model charts + activity + quick actions +
wallet + system status) over the narrower AiMetricsModule lead. AiMetrics stays
its own route. One home dashboard, one way.
2026-06-30 00:34:18 -07:00
z a5da0bd863 merge Functions — canonical ui/Charts.tsx wins (its dup was unused orphan; removed)
Functions' add/add ui/Charts.tsx was dead code (no consumer imported CHART_COLORS/
STATUS_COLORS). Take the canonical superset (Sparkline/LineChart/BarChart/Donut/
BarRows). One chart module, one palette name, no backwards-compat alias. Fixed the
legend swatch to the file's own styled-div idiom (was a themed bg with a hex).
2026-06-30 00:29:29 -07:00
z 31c19cbf9c merge Functions — canonical ui/Charts.tsx wins (its dup was unused orphan; removed)
Functions' add/add ui/Charts.tsx was dead code (no consumer imported CHART_COLORS/
STATUS_COLORS). Take the canonical superset (Sparkline/LineChart/BarChart/Donut/
BarRows). One chart module, one palette name, no backwards-compat alias. Fixed the
legend swatch to the file's own styled-div idiom (was a themed bg with a hex).
2026-06-30 00:29:29 -07:00
Hanzo AI 47f296b2fb feat(console): metrics-led Overview — lead home with the real AI usage dashboard
The Overview was a product grid; now it leads with the same tested AI Metrics
dashboard (requests/tokens/spend over time + per-model breakdown, real per-org
data) — the Langfuse-style project home — with the product catalog below for
navigation. DRY: reuses AiMetricsModule exactly. v0.7.25
2026-06-30 00:29:22 -07:00
Hanzo AI 1574d6dcb9 feat(console): metrics-led Overview — lead home with the real AI usage dashboard
The Overview was a product grid; now it leads with the same tested AI Metrics
dashboard (requests/tokens/spend over time + per-model breakdown, real per-org
data) — the Langfuse-style project home — with the product catalog below for
navigation. DRY: reuses AiMetricsModule exactly. v0.7.25
2026-06-30 00:29:22 -07:00
z 584a73ff53 Merge remote-tracking branch 'origin/feat/gpus-page' into integrate/console2-v0.7.23 2026-06-30 00:26:56 -07:00
z 9b3dd2382f Merge remote-tracking branch 'origin/feat/gpus-page' into integrate/console2-v0.7.23 2026-06-30 00:26:56 -07:00
z c4ba6846b8 Merge remote-tracking branch 'origin/feat/machines-page' into integrate/console2-v0.7.23 2026-06-30 00:26:48 -07:00
z aacf716936 Merge remote-tracking branch 'origin/feat/machines-page' into integrate/console2-v0.7.23 2026-06-30 00:26:48 -07:00
z 286b1af431 merge main (v0.7.24: AI Metrics + mobile shell) → v0.7.25 2026-06-30 00:26:48 -07:00
z 4aaae77137 merge main (v0.7.24: AI Metrics + mobile shell) → v0.7.25 2026-06-30 00:26:48 -07:00
z 884772402c console2: consolidate Overview+Embeddings charts → one ui/Charts.tsx (DRY)
Merge Overview + Embeddings; collapse the two duplicate chart implementations
(components/charts/Charts.tsx + components/ui/Charts.tsx) into ONE canonical
ui/Charts.tsx superset built on pure @hanzo/gui primitives (promotable to the
shared @hanzo/gui package as a charts category). Repoint OverviewModule +
embeddings/OverviewView. tsc clean.
2026-06-30 00:26:37 -07:00
z 84fe638a19 console2: consolidate Overview+Embeddings charts → one ui/Charts.tsx (DRY)
Merge Overview + Embeddings; collapse the two duplicate chart implementations
(components/charts/Charts.tsx + components/ui/Charts.tsx) into ONE canonical
ui/Charts.tsx superset built on pure @hanzo/gui primitives (promotable to the
shared @hanzo/gui package as a charts category). Repoint OverviewModule +
embeddings/OverviewView. tsc clean.
2026-06-30 00:26:37 -07:00
z 53f188d131 console2: Functions — serverless (OpenFaaS-class) on real /v1, honest states
Compute → Functions (6 tabs: Overview/Functions/Deployments/Triggers/Secrets/
Settings): functions table, invocations-over-time, status donut, selected-function
detail rail (about/triggers/recent invocations + View/Edit/Delete). Wired to the
real functions backend with honest not-configured/empty/error states; metrics from
the usage ledger degrade to '—'. currentOrg-scoped; destructive actions confirm /
honest-disabled. Fixed 2 leftover tsc errors (icon→ReactElement; dropped dead
?? true after non-nullish !bool). tsc clean.
2026-06-30 00:22:32 -07:00
z bb71f843bb console2: Functions — serverless (OpenFaaS-class) on real /v1, honest states
Compute → Functions (6 tabs: Overview/Functions/Deployments/Triggers/Secrets/
Settings): functions table, invocations-over-time, status donut, selected-function
detail rail (about/triggers/recent invocations + View/Edit/Delete). Wired to the
real functions backend with honest not-configured/empty/error states; metrics from
the usage ledger degrade to '—'. currentOrg-scoped; destructive actions confirm /
honest-disabled. Fixed 2 leftover tsc errors (icon→ReactElement; dropped dead
?? true after non-nullish !bool). tsc clean.
2026-06-30 00:22:32 -07:00
z 7f0f7a683f console2: GPUs — real GPU/cluster inventory + honest derived/not-configured states
Compute → GPUs (7 tabs): GPU model+count derived from cluster nodeSize slug
(gpuSpecOf, pure+tested), per-GPU rows/telemetry from /paas/gpus when present.
Three honest renders: not-configured (501) · clusters-only (real derived counts +
distribution donut + top-clusters) · inventory-live (full table+telemetry).
Telemetry-only metrics (util/mem/temp/sparklines) and GPU-hours have no backend →
'—'. Est-cost reads the usage ledger, degrades to '—' (never relabels account
total as GPU cost). New compute.ts (zero platform.ts edits); registry gpus entry
upgraded. tsc clean · next build 14/14 · 18 compute tests green.
2026-06-30 00:20:31 -07:00
z 23004bebdd console2: GPUs — real GPU/cluster inventory + honest derived/not-configured states
Compute → GPUs (7 tabs): GPU model+count derived from cluster nodeSize slug
(gpuSpecOf, pure+tested), per-GPU rows/telemetry from /paas/gpus when present.
Three honest renders: not-configured (501) · clusters-only (real derived counts +
distribution donut + top-clusters) · inventory-live (full table+telemetry).
Telemetry-only metrics (util/mem/temp/sparklines) and GPU-hours have no backend →
'—'. Est-cost reads the usage ledger, degrades to '—' (never relabels account
total as GPU cost). New compute.ts (zero platform.ts edits); registry gpus entry
upgraded. tsc clean · next build 14/14 · 18 compute tests green.
2026-06-30 00:20:31 -07:00
z ddd5aeda18 console2: Machines — real DOKS-cluster-node inventory, honest states
Compute → Machines as one node of a real DOKS cluster pool (GET /v1/org/{org}/
cluster → machinesFromClusters: sum(pool.count) rows, vCPU/RAM from the DO slug,
monthly cost from the platform bill-from table labeled 'est.'). 8 tabs, 7 metric
cards, status donut, machine table, selected-machine right rail. Per-node CPU%/
MEM%/GPU/uptime/IP the control plane doesn't expose → '—' (never fabricated).
Reboot/Terminate honest-disabled (no real endpoint at that altitude). currentOrg-
scoped; not-configured (501, PaaS token disabled) is the honest first state.
Additive platform.ts only. tsc clean · 20 machines tests green.
2026-06-30 00:20:11 -07:00
z 01856e92dd console2: Machines — real DOKS-cluster-node inventory, honest states
Compute → Machines as one node of a real DOKS cluster pool (GET /v1/org/{org}/
cluster → machinesFromClusters: sum(pool.count) rows, vCPU/RAM from the DO slug,
monthly cost from the platform bill-from table labeled 'est.'). 8 tabs, 7 metric
cards, status donut, machine table, selected-machine right rail. Per-node CPU%/
MEM%/GPU/uptime/IP the control plane doesn't expose → '—' (never fabricated).
Reboot/Terminate honest-disabled (no real endpoint at that altitude). currentOrg-
scoped; not-configured (501, PaaS token disabled) is the honest first state.
Additive platform.ts only. tsc clean · 20 machines tests green.
2026-06-30 00:20:11 -07:00
Hanzo AI b78f48e0b3 Merge branch 'feat/ai-metrics-o11y' 2026-06-30 00:18:54 -07:00
Hanzo AI ecabe593e7 Merge branch 'feat/ai-metrics-o11y' 2026-06-30 00:18:54 -07:00
Hanzo AI c75da83217 feat(console): AI Metrics page + fix Cost usage mapping (real model/tokens, cents)
The /v1/billing/usage ledger carries per-request rows (metadata.model, totalTokens,
amount in CENTS). The Cost page flattened it with a generic reader → every row
'Usage', no tokens, and cost ×100 ($1.06 shown as $106). Now: looksLikeLedger()
+ perModel() roll up by real model name with correct cents — shared (DRY) with the
new AI Metrics module (StatTiles: requests/tokens/spend/balance, usage-over-time,
per-model breakdown, recent activity) reading the same real per-org data. o11y
RuntimeNotice points at AI Metrics (which has data) when traces are uninitialized.
32 unit tests (billing/aimetrics/format), tsc+build green.
2026-06-30 00:18:52 -07:00
Hanzo AI 289daf0ad1 feat(console): AI Metrics page + fix Cost usage mapping (real model/tokens, cents)
The /v1/billing/usage ledger carries per-request rows (metadata.model, totalTokens,
amount in CENTS). The Cost page flattened it with a generic reader → every row
'Usage', no tokens, and cost ×100 ($1.06 shown as $106). Now: looksLikeLedger()
+ perModel() roll up by real model name with correct cents — shared (DRY) with the
new AI Metrics module (StatTiles: requests/tokens/spend/balance, usage-over-time,
per-model breakdown, recent activity) reading the same real per-org data. o11y
RuntimeNotice points at AI Metrics (which has data) when traces are uninitialized.
32 unit tests (billing/aimetrics/format), tsc+build green.
2026-06-30 00:18:52 -07:00
z 4679d1af14 release(console2): v0.7.23 — Playground multi-model + UX sweep live to prod
Ships the merged work (multi-model compare Playground with abort-safe billing,
the unified EmptyState/Cost sweep) as a public semver. Overview/Embeddings/
Machines/GPUs/Functions land in v0.7.24 as the conflict integration completes.
2026-06-30 00:08:07 -07:00
z 1e4df57ca3 release(console2): v0.7.23 — Playground multi-model + UX sweep live to prod
Ships the merged work (multi-model compare Playground with abort-safe billing,
the unified EmptyState/Cost sweep) as a public semver. Overview/Embeddings/
Machines/GPUs/Functions land in v0.7.24 as the conflict integration completes.
2026-06-30 00:08:07 -07:00
Hanzo AI f084d89918 Merge remote-tracking branch 'origin/feat/embeddings-page' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/components/products/stores/StoreListView.tsx
2026-06-30 00:02:06 -07:00
Hanzo AI e06c23ef74 Merge remote-tracking branch 'origin/feat/embeddings-page' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
#	src/components/products/stores/StoreListView.tsx
2026-06-30 00:02:06 -07:00
Hanzo AI d284a8e076 Merge remote-tracking branch 'origin/feat/overview-dashboard' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
2026-06-30 00:00:16 -07:00
Hanzo AI d682209d75 Merge remote-tracking branch 'origin/feat/overview-dashboard' into integrate/console2-v0.7.23
# Conflicts:
#	app/(dashboard)/page.tsx
2026-06-30 00:00:16 -07:00
Hanzo AI 55e14e85f3 feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:52:56 -07:00
Hanzo AI cadda55c3d feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:52:56 -07:00
z 44435ca291 console2: Embeddings — 6-tab vector product on real /v1, honest-empty (upgrade StoresModule)
Upgrades the thin StoresModule in place into the Embeddings product (Overview·
Explore·Collections·Jobs·Models·Settings): collections=per-org vector stores
(get-stores), Explore=POST /v1/search on {owner}-{store}-docs (server-resolved
owner — store is only a query param), Models+generate=/v1/models + /v1/embeddings
via the keyless /ai proxy. currentOrg-scoped throughout; no secret reaches the
browser; /ai allow-list NOT widened. Honest-empty everywhere a field/endpoint is
absent (get-cloud-usages not merged yet → metric cards degrade to —; RRF drops
score → —; no vector point-lookup → empty) — never fabricated. Deletes the dead
StoresModule/StoreListView (registry was the only consumer); reuses StoreEditView.
Drive-by: corrects the stale 'built-in' admin-policy assertion to false (gate code
untouched). tsc clean · vitest 67/67 · next build 14/14.
2026-06-29 23:48:33 -07:00
z 1d31a5128e console2: Embeddings — 6-tab vector product on real /v1, honest-empty (upgrade StoresModule)
Upgrades the thin StoresModule in place into the Embeddings product (Overview·
Explore·Collections·Jobs·Models·Settings): collections=per-org vector stores
(get-stores), Explore=POST /v1/search on {owner}-{store}-docs (server-resolved
owner — store is only a query param), Models+generate=/v1/models + /v1/embeddings
via the keyless /ai proxy. currentOrg-scoped throughout; no secret reaches the
browser; /ai allow-list NOT widened. Honest-empty everywhere a field/endpoint is
absent (get-cloud-usages not merged yet → metric cards degrade to —; RRF drops
score → —; no vector point-lookup → empty) — never fabricated. Deletes the dead
StoresModule/StoreListView (registry was the only consumer); reuses StoreEditView.
Drive-by: corrects the stale 'built-in' admin-policy assertion to false (gate code
untouched). tsc clean · vitest 67/67 · next build 14/14.
2026-06-29 23:48:33 -07:00
39d88d0cfd feat(console): side-by-side multi-model compare Playground (#7)
* feat(console): side-by-side multi-model compare Playground

Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.

* fix(console): cancel upstream on abort + release reader + honest stopped state

Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-29 23:45:35 -07:00
32e0cb0dbb feat(console): side-by-side multi-model compare Playground (#7)
* feat(console): side-by-side multi-model compare Playground

Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.

* fix(console): cancel upstream on abort + release reader + honest stopped state

Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-29 23:45:35 -07:00
Hanzo AI 1fc3c6eca6 fix(console): cancel upstream on abort + release reader + honest stopped state
Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).
2026-06-29 23:43:16 -07:00
Hanzo AI ad4db582de fix(console): cancel upstream on abort + release reader + honest stopped state
Red review follow-ups on the compare Playground (no scope creep):

1. [MED] Client abort now cancels upstream generation (stops over-billing the
   user's own org + leaking N sockets after Stop/tab-switch/unmount):
   - app/ai/[...path]/route.ts: pass `signal: req.signal` to the gateway fetch so
     a browser->proxy abort propagates proxy->gateway.
   - ComparePlayground: useEffect cleanup calls compare.cancel() on unmount;
     cancel() aborts every column's AbortController.

2. [MED] runner.ts: wrap the SSE read loop in try/finally with
   `reader.cancel().catch(()=>{})` so the ReadableStream + connection are
   released on every exit — normal [DONE], a thrown mid-stream error chunk, or an
   abort. New test asserts the reader is cancelled on a mid-stream error chunk.

3. [LOW] An aborted run no longer renders/records as success: new 'stopped'
   RunPhase, CompareColumn shows a Stopped state, History uses
   ok = !error && !aborted and shows a 'stopped' badge.

tsc --noEmit clean; vitest 29 playground tests green (28 + new MED-2 test).
2026-06-29 23:43:16 -07:00
Hanzo AI 86e26e5e54 feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:41:42 -07:00
Hanzo AI 25239356ca feat(console): mobile/responsive shell + floating AI chat + wallet identity → v0.7.23
Comprehensive responsive pass so the console works at phone/tablet/laptop/
desktop, plus a floating assistant reachable from every page and a wallet that
doubles as the signed-in identity + a click-through to in-console billing.

P1 — Mobile/responsive shell (DashboardShell.tsx)
- Layout responsiveness is CSS-driven (Tamagui v5 media style props:
  `display="none"` + `$lg={{ display:'flex' }}`), NOT a JS `useMedia()` branch —
  the server and the client's first paint emit identical markup, so there is no
  hydration mismatch and no flash of the compact layout on a wide screen.
- < lg (1024px): the persistent sidebar is HIDDEN behind a hamburger in the
  topbar that opens the SAME nav as a left drawer (closes on select/backdrop);
  the topbar condenses — org/scope/user/sign-out fold into one right-side menu
  so nothing overflows at 375px; the Apps button collapses to icon-only.
- ≥ lg: the persistent sidebar is always on (collapsible) with the full inline
  topbar controls.
- Fixed the collapsed-rail icons (20px, 44px hit targets — were too small).
- The nav body (SidebarNav) is shared by the desktop sidebar and the drawer (DRY).
- Responsive content padding ($md), wallet always reachable.

P2 — Floating AI chat (FloatingChat.tsx, mounted once in the dashboard layout)
- A chat bubble fixed bottom-right on every page. Click → opens the assistant:
  a full-screen sheet < lg, a ~380×560 popover ≥ lg (sizing is CSS-driven too).
- REUSES the one working chat surface (ChatConversation → AiApi.chat → the
  keyless /ai proxy). No AI rebuilt. A new `compact` mode on ChatConversation
  drops the page header + fixed min-height so it fills the sheet; "History"
  deep-links to the full /chat page.

P3 — Wallet as identity + billing click-through (SidebarWallet.tsx)
- The wallet now shows the signed-in user's avatar (IAM photo, else initials)
  + display name from useSession, alongside the live balance.
- Clicking the wallet/identity routes into the in-console Cost module (/cost:
  balance, usage, invoices). "Top up" still deep-links to the brand billing
  portal (billing.hanzo.ai) — payment is never rebuilt.

Verification
- `tsc --noEmit` clean; `next build` succeeds (14/14 pages).
- Playwright (headless) verified live at 375/768/1024/1440 with the backend
  mocked: persistent-sidebar↔hamburger swap, drawer open/close, full-screen chat
  sheet, account menu, wallet identity — all PASS, and NO React hydration
  mismatch. Apps-label collapse confirmed (icon-only 375 / labeled 1440).
2026-06-29 23:41:42 -07:00
Hanzo AI a2c925ab7b feat(finetuning): Unsloth-class, HuggingFace-native cloud training UI
Upgrades the Fine-tuning module from a read-only job list into a full training
surface wired to the cloud broker (hanzoai/ai /v1/finetune/*) via a same-origin,
user-scoped proxy.

- app/training/[...path]/route.ts  same-origin proxy → /v1/finetune/* (cookie
                                   forward, the get-account pattern; allow-listed
                                   sub-paths; org re-resolved server-side). No key
                                   or HF token ever reaches the browser.
- src/lib/api/finetune.ts          typed client (unwraps the {status,msg,data}
                                   envelope incl. the HTTP-200 error shape)
- finetuning/HfPicker.tsx          browse/search HuggingFace models + datasets;
                                   private/gated flagged; pick → start a job
- finetuning/NewJobPanel.tsx       base-model + dataset pickers, LoRA/QLoRA/full,
                                   Recommended preset that just works, GPU with a
                                   live time/cost estimate, Start + Save-as-config
- finetuning/JobsView.tsx          jobs list (honest empty/error states)
- finetuning/JobDetail.tsx         live status polling, checkpoints, GPU-hours,
                                   Deploy-to-inference, Cancel
- finetuning/logic.ts(+test)       pure formatters + cost mirror + config store (17
                                   vitest cases)
- FinetuningModule.tsx             tabbed shell (Jobs/New/Models/Datasets/Configs);
                                   internal nav, no new registry routes

tsc --noEmit clean · 17 finetuning vitest pass · next build clean.
2026-06-29 23:37:33 -07:00
Hanzo AI c1dea5f0f6 feat(finetuning): Unsloth-class, HuggingFace-native cloud training UI
Upgrades the Fine-tuning module from a read-only job list into a full training
surface wired to the cloud broker (hanzoai/ai /v1/finetune/*) via a same-origin,
user-scoped proxy.

- app/training/[...path]/route.ts  same-origin proxy → /v1/finetune/* (cookie
                                   forward, the get-account pattern; allow-listed
                                   sub-paths; org re-resolved server-side). No key
                                   or HF token ever reaches the browser.
- src/lib/api/finetune.ts          typed client (unwraps the {status,msg,data}
                                   envelope incl. the HTTP-200 error shape)
- finetuning/HfPicker.tsx          browse/search HuggingFace models + datasets;
                                   private/gated flagged; pick → start a job
- finetuning/NewJobPanel.tsx       base-model + dataset pickers, LoRA/QLoRA/full,
                                   Recommended preset that just works, GPU with a
                                   live time/cost estimate, Start + Save-as-config
- finetuning/JobsView.tsx          jobs list (honest empty/error states)
- finetuning/JobDetail.tsx         live status polling, checkpoints, GPU-hours,
                                   Deploy-to-inference, Cancel
- finetuning/logic.ts(+test)       pure formatters + cost mirror + config store (17
                                   vitest cases)
- FinetuningModule.tsx             tabbed shell (Jobs/New/Models/Datasets/Configs);
                                   internal nav, no new registry routes

tsc --noEmit clean · 17 finetuning vitest pass · next build clean.
2026-06-29 23:37:33 -07:00
Hanzo AI a3f7412653 feat(overview): real-data Overview dashboard — the cloud coherence centerpiece
Build the Overview page wired end-to-end to live data (no mock data):
- metrics / charts / spend-by-model / recent activity ← /v1/get-cloud-usages
  (the hanzo.cloud_usage ledger), org-scoped by the X-Org-Id header;
- wallet balance ← commerce /v1/billing/balance (WalletApi.cloudBalance);
- quick actions ← the real product registry (no dead links).

- src/lib/api/usage.ts: typed UsageApi.overview client (+ allOrgs god-view).
- src/components/charts/Charts.tsx: dependency-free SVG charts (sparkline, line,
  bar, donut) — console2 ships no chart lib; these theme to the dark shell and
  render in the Next web DOM alongside @hanzo/gui.
- src/components/products/OverviewModule.tsx: the dashboard — time range
  (24H/7D/30D/Custom), 4 metric cards w/ sparkline + "vs prior" delta, tokens
  line, spend bar, spend-by-model donut + ranked list, recent activity (filter
  tabs + pagination), quick actions, honest system-status footer, wallet.
  Uniform loading/empty/error states; honest "—" for data with no live source
  (GPU/latency → linked to the Status page rather than faked).
- app/(dashboard)/page.tsx: the home renders the Overview (the Explore-products
  catalog is kept below).
- src/lib/products/registry.tsx: 'overview' module at /overview — also the
  all-orgs admin surface via <OverviewDashboard allOrgs/>.
2026-06-29 23:25:54 -07:00
Hanzo AI e4580ec1f7 feat(overview): real-data Overview dashboard — the cloud coherence centerpiece
Build the Overview page wired end-to-end to live data (no mock data):
- metrics / charts / spend-by-model / recent activity ← /v1/get-cloud-usages
  (the hanzo.cloud_usage ledger), org-scoped by the X-Org-Id header;
- wallet balance ← commerce /v1/billing/balance (WalletApi.cloudBalance);
- quick actions ← the real product registry (no dead links).

- src/lib/api/usage.ts: typed UsageApi.overview client (+ allOrgs god-view).
- src/components/charts/Charts.tsx: dependency-free SVG charts (sparkline, line,
  bar, donut) — console2 ships no chart lib; these theme to the dark shell and
  render in the Next web DOM alongside @hanzo/gui.
- src/components/products/OverviewModule.tsx: the dashboard — time range
  (24H/7D/30D/Custom), 4 metric cards w/ sparkline + "vs prior" delta, tokens
  line, spend bar, spend-by-model donut + ranked list, recent activity (filter
  tabs + pagination), quick actions, honest system-status footer, wallet.
  Uniform loading/empty/error states; honest "—" for data with no live source
  (GPU/latency → linked to the Status page rather than faked).
- app/(dashboard)/page.tsx: the home renders the Overview (the Explore-products
  catalog is kept below).
- src/lib/products/registry.tsx: 'overview' module at /overview — also the
  all-orgs admin surface via <OverviewDashboard allOrgs/>.
2026-06-29 23:25:54 -07:00
Hanzo AI d3a47c44d6 feat(console): side-by-side multi-model compare Playground
Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.
2026-06-29 23:23:03 -07:00
Hanzo AI f6c07c7a9e feat(console): side-by-side multi-model compare Playground
Tabbed Playground (Chat/Completions/Embeddings/Audio/Vision) whose marquee is a
side-by-side compare board: ONE shared System+User (or Prompt) broadcasts to N
model columns that run in PARALLEL through the keyless /ai proxy, each streaming
its own output while reporting REAL tokens, cost (catalog $/Mtok) and latency
(time-to-first-token + total). Per-column model + optional settings override with
a sync-across-all toggle; one column erroring/stopping never disturbs the others.
Single-model mode is one column. Examples seed the prompt; History records a run.

- app/ai/[...path]: stream the upstream body through (real TTFT) instead of
  buffering; allow-list v1/audio/speech for the Audio (TTS) tab. Both additive +
  backward-compatible for existing non-streaming callers.
- lib/api/playground.ts: add streamChat (SSE, stream_options.include_usage),
  embeddings, speech — additive to PlaygroundApi.
- Models selectable from the LIVE catalog (CloudModelApi → /ai/v1/models +
  /v1/pricing/models), the same source the Models page uses. No mocks.
- Pure, unit-tested core (cost/SSE-parse/runner/history): 28 vitest tests.
2026-06-29 23:23:03 -07:00
Hanzo AI 937ef0f0d8 chore(console): model + provider click-through detail pages → v0.7.22 2026-06-29 23:08:39 -07:00
Hanzo AI 7733f3defe chore(console): model + provider click-through detail pages → v0.7.22 2026-06-29 23:08:39 -07:00
Hanzo AI 75064e8d42 feat(console): provider detail page (metadata + provider's model list)
Click a provider card → ProviderDetailPanel: provider name + stats (models, max
context, from-price, available) + the provider's full model list (name, type,
context, in/out price, status). Pairs with the model click-through. v0.7.21
2026-06-29 23:08:39 -07:00
Hanzo AI 39cc378b15 feat(console): provider detail page (metadata + provider's model list)
Click a provider card → ProviderDetailPanel: provider name + stats (models, max
context, from-price, available) + the provider's full model list (name, type,
context, in/out price, status). Pairs with the model click-through. v0.7.21
2026-06-29 23:08:39 -07:00
a464a12efb feat(console2): unified EmptyState + cost/billing + wired AI pages (#6)
* fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval

The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.

* console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)

The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
Co-authored-by: Hanzo <z@hanzo.ai>
2026-06-29 23:05:59 -07:00
d0c497b19a feat(console2): unified EmptyState + cost/billing + wired AI pages (#6)
* fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval

The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.

* console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)

The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
Co-authored-by: Hanzo <z@hanzo.ai>
2026-06-29 23:05:59 -07:00
z 1823b4e94f console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)
The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.
2026-06-29 23:05:26 -07:00
z 94cbc1bf0b console2: unified empty-state + cost/billing + wired AI pages (one way, forward-only)
The DRY pass toward 'every page real + useful': ONE EmptyState (the honest
first-run onboarding surface every module reuses instead of a dead screen), ONE
CostModule + billing client (real wallet/spend from commerce), and the AI pages
(Providers/Agents/Inference/Fine-tuning) wired to real data with that shared
empty state. Aligned the stray longhand maxWidth→maxW (onlyShorthandStyleProps:
one way, the redundant alias is off) and narrowed EmptyAction.icon to ReactElement.
tsc --noEmit clean (0 errors). Salvaged + verified from the throttled agent's tree.
2026-06-29 23:05:26 -07:00
Hanzo AI f5ff37fc34 feat(console): click-through model detail (specs/pricing/features + config actions)
Catalog rows are clickable → ModelDetailPanel: full specs (arch/params), context,
all pricing (input/output/cache read+write), tier, features, status + actions
(Open in Playground, Configure routing → /models/routing/<name>, Copy ID). v0.7.20
2026-06-29 23:04:19 -07:00
Hanzo AI 3e5a8103e3 feat(console): click-through model detail (specs/pricing/features + config actions)
Catalog rows are clickable → ModelDetailPanel: full specs (arch/params), context,
all pricing (input/output/cache read+write), tier, features, status + actions
(Open in Playground, Configure routing → /models/routing/<name>, Copy ID). v0.7.20
2026-06-29 23:04:19 -07:00
Hanzo AI 39d690072f fix(console): ChunkGuard — auto-recover stale-deploy chunk errors
Open tabs reference the prior build's hashed chunks after a deploy; those URLs
fall through to the app shell (HTML), so the browser throws ChunkLoadError /
'Unexpected token <' and blanks. ChunkGuard catches that exact failure and does
ONE guarded full reload to pull fresh HTML + current chunks. v0.7.19
2026-06-29 22:50:23 -07:00
Hanzo AI 9b70d8f695 fix(console): ChunkGuard — auto-recover stale-deploy chunk errors
Open tabs reference the prior build's hashed chunks after a deploy; those URLs
fall through to the app shell (HTML), so the browser throws ChunkLoadError /
'Unexpected token <' and blanks. ChunkGuard catches that exact failure and does
ONE guarded full reload to pull fresh HTML + current chunks. v0.7.19
2026-06-29 22:50:23 -07:00
Hanzo AI d4c361f3b5 fix(console): carry available-count in ProvidersExplore state (RichModel has no .available) 2026-06-29 22:40:39 -07:00
Hanzo AI 68649d3b26 fix(console): carry available-count in ProvidersExplore state (RichModel has no .available) 2026-06-29 22:40:39 -07:00
Hanzo AI 330c247bfb feat(console): polished Models + Providers on real rich catalog
- aicatalog: ONE source (/v1/pricing/models via /ai proxy) — 444 real models,
  15 real providers, with context/pricing/specs/tier/provider. No fabrication.
- Model Catalog: polished table (model+params, type, context, in/out $/Mtok,
  TRUE provider, live Available status) + stats bar. Fixes the qwen/glm 'Hanzo
  (Zen)' mislabel — real provider per model.
- Providers: Explore grid (action cards + real provider cards + stats), was
  '0 built-in'. /providers/manage keeps the custom-provider CRUD.
- Brand: our models show as 'Zen', never 'Hanzo (Zen)'. v0.7.18
2026-06-29 22:39:11 -07:00
Hanzo AI a3c625a1a8 feat(console): polished Models + Providers on real rich catalog
- aicatalog: ONE source (/v1/pricing/models via /ai proxy) — 444 real models,
  15 real providers, with context/pricing/specs/tier/provider. No fabrication.
- Model Catalog: polished table (model+params, type, context, in/out $/Mtok,
  TRUE provider, live Available status) + stats bar. Fixes the qwen/glm 'Hanzo
  (Zen)' mislabel — real provider per model.
- Providers: Explore grid (action cards + real provider cards + stats), was
  '0 built-in'. /providers/manage keeps the custom-provider CRUD.
- Brand: our models show as 'Zen', never 'Hanzo (Zen)'. v0.7.18
2026-06-29 22:39:11 -07:00
Hanzo AI 43062cfa5e Merge remote-tracking branch 'origin/fix/ai-surfaces-tenant-scope-rag' into feat/console2-complete-cloud-ux 2026-06-29 22:21:35 -07:00
Hanzo AI fabba89e5e Merge remote-tracking branch 'origin/fix/ai-surfaces-tenant-scope-rag' into feat/console2-complete-cloud-ux 2026-06-29 22:21:35 -07:00
Hanzo AI 8c5d4e5902 fix(console): add missing SidebarWallet import (v0.7.17 build) 2026-06-29 22:08:30 -07:00
Hanzo AI b2e66371c5 fix(console): add missing SidebarWallet import (v0.7.17 build) 2026-06-29 22:08:30 -07:00
Hanzo AI a382542fd7 feat(console): always-visible wallet (balance + top-up) bottom-left of sidebar
Pinned wallet widget on every page — per-tenant balance via the /billing proxy +
Top-up deep-links to billing.hanzo.ai/topup (pay.hanzo.ai Square/crypto checkout),
never rebuilds payment. Collapsed mode = wallet icon. v0.7.17.
TODO: promote to @hanzo/ui as a reusable cross-app component (one way, composable).
2026-06-29 22:07:12 -07:00
Hanzo AI 84ee8e9ce0 feat(console): always-visible wallet (balance + top-up) bottom-left of sidebar
Pinned wallet widget on every page — per-tenant balance via the /billing proxy +
Top-up deep-links to billing.hanzo.ai/topup (pay.hanzo.ai Square/crypto checkout),
never rebuilds payment. Collapsed mode = wallet icon. v0.7.17.
TODO: promote to @hanzo/ui as a reusable cross-app component (one way, composable).
2026-06-29 22:07:12 -07:00
Hanzo AI 92c8155ee9 fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval
The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.
2026-06-29 22:04:41 -07:00
Hanzo AI 94801921d4 fix(ai): tenant-scope Providers/Stores/Models/Apps to the org + restore RAG retrieval
The AI/data admin views (ProviderListView, StoreListView/EditView,
ModelRouteList/EditView, ApplicationListView) used account.name (the USERNAME)
or a hardcoded 'admin' as the casibase owner. casibase entities are org-owned:
get-* scopes to the session org (GetScopedOwner) and honors the owner param only
for global admins, and AddStore trusts the body owner. So a username owner broke
global-admin org switching and orphaned newly-created stores. Switch all six
call sites to currentOrg() — the one active org-scope value (also stamped as
X-Org-Id), matching the v0.7.0 org-as-a-value model.

Also stamp X-IAM-Org-Id alongside X-Org-Id in the cloud client: the casibase
header-scoped filters (GetEffectiveOrg: usage/vectors/activities) read
X-IAM-Org-Id, so org switching now re-scopes those too (honored only for the
principal's own org or a global admin — safe).

RAG: the keyless /ai proxy rebuilt upstream headers from scratch and dropped
X-Retrieval/X-Retrieval-Store, so AiApi.ragChat silently degraded to a plain
answer. Forward the allow-listed retrieval headers (extracted to the pure,
tested lib/server/ai-proxy). 4 new tests; typecheck clean, 52 tests pass.
2026-06-29 22:04:41 -07:00
Hanzo AI fb2a276496 chore(console): scrub upstream brand names from comments — 'admin' org + IAM, our way. v0.7.16 2026-06-29 21:59:21 -07:00
Hanzo AI cad8c5de92 chore(console): scrub upstream brand names from comments — 'admin' org + IAM, our way. v0.7.16 2026-06-29 21:59:21 -07:00
Hanzo AI 9b59decccf chore(console): standardize the global-admin org on 'admin' (drop casdoor built-in dual-recognition) — matches commerce/ai/gateway. v0.7.15 2026-06-29 21:57:24 -07:00
Hanzo AI 99f41eb993 chore(console): standardize the global-admin org on 'admin' (drop casdoor built-in dual-recognition) — matches commerce/ai/gateway. v0.7.15 2026-06-29 21:57:24 -07:00
Hanzo AI 9289698825 fix(console): admin.hanzo.ai = GLOBAL admins only (org admins were leaking in)
SECURITY: an org owner (Dave/maxpower, org-level isAdmin) could reach the admin UI.
- gateAllows (server authority for /admin/* proxies): require isGlobalAdmin, NOT
  isAdminGranted (which accepted org-level isAdmin). Verified brand-email stays as
  2nd factor. Org admins now 403 on admin ops even with @adminDomain email.
- OrgGate: redirect non-global-admins OFF admin.hanzo.ai to the console host +
  render-guard so the admin console never flashes. Banner already isGlobalAdmin.
- isGlobalAdmin now recognizes BOTH metadata orgs (admin + built-in), matching
  admin-policy ORG_METADATA_OWNERS.
Bundles v0.7.13 (models owner/name token fix + banner gate). v0.7.14.
2026-06-29 21:51:58 -07:00
Hanzo AI 427d44aac0 fix(console): admin.hanzo.ai = GLOBAL admins only (org admins were leaking in)
SECURITY: an org owner (Dave/maxpower, org-level isAdmin) could reach the admin UI.
- gateAllows (server authority for /admin/* proxies): require isGlobalAdmin, NOT
  isAdminGranted (which accepted org-level isAdmin). Verified brand-email stays as
  2nd factor. Org admins now 403 on admin ops even with @adminDomain email.
- OrgGate: redirect non-global-admins OFF admin.hanzo.ai to the console host +
  render-guard so the admin console never flashes. Banner already isGlobalAdmin.
- isGlobalAdmin now recognizes BOTH metadata orgs (admin + built-in), matching
  admin-policy ORG_METADATA_OWNERS.
Bundles v0.7.13 (models owner/name token fix + banner gate). v0.7.14.
2026-06-29 21:51:58 -07:00
Hanzo AI ad4d6ca8aa fix(console): models 'wrong token count' + admin banner leak to org admins
1. identity.ts: user.id must be <owner>/<name> (IAM GetOwnerAndNameFromId), not the
   bare casdoor UUID — fixes Model Catalog 'Could not authorize: wrong token count
   for ID <uuid>' for org-member accounts (Dave/maxpower) that carry an id field.
2. OrgGate.tsx: the admin.hanzo.ai ops banner gated on isAdmin (org-level) so an
   ORG owner (Dave/maxpower) saw it. Gate on isGlobalAdmin (admin/built-in org) —
   org admins are not cross-tenant admins. v0.7.13.
2026-06-29 21:46:56 -07:00
Hanzo AI ef12969400 fix(console): models 'wrong token count' + admin banner leak to org admins
1. identity.ts: user.id must be <owner>/<name> (IAM GetOwnerAndNameFromId), not the
   bare casdoor UUID — fixes Model Catalog 'Could not authorize: wrong token count
   for ID <uuid>' for org-member accounts (Dave/maxpower) that carry an id field.
2. OrgGate.tsx: the admin.hanzo.ai ops banner gated on isAdmin (org-level) so an
   ORG owner (Dave/maxpower) saw it. Gate on isGlobalAdmin (admin/built-in org) —
   org admins are not cross-tenant admins. v0.7.13.
2026-06-29 21:46:56 -07:00
Hanzo AI 790c65b951 feat(billing): per-tenant billing BFF — Wallet/Cost show real balance/usage/invoices
The one remaining cross-service gap (v0.7.x already proxies AI/IAM/KMS). wallet
still hit cookie-only /v1/billing/balance -> 404. New app/billing/[...path] proxy
forwards /billing/* -> commerce with the service token + server-resolved own-org
scope (X-Hanzo-Org + BillingSubject; client cannot widen). Matches the /admin/*
proxy pattern + resolveUser. Wallet.cloudBalance repointed same-origin. v0.7.12.
2026-06-29 21:31:25 -07:00
Hanzo AI 8fa299024d feat(billing): per-tenant billing BFF — Wallet/Cost show real balance/usage/invoices
The one remaining cross-service gap (v0.7.x already proxies AI/IAM/KMS). wallet
still hit cookie-only /v1/billing/balance -> 404. New app/billing/[...path] proxy
forwards /billing/* -> commerce with the service token + server-resolved own-org
scope (X-Hanzo-Org + BillingSubject; client cannot widen). Matches the /admin/*
proxy pattern + resolveUser. Wallet.cloudBalance repointed same-origin. v0.7.12.
2026-06-29 21:31:25 -07:00
hanzo-dev 55819f81d4 fix(console2): register @hanzogui/core config augmentation; tsc clean (0 errors)
gui.d.ts was augmenting @hanzogui/web which is not a direct dep (resolves only
via pnpm .pnpm path). Add @hanzogui/core augmentation (which is a direct dep) so
GuiCustomConfig → Conf flows through and shorthand props (bg/px/py/items/justify
etc.) are typed correctly. Also fix Button `color=` → `theme=` in OrgGate banner.

Before: 371 type errors. After: 0.
2026-06-29 13:56:38 -07:00
hanzo-dev dacef7c4ff fix(console2): register @hanzogui/core config augmentation; tsc clean (0 errors)
gui.d.ts was augmenting @hanzogui/web which is not a direct dep (resolves only
via pnpm .pnpm path). Add @hanzogui/core augmentation (which is a direct dep) so
GuiCustomConfig → Conf flows through and shorthand props (bg/px/py/items/justify
etc.) are typed correctly. Also fix Button `color=` → `theme=` in OrgGate banner.

Before: 371 type errors. After: 0.
2026-06-29 13:56:38 -07:00
hanzo-dev 76a4380d9e feat(console2): ProviderListView shows global built-in + per-org custom providers (v0.7.11)
List both get-global-providers (Hanzo platform-keyed, read-only, Built-in badge)
and per-org custom providers in one view. Users see all providers enabled out of
the box via Hanzo's DO-AI keys; adding a custom provider overrides/extends per org.
2026-06-29 13:53:37 -07:00
hanzo-dev dad3aafd0d feat(console2): ProviderListView shows global built-in + per-org custom providers (v0.7.11)
List both get-global-providers (Hanzo platform-keyed, read-only, Built-in badge)
and per-org custom providers in one view. Users see all providers enabled out of
the box via Hanzo's DO-AI keys; adding a custom provider overrides/extends per org.
2026-06-29 13:53:37 -07:00
hanzo-dev 6c745bc4dc fix(console2): OrgGate — admins get banner not hard-block; add Playwright e2e (v0.7.11)
- OrgGate: replace hard hard-block for hanzo-org users with a dismissible
  amber banner pointing at admin.hanzo.ai; staff can use console.hanzo.ai
  normally for cloud work (models, API keys, AI inference etc.)
- OrgGate: restore last selected org from localStorage on sign-in so the
  scope remembers where the user left off
- Add e2e/ Playwright tests: sign-in, admin banner, API key create/confirm,
  /v1/models verification, OpenAI + Anthropic inference tests
- Add playwright.config.ts targeting https://console.hanzo.ai by default
- package.json: add e2e + e2e:headed scripts
2026-06-29 13:50:10 -07:00
hanzo-dev ff0a02b5fd fix(console2): OrgGate — admins get banner not hard-block; add Playwright e2e (v0.7.11)
- OrgGate: replace hard hard-block for hanzo-org users with a dismissible
  amber banner pointing at admin.hanzo.ai; staff can use console.hanzo.ai
  normally for cloud work (models, API keys, AI inference etc.)
- OrgGate: restore last selected org from localStorage on sign-in so the
  scope remembers where the user left off
- Add e2e/ Playwright tests: sign-in, admin banner, API key create/confirm,
  /v1/models verification, OpenAI + Anthropic inference tests
- Add playwright.config.ts targeting https://console.hanzo.ai by default
- package.json: add e2e + e2e:headed scripts
2026-06-29 13:50:10 -07:00
zeekayandClaude Opus 4.8 8da1f80314 feat(console2): complete console IA parity port — feature-module shells (v0.7.10)
Port the remaining old-console surfaces into console2 as forward-compatible
modules: Zero Trust, Integrations, Referrals, Experiments, Dashboards,
Score Analytics, plus prompt-create/metrics and dataset items/runs sub-modules.

Each surface points at its real/planned /v1 endpoint and renders real rows when
present; a 404/405/503 collapses to the shared honest BackendState card (no demo
rows). console2 now carries the old console's full information architecture while
backend routes light up independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 01:13:14 -07:00
zeekayandhanzo-dev 25b7f473e4 feat(console2): complete console IA parity port — feature-module shells (v0.7.10)
Port the remaining old-console surfaces into console2 as forward-compatible
modules: Zero Trust, Integrations, Referrals, Experiments, Dashboards,
Score Analytics, plus prompt-create/metrics and dataset items/runs sub-modules.

Each surface points at its real/planned /v1 endpoint and renders real rows when
present; a 404/405/503 collapses to the shared honest BackendState card (no demo
rows). console2 now carries the old console's full information architecture while
backend routes light up independently.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-29 01:13:14 -07:00
zeekay 55409ef473 ci(console2): avoid Docker Hub base image pulls (v0.7.9) 2026-06-29 00:21:41 -07:00
zeekay 3516fe00c7 ci(console2): avoid Docker Hub base image pulls (v0.7.9) 2026-06-29 00:21:41 -07:00
zeekay ad5f082926 feat(console2): first-run org onboarding and waitlists (v0.7.8) 2026-06-29 00:19:12 -07:00
zeekay cfc6427790 feat(console2): first-run org onboarding and waitlists (v0.7.8) 2026-06-29 00:19:12 -07:00
hanzo-dev 88d4c68c2a ci(console2): raw buildx on host builder — fix Docker Hub 429 + artifact quota
Builds were red: docker/setup-buildx-action + docker/build-push-action
spin up an ephemeral buildkit container that can't see the host image
cache, so it re-pulled node:22-alpine from Docker Hub every run and hit
the unauthenticated 429 pull-rate limit; the action's build-summary
artifact upload also hit the Actions storage quota.

Switch to the canonical hanzoai/ci pattern: raw `docker buildx build
--push` on the host builder, which reuses the cached base layer (no
re-pull, no artifact upload). Adds a cache-aware base-image guard that
retries a cold pull with linear backoff. SEMVER tag + GHCR push unchanged.
2026-06-28 23:15:26 -07:00
hanzo-dev 213675e2ea ci(console2): raw buildx on host builder — fix Docker Hub 429 + artifact quota
Builds were red: docker/setup-buildx-action + docker/build-push-action
spin up an ephemeral buildkit container that can't see the host image
cache, so it re-pulled node:22-alpine from Docker Hub every run and hit
the unauthenticated 429 pull-rate limit; the action's build-summary
artifact upload also hit the Actions storage quota.

Switch to the canonical hanzoai/ci pattern: raw `docker buildx build
--push` on the host builder, which reuses the cached base layer (no
re-pull, no artifact upload). Adds a cache-aware base-image guard that
retries a cold pull with linear backoff. SEMVER tag + GHCR push unchanged.
2026-06-28 23:15:26 -07:00
hanzo-dev 3e0325bb14 feat(console2): scope the PaaS surface by org → project → environment
The /paas proxy forwarded only the god-mode service token, so PaaS
resources couldn't scope per tenant. Now it forwards the full tenant
path the browser stamps:

- X-Org-Id is RE-RESOLVED server-side via the admin policy (orgFor): a
  global admin's switched org (the X-Org-Id it already sends) is honored,
  a brand admin is pinned to their own — authoritative, never the raw
  spoofable claim. Same trust boundary as the IAM/KMS admin proxies.
- X-Project-Id + X-Environment pass through verbatim as sub-scopes within
  that org.

Forward-correct: the control plane scopes by tenant once it reads these,
harmless until then. Adds scope.test.ts (9 tests: store round-trip, merge
semantics, persistence, projectEnvironments intrinsic-3 + custom). 38 green.
2026-06-28 23:10:40 -07:00
hanzo-dev ee19949ae9 feat(console2): scope the PaaS surface by org → project → environment
The /paas proxy forwarded only the god-mode service token, so PaaS
resources couldn't scope per tenant. Now it forwards the full tenant
path the browser stamps:

- X-Org-Id is RE-RESOLVED server-side via the admin policy (orgFor): a
  global admin's switched org (the X-Org-Id it already sends) is honored,
  a brand admin is pinned to their own — authoritative, never the raw
  spoofable claim. Same trust boundary as the IAM/KMS admin proxies.
- X-Project-Id + X-Environment pass through verbatim as sub-scopes within
  that org.

Forward-correct: the control plane scopes by tenant once it reads these,
harmless until then. Adds scope.test.ts (9 tests: store round-trip, merge
semantics, persistence, projectEnvironments intrinsic-3 + custom). 38 green.
2026-06-28 23:10:40 -07:00
hanzo-dev ce67bb16e8 fix(console2): staff gate — collapse "admin. <domain>" gap + header "<Brand> Admin"
The staff redirect button passed two children ("Go to admin." + the
{adminDomain} expression); Tamagui Button lays children out with its
icon-gap, so the two text nodes rendered with a stray space
("Go to admin. hanzo.ai"). Collapse to a single template-literal child.
Header retitled "<Brand> staff" → "<Brand> Admin" (white-label via brand).
2026-06-28 23:07:32 -07:00
hanzo-dev 31fff74f35 fix(console2): staff gate — collapse "admin. <domain>" gap + header "<Brand> Admin"
The staff redirect button passed two children ("Go to admin." + the
{adminDomain} expression); Tamagui Button lays children out with its
icon-gap, so the two text nodes rendered with a stray space
("Go to admin. hanzo.ai"). Collapse to a single template-literal child.
Header retitled "<Brand> staff" → "<Brand> Admin" (white-label via brand).
2026-06-28 23:07:32 -07:00
hanzo-dev 75f78e1a17 feat(console2): org → project → environment multi-tenancy scope
Projects under the org, each with intrinsic mainnet/testnet/devnet
environments (+ custom), scoping every module at once.

- lib/scope.ts: module-level active { org, project?, environment } the
  non-React API client reads synchronously to stamp headers. Org pinned
  to the brand org; STOCK_ENVIRONMENTS = mainnet/testnet/devnet.
- lib/api/client.ts: baseHeaders stamps X-Project-Id (when a project is
  selected) + X-Environment on every cloud call — one change scopes all
  existing modules (o11y, api-keys, deploys, …) with no per-module edits.
- lib/api/projects.ts: ProjectApi over the REAL Hanzo IAM contract
  (/v1/iam/get-organization-projects | add-project | delete-project),
  keyed (owner, name) with indexed organization = the brand org.
- lib/scope-context.tsx: ScopeProvider — loads the org's projects once,
  holds the active selection, mirrors it into the module scope +
  localStorage, derives per-project environments. Honest: an unrouted
  projects endpoint resolves to org-level only, never a fabricated row.
- components/ScopeSwitcher.tsx: project + environment pickers in the top
  bar (next to OrgSwitcher); env dots keyed to network tier.
- components/products/ProjectsModule.tsx: projects CRUD + "Use" to set
  active scope; honest not-routed/empty states, no fakes.
- registry: Projects flips external → in-console module (Deploy).

Environments are a console-side scoping dimension (IAM Project has no
environments column); X-Environment is sent canonically for backend
adoption. tsc --noEmit clean.
2026-06-28 23:04:49 -07:00
hanzo-dev 260d46b20d feat(console2): org → project → environment multi-tenancy scope
Projects under the org, each with intrinsic mainnet/testnet/devnet
environments (+ custom), scoping every module at once.

- lib/scope.ts: module-level active { org, project?, environment } the
  non-React API client reads synchronously to stamp headers. Org pinned
  to the brand org; STOCK_ENVIRONMENTS = mainnet/testnet/devnet.
- lib/api/client.ts: baseHeaders stamps X-Project-Id (when a project is
  selected) + X-Environment on every cloud call — one change scopes all
  existing modules (o11y, api-keys, deploys, …) with no per-module edits.
- lib/api/projects.ts: ProjectApi over the REAL Hanzo IAM contract
  (/v1/iam/get-organization-projects | add-project | delete-project),
  keyed (owner, name) with indexed organization = the brand org.
- lib/scope-context.tsx: ScopeProvider — loads the org's projects once,
  holds the active selection, mirrors it into the module scope +
  localStorage, derives per-project environments. Honest: an unrouted
  projects endpoint resolves to org-level only, never a fabricated row.
- components/ScopeSwitcher.tsx: project + environment pickers in the top
  bar (next to OrgSwitcher); env dots keyed to network tier.
- components/products/ProjectsModule.tsx: projects CRUD + "Use" to set
  active scope; honest not-routed/empty states, no fakes.
- registry: Projects flips external → in-console module (Deploy).

Environments are a console-side scoping dimension (IAM Project has no
environments column); X-Environment is sent canonically for backend
adoption. tsc --noEmit clean.
2026-06-28 23:04:49 -07:00
zeekay 98a7088be7 feat(console2): models-first, docs-out, collapsible+filterable sidebar, cmd+K actions (v0.7.4)
Build Docker Image / docker (push) Successful in 3m5s
The 'I don't see any models' wave + sidebar/command UX.

- Models is catalog-first: the default tab is the LIVE model list (~49 Zen
  models via the /ai proxy); routing policy moves to a secondary 'Routing' tab
  (/models/routing). Retires the duplicate empty-by-default 'Model Catalog' nav
  entry — one product, real models on the obvious click.
- Docs are EXTERNAL (brand docsUrl, new tab): a top sidebar 'Docs' entry, the
  header '?' icon, and a server /docs -> docs.<brand> redirect (no in-app 404).
  ComingSoon's API-docs link repointed off the 404-ing ${cloudUrl}/docs.
- Sidebar collapses/expands from the brand 'H' mark (icon-only <-> full),
  persisted to the account; the grid launcher stays. A filter box narrows the
  whole sidebar to find any product fast; Overview/Docs are fixed top links.
- cmd+K runs ACTIONS too (toggle theme, browse all apps, open settings, switch
  org, ask AI / search docs, sign out, per-org switch), ranked alongside catalog
  nav — no dead entries. Removed unused openMode.
- DRY: switchOrg() shared by the switcher + palette; pure match-core (resolveRoute
  + entryMatches) is unit-tested without the GUI tree. Default pins fixed
  (dead 'billing' -> 'models','chat'). tsc + 29 vitest + next build clean.
2026-06-28 22:44:30 -07:00
zeekay 73673a4555 feat(console2): models-first, docs-out, collapsible+filterable sidebar, cmd+K actions (v0.7.4)
The 'I don't see any models' wave + sidebar/command UX.

- Models is catalog-first: the default tab is the LIVE model list (~49 Zen
  models via the /ai proxy); routing policy moves to a secondary 'Routing' tab
  (/models/routing). Retires the duplicate empty-by-default 'Model Catalog' nav
  entry — one product, real models on the obvious click.
- Docs are EXTERNAL (brand docsUrl, new tab): a top sidebar 'Docs' entry, the
  header '?' icon, and a server /docs -> docs.<brand> redirect (no in-app 404).
  ComingSoon's API-docs link repointed off the 404-ing ${cloudUrl}/docs.
- Sidebar collapses/expands from the brand 'H' mark (icon-only <-> full),
  persisted to the account; the grid launcher stays. A filter box narrows the
  whole sidebar to find any product fast; Overview/Docs are fixed top links.
- cmd+K runs ACTIONS too (toggle theme, browse all apps, open settings, switch
  org, ask AI / search docs, sign out, per-org switch), ranked alongside catalog
  nav — no dead entries. Removed unused openMode.
- DRY: switchOrg() shared by the switcher + palette; pure match-core (resolveRoute
  + entryMatches) is unit-tested without the GUI tree. Default pins fixed
  (dead 'billing' -> 'models','chat'). tsc + 29 vitest + next build clean.
2026-06-28 22:44:30 -07:00
zeekay 28c186b508 fix(console2): customer console scopes to the user's own org (v0.7.3)
The org-scope module defaults the active org to the brand org (for the cross-org
admin). On a customer host, seed the signed-in user's OWN org as the active scope
the first time (only when no explicit switch is in effect), so the OrgSwitcher
chip and X-Org-Id reflect the real tenant (e.g. maxpower) instead of the brand
org. Never clobbers an admin's deliberate switch.
2026-06-28 22:16:30 -07:00
zeekay ce0d18a0ac fix(console2): customer console scopes to the user's own org (v0.7.3)
The org-scope module defaults the active org to the brand org (for the cross-org
admin). On a customer host, seed the signed-in user's OWN org as the active scope
the first time (only when no explicit switch is in effect), so the OrgSwitcher
chip and X-Org-Id reflect the real tenant (e.g. maxpower) instead of the brand
org. Never clobbers an admin's deliberate switch.
2026-06-28 22:16:30 -07:00
zeekay bba56046e4 fix(console2): mask the password field on the sign-in form (v0.7.2)
The @hanzo/gui Input ignores the RN secureTextEntry/keyboardType props on web,
so the password rendered as type=text (visible while typing). Set the web input
type explicitly (type="password") so it masks; also corrects autoComplete to
current-password.
2026-06-28 21:58:56 -07:00
zeekay f946554575 fix(console2): mask the password field on the sign-in form (v0.7.2)
The @hanzo/gui Input ignores the RN secureTextEntry/keyboardType props on web,
so the password rendered as type=text (visible while typing). Set the web input
type explicitly (type="password") so it masks; also corrects autoComplete to
current-password.
2026-06-28 21:58:56 -07:00
zeekay 948a627c72 ci(console2): resolve semver tag without node (ARC runner has no node)
The build step used `node -p require('./package.json').version`; node is not on
the ARC runner PATH, so the substitution silently yielded the tag `:v` and the
operator deploy 404'd. Resolve the version with grep/sed and fail-loud if empty.
2026-06-28 21:45:33 -07:00
zeekay 491b5ac97c ci(console2): resolve semver tag without node (ARC runner has no node)
The build step used `node -p require('./package.json').version`; node is not on
the ARC runner PATH, so the substitution silently yielded the tag `:v` and the
operator deploy 404'd. Resolve the version with grep/sed and fail-loud if empty.
2026-06-28 21:45:33 -07:00
zeekay bdda312f5e feat(console2): multi-tenant login (org by email) + /paas admin gate (v0.7.1)
Login resolves the user's ORG from their email instead of pinning the brand's
own org: a customer in any org signs into the brand console with email+password.

- iam-login.ts: POST /v1/iam/login with organization: (cross-org email
  resolution) → OAuth code → existing completeSignIn → /v1/signin exchange.
  Replaces the SDK org-pinned redirect that made non-brand-org users
  un-signinable. MFA (NextMfa/RequiredMfa) hands off to IAM's same-site hosted
  flow — IAM sets iam_session_id SameSite=Lax, so cross-site fetch MFA can't
  complete here; no faked inline step.
- SignInForm: email+password state machine; social (getProviderSigninUrl) kept.
- OrgGate: the console runs in the user's CUSTOMER org — blocks the internal
  brand org (owner===config.iamOrgName → admin.<domain>) and the zero-org case.
- SECURITY /paas/[...path]: gate with getAdminGate (403 if not a brand admin) +
  runtime nodejs. The forwarded PAAS_SERVICE_TOKEN is control-plane god-mode and
  was previously reachable by any authenticated browser.
- getAdminGate: orgScope=user.owner (was brand.id) so the IAM/KMS proxies pin a
  non-global admin to their OWN org; require a VERIFIED email (authoritative IAM
  recheck for thin claims). admin-policy + tests updated (14 pass).
2026-06-28 21:37:42 -07:00
zeekay 92406357c7 feat(console2): multi-tenant login (org by email) + /paas admin gate (v0.7.1)
Login resolves the user's ORG from their email instead of pinning the brand's
own org: a customer in any org signs into the brand console with email+password.

- iam-login.ts: POST /v1/iam/login with organization: (cross-org email
  resolution) → OAuth code → existing completeSignIn → /v1/signin exchange.
  Replaces the SDK org-pinned redirect that made non-brand-org users
  un-signinable. MFA (NextMfa/RequiredMfa) hands off to IAM's same-site hosted
  flow — IAM sets iam_session_id SameSite=Lax, so cross-site fetch MFA can't
  complete here; no faked inline step.
- SignInForm: email+password state machine; social (getProviderSigninUrl) kept.
- OrgGate: the console runs in the user's CUSTOMER org — blocks the internal
  brand org (owner===config.iamOrgName → admin.<domain>) and the zero-org case.
- SECURITY /paas/[...path]: gate with getAdminGate (403 if not a brand admin) +
  runtime nodejs. The forwarded PAAS_SERVICE_TOKEN is control-plane god-mode and
  was previously reachable by any authenticated browser.
- getAdminGate: orgScope=user.owner (was brand.id) so the IAM/KMS proxies pin a
  non-global admin to their OWN org; require a VERIFIED email (authoritative IAM
  recheck for thin claims). admin-policy + tests updated (14 pass).
2026-06-28 21:37:42 -07:00
zeekay 4cfcd1f779 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	package-lock.json
#	package.json
2026-06-28 21:23:02 -07:00
zeekay aaa607e9d5 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	package-lock.json
#	package.json
2026-06-28 21:23:02 -07:00
zeekay b3fc76186f feat(console2): live admin data + org switching (v0.7.0)
Root cause of empty models + broken org switcher: /v1/{iam,kms,models} 404/401
(cookie-only) on the console host. Route every privileged call through console2's
own server proxies (user bearer + admin gate), and make org scope a switchable value.

- models-catalog: repoint /v1/models -> /ai proxy (shared aiV1Url in client.ts);
  catalog now populates with live Zen models. playground reuses the shared aiBase.
- org-scope.ts: currentOrg/setCurrentOrg/isScopedAway/filterOrgs. client.ts stamps
  X-Org-Id: currentOrg() so data modules re-scope on switch.
- OrgSwitcher: lists ALL visible orgs, filter box, in-place re-scope (set+reload).
- IamModule/AuditModule read currentOrg(); KmsModule now a names-only inventory
  over KmsAdminApi.list (the /admin/kms proxy), values never fetched.
- admin-policy.ts: pure gateAllows/ownerAllowed/orgFor extracted from the gate +
  routes (decomplect) so the shipped predicate is the tested one.
- vitest: 22 tests RED->GREEN (gate allow/deny+scoping, scope+filter, catalog /ai).
  tsc --noEmit + next build clean.
2026-06-28 21:19:50 -07:00
zeekay 1f86ab2eca feat(console2): live admin data + org switching (v0.7.0)
Root cause of empty models + broken org switcher: /v1/{iam,kms,models} 404/401
(cookie-only) on the console host. Route every privileged call through console2's
own server proxies (user bearer + admin gate), and make org scope a switchable value.

- models-catalog: repoint /v1/models -> /ai proxy (shared aiV1Url in client.ts);
  catalog now populates with live Zen models. playground reuses the shared aiBase.
- org-scope.ts: currentOrg/setCurrentOrg/isScopedAway/filterOrgs. client.ts stamps
  X-Org-Id: currentOrg() so data modules re-scope on switch.
- OrgSwitcher: lists ALL visible orgs, filter box, in-place re-scope (set+reload).
- IamModule/AuditModule read currentOrg(); KmsModule now a names-only inventory
  over KmsAdminApi.list (the /admin/kms proxy), values never fetched.
- admin-policy.ts: pure gateAllows/ownerAllowed/orgFor extracted from the gate +
  routes (decomplect) so the shipped predicate is the tested one.
- vitest: 22 tests RED->GREEN (gate allow/deny+scoping, scope+filter, catalog /ai).
  tsc --noEmit + next build clean.
2026-06-28 21:19:50 -07:00
hanzo-dev fecaf7d49d fix(console2): Pager props + O11yUser barrel export (tsc clean) 2026-06-28 20:53:43 -07:00
hanzo-dev b1ba5bd493 fix(console2): Pager props + O11yUser barrel export (tsc clean) 2026-06-28 20:53:43 -07:00
hanzo-dev 1d9c2a9225 feat(console2): port Observations + Users observability views from old console
Two more old-console pages as real o11y modules: Observations (the flat
cross-trace span/generation view, GET /v1/o11y/observations) and Users (per-user
analytics rollup, GET /v1/o11y/users). O11yApi extended with both endpoints +
the O11yUser type. Same paged DataTable + honest RuntimeNotice pattern as the
other o11y modules; registered under Observe. tsc clean.
2026-06-28 20:52:55 -07:00
hanzo-dev 0e1edbe2c9 feat(console2): port Observations + Users observability views from old console
Two more old-console pages as real o11y modules: Observations (the flat
cross-trace span/generation view, GET /v1/o11y/observations) and Users (per-user
analytics rollup, GET /v1/o11y/users). O11yApi extended with both endpoints +
the O11yUser type. Same paged DataTable + honest RuntimeNotice pattern as the
other o11y modules; registered under Observe. tsc clean.
2026-06-28 20:52:55 -07:00
hanzo-dev 7a3b13d55e feat(console2): build the 25 remaining products into real modules
Flip every 'soon' product (ComingSoon stub) to a real, enabled in-console module
— attestations/oracles/indexer/tokens/settlement, alerts/logs, pipelines/
releases/builds/environments, hsm/authz/service-mesh/load-balancer/vpc, jobs/
edge/functions/containers/machines/gpus, agents/inference/finetuning. Each
follows the established module pattern: typed restGet over the same-origin /paas
proxy, DataTable, honest interpretPlatformError→PlatformStateCard not-configured
state, Refresh. Registry: status soon→enabled + routes wired. Full tsc clean.

Catalog is now 61 enabled modules + 16 external; no 'soon' stubs remain.
2026-06-28 20:06:44 -07:00
hanzo-dev 4543c19271 feat(console2): build the 25 remaining products into real modules
Flip every 'soon' product (ComingSoon stub) to a real, enabled in-console module
— attestations/oracles/indexer/tokens/settlement, alerts/logs, pipelines/
releases/builds/environments, hsm/authz/service-mesh/load-balancer/vpc, jobs/
edge/functions/containers/machines/gpus, agents/inference/finetuning. Each
follows the established module pattern: typed restGet over the same-origin /paas
proxy, DataTable, honest interpretPlatformError→PlatformStateCard not-configured
state, Refresh. Registry: status soon→enabled + routes wired. Full tsc clean.

Catalog is now 61 enabled modules + 16 external; no 'soon' stubs remain.
2026-06-28 20:06:44 -07:00
z bc7f92665e docs(brand): add hero banner 2026-06-28 20:05:13 -07:00
z a9765ec699 docs(brand): add hero banner 2026-06-28 20:05:13 -07:00
z 509e511b6e chore(brand): dynamic hero banner 2026-06-28 20:05:12 -07:00
z 47a4c5ed61 chore(brand): dynamic hero banner 2026-06-28 20:05:12 -07:00
hanzo-dev ab9f8751b7 feat: brand-aware animated logo in console loader/sign-in
The mark now follows config.brand — each brand's OWN published, interactive
animated SVG (@hanzo/logo, @luxfi/logo, @zooai/logo: getAnimatedSVG, load→hover→
press). Static block-H during SSR/first paint (no hydration mismatch). The logo
is now the brand's playable 'AI' on every loading + sign-in surface.
2026-06-28 19:49:01 -07:00
hanzo-dev 5b1209bf4a feat: brand-aware animated logo in console loader/sign-in
The mark now follows config.brand — each brand's OWN published, interactive
animated SVG (@hanzo/logo, @luxfi/logo, @zooai/logo: getAnimatedSVG, load→hover→
press). Static block-H during SSR/first paint (no hydration mismatch). The logo
is now the brand's playable 'AI' on every loading + sign-in surface.
2026-06-28 19:49:01 -07:00
zeekay 5ea5cde253 feat(console2): add MPC product (Security) so console.hanzo.ai shows IAM/KMS/MPC/PaaS
MPC = threshold signing & multi-party computation, external→mpc.hanzo.ai.
Completes the 4 platform apps the launcher surfaces: IAM (module), KMS (module),
MPC (external), PaaS (projects→platform.hanzo.ai, embedded PlatformModule).
Verified locally: dev recompiled clean, tsc --noEmit clean.
2026-06-28 19:26:29 -07:00
zeekay b45cd7234d feat(console2): add MPC product (Security) so console.hanzo.ai shows IAM/KMS/MPC/PaaS
MPC = threshold signing & multi-party computation, external→mpc.hanzo.ai.
Completes the 4 platform apps the launcher surfaces: IAM (module), KMS (module),
MPC (external), PaaS (projects→platform.hanzo.ai, embedded PlatformModule).
Verified locally: dev recompiled clean, tsc --noEmit clean.
2026-06-28 19:26:29 -07:00
zeekay f28d79673a wip(console2): agent admin-console IAM/KMS savepoint (proxy+gate+branding, in progress) 2026-06-28 19:20:38 -07:00
zeekay 5a2573d1dc wip(console2): agent admin-console IAM/KMS savepoint (proxy+gate+branding, in progress) 2026-06-28 19:20:38 -07:00
zeekay 40a6236aa3 fix(console2): API keys — drop reload() that unmounted the view via AuthGate (v0.6.1)
Build Docker Image / docker (push) Successful in 2m37s
The mint/revoke success path called useSession().reload(), which flips the
session loading flag -> AuthGate renders its Loader -> the dashboard (and
ApiKeysView) unmounts -> the freshly-minted key (local newKey state) is lost on
remount, so 'Your new API key' never showed. The session masks accessKey anyway,
so the reload provided no benefit. Show the new key from local state only.

Verified live (v0.6.0): /keys POST 200 + /ai chat proxy 200 (real 'PONG'/Hanzo
answer); only the keys UI render was affected.
2026-06-28 18:54:02 -07:00
zeekay 092829bb0d fix(console2): API keys — drop reload() that unmounted the view via AuthGate (v0.6.1)
The mint/revoke success path called useSession().reload(), which flips the
session loading flag -> AuthGate renders its Loader -> the dashboard (and
ApiKeysView) unmounts -> the freshly-minted key (local newKey state) is lost on
remount, so 'Your new API key' never showed. The session masks accessKey anyway,
so the reload provided no benefit. Show the new key from local state only.

Verified live (v0.6.0): /keys POST 200 + /ai chat proxy 200 (real 'PONG'/Hanzo
answer); only the keys UI render was affected.
2026-06-28 18:54:02 -07:00
zeekay 97c385f5ca feat(console2): working AI (keyless proxy) + API keys + chrome polish (v0.6.0)
Build Docker Image / docker (push) Successful in 2m25s
P0 — make it work for Dave:
- Root cause of 'chats/playground don't work': /v1/chat/completions requires
  Authorization: Bearer; the browser sent cookie-only -> rejected. Fixed with a
  keyless server-side AI proxy (app/ai/[...path]) that mints a short-lived
  user-bound IAM token (issue-user-token, hanzo-console app-on-behalf) and
  forwards to the gateway. No key in the browser, no rotation per turn.
- API keys: app/keys route mints/rotates/revokes the per-user hk- key via IAM
  (mint-user-keys/revoke-user-keys); ApiKeysModule is now create/copy/rotate/
  revoke with show-once secret handling.
- Chat is interactive (ChatConversation) over AiApi.chat; honest 402 billing state.
- src/lib/server/identity.ts: server-only trust boundary (resolveUser + IAM ops).

P1 — chrome polish:
- Header/sidebar show the Hanzo H mark + 'Console' (HanzoMark, BrandLogo with
  org-logo fallback).
- Fullscreen Launchpad-style app launcher (AppLauncher) with filter, from the
  header 'Apps' button, sidebar grid icon, and the command palette.
- cmd+K palette gains a 'Browse all apps' affordance.

Verified end-to-end (curl, real creds): minted hk- key + issued user JWT both
200 on api.hanzo.ai/v1/chat/completions. typecheck + next build clean.
2026-06-28 18:19:38 -07:00
zeekay 5e088557d0 feat(console2): working AI (keyless proxy) + API keys + chrome polish (v0.6.0)
P0 — make it work for Dave:
- Root cause of 'chats/playground don't work': /v1/chat/completions requires
  Authorization: Bearer; the browser sent cookie-only -> rejected. Fixed with a
  keyless server-side AI proxy (app/ai/[...path]) that mints a short-lived
  user-bound IAM token (issue-user-token, hanzo-console app-on-behalf) and
  forwards to the gateway. No key in the browser, no rotation per turn.
- API keys: app/keys route mints/rotates/revokes the per-user hk- key via IAM
  (mint-user-keys/revoke-user-keys); ApiKeysModule is now create/copy/rotate/
  revoke with show-once secret handling.
- Chat is interactive (ChatConversation) over AiApi.chat; honest 402 billing state.
- src/lib/server/identity.ts: server-only trust boundary (resolveUser + IAM ops).

P1 — chrome polish:
- Header/sidebar show the Hanzo H mark + 'Console' (HanzoMark, BrandLogo with
  org-logo fallback).
- Fullscreen Launchpad-style app launcher (AppLauncher) with filter, from the
  header 'Apps' button, sidebar grid icon, and the command palette.
- cmd+K palette gains a 'Browse all apps' affordance.

Verified end-to-end (curl, real creds): minted hk- key + issued user JWT both
200 on api.hanzo.ai/v1/chat/completions. typecheck + next build clean.
2026-06-28 18:19:38 -07:00
zeekay 7cf8015c07 chore(console2): remove unused lib/zap client + @zap-proto deps
Zero importers (grep lib/zap = 0). Drops src/lib/zap/* and the
@zap-proto/{web,zap} deps. typecheck + build clean. One-way hygiene.
2026-06-28 05:45:21 -07:00
zeekay 9797823d81 chore(console2): remove unused lib/zap client + @zap-proto deps
Zero importers (grep lib/zap = 0). Drops src/lib/zap/* and the
@zap-proto/{web,zap} deps. typecheck + build clean. One-way hygiene.
2026-06-28 05:45:21 -07:00
zandGitHub 5874ec5acc Merge pull request #4 from hanzoai/feat/memory-tasks-modules
feat: Memory + Tasks modules (v0.5.0) — unify work/tasks/memory in the console
2026-06-27 22:16:00 -07:00
zandGitHub 6e3001fc60 Merge pull request #4 from hanzoai/feat/memory-tasks-modules
feat: Memory + Tasks modules (v0.5.0) — unify work/tasks/memory in the console
2026-06-27 22:16:00 -07:00
zeekay 01844deb23 catalog: Tasks under appended Async category (Tasks/Temporal)
Async is the canonical home for Tasks per the org taxonomy. Appended as an 11th
category so the ten GCP-equivalent groups keep their exact labels and order
(no reorder); catalogByCategory is data-driven, so it renders generically.
2026-06-27 22:11:27 -07:00
zeekay ee44956182 catalog: Tasks under appended Async category (Tasks/Temporal)
Async is the canonical home for Tasks per the org taxonomy. Appended as an 11th
category so the ten GCP-equivalent groups keep their exact labels and order
(no reorder); catalogByCategory is data-driven, so it renders generically.
2026-06-27 22:11:27 -07:00
zeekay f7a90ffe65 release: v0.5.0 — Memory + Tasks modules 2026-06-27 22:06:52 -07:00
zeekay beb58930b1 release: v0.5.0 — Memory + Tasks modules 2026-06-27 22:06:52 -07:00
zeekay 7f7a9341da feat(console): Memory + Tasks modules in the unified catalog
Memory (Data): list + search (kind filter + text) + detail/edit + add/delete,
honest 'initializing' card until /v1/memory deploys. Tasks (Compute, GCP Cloud
Tasks): namespace selector + Workflows/Schedules tabs + cluster strip, workflow
detail + durable history, on the LIVE /v1/tasks engine. Both render honest
states on a gated/absent route; mutations report through the shared toast.
Entries appended (no reorder); grouped by category.
2026-06-27 22:06:48 -07:00
zeekay ae20b80f16 feat(console): Memory + Tasks modules in the unified catalog
Memory (Data): list + search (kind filter + text) + detail/edit + add/delete,
honest 'initializing' card until /v1/memory deploys. Tasks (Compute, GCP Cloud
Tasks): namespace selector + Workflows/Schedules tabs + cluster strip, workflow
detail + durable history, on the LIVE /v1/tasks engine. Both render honest
states on a gated/absent route; mutations report through the shared toast.
Entries appended (no reorder); grouped by category.
2026-06-27 22:06:48 -07:00
zeekay 13978c0ca9 feat(api): Memory + Tasks /v1 clients
Memory (hanzoai/ai /v1/memory): remember/search/list/recall/update/remove/facts
over restGet/restPost; per-user, server-scoped. Tasks (hanzoai/tasks /v1/tasks):
namespaces/workflows/workflow/history/schedules + cluster health, contract
verified against pkg/tasks/embed.go. Both plain-REST, cookie-credentialed; org
scoping is server-side.
2026-06-27 22:06:41 -07:00
zeekay fee54d2d4e feat(api): Memory + Tasks /v1 clients
Memory (hanzoai/ai /v1/memory): remember/search/list/recall/update/remove/facts
over restGet/restPost; per-user, server-scoped. Tasks (hanzoai/tasks /v1/tasks):
namespaces/workflows/workflow/history/schedules + cluster health, contract
verified against pkg/tasks/embed.go. Both plain-REST, cookie-credentialed; org
scoping is server-side.
2026-06-27 22:06:41 -07:00
zandGitHub d564d3c488 Merge pull request #3 from hanzoai/feat/shell-foundation
feat: shell foundation v0.4.0 — cmd+K, AI assist, toasts, theme, org-switcher, breadcrumbs
2026-06-27 21:47:44 -07:00
zandGitHub 0157474784 Merge pull request #3 from hanzoai/feat/shell-foundation
feat: shell foundation v0.4.0 — cmd+K, AI assist, toasts, theme, org-switcher, breadcrumbs
2026-06-27 21:47:44 -07:00
zeekay bd933e5984 chore: v0.4.0 2026-06-27 21:45:52 -07:00
zeekay c4892e1330 chore: v0.4.0 2026-06-27 21:45:52 -07:00
zeekay c0da836191 feat(shell): mount the foundation blocks once in the dashboard shell
Dashboard layout wraps the shell in ToastProvider + CommandPaletteProvider. The
shell top bar gains the command search box (opens the palette), theme toggle, a
help launcher (opens the palette in docs mode), and the org switcher; a breadcrumb
bar sits below it. ResourceModule create/delete now report through the toast, so
the one feedback primitive is actually used.
2026-06-27 21:45:48 -07:00
zeekay 91cacc21c8 feat(shell): mount the foundation blocks once in the dashboard shell
Dashboard layout wraps the shell in ToastProvider + CommandPaletteProvider. The
shell top bar gains the command search box (opens the palette), theme toggle, a
help launcher (opens the palette in docs mode), and the org switcher; a breadcrumb
bar sits below it. ResourceModule create/delete now report through the toast, so
the one feedback primitive is actually used.
2026-06-27 21:45:48 -07:00
zeekay abb45c9481 feat(console): command palette + org switcher
CommandPalette is ONE command surface (Cmd/Ctrl+K) with modes selected by the
query: default fuzzy-filters the catalog and jumps to any product; > asks the AI
to find a product (NAV <id>) or answer; ? asks the docs knowledge store (RAG).
searchCatalog is the dependency-free fuzzy ranker. OrgSwitcher shows the account's
org and, only for a real multi-brand membership, switches to that brand's console
host. Honest throughout — AI/RAG/IAM failures degrade to truthful states.
2026-06-27 21:45:41 -07:00
zeekay 661384c729 feat(console): command palette + org switcher
CommandPalette is ONE command surface (Cmd/Ctrl+K) with modes selected by the
query: default fuzzy-filters the catalog and jumps to any product; > asks the AI
to find a product (NAV <id>) or answer; ? asks the docs knowledge store (RAG).
searchCatalog is the dependency-free fuzzy ranker. OrgSwitcher shows the account's
org and, only for a real multi-brand membership, switches to that brand's console
host. Honest throughout — AI/RAG/IAM failures degrade to truthful states.
2026-06-27 21:45:41 -07:00
zeekay a09e9483c8 feat(ui): toast, theme toggle, breadcrumbs primitives
Toast is the one feedback primitive — ToastProvider + useToast() with a portalled
top-right viewport, theme-aware accents, auto-dismiss. ThemeToggle flips dark/light
via next-theme. Breadcrumbs derive Home/Category/Product/detail from the route +
catalog, so a new product gets correct crumbs for free. GUI primitives only.
2026-06-27 21:45:35 -07:00
zeekay 72c36280dd feat(ui): toast, theme toggle, breadcrumbs primitives
Toast is the one feedback primitive — ToastProvider + useToast() with a portalled
top-right viewport, theme-aware accents, auto-dismiss. ThemeToggle flips dark/light
via next-theme. Breadcrumbs derive Home/Category/Product/detail from the route +
catalog, so a new product gets correct crumbs for free. GUI primitives only.
2026-06-27 21:45:35 -07:00
zeekay 5a61a9571c feat(api): one AI client over the cloud /v1 (chat + ragChat docs + listModels)
AiApi composes the single OpenAI-compatible gateway binding (PlaygroundApi):
plain chat, listModels, and ragChat grounded in a knowledge store (docs by
default) via the built-in retrieval path — the X-Retrieval-Store header turns
on RAG and names the store; the org owner is resolved server-side. restPost and
PlaygroundApi.chat gain an optional headers arg so RAG and plain chat share ONE
binding. No parallel AI client; failures throw ApiError for honest states.
2026-06-27 21:45:29 -07:00
zeekay 886823da68 feat(api): one AI client over the cloud /v1 (chat + ragChat docs + listModels)
AiApi composes the single OpenAI-compatible gateway binding (PlaygroundApi):
plain chat, listModels, and ragChat grounded in a knowledge store (docs by
default) via the built-in retrieval path — the X-Retrieval-Store header turns
on RAG and names the store; the org owner is resolved server-side. restPost and
PlaygroundApi.chat gain an optional headers arg so RAG and plain chat share ONE
binding. No parallel AI client; failures throw ApiError for honest states.
2026-06-27 21:45:29 -07:00
zandGitHub 7b9a02ab9a Merge pull request #2 from hanzoai/feat/billing-per-brand-url
Per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
2026-06-27 20:01:54 -07:00
zandGitHub 0d198fb420 Merge pull request #2 from hanzoai/feat/billing-per-brand-url
Per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
2026-06-27 20:01:54 -07:00
zeekay 03d80356bf feat(config): per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
billingUrl moves from SHARED to the per-brand BRANDS table, resolved like
iamUrl. Each brand's console (Cost product, PlansModule manage-billing) links
to ITS billing host, scoped to ITS org by the brand JWT. Cloud backend stays
shared/multi-tenant.
2026-06-27 19:24:08 -07:00
zeekay 08c2f31bad feat(config): per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
billingUrl moves from SHARED to the per-brand BRANDS table, resolved like
iamUrl. Each brand's console (Cost product, PlansModule manage-billing) links
to ITS billing host, scoped to ITS org by the brand JWT. Cloud backend stays
shared/multi-tenant.
2026-06-27 19:24:08 -07:00
zeekay 35e0ea4c30 merge: page-port wave 2 (trace-graph tree, score-configs, annotation-queues) — v0.3.0
Build Docker Image / docker (push) Successful in 2m25s
2026-06-27 18:52:04 -07:00
zeekay ba1d745b8f merge: page-port wave 2 (trace-graph tree, score-configs, annotation-queues) — v0.3.0 2026-06-27 18:52:04 -07:00
zeekay cf44eff091 feat(console2): port-wave2 — span tree, score-configs, annotation-queues (v0.3.0)
Native @hanzo/gui modules over the REAL /v1/o11y REST surface; honest
RuntimeNotice on 503/404 — no fabricated data.

- Traces detail: flat observations table -> nested span TREE waterfall
  (SpanTree) built from parentObservationId, with a Tree|Table toggle.
  Orphans/cycles surface as roots so every observation appears once.
- Score Configs: new Observe module on /v1/o11y/score-configs (read-only).
- Annotation Queues: new Observe module on /v1/o11y/annotation-queues
  (real public REST list — contradicts the earlier 'no backend' finding).

Skipped (honest, no usable Hanzo backend or would duplicate/fake):
dashboards/widgets + score-analytics (no saved-dashboard REST, charts
would be fabricated), llm-connections/mcp (covered by Providers/ModelCatalog),
agents (backend not routed at gateway — real paths 404), org/projects deep
settings (already covered by Settings + IAM, no write endpoints), and the
Langfuse integration surfaces (feature-flags/entitlements/automations/
batch-exports/developer-tools/slack/mixpanel/blobstorage).

catalog 73 -> 75 entries; appended only (no reorder).
2026-06-27 18:44:19 -07:00
zeekay 7a139276ee feat(console2): port-wave2 — span tree, score-configs, annotation-queues (v0.3.0)
Native @hanzo/gui modules over the REAL /v1/o11y REST surface; honest
RuntimeNotice on 503/404 — no fabricated data.

- Traces detail: flat observations table -> nested span TREE waterfall
  (SpanTree) built from parentObservationId, with a Tree|Table toggle.
  Orphans/cycles surface as roots so every observation appears once.
- Score Configs: new Observe module on /v1/o11y/score-configs (read-only).
- Annotation Queues: new Observe module on /v1/o11y/annotation-queues
  (real public REST list — contradicts the earlier 'no backend' finding).

Skipped (honest, no usable Hanzo backend or would duplicate/fake):
dashboards/widgets + score-analytics (no saved-dashboard REST, charts
would be fabricated), llm-connections/mcp (covered by Providers/ModelCatalog),
agents (backend not routed at gateway — real paths 404), org/projects deep
settings (already covered by Settings + IAM, no write endpoints), and the
Langfuse integration surfaces (feature-flags/entitlements/automations/
batch-exports/developer-tools/slack/mixpanel/blobstorage).

catalog 73 -> 75 entries; appended only (no reorder).
2026-06-27 18:44:19 -07:00
Hanzo AI e3c4d52bb4 build: cap Node heap (NODE_OPTIONS=6144) to fix Next build OOMKill (exit 137) 2026-06-27 18:35:53 -07:00
Hanzo AI e54f014bf8 build: cap Node heap (NODE_OPTIONS=6144) to fix Next build OOMKill (exit 137) 2026-06-27 18:35:53 -07:00
zeekay da83b67e89 release: v0.2.0 — consolidate page-port wave 1 (o11y traces/sessions/scores, playground/evals/datasets/prompts, settings/models/api-keys)
Build Docker Image / docker (push) Successful in 2m45s
Merges port-{settings-models,prompts-evals,o11y-core}. 73 catalog entries across the 10 categories; new modules wired to real /v1 backends with honest states (o11y 503 until runtime init). tsc + next build green.
2026-06-27 18:19:45 -07:00
zeekay ea21bf8c78 release: v0.2.0 — consolidate page-port wave 1 (o11y traces/sessions/scores, playground/evals/datasets/prompts, settings/models/api-keys)
Merges port-{settings-models,prompts-evals,o11y-core}. 73 catalog entries across the 10 categories; new modules wired to real /v1 backends with honest states (o11y 503 until runtime init). tsc + next build green.
2026-06-27 18:19:45 -07:00
zeekay a13de116e8 merge: port-o11y-core (Traces/Sessions/Scores)
# Conflicts:
#	src/lib/api/index.ts
#	src/lib/products/registry.tsx
2026-06-27 18:17:56 -07:00
zeekay 217b778a98 merge: port-o11y-core (Traces/Sessions/Scores)
# Conflicts:
#	src/lib/api/index.ts
#	src/lib/products/registry.tsx
2026-06-27 18:17:56 -07:00
zeekay 725beb1d98 merge: port-prompts-evals (page-port wave 1)
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-27 18:17:16 -07:00
zeekay 5540f1d493 merge: port-prompts-evals (page-port wave 1)
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-27 18:17:16 -07:00
zeekay 21c1016292 merge: port-settings-models (page-port wave 1) 2026-06-27 18:16:09 -07:00
zeekay f3679ad6ff merge: port-settings-models (page-port wave 1) 2026-06-27 18:16:09 -07:00
Hanzo AI bc76e6fc76 release: v0.1.9 — REST client fix over v0.1.8 (working REST, not dead ZAP /zap WS) 2026-06-27 18:10:23 -07:00
Hanzo AI 05e8d5a6d9 release: v0.1.9 — REST client fix over v0.1.8 (working REST, not dead ZAP /zap WS) 2026-06-27 18:10:23 -07:00
Hanzo AI 24f9b14e69 fix(providers): use working REST client, not the dead ZAP /zap WS
ProviderListView/ProviderEditView imported ~/lib/zap, but the cloud /zap
WebSocket face is not served (edge returns SPA HTML, not a WS upgrade — per
lib/zap/client.ts LIVE STATUS), so Providers rendered 'Failed to load
providers'. Switch both to ~/lib/api (identical surface). Part of v0.1.8.
2026-06-27 17:46:04 -07:00
Hanzo AI 2458a03cb7 fix(providers): use working REST client, not the dead ZAP /zap WS
ProviderListView/ProviderEditView imported ~/lib/zap, but the cloud /zap
WebSocket face is not served (edge returns SPA HTML, not a WS upgrade — per
lib/zap/client.ts LIVE STATUS), so Providers rendered 'Failed to load
providers'. Switch both to ~/lib/api (identical surface). Part of v0.1.8.
2026-06-27 17:46:04 -07:00
zeekay ac0ecfcb84 feat(console2): port prompts/playground/datasets/evals as native @hanzo/gui modules
Port the old console (Langfuse-fork) eval surfaces into console2 as native
modules on @hanzo/gui + @hanzogui/lucide-icons-2 — no antd/shadcn/tremor.
Wired to the REAL cloud /v1 backend; honest loading/empty/unavailable states
on 404/503 (NEVER fabricated prompts/datasets/scores).

- Playground (AI): GET /v1/models + POST /v1/chat/completions — fully working
  model run (system prompt, message thread, sampling params, token usage).
- Evals (Observe): POST /v1/evals/runs (real per-item run summary) +
  GET /v1/evals/scores (real scores list), Run/Scores tabs.
- Datasets (Observe): POST /v1/evals/datasets + /v1/evals/dataset-items (real
  create); forward-compatible GET list with honest unavailable card.
- Prompts (AI): forward-compatible GET /v1/prompts probe; honest deep-link card
  (no /v1 prompts route mounted yet).

Shared: ModelPicker (one way to pick a gateway model), BackendState (honest
/v1 error → card). API: lib/api/{playground,evals}.ts via the REST client.
Registry: playground+evals stubs upgraded in place (no reorder); prompts+
datasets appended. tsc --noEmit + next build both green.
2026-06-27 17:43:45 -07:00
zeekay 38a00963ec feat(console2): port prompts/playground/datasets/evals as native @hanzo/gui modules
Port the old console (Langfuse-fork) eval surfaces into console2 as native
modules on @hanzo/gui + @hanzogui/lucide-icons-2 — no antd/shadcn/tremor.
Wired to the REAL cloud /v1 backend; honest loading/empty/unavailable states
on 404/503 (NEVER fabricated prompts/datasets/scores).

- Playground (AI): GET /v1/models + POST /v1/chat/completions — fully working
  model run (system prompt, message thread, sampling params, token usage).
- Evals (Observe): POST /v1/evals/runs (real per-item run summary) +
  GET /v1/evals/scores (real scores list), Run/Scores tabs.
- Datasets (Observe): POST /v1/evals/datasets + /v1/evals/dataset-items (real
  create); forward-compatible GET list with honest unavailable card.
- Prompts (AI): forward-compatible GET /v1/prompts probe; honest deep-link card
  (no /v1 prompts route mounted yet).

Shared: ModelPicker (one way to pick a gateway model), BackendState (honest
/v1 error → card). API: lib/api/{playground,evals}.ts via the REST client.
Registry: playground+evals stubs upgraded in place (no reorder); prompts+
datasets appended. tsc --noEmit + next build both green.
2026-06-27 17:43:45 -07:00
zeekay e547011c96 feat(console2): native Traces/Sessions/Scores modules under Observe
Port the old console's observability core (Langfuse-shaped) to native
@hanzo/gui modules:
- Traces: list + detail (overview, I/O, observations, scores) at /o11y
- Sessions: list + detail (overview + traces) at /sessions
- Scores: list at /scores

All read the REAL /v1/o11y endpoints and render honest states (loading /
runtime-initializing / empty) on 503/404 — never fabricated traces or
charts. Replaces the Traces deep-link placeholder (ObservabilityModule
removed); appends Sessions + Scores entries (no reorder of existing).
tsc --noEmit and next build both pass.
2026-06-27 17:43:28 -07:00
zeekay dd933b34de feat(console2): native Traces/Sessions/Scores modules under Observe
Port the old console's observability core (Langfuse-shaped) to native
@hanzo/gui modules:
- Traces: list + detail (overview, I/O, observations, scores) at /o11y
- Sessions: list + detail (overview + traces) at /sessions
- Scores: list at /scores

All read the REAL /v1/o11y endpoints and render honest states (loading /
runtime-initializing / empty) on 503/404 — never fabricated traces or
charts. Replaces the Traces deep-link placeholder (ObservabilityModule
removed); appends Sessions + Scores entries (no reorder of existing).
tsc --noEmit and next build both pass.
2026-06-27 17:43:28 -07:00
zeekay 164f073b50 feat(console2): shared observability primitives
DRY pieces for the o11y surfaces: pure formatters (date/latency/cost/
score value/JSON), shared detail parts (DetailRow/Badge/Tags/JsonCard),
the honest RuntimeNotice (503 not-initialized / 404 unrouted / access /
error), and the list Pager. No fabricated data.
2026-06-27 17:43:16 -07:00
zeekay 64f31ca1b2 feat(console2): shared observability primitives
DRY pieces for the o11y surfaces: pure formatters (date/latency/cost/
score value/JSON), shared detail parts (DetailRow/Badge/Tags/JsonCard),
the honest RuntimeNotice (503 not-initialized / 404 unrouted / access /
error), and the list Pager. No fabricated data.
2026-06-27 17:43:16 -07:00
zeekay 6df405adcd feat(console2): o11y API client (traces/sessions/scores)
Typed client for the /v1/o11y surface over the plain-REST transport
(restGet/v1Url): list endpoints return { data, meta }, detail endpoints
one object. 503/404 surface as typed ApiError so callers render honest
states. Tenancy is server-side (cookie credentials only).
2026-06-27 17:43:16 -07:00
zeekay e0cc8757df feat(console2): o11y API client (traces/sessions/scores)
Typed client for the /v1/o11y surface over the plain-REST transport
(restGet/v1Url): list endpoints return { data, meta }, detail endpoints
one object. 503/404 surface as typed ApiError so callers render honest
states. Tenancy is server-side (cookie credentials only).
2026-06-27 17:43:16 -07:00
zeekay f50dbdd9a6 feat(console2): port settings + model catalog pages as native @hanzo/gui modules
Port hanzoai/console's deep settings + models pages into console2 as native
modules wired to the REAL /v1 + /v1/iam endpoints. Honest loading/404/401/503
states everywhere — never fabricates keys, models, or settings.

New modules (registry, appended — no reorder):
- Model Catalog (AI): real GET /v1/models + best-effort /v1/pricing/models
  overlay; provider filter, premium tier, $/Mtok columns. Read-only catalog
  (routing lives in Models, credentials in Providers).
- API Keys (Dev): the account's real cloud credential (accessKey/accessSecret
  from get-account), masked by default with explicit reveal + copy; honest
  "managed by gateway" state when no key material is exposed to the browser.
- Settings (Security): tabbed General / API Keys / Members / Branding. General
  reads real account (get-account) + org (/v1/iam/get-organization); Members
  reads /v1/iam/get-users; API Keys embeds the shared ApiKeysView (DRY);
  Branding shows the real per-host runtime config. Identity mutations deep-link
  to IAM rather than being re-implemented.

Shared + API:
- ui/States.tsx: one honest async-state renderer (honestError + ErrorState +
  asApiError) with per-surface copy overrides. AdminModule refactored onto it
  (removes its duplicate honestError/ErrorCard).
- api/models-catalog.ts: CloudModelApi over the REST (non-envelope) /v1/models.
- api/admin.ts: IamAdminApi.organization(name) single-org getter.

Shell: top-bar account name now links to /settings (canonical account menu).

Gates: tsc --noEmit exit 0; next build exit 0.

Skipped (no real console2 backend — porting as shells would be slop): Langfuse
feature-flags (2 internal flags), developer-tools (Langfuse-branded copy),
automations/batch-exports/integrations (tRPC+Prisma only). Members/Audit/
Secrets/LLM-connections/Billing already exist as IAM/Audit/KMS/Providers/Cost.
2026-06-27 17:40:53 -07:00
zeekay d6459b11e0 feat(console2): port settings + model catalog pages as native @hanzo/gui modules
Port hanzoai/console's deep settings + models pages into console2 as native
modules wired to the REAL /v1 + /v1/iam endpoints. Honest loading/404/401/503
states everywhere — never fabricates keys, models, or settings.

New modules (registry, appended — no reorder):
- Model Catalog (AI): real GET /v1/models + best-effort /v1/pricing/models
  overlay; provider filter, premium tier, $/Mtok columns. Read-only catalog
  (routing lives in Models, credentials in Providers).
- API Keys (Dev): the account's real cloud credential (accessKey/accessSecret
  from get-account), masked by default with explicit reveal + copy; honest
  "managed by gateway" state when no key material is exposed to the browser.
- Settings (Security): tabbed General / API Keys / Members / Branding. General
  reads real account (get-account) + org (/v1/iam/get-organization); Members
  reads /v1/iam/get-users; API Keys embeds the shared ApiKeysView (DRY);
  Branding shows the real per-host runtime config. Identity mutations deep-link
  to IAM rather than being re-implemented.

Shared + API:
- ui/States.tsx: one honest async-state renderer (honestError + ErrorState +
  asApiError) with per-surface copy overrides. AdminModule refactored onto it
  (removes its duplicate honestError/ErrorCard).
- api/models-catalog.ts: CloudModelApi over the REST (non-envelope) /v1/models.
- api/admin.ts: IamAdminApi.organization(name) single-org getter.

Shell: top-bar account name now links to /settings (canonical account menu).

Gates: tsc --noEmit exit 0; next build exit 0.

Skipped (no real console2 backend — porting as shells would be slop): Langfuse
feature-flags (2 internal flags), developer-tools (Langfuse-branded copy),
automations/batch-exports/integrations (tRPC+Prisma only). Members/Audit/
Secrets/LLM-connections/Billing already exist as IAM/Audit/KMS/Providers/Cost.
2026-06-27 17:40:53 -07:00
Hanzo AI b4274bda4a release: v0.1.8 (cumulative — supersedes parallel v0.1.7) 2026-06-27 17:37:11 -07:00
Hanzo AI 4032a90ba4 release: v0.1.8 (cumulative — supersedes parallel v0.1.7) 2026-06-27 17:37:11 -07:00
Hanzo AI c9adc16cad merge: integrate fix/paas-live-data (CTO v0.1.7 paas wiring) into main
main already supersedes it: same real platform contract (/v1/apps +
/v1/org/{org}/cluster) PLUS X-Org-Id (resource modules), Bot/Wallet honest
states, Clusters real-contract rewrite, and the PaaS token fix. Recording the
integration so the line is single; cutting v0.1.8 as the cumulative release.
2026-06-27 17:36:51 -07:00
Hanzo AI fd0fcfbe98 merge: integrate fix/paas-live-data (CTO v0.1.7 paas wiring) into main
main already supersedes it: same real platform contract (/v1/apps +
/v1/org/{org}/cluster) PLUS X-Org-Id (resource modules), Bot/Wallet honest
states, Clusters real-contract rewrite, and the PaaS token fix. Recording the
integration so the line is single; cutting v0.1.8 as the cumulative release.
2026-06-27 17:36:51 -07:00
Hanzo AI f30cdbbee5 fix(modules): wire every embedded module to the real /v1 backend + cut v0.1.7
Live Playwright verification surfaced real wiring bugs; fixed all in console2
(honest states everywhere, no fakes):

- client.ts: stamp X-Org-Id (brand org) on every cloud call — the provisioning
  service 403'd 'X-Org-Id required' on the direct cloud-api path. Fixes the 7
  data modules (vector/sql/kv/s3/datastore/docdb/search) → real data / empty.
- platform.ts: rework to the REAL platform contract — GET /v1/apps (apps
  inventory) + GET|POST /v1/org/{org}/cluster; drop dead /v1/clusters + k8s
  passthrough. Status = real health board; Kubernetes = real workloads per
  cluster; Clusters = real dedicated-DOKS list (honest empty).
- platform/state.tsx: upstream 401/403 → honest 'not configured'.
- BotModule: /v1/bot/health 404 → honest 'not routed on this host'.
- WalletModule: /v1/billing/balance 404 → honest 'not available'.
- StatusTag: understand platform health verdicts (green/yellow/red).

typecheck + build clean. Operator CR token repointed (universe) to the correct
paas-console-token (the old hanzo-paas/MASTERTOKEN is rejected by platform).
2026-06-27 17:34:03 -07:00
Hanzo AI ebca336369 fix(modules): wire every embedded module to the real /v1 backend + cut v0.1.7
Live Playwright verification surfaced real wiring bugs; fixed all in console2
(honest states everywhere, no fakes):

- client.ts: stamp X-Org-Id (brand org) on every cloud call — the provisioning
  service 403'd 'X-Org-Id required' on the direct cloud-api path. Fixes the 7
  data modules (vector/sql/kv/s3/datastore/docdb/search) → real data / empty.
- platform.ts: rework to the REAL platform contract — GET /v1/apps (apps
  inventory) + GET|POST /v1/org/{org}/cluster; drop dead /v1/clusters + k8s
  passthrough. Status = real health board; Kubernetes = real workloads per
  cluster; Clusters = real dedicated-DOKS list (honest empty).
- platform/state.tsx: upstream 401/403 → honest 'not configured'.
- BotModule: /v1/bot/health 404 → honest 'not routed on this host'.
- WalletModule: /v1/billing/balance 404 → honest 'not available'.
- StatusTag: understand platform health verdicts (green/yellow/red).

typecheck + build clean. Operator CR token repointed (universe) to the correct
paas-console-token (the old hanzo-paas/MASTERTOKEN is rejected by platform).
2026-06-27 17:34:03 -07:00
zeekay a307d64b07 fix(paas): wire Clusters/Kubernetes/Status to real platform /v1 surface
Build Docker Image / docker (push) Successful in 2m36s
The PaaS modules targeted an assumed platform surface (/v1/clusters,
/v1/org/{org}/cluster/{id}/k8s/{kind}) that does not exist — the live
platform.hanzo.ai/v1 REST API serves /v1/org/{org}/cluster (org-scoped
dedicated clusters) and /v1/apps (the workload/drift board). Re-point the
single data layer (src/lib/api/platform.ts) at the real endpoints:

- listClusters → /v1/org/{org}/cluster (unwrap {clusters}); org from config.
- add AppsApi.listApps → /v1/apps (unwrap {apps}) — the live deploy data.
- drop the dead k8s-browse client (no backend) + CLUSTER_ROUTES guess.

Status + Kubernetes now render the real /v1/apps workloads (cluster,
namespace, image, health) instead of gating on a non-existent k8s-browse
behind a (for hanzo) empty dedicated-cluster list. StatusTag tones the
drift-board health (healthy/warning/down). Auth is unchanged: the /paas
proxy already sends Authorization: Bearer; the fix is the token VALUE
(operator CR → paas-console-token) + these paths.
2026-06-27 17:28:37 -07:00
zeekay e7f44a1906 fix(paas): wire Clusters/Kubernetes/Status to real platform /v1 surface
The PaaS modules targeted an assumed platform surface (/v1/clusters,
/v1/org/{org}/cluster/{id}/k8s/{kind}) that does not exist — the live
platform.hanzo.ai/v1 REST API serves /v1/org/{org}/cluster (org-scoped
dedicated clusters) and /v1/apps (the workload/drift board). Re-point the
single data layer (src/lib/api/platform.ts) at the real endpoints:

- listClusters → /v1/org/{org}/cluster (unwrap {clusters}); org from config.
- add AppsApi.listApps → /v1/apps (unwrap {apps}) — the live deploy data.
- drop the dead k8s-browse client (no backend) + CLUSTER_ROUTES guess.

Status + Kubernetes now render the real /v1/apps workloads (cluster,
namespace, image, health) instead of gating on a non-existent k8s-browse
behind a (for hanzo) empty dedicated-cluster list. StatusTag tones the
drift-board health (healthy/warning/down). Auth is unchanged: the /paas
proxy already sends Authorization: Bearer; the fix is the token VALUE
(operator CR → paas-console-token) + these paths.
2026-06-27 17:28:37 -07:00
zeekay c32bb30154 docs: correct catalog home comment for the 3-state enablement model 2026-06-27 16:44:35 -07:00
zeekay f8ea7ba330 docs: correct catalog home comment for the 3-state enablement model 2026-06-27 16:44:35 -07:00
zeekay c27a62d271 catalog: canonical 10-category Open AI Cloud (GCP-compatible) + all-services Status
Build Docker Image / docker (push) Successful in 2m33s
Rebuild the product catalog to the canonical ten categories, in order:
AI · Compute · Data · Network · Security · Dev · Deploy · Observe · Web3 · Apps.
66 entries; each names its Google Cloud equivalent. Honest 3-state enablement:
enabled (23, in-console modules) / external (16, live Hanzo surfaces) / soon (27).
Every real working module is preserved (recategorized, not broken).

Add Status (Observe): live health of every Hanzo service across clusters, from
REAL data only — composes PlatformApi.listClusters + KubernetesApi deployments
over the /paas control plane, with honest not-configured/unavailable/empty
states and no fabricated dots.

Decomplect: one coming-soon surface for all soon leaves; delete the dead
duplicate PaaS client (PlatformModule + lib/paas) and the unused coming-soon
factory. Release v0.1.6.
2026-06-27 16:43:30 -07:00
zeekay d2b667430e catalog: canonical 10-category Open AI Cloud (GCP-compatible) + all-services Status
Rebuild the product catalog to the canonical ten categories, in order:
AI · Compute · Data · Network · Security · Dev · Deploy · Observe · Web3 · Apps.
66 entries; each names its Google Cloud equivalent. Honest 3-state enablement:
enabled (23, in-console modules) / external (16, live Hanzo surfaces) / soon (27).
Every real working module is preserved (recategorized, not broken).

Add Status (Observe): live health of every Hanzo service across clusters, from
REAL data only — composes PlatformApi.listClusters + KubernetesApi deployments
over the /paas control plane, with honest not-configured/unavailable/empty
states and no fabricated dots.

Decomplect: one coming-soon surface for all soon leaves; delete the dead
duplicate PaaS client (PlatformModule + lib/paas) and the unused coming-soon
factory. Release v0.1.6.
2026-06-27 16:43:30 -07:00
zeekay 102a37a48c release: v0.1.5
Build Docker Image / docker (push) Successful in 4m57s
Phases 1-5: nine-category catalog (mirrors hanzo.ai), in-console admin
(Identity/Secrets/Audit), Kubernetes workloads browser + real Clusters over
/paas, and an honest Observability category. tsc --noEmit + next build green.
2026-06-27 16:10:25 -07:00
zeekay 7f269b61d1 release: v0.1.5
Phases 1-5: nine-category catalog (mirrors hanzo.ai), in-console admin
(Identity/Secrets/Audit), Kubernetes workloads browser + real Clusters over
/paas, and an honest Observability category. tsc --noEmit + next build green.
2026-06-27 16:10:25 -07:00
zeekay 6beb670744 o11y: honest Observability category (traces/evals/prompts)
Phase 5 — add the Observability category with truthful entries, no fake charts:

- Observability (/o11y): console-native module that probes the REAL /v1/o11y
  runtime and reports status (online / not-initialized 503 / not-routed 404 /
  access / error), deep-links to the full observability surface for traces,
  evals, and prompts, and marks the native in-console browser as coming
  (HIP-0106). Never renders placeholder telemetry.
- Insights (external) + Analytics (relocated here) round out the category.

This is the staged first step for the largest old-console surface; the deeper
native port is honestly deferred, not faked.
2026-06-27 16:06:34 -07:00
zeekay 10b8d6be54 o11y: honest Observability category (traces/evals/prompts)
Phase 5 — add the Observability category with truthful entries, no fake charts:

- Observability (/o11y): console-native module that probes the REAL /v1/o11y
  runtime and reports status (online / not-initialized 503 / not-routed 404 /
  access / error), deep-links to the full observability surface for traces,
  evals, and prompts, and marks the native in-console browser as coming
  (HIP-0106). Never renders placeholder telemetry.
- Insights (external) + Analytics (relocated here) round out the category.

This is the staged first step for the largest old-console surface; the deeper
native port is honestly deferred, not faked.
2026-06-27 16:06:34 -07:00
zeekay 82e81ddf75 k8s: Kubernetes workloads browser + real Clusters over /paas
Phase 4 — one platform transport: route ALL control-plane calls through the
same-origin /paas proxy (server-side token injection, no CORS, honest 501
when unset) instead of direct cross-origin platform.hanzo.ai.

- platform.ts: clusters now go via /paas; add KubernetesApi over
  /v1/org/{org}/cluster/{id}/k8s/{deployments,pods,services,ingresses,events,crs}.
- KubernetesModule: cluster picker + resource tabs, defensive per-kind columns,
  honest loading/not-configured/backend-unavailable/error/empty states.
- ClustersModule: flip 'soon' -> real; render the shared honest state card for
  not-configured (501) / backend-unavailable (404).
- New shared platform/state.tsx interprets /paas errors one way.
2026-06-27 16:01:25 -07:00
zeekay 2dce1ddefb k8s: Kubernetes workloads browser + real Clusters over /paas
Phase 4 — one platform transport: route ALL control-plane calls through the
same-origin /paas proxy (server-side token injection, no CORS, honest 501
when unset) instead of direct cross-origin platform.hanzo.ai.

- platform.ts: clusters now go via /paas; add KubernetesApi over
  /v1/org/{org}/cluster/{id}/k8s/{deployments,pods,services,ingresses,events,crs}.
- KubernetesModule: cluster picker + resource tabs, defensive per-kind columns,
  honest loading/not-configured/backend-unavailable/error/empty states.
- ClustersModule: flip 'soon' -> real; render the shared honest state card for
  not-configured (501) / backend-unavailable (404).
- New shared platform/state.tsx interprets /paas errors one way.
2026-06-27 16:01:25 -07:00
zeekay 1ad8ff3fa0 admin: in-console Identity, Secrets (KMS), and Audit modules
Phase 3 — wire the console to the existing identity/secrets subsystems over
the canonical /v1 surface (HIP-0111), each an honest module:

- Identity (/iam): tabbed Organizations / Users / Roles (RBAC) over Hanzo
  IAM /v1/iam/get-{organizations,users,roles} (casdoor envelope). One generic
  AdminListView drives all three; 404/401/empty are explicit honest states.
- Audit (/audit): identity & access event log over /v1/iam/get-records.
- Secrets (/kms): KMS is zero-knowledge (encrypted names+values, token/ZAP
  auth, no list-values endpoint) — the module states the model, probes the
  real /v1/kms surface to report reachability, and deep-links to the KMS
  console. It never fabricates a secret table.

iam/kms flip from external links to modules; ext.iam/ext.kms removed.
2026-06-27 15:56:31 -07:00
zeekay 18ade1dce7 admin: in-console Identity, Secrets (KMS), and Audit modules
Phase 3 — wire the console to the existing identity/secrets subsystems over
the canonical /v1 surface (HIP-0111), each an honest module:

- Identity (/iam): tabbed Organizations / Users / Roles (RBAC) over Hanzo
  IAM /v1/iam/get-{organizations,users,roles} (casdoor envelope). One generic
  AdminListView drives all three; 404/401/empty are explicit honest states.
- Audit (/audit): identity & access event log over /v1/iam/get-records.
- Secrets (/kms): KMS is zero-knowledge (encrypted names+values, token/ZAP
  auth, no list-values endpoint) — the module states the model, probes the
  real /v1/kms surface to report reachability, and deep-links to the KMS
  console. It never fabricates a secret table.

iam/kms flip from external links to modules; ext.iam/ext.kms removed.
2026-06-27 15:56:31 -07:00
zeekay 631dc417b2 catalog: mirror hanzo.ai's nine product categories
Replace the ad-hoc AI/Data/Apps/Identity/Infrastructure/Commerce grouping
with the exact nine categories + order from the marketing site product
dropdown (navigation-data.ts productsNav): AI & Agents, Developer, Apps,
Compute, Data, Async, Platform, Observability, Web3. One taxonomy across
every Hanzo surface. Empty groups don't render (catalogByCategory skips them).
2026-06-27 15:50:57 -07:00
zeekay 9323d938da catalog: mirror hanzo.ai's nine product categories
Replace the ad-hoc AI/Data/Apps/Identity/Infrastructure/Commerce grouping
with the exact nine categories + order from the marketing site product
dropdown (navigation-data.ts productsNav): AI & Agents, Developer, Apps,
Compute, Data, Async, Platform, Observability, Web3. One taxonomy across
every Hanzo surface. Empty groups don't render (catalogByCategory skips them).
2026-06-27 15:50:57 -07:00
zeekay 57371b1c9d feat(console2): Wallet & HUSD top-up module + verify-and-record endpoint; v0.1.4
Build Docker Image / docker (push) Successful in 2m47s
- WalletModule (Commerce): connect a non-custodial wallet on Hanzo Mainnet
  (36900) via ethers/EIP-1193, show wallet HUSD + cloud credit balances, and
  top up credit with HUSD. Honest states throughout (no wallet, HUSD greenfield,
  chain unreachable, endpoint unconfigured) — never a fabricated balance.
- src/lib/wallet/hanzo-evm.ts: one canonical Hanzo Mainnet + HUSD definition
  (ethers v6), env-overridable RPC; HUSD address is public (NEXT_PUBLIC), never
  a secret.
- src/lib/api/wallet.ts: cloud balance via the real GET /v1/billing/balance, and
  recordWalletTopup → the console's own POST /billing/topup/wallet.
- app/billing/topup/wallet/route.ts: server route (mirrors /paas) — verifies the
  HUSD transfer on-chain, then records to commerce as a husd crypto payment and
  credits the balance. Hosted here because billing.hanzo.ai is a static export
  and commerce is owned elsewhere. Server-only config (KMS, never NEXT_PUBLIC).
- registry: wallet entry; api/index: WalletApi export. Adds ethers 6.17.0.
2026-06-27 14:56:35 -07:00
zeekay 7f892fcf7f feat(console2): Wallet & HUSD top-up module + verify-and-record endpoint; v0.1.4
- WalletModule (Commerce): connect a non-custodial wallet on Hanzo Mainnet
  (36900) via ethers/EIP-1193, show wallet HUSD + cloud credit balances, and
  top up credit with HUSD. Honest states throughout (no wallet, HUSD greenfield,
  chain unreachable, endpoint unconfigured) — never a fabricated balance.
- src/lib/wallet/hanzo-evm.ts: one canonical Hanzo Mainnet + HUSD definition
  (ethers v6), env-overridable RPC; HUSD address is public (NEXT_PUBLIC), never
  a secret.
- src/lib/api/wallet.ts: cloud balance via the real GET /v1/billing/balance, and
  recordWalletTopup → the console's own POST /billing/topup/wallet.
- app/billing/topup/wallet/route.ts: server route (mirrors /paas) — verifies the
  HUSD transfer on-chain, then records to commerce as a husd crypto payment and
  credits the balance. Hosted here because billing.hanzo.ai is a static export
  and commerce is owned elsewhere. Server-only config (KMS, never NEXT_PUBLIC).
- registry: wallet entry; api/index: WalletApi export. Adds ethers 6.17.0.
2026-06-27 14:56:35 -07:00
zeekay 0076f411b0 console2: per-product discover interstitials (docs + GitHub OSS + dividends); v0.1.3
Build Docker Image / docker (push) Successful in 2m29s
'Interstitial screens to discover guides/docs/how-tos + link to GitHub OSS for
each product' + the OSS-gets-paid hook:
- /discover/<id> route + ProductInterstitial: identity + open/get-started CTA,
  Docs & guides + Open-source (GitHub) link cards, and an 'Open source gets paid'
  card stating the model (25% of cloud revenue -> OSS contributors, computed from
  each deployment's SBOM, settled on-chain in HUSD) with Contribute + OSS-dividends
  links. Catalog cards gain a Learn-more (info) affordance; non-enabled Get-started
  routes to the interstitial.
- OSS_PROGRAM constant (one source of truth: 25% / HUSD / SBOM / dividends dashboard),
  aligned with hanzo.ai sbomRevenueConfig + the commerce contributor/payout system.

tsc clean; next build green (/discover/[id] live).
2026-06-27 14:22:42 -07:00
zeekay 887c408e6d console2: per-product discover interstitials (docs + GitHub OSS + dividends); v0.1.3
'Interstitial screens to discover guides/docs/how-tos + link to GitHub OSS for
each product' + the OSS-gets-paid hook:
- /discover/<id> route + ProductInterstitial: identity + open/get-started CTA,
  Docs & guides + Open-source (GitHub) link cards, and an 'Open source gets paid'
  card stating the model (25% of cloud revenue -> OSS contributors, computed from
  each deployment's SBOM, settled on-chain in HUSD) with Contribute + OSS-dividends
  links. Catalog cards gain a Learn-more (info) affordance; non-enabled Get-started
  routes to the interstitial.
- OSS_PROGRAM constant (one source of truth: 25% / HUSD / SBOM / dividends dashboard),
  aligned with hanzo.ai sbomRevenueConfig + the commerce contributor/payout system.

tsc clean; next build green (/discover/[id] live).
2026-06-27 14:22:42 -07:00
zeekay 9b04ea848c ci: semver image tags only (no sha, no :latest); v0.1.2
Build Docker Image / docker (push) Successful in 2m32s
Per directive 'use proper semver for all, no sha': build-image.yml now tags
ghcr.io/hanzoai/console2 with the semver version only — a v* git tag publishes
that exact version, a main push publishes v<package.json version> (bump to
release). Drops the sha-<sha7> and floating :latest tags. Bump 0.1.1 -> 0.1.2
(GCP-grade resource detail + Plans & Pricing + Team launcher).
2026-06-27 13:58:21 -07:00
zeekay 02dd1897a5 ci: semver image tags only (no sha, no :latest); v0.1.2
Per directive 'use proper semver for all, no sha': build-image.yml now tags
ghcr.io/hanzoai/console2 with the semver version only — a v* git tag publishes
that exact version, a main push publishes v<package.json version> (bump to
release). Drops the sha-<sha7> and floating :latest tags. Bump 0.1.1 -> 0.1.2
(GCP-grade resource detail + Plans & Pricing + Team launcher).
2026-06-27 13:58:21 -07:00
zeekay e30e9c7b3e console2: GCP-grade resource detail + Plans & Pricing + Team launcher
The 'manage/create databases ala GCP' + 'discover, enable, pay' surface,
all on REAL backend contracts (no invented controls):

- ResourceModule gains an instance DETAIL view (list+detail in one component,
  mirroring ProvidersModule's params.name): click a resource -> GET /v1/<kind>/<name>
  overview (status/kind/endpoint/user/db/created) + connection guidance + danger-zone
  delete. New resourceRoutes() helper binds index + :name to one instance (DRY);
  the 7 data products (sql/vector/datastore/kv/search/s3/docdb) use it. Resources
  are serverless/create-by-name (POST /v1/<kind> takes only {name}) — no fake
  tier/size/region knobs.
- PlansModule: GCP-style Plans & Pricing on the live rate card (GET /v1/pricing):
  per-tier cards (vCPU/RAM/SSD/transfer, monthly+hourly, feature bullets, free/popular
  badges) + block-storage metering. Paying delegates to config.billingUrl (the one
  money surface, never reimplemented). New typed PlansApi (plans.ts).
- Team launcher: hanzo.team catalog entry (Apps, external team.hanzo.ai).

tsc --noEmit clean; next build green (9/9).
2026-06-27 13:26:41 -07:00
zeekay 9850dca0e3 console2: GCP-grade resource detail + Plans & Pricing + Team launcher
The 'manage/create databases ala GCP' + 'discover, enable, pay' surface,
all on REAL backend contracts (no invented controls):

- ResourceModule gains an instance DETAIL view (list+detail in one component,
  mirroring ProvidersModule's params.name): click a resource -> GET /v1/<kind>/<name>
  overview (status/kind/endpoint/user/db/created) + connection guidance + danger-zone
  delete. New resourceRoutes() helper binds index + :name to one instance (DRY);
  the 7 data products (sql/vector/datastore/kv/search/s3/docdb) use it. Resources
  are serverless/create-by-name (POST /v1/<kind> takes only {name}) — no fake
  tier/size/region knobs.
- PlansModule: GCP-style Plans & Pricing on the live rate card (GET /v1/pricing):
  per-tier cards (vCPU/RAM/SSD/transfer, monthly+hourly, feature bullets, free/popular
  badges) + block-storage metering. Paying delegates to config.billingUrl (the one
  money surface, never reimplemented). New typed PlansApi (plans.ts).
- Team launcher: hanzo.team catalog entry (Apps, external team.hanzo.ai).

tsc --noEmit clean; next build green (9/9).
2026-06-27 13:26:41 -07:00
zeekay f057352fc3 feat(console2): Bot module — in-console /v1/bot status + operator deep-links
Flip the existing 'bot' catalog entry from an external link to an in-console
module. New BotModule reports live gateway status from /v1/bot/health (via a
small BotApi on the REST layer, since the bot speaks plain JSON, not the
casibase envelope) and deep-links the operator surfaces (bot home, control UI,
docs, source). Backend (hanzoai/bot -> bot-gateway) is already routed at
/v1/bot/* by the unified gateway.
2026-06-27 12:45:58 -07:00
zeekay 5ed183ea75 feat(console2): Bot module — in-console /v1/bot status + operator deep-links
Flip the existing 'bot' catalog entry from an external link to an in-console
module. New BotModule reports live gateway status from /v1/bot/health (via a
small BotApi on the REST layer, since the bot speaks plain JSON, not the
casibase envelope) and deep-links the operator surfaces (bot home, control UI,
docs, source). Backend (hanzoai/bot -> bot-gateway) is already routed at
/v1/bot/* by the unified gateway.
2026-06-27 12:45:58 -07:00
zeekay a0db49f282 fix(console2): cloud fallback → api.hanzo.ai (gated gateway), not the SPA host
Found via testing: cloud.hanzo.ai serves the SPA catch-all (200 text/html for any /v1 path), NOT the backend; the real /v1 is behind the unified gateway api.hanzo.ai (hanzoai/ingress→hanzoai/gateway v2.13.0 → cloud / separate services / per-org k8s, rate-limited+gated+priced). Browser uses same-origin /v1 (each console host's ingress proxies to the gateway); this SSR fallback now points at the gateway.
2026-06-27 12:21:14 -07:00
zeekay da833693d8 fix(console2): cloud fallback → api.hanzo.ai (gated gateway), not the SPA host
Found via testing: cloud.hanzo.ai serves the SPA catch-all (200 text/html for any /v1 path), NOT the backend; the real /v1 is behind the unified gateway api.hanzo.ai (hanzoai/ingress→hanzoai/gateway v2.13.0 → cloud / separate services / per-org k8s, rate-limited+gated+priced). Browser uses same-origin /v1 (each console host's ingress proxies to the gateway); this SSR fallback now points at the gateway.
2026-06-27 12:21:14 -07:00
zeekay c09943eb08 feat(console2): one image, multi-brand by hostname (hanzo/lux/zoo)
Resolves the brand at RUNTIME from the request hostname (console.hanzo.ai→hanzo, console.lux.cloud→lux, console.zoo.cloud→zoo). Tenancy: ONE cloud /v1 backend serves all orgs (same-origin per host → first-party cookie, no CORS), each brand authenticates against its OWN live IAM (hanzo.id / lux.id / zoolabs.id / pars.id; client_id <org>-cloud per HIP-0111). config is a brand-aware Proxy so the /v1 client + IAM SDK go per-host with no consumer changes; cloudUrl is same-origin (window.origin); NEXT_PUBLIC_* still override. Dockerfile + CI no longer bake NEXT_PUBLIC_* (that pinned one brand) → one brand-agnostic image (sha-<sha7>+latest). Fixed a 'Hanzo' subtitle brand-leak. Verified: tsc clean + next build green. NOTE: cloud backend must accept all brand issuers/auds; each console host's ingress must proxy /v1 to the backend + register the redirect URI in that brand's IAM app.
2026-06-27 12:11:09 -07:00
zeekay 5ee3b622de feat(console2): one image, multi-brand by hostname (hanzo/lux/zoo)
Resolves the brand at RUNTIME from the request hostname (console.hanzo.ai→hanzo, console.lux.cloud→lux, console.zoo.cloud→zoo). Tenancy: ONE cloud /v1 backend serves all orgs (same-origin per host → first-party cookie, no CORS), each brand authenticates against its OWN live IAM (hanzo.id / lux.id / zoolabs.id / pars.id; client_id <org>-cloud per HIP-0111). config is a brand-aware Proxy so the /v1 client + IAM SDK go per-host with no consumer changes; cloudUrl is same-origin (window.origin); NEXT_PUBLIC_* still override. Dockerfile + CI no longer bake NEXT_PUBLIC_* (that pinned one brand) → one brand-agnostic image (sha-<sha7>+latest). Fixed a 'Hanzo' subtitle brand-leak. Verified: tsc clean + next build green. NOTE: cloud backend must accept all brand issuers/auds; each console host's ingress must proxy /v1 to the backend + register the redirect URI in that brand's IAM app.
2026-06-27 12:11:09 -07:00
Hanzo AI 0e2ee1f394 feat(console2): brand polish — favicon, monochrome, sized loader, social sign-in
- favicon: canonical hanzo.app ▼/H mark via app-router icon files
  (favicon.ico, icon.svg, apple-icon.png); viewport themeColor #000000
- loader: monochrome HanzoMark ~40–48px centered (not viewport-filling),
  replacing the oversized @hanzo/gui Spinner in AuthGate + OAuth callback
- monochrome: black/white/grey chrome, white (theme="light") primary
  buttons; drop every blue/green/red/yellow/violet theme + $color accent
  across products, status tags, dashboard badges, default store color
- sign-in: dedicated GitHub + Google buttons (provider_hint) + Hanzo ID,
  all via hanzo.id OIDC (client_id=hanzo-cloud); IAM owns provider OAuth,
  console never reconstructs github.com/accounts.google.com URLs
2026-06-26 19:22:26 -07:00
Hanzo AI 312deacac6 feat(console2): brand polish — favicon, monochrome, sized loader, social sign-in
- favicon: canonical hanzo.app ▼/H mark via app-router icon files
  (favicon.ico, icon.svg, apple-icon.png); viewport themeColor #000000
- loader: monochrome HanzoMark ~40–48px centered (not viewport-filling),
  replacing the oversized @hanzo/gui Spinner in AuthGate + OAuth callback
- monochrome: black/white/grey chrome, white (theme="light") primary
  buttons; drop every blue/green/red/yellow/violet theme + $color accent
  across products, status tags, dashboard badges, default store color
- sign-in: dedicated GitHub + Google buttons (provider_hint) + Hanzo ID,
  all via hanzo.id OIDC (client_id=hanzo-cloud); IAM owns provider OAuth,
  console never reconstructs github.com/accounts.google.com URLs
2026-06-26 19:22:26 -07:00
Hanzo AI c6f55d8646 docs(LLM): canonical issuer hanzo.id + hanzo-cloud app + deploy reality [skip ci] 2026-06-26 18:27:53 -07:00
Hanzo AI da1ed0cae4 docs(LLM): canonical issuer hanzo.id + hanzo-cloud app + deploy reality [skip ci] 2026-06-26 18:27:53 -07:00
Hanzo AI 14519c41c5 fix(ci): emit sha+latest tags via GITHUB_OUTPUT heredoc
The mainnet branch wrote a two-line tags value with $'\n', which GitHub
Actions rejects for key=value outputs ("Invalid format ...:latest") —
so every Build Docker Image run failed at 'Compute per-env config' and
no image was ever produced by CI. Use the multi-line heredoc output form
so build-push-action receives both newline-separated tags.
2026-06-26 18:15:50 -07:00
Hanzo AI 0e83bc1a49 fix(ci): emit sha+latest tags via GITHUB_OUTPUT heredoc
The mainnet branch wrote a two-line tags value with $'\n', which GitHub
Actions rejects for key=value outputs ("Invalid format ...:latest") —
so every Build Docker Image run failed at 'Compute per-env config' and
no image was ever produced by CI. Use the multi-line heredoc output form
so build-push-action receives both newline-separated tags.
2026-06-26 18:15:50 -07:00
Hanzo AI 1f80429f97 fix(auth): sign-in via canonical issuer hanzo.id, not iam.hanzo.ai
NEXT_PUBLIC_IAM_URL is inlined at build time, so the deployed image sent
the browser authorize redirect to https://iam.hanzo.ai — which mints
iss=https://iam.hanzo.ai, a different issuer than the canonical
https://hanzo.id that the cloud /v1 backend validates. Sign-in dropped
on iam.hanzo.ai and never round-tripped back to console2.

- src/config, .env.example, Dockerfile ARG, mainnet CI build-arg: point
  the browser at https://hanzo.id (the value baked into the image).
- Align iamAppName/iamClientId source defaults to hanzo-cloud (was the
  stale hanzo-console / empty), matching the cloud-api backend binding
  (iamApplication / IAM_AUDIENCE = hanzo-cloud). A default build now
  targets the correct app instead of a non-existent one with no client.

App/client stay hanzo-cloud on purpose: console2 is a front-end of the
shared cloud /v1 backend, which exchanges the code and validates aud as
hanzo-cloud — it is not its own IAM principal.
2026-06-26 18:14:12 -07:00
Hanzo AI ef654174fb fix(auth): sign-in via canonical issuer hanzo.id, not iam.hanzo.ai
NEXT_PUBLIC_IAM_URL is inlined at build time, so the deployed image sent
the browser authorize redirect to https://iam.hanzo.ai — which mints
iss=https://iam.hanzo.ai, a different issuer than the canonical
https://hanzo.id that the cloud /v1 backend validates. Sign-in dropped
on iam.hanzo.ai and never round-tripped back to console2.

- src/config, .env.example, Dockerfile ARG, mainnet CI build-arg: point
  the browser at https://hanzo.id (the value baked into the image).
- Align iamAppName/iamClientId source defaults to hanzo-cloud (was the
  stale hanzo-console / empty), matching the cloud-api backend binding
  (iamApplication / IAM_AUDIENCE = hanzo-cloud). A default build now
  targets the correct app instead of a non-existent one with no client.

App/client stay hanzo-cloud on purpose: console2 is a front-end of the
shared cloud /v1 backend, which exchanges the code and validates aud as
hanzo-cloud — it is not its own IAM principal.
2026-06-26 18:14:12 -07:00
Hanzo AI 800b3481d7 feat(console2): 10-category cloud axis + embedded PaaS, no fakes
Job 2: reorganize the catalog into the canonical 10-category CLOUD AXIS
(AI/Compute/Data/Network/Security/Dev/Deploy/Observe/Chain/Apps) so console2
reads like a cloud console. Three entry kinds (module/external/soon) => zero
dead links, zero fakes; 'soon' primitives render an honest ComingSoon overview.

Job 3: PaaS embedded natively under Deploy (PlatformModule) wired to the real
platform.hanzo.ai control plane via a same-origin /paas proxy (service token
server-side from KMS). Real apps + declared/running/drift + redeploy; honest
loading/not-configured/empty states.

Job 4: catalog honest by construction; PaaS shows only real data. No placeholder
cards, demo projects, or lorem stats.
2026-06-26 14:48:16 -07:00
Hanzo AI 631bbea89b feat(console2): 10-category cloud axis + embedded PaaS, no fakes
Job 2: reorganize the catalog into the canonical 10-category CLOUD AXIS
(AI/Compute/Data/Network/Security/Dev/Deploy/Observe/Chain/Apps) so console2
reads like a cloud console. Three entry kinds (module/external/soon) => zero
dead links, zero fakes; 'soon' primitives render an honest ComingSoon overview.

Job 3: PaaS embedded natively under Deploy (PlatformModule) wired to the real
platform.hanzo.ai control plane via a same-origin /paas proxy (service token
server-side from KMS). Real apps + declared/running/drift + redeploy; honest
loading/not-configured/empty states.

Job 4: catalog honest by construction; PaaS shows only real data. No placeholder
cards, demo projects, or lorem stats.
2026-06-26 14:48:16 -07:00
Hanzo AI 7e3fa15425 Merge feat/cloud-taxonomy-10cat into main (union)
Union merge — feature's categorized catalog architecture is canonical, with
main's data/storage products + status badges folded in (lose nothing):

- registry.tsx: feature's CatalogEntry discriminated union (category + kind +
  admin + derived productModules) as the base; FOLD IN main's data/storage cloud
  products (vector, sql, datastore, kv, search, s3, docdb, base, clusters) as
  categorized 'module' entries via resourceModule/comingSoon — these are the
  console frontend for cloud's /v1 provisioning control plane. Extend
  ProductStatus to 'enabled'|'available'|'soon'|'waitlist' and add repo? so the
  folded products keep their status badges. Resolve the id 'search' collision:
  managed Search data product keeps id 'search' (owns the route, maps to the
  provisioning kind); feature's external search.hanzo.ai becomes 'ai-search'.
- DashboardShell.tsx: feature's Pinned + categorized NavRow shell; FOLD main's
  SOON/WAITLIST status badge into NavRow (feature had dropped it).
- page.tsx: feature's grouped ProductCard grid; extend StatusBadge to render
  Soon/Waitlist (blue/yellow) alongside Enabled/Available (folds main's badge).

Verify: tsc --noEmit strict CLEAN; next build green (6/6 pages). The repo's
untracked PaaS WIP (ComingSoon/PlatformModule/paas — another agent's, in neither
branch) is left untouched and excluded from this commit.
2026-06-26 14:47:11 -07:00
Hanzo AI bc840931f4 Merge feat/cloud-taxonomy-10cat into main (union)
Union merge — feature's categorized catalog architecture is canonical, with
main's data/storage products + status badges folded in (lose nothing):

- registry.tsx: feature's CatalogEntry discriminated union (category + kind +
  admin + derived productModules) as the base; FOLD IN main's data/storage cloud
  products (vector, sql, datastore, kv, search, s3, docdb, base, clusters) as
  categorized 'module' entries via resourceModule/comingSoon — these are the
  console frontend for cloud's /v1 provisioning control plane. Extend
  ProductStatus to 'enabled'|'available'|'soon'|'waitlist' and add repo? so the
  folded products keep their status badges. Resolve the id 'search' collision:
  managed Search data product keeps id 'search' (owns the route, maps to the
  provisioning kind); feature's external search.hanzo.ai becomes 'ai-search'.
- DashboardShell.tsx: feature's Pinned + categorized NavRow shell; FOLD main's
  SOON/WAITLIST status badge into NavRow (feature had dropped it).
- page.tsx: feature's grouped ProductCard grid; extend StatusBadge to render
  Soon/Waitlist (blue/yellow) alongside Enabled/Available (folds main's badge).

Verify: tsc --noEmit strict CLEAN; next build green (6/6 pages). The repo's
untracked PaaS WIP (ComingSoon/PlatformModule/paas — another agent's, in neither
branch) is left untouched and excluded from this commit.
2026-06-26 14:47:11 -07:00
07db3af7fe feat(products): register full data/storage catalog + enablement status (#1)
* feat(products): register full data/storage catalog + enablement status

Adds `status` ('enabled'|'soon'|'waitlist') + `repo` to every ProductModule so
the console tracks enablement of all Hanzo cloud products in ONE place. Registers
the OSS-Google-Cloud data/storage suite as modules — Vector, SQL, Datastore, KV,
Search, S3, Base (soon) and DocDB (waitlist) — each a ZAP-native Hanzo fork mapped
to its repo, with a ComingSoon placeholder (repo link + status) until its admin
module lands. Nav + dashboard cards render the status badge.

Products → repos: Vector=hanzoai/vector (Qdrant-compat), SQL=hanzoai/sql,
Datastore=hanzoai/datastore (ClickHouse-compat), KV=hanzoai/kv (Redis-compat),
Search=hanzoai/search, S3=hanzoai/s3, Base=hanzoai/base, DocDB=hanzoai/docdb.

* feat(products): add Clusters module (shared Hanzo Cloud vs BYO DOKS)

The data/storage products (Vector/SQL/Datastore/KV/Search/S3/Base/DocDB) are
all live on hanzo-k8s via the operator. Adds the one new control-plane surface
the platform needs: Clusters — choose where workloads run (shared multi-tenant
Hanzo Cloud, or your own/Hanzo-provisioned DOKS cluster, reconciled by the same
operator). Deploy-any-repo stays under Applications.

* feat(products): working data/storage + clusters admin modules

Replace the comingSoon() placeholders for sql/vector/datastore/kv/search/s3/
docdb with a DRY resourceModule() factory over the provisioning REST contract
(POST/GET/DELETE /v1/<kind>): list, create-with-once-shown connectionString +
password reveal ("store this now"), and per-row delete. Tenancy is server-side
(gateway injects X-Org-Id), so the browser sends cookie creds only.

- lib/api/client.ts: plain-REST helpers (restGet/restPost/restDelete + v1Url)
  beside the casibase envelope path — provisioning + platform speak raw JSON /
  201 / 204 / DELETE, reusing the same cookie creds + ApiError. One transport.
- lib/api/provisioning.ts: ProvisioningApi keyed by ResourceKind.
- lib/api/platform.ts: PlatformApi (DOKS clusters) with centralized
  CLUSTER_ROUTES + DOKS region/size options; base = NEXT_PUBLIC_PLATFORM_URL.
- components/products/ResourceModule.tsx: the factory (list/create/delete,
  copyable masked secret reveal, slug validation, loading/empty/error).
- components/products/ClustersModule.tsx: list + provision DOKS + attach (name
  + kubeconfig) against the platform control plane.
- components/ui/StatusTag.tsx, lib/slug.ts: shared/DRY across both modules.
- registry: swap routes to the working modules and clear 'soon'/'waitlist'
  status so nav badges clear. Base stays a placeholder (no single-resource).
- fix two pre-existing type errors in ComingSoonModule (maxWidth->maxW,
  theme active->blue) so the branch typechecks clean.

kind map: sql->databases, vector->vector, datastore->datastore, kv->kv,
search->search, s3->storage, docdb->docdb.

Verified: npm ci + tsc --noEmit => 0 errors (CI-equivalent flat install).

* console: native product naming + gate Clusters to coming-soon

Native naming (Hanzo brand rule — product name only, no upstream OSS
name in any surface):
- ResourceKind wire kinds align to the cloud /v1 contract exactly:
  databases→sql, storage→s3 (vector/datastore/kv/search/docdb unchanged).
- Strip every upstream name from product descriptions + connectionHints
  (Postgres/Qdrant/ClickHouse/Redis/Meilisearch/Mongo/-compatible → the
  Hanzo product name). Zero forbidden names remain in registry.tsx.

Clusters: register as status:'soon' rendering the coming-soon placeholder
(repo hanzoai/operator). The platform attach/provision endpoints in
platform.ts are unconfirmed (PR not merged), so we don't ship a button to
a non-existent endpoint. ClustersModule.tsx + platform.ts stay in the tree
(unreferenced from the live route) to flip back to enabled in one line once
the platform surface lands. Data products (sql/vector/datastore/kv/search/
s3/docdb) stay ENABLED — their /v1 kinds are real and shipping now.

tsc --noEmit: clean.

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-26 13:20:20 -07:00
85bc5997ce feat(products): register full data/storage catalog + enablement status (#1)
* feat(products): register full data/storage catalog + enablement status

Adds `status` ('enabled'|'soon'|'waitlist') + `repo` to every ProductModule so
the console tracks enablement of all Hanzo cloud products in ONE place. Registers
the OSS-Google-Cloud data/storage suite as modules — Vector, SQL, Datastore, KV,
Search, S3, Base (soon) and DocDB (waitlist) — each a ZAP-native Hanzo fork mapped
to its repo, with a ComingSoon placeholder (repo link + status) until its admin
module lands. Nav + dashboard cards render the status badge.

Products → repos: Vector=hanzoai/vector (Qdrant-compat), SQL=hanzoai/sql,
Datastore=hanzoai/datastore (ClickHouse-compat), KV=hanzoai/kv (Redis-compat),
Search=hanzoai/search, S3=hanzoai/s3, Base=hanzoai/base, DocDB=hanzoai/docdb.

* feat(products): add Clusters module (shared Hanzo Cloud vs BYO DOKS)

The data/storage products (Vector/SQL/Datastore/KV/Search/S3/Base/DocDB) are
all live on hanzo-k8s via the operator. Adds the one new control-plane surface
the platform needs: Clusters — choose where workloads run (shared multi-tenant
Hanzo Cloud, or your own/Hanzo-provisioned DOKS cluster, reconciled by the same
operator). Deploy-any-repo stays under Applications.

* feat(products): working data/storage + clusters admin modules

Replace the comingSoon() placeholders for sql/vector/datastore/kv/search/s3/
docdb with a DRY resourceModule() factory over the provisioning REST contract
(POST/GET/DELETE /v1/<kind>): list, create-with-once-shown connectionString +
password reveal ("store this now"), and per-row delete. Tenancy is server-side
(gateway injects X-Org-Id), so the browser sends cookie creds only.

- lib/api/client.ts: plain-REST helpers (restGet/restPost/restDelete + v1Url)
  beside the casibase envelope path — provisioning + platform speak raw JSON /
  201 / 204 / DELETE, reusing the same cookie creds + ApiError. One transport.
- lib/api/provisioning.ts: ProvisioningApi keyed by ResourceKind.
- lib/api/platform.ts: PlatformApi (DOKS clusters) with centralized
  CLUSTER_ROUTES + DOKS region/size options; base = NEXT_PUBLIC_PLATFORM_URL.
- components/products/ResourceModule.tsx: the factory (list/create/delete,
  copyable masked secret reveal, slug validation, loading/empty/error).
- components/products/ClustersModule.tsx: list + provision DOKS + attach (name
  + kubeconfig) against the platform control plane.
- components/ui/StatusTag.tsx, lib/slug.ts: shared/DRY across both modules.
- registry: swap routes to the working modules and clear 'soon'/'waitlist'
  status so nav badges clear. Base stays a placeholder (no single-resource).
- fix two pre-existing type errors in ComingSoonModule (maxWidth->maxW,
  theme active->blue) so the branch typechecks clean.

kind map: sql->databases, vector->vector, datastore->datastore, kv->kv,
search->search, s3->storage, docdb->docdb.

Verified: npm ci + tsc --noEmit => 0 errors (CI-equivalent flat install).

* console: native product naming + gate Clusters to coming-soon

Native naming (Hanzo brand rule — product name only, no upstream OSS
name in any surface):
- ResourceKind wire kinds align to the cloud /v1 contract exactly:
  databases→sql, storage→s3 (vector/datastore/kv/search/docdb unchanged).
- Strip every upstream name from product descriptions + connectionHints
  (Postgres/Qdrant/ClickHouse/Redis/Meilisearch/Mongo/-compatible → the
  Hanzo product name). Zero forbidden names remain in registry.tsx.

Clusters: register as status:'soon' rendering the coming-soon placeholder
(repo hanzoai/operator). The platform attach/provision endpoints in
platform.ts are unconfirmed (PR not merged), so we don't ship a button to
a non-existent endpoint. ClustersModule.tsx + platform.ts stay in the tree
(unreferenced from the live route) to flip back to enabled in one line once
the platform surface lands. Data products (sql/vector/datastore/kv/search/
s3/docdb) stay ENABLED — their /v1 kinds are real and shipping now.

tsc --noEmit: clean.

---------

Co-authored-by: zeekay <z@zeekay.io>
2026-06-26 13:20:20 -07:00
Hanzo AI 85e68e433e feat(console): unified product hub — catalog, pinnable favorites, account-backed prefs
Turn console2 into the one place to see, enable, and manage every Hanzo
product, with billing → billing.hanzo.ai for all.

- registry: ONE product catalog (categories + module-vs-external + enablement
  status), the single source of truth for nav, overview, and router. Adding a
  product = one CatalogEntry.
- favorites: pin products to the sidebar. Built on a new account-backed
  preferences layer (usePreferences) — customizations persist to the IAM user
  account, so they follow the user across every device/login and product.
  localStorage is only a fast-paint cache.
- shell: Pinned section + categorized catalog; exact-match active (no
  double-highlight); each row opens (in-console route or external tab) + pin toggle.
- overview: product catalog grouped by category with status, pin, and
  Open/Get-started.
- api: AccountApi.updatePreferences → POST /v1/update-preferences (self-scoped).

Typecheck + next build clean.
2026-06-26 11:15:21 -07:00
Hanzo AI 5527bb30dd fix(docker): npm install over npm ci for the @hanzo/gui dep tree
npm ci failed in CI (node:22-alpine npm 10.9) with EUSAGE 'Missing:
react-native-worklets@0.8.3 from lock file' — @hanzo/gui's react-native
optional/platform deps resolve differently across npm versions, so a
lockfile built by one npm is rejected by another's strict ci. npm install
reconciles deterministically for the build platform.
2026-06-25 13:57:42 -07:00
Hanzo AI 1b03d838db deps(console2): declare @zap-proto/web, @zap-proto/zap, superjson
The ZAP-native data layer (src/lib/zap/{client,transport,providers}.ts)
imports @zap-proto/web/client, @zap-proto/zap, and superjson; declare them
so a clean `npm ci` in CI resolves them (the prior build failed: 'Module
not found: @zap-proto/web/client' because the lockfile lacked them).
2026-06-25 13:48:28 -07:00
Hanzo AI 42f591214c feat(providers): cut Providers module to ZAP-native transport
Swap the Providers views from the REST `~/lib/api` to the ZAP-native
`~/lib/zap` (one import line each) — proving the @hanzo/gui + @zap-proto/web
go-forward: same call surface, binary ZAP over WebSocket instead of
JSON-over-HTTP, zero view-component changes.

- src/lib/zap/index.ts: barrel re-exporting ProviderApi (= ProviderApiZap),
  ApiError, Provider — the drop-in twin of ~/lib/api.
- ProviderListView/ProviderEditView: import from ~/lib/zap.
- providers.ts list(): return { rows, total } to match the REST getList
  contract exactly (true drop-in; fixes the list-view shape).

tsc --noEmit (strict) clean. Backs onto cloud's new /zap WS face which
dispatches each call into the same /v1 casibase handlers.
2026-06-25 13:44:26 -07:00
Antje Worring 668f6b8951 ci: env-aware image build (mainnet/testnet/devnet)
workflow_dispatch input 'env' bakes per-env NEXT_PUBLIC_* + tags the image
:dev/:test (mainnet stays :sha-+:latest). Hosts follow svc.env.hanzo.ai. Adds
NEXT_PUBLIC_BILLING_URL build-arg (per-env billing portal).
2026-06-22 03:17:50 -07:00
Antje Worring 7895e104b0 feat(nav): Billing link to the existing billing portal (no rebuild)
Billing is owned by hanzoai/commerce (backend) + hanzoai/billing (portal at
billing.hanzo.ai) — the console must not reimplement payments/balance. Add an
externalLinks registry (orthogonal to product modules) and render it in the
shell; Billing opens config.billingUrl (NEXT_PUBLIC_BILLING_URL, default
https://billing.hanzo.ai) in a new tab.
2026-06-22 02:44:56 -07:00
Antje Worring bfd2a2dd37 fix(auth): treat casibase anonymous-user as logged-out
The backend auto-creates an anonymous-user session for the chat product, so
get-account returns status:ok even with no real sign-in. As an ADMIN console
that made AuthGate show the dashboard for an unauthenticated visitor, while
admin endpoints (get-providers, etc.) rejected the anon session with 'Please
sign in first'. Treat type==='anonymous-user' as null so the console requires
a real IAM sign-in.
2026-06-22 00:39:44 -07:00
Antje Worring cf5d3094ae feat(console2): Models, Applications, Stores, Chat admin surfaces (@hanzo/gui /v1)
Mirror the Providers pattern: product-module + list/edit views on @hanzo/gui +
typed /v1 API modules, registered in the nav. tsc 0 errors, next build green.
2026-06-21 15:58:05 -07:00
Antje Worring 7a3dd59128 fix(ci): ensure public/ exists in image build + skip provenance artifacts
- Dockerfile: mkdir -p public before build (git doesn't track empty public/,
  so the runner COPY /app/public was failing)
- build-image: provenance/sbom false (avoid GitHub artifact-quota upload)
2026-06-21 15:05:32 -07:00
Antje Worring f117a01fac ci: Dockerfile + build-image workflow → ghcr.io/hanzoai/console2
Next.js 15 multi-stage build; NEXT_PUBLIC_* baked at build (same-origin /v1,
IAM client_id=hanzo-cloud to match cloud-api's /v1/signin). Self-hosted ARC
runner, push to ghcr.io/hanzoai/console2.
2026-06-21 15:01:36 -07:00
hanzo-dev 2b4d64c9cf feat: Hanzo Cloud Console (console2) on @hanzo/gui over the unified /v1 backend
Next.js 15 (app router) + @hanzo/gui consumed at runtime via transpilePackages.
Typed /v1 client (Provider/ModelRoute/Application/Store/Chat/Account), Hanzo IAM
OIDC auth, extensible product-module registry, and a full Providers admin surface
(list + view/edit) ported clean from hanzoai/ai onto Gui. BSD-3-Clause.
2026-06-21 14:58:20 -07:00
4737 changed files with 225990 additions and 748729 deletions
-101
View File
@@ -1,101 +0,0 @@
# Agent Guidelines for Langfuse
Langfuse is an open source LLM engineering platform for developing, monitoring,
evaluating, and debugging AI applications.
## How To Work
- Read the minimal local context required for the task.
- Keep changes scoped and avoid unrelated refactors.
- For bug fixes, write the failing test first, confirm it fails, then fix the
bug.
- For user-visible frontend changes in `web/**`, review the affected flow in a
real browser before signoff.
- For documentation screenshots in Markdown, avoid fixed `height` on `<img>`
tags; prefer Markdown images or width-only HTML so previews preserve aspect
ratio.
- Never commit secrets or credentials. Keep `.env*.example` files in
sync with required env vars.
## Project Structure
```text
langfuse/
|- web/ # Next.js app (UI + tRPC + public REST)
|- worker/ # Queue consumers and background processing
|- packages/shared/ # Shared domain, DB, queue contracts, repositories
|- ee/ # Enterprise package consumed by web
|- generated/ # Generated API clients (do not hand-edit)
|- fern/ # API definition sources
`- scripts/ # Repo scripts
```
- Dependency direction:
- `web` -> `@langfuse/shared`, `@langfuse/ee`
- `worker` -> `@langfuse/shared`
- `@langfuse/ee` -> `@langfuse/shared`
- `@langfuse/shared` -> no imports from `web`, `worker`, or `ee`
- Queue payload schemas and queue-name contracts are owned by
`packages/shared/src/server/queues.ts`.
- High-signal shared entry points:
- Domain models: `packages/shared/src/domain/{observations,traces,scores}.ts`
- Postgres schema: `packages/shared/prisma/schema.prisma`
- ClickHouse migrations:
`packages/shared/clickhouse/migrations/{clustered,unclustered}/*.sql`
- Architecture principles live in `.agents/ARCHITECTURE_PRINCIPLES.md`.
## Core Commands
- Install deps: `pnpm install`
- Dev all packages: `pnpm run dev`
- Dev web only: `pnpm run dev:web`
- Dev worker only: `pnpm run dev:worker`
- Lint all: `pnpm run lint`
- Typecheck all: `pnpm run typecheck` / `pnpm tc`
- Build check: `pnpm run build:check`
- Full build: `pnpm run build`
- Worktree bootstrap: `bash scripts/codex/setup.sh`
- Worktree maintenance: `bash scripts/codex/maintenance.sh`
- Install Playwright Chromium: `pnpm run playwright:install`
## Verification
- `web/**`: `pnpm run lint` plus targeted web tests.
- `worker/**`: `pnpm run lint` plus targeted worker tests.
- `packages/shared/**` non-schema changes:
`pnpm run lint` plus one targeted web check and one targeted worker check.
- `packages/shared/prisma/**` or `packages/shared/clickhouse/**`:
`pnpm run lint`, `pnpm run db:generate`, and targeted web/worker
regressions.
- Public API contracts in `web/src/pages/api/public/**`,
`web/src/features/public-api/types/**`, or `fern/apis/**`: `pnpm run lint`,
targeted server API tests, and Fern update/regeneration.
- Cross-package refactors: `pnpm run lint`, `pnpm run typecheck`, and targeted
tests for impacted packages.
## Generated Files
Do not hand-edit generated or build artifacts:
- `generated/*`
- `web/.next/*`
- `web/.next-check/*`
- `*/dist/*`
- `packages/shared/prisma/generated/*`
Public API contract changes must update Fern sources in `fern/apis/**` and
regenerated outputs. Never hand-edit `generated/**`.
## Shared Agent Setup
- `.agents/AGENTS.md` is the canonical root guide.
- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`.
- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`.
- When creating or editing `.agents/skills/**`, use
`.agents/skills/skill-creator/SKILL.md`; keep skills concise with
progressive disclosure.
- After changing shared agent setup, run `pnpm run agents:sync` and
`pnpm run agents:check`.
- Generated provider config and shim outputs under `.claude/`, `.cursor/`,
`.codex/`, `.vscode/`, or `.mcp.json` are local artifacts, not source of
truth files.
-49
View File
@@ -1,49 +0,0 @@
# Underlying Architecture Principles
Langfuse architecture should optimize for high-scale, exploratory observability
on wide, structured event data. These principles are grounded in current
production scale and the reference material below.
## Reference Posts
- [Simplifying Langfuse for Scale](https://langfuse.com/blog/2026-03-10-simplify-langfuse-for-scale)
- [Charity Majors on Observability 2.0](https://charity.wtf/tag/observability-2-0/)
- [All you need is Wide Events, not "Metrics, Logs and Traces"](https://isburmistrov.substack.com/p/all-you-need-is-wide-events-not-metrics)
## Principles
- Model observations as the primary analytical unit. A trace is a correlation
handle that links related observations, not the only useful entry point.
- Prefer wide, richly attributed events over fragmented metrics, logs, and trace
records that require later reconstruction.
- Preserve high-cardinality context so users can slice, group, filter, and debug
unknown unknowns without predefining every future question.
- Favor immutable or append-oriented event records for high-volume telemetry.
Updates that force read-time deduplication create hidden query costs at scale.
- Denormalize carefully when it removes hot-path joins and makes common filters
into direct column predicates.
- Design storage and query paths around columnar access patterns: narrow field
selection, time-bounded scans, useful ordering keys, and data pruning.
- Keep list, dashboard, and aggregate views on compact query-optimized
representations. Fetch large raw payloads only for focused detail views.
- Make API contracts scale-aware: require time windows where needed, expose field
selection, use token pagination, and avoid defaults that can scan all history.
- Treat cost and operational simplicity as architectural constraints. Extra
databases, queues, materialized views, and migrations must earn their long-term
operational burden.
- Preserve real-time or near-real-time debugging workflows. Batch processing can
help, but it should not make fresh production behavior invisible.
## Practical Defaults For Agents
- Before adding a metric, ask whether the same question is better answered from
wide event data.
- Before adding a join, ask whether the attribute should be propagated or
denormalized onto the observation path.
- Before reading large fields, ask whether the view needs them or can defer them
until a single-record fetch.
- Before adding an update-heavy design, ask whether immutable events plus
derived representations would be simpler at production scale.
- Before documenting public behavior, separate stable public contracts from
private production topology, account details, secret names, and incident
runbooks.
-209
View File
@@ -1,209 +0,0 @@
# Shared Agent Setup
This directory is the neutral, repo-owned source of truth for agent behavior in
Langfuse.
Use `.agents/` for configuration and guidance that should apply across tools.
Do not put durable shared guidance only in `.claude/`, `.codex/`, `.cursor/`,
or `.vscode/`.
## Layout
- `AGENTS.md`: canonical shared root instructions
- `ARCHITECTURE_PRINCIPLES.md`: architecture principles for high-scale
observability
- `config.json`: shared bootstrap and MCP configuration used to generate
tool-specific shims
- `skills/`: shared, tool-neutral implementation guidance for recurring
workflows
## `config.json`
`.agents/config.json` contains four kinds of data:
- `shared`: defaults used across tools
- `mcpServers`: project MCP servers and how to connect to them
- `claude`: Claude-specific generated settings inputs
- `codex`: Codex-specific generated settings inputs
- `cursor`: Cursor-specific generated settings inputs
Current shape:
```json
{
"shared": {
"setupScript": "bash scripts/codex/setup.sh",
"devCommand": "pnpm run dev",
"devTerminalDescription": "Main development terminal running the development server"
},
"mcpServers": {
"playwright": {
"transport": "stdio",
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest",
"--isolated",
"--save-session",
"--output-dir",
"/tmp/playwright-mcp",
"--test-id-attribute",
"data-testid"
]
},
"langfuse-docs": {
"transport": "http",
"url": "https://langfuse.com/api/mcp"
},
"linear": {
"transport": "http",
"url": "https://mcp.linear.app/mcp"
}
},
"claude": {
"settings": {
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": []
},
"enableAllProjectMcpServers": true
}
},
"codex": {
"environment": {
"version": 1,
"name": "langfuse"
}
},
"cursor": {
"environment": {
"agentCanUpdateSnapshot": false
}
}
}
```
## How Shims Are Generated
`scripts/agents/sync-agent-shims.mjs` reads `.agents/config.json` and writes the
tool discovery files that those products require.
Generated local artifacts:
- `.claude/settings.json`
- `.claude/skills/*`
- `.cursor/environment.json`
- `.cursor/mcp.json`
- `.vscode/mcp.json`
- `.mcp.json`
- `.codex/config.toml`
- `.codex/environments/environment.toml`
The repo root discovery files remain committed as symlinks:
- `AGENTS.md` -> `.agents/AGENTS.md`
- `CLAUDE.md` -> `AGENTS.md`
This keeps provider discovery stable while `.agents/` remains the source of
truth.
## When To Edit `config.json`
Edit `.agents/config.json` when you need to:
- add, remove, or update a shared MCP server
- change the shared setup/bootstrap command
- change the default dev command or terminal label used by generated shims
- adjust generated Claude, Cursor, or Codex settings that are intentionally
modeled in the shared config
Do not edit generated shim files by hand. Edit the canonical files in
`.agents/` instead.
## How To Extend `config.json`
### Add an MCP server
Add a new entry under `mcpServers`.
For `stdio` servers:
```json
{
"mcpServers": {
"example": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "some-package"]
}
}
}
```
For HTTP servers:
```json
{
"mcpServers": {
"example": {
"transport": "http",
"url": "https://example.com/mcp"
}
}
}
```
Optional fields:
- `env` for `stdio` servers
- `headers` for HTTP servers
### Change bootstrap or default dev command
Update values in `shared`:
- `setupScript`
- `devCommand`
- `devTerminalDescription`
### Add tool-specific generated inputs
Only add tool-specific fields when they are required to generate a discovery
file for a supported tool. Keep the shared config minimal and neutral.
## Workflow
After editing `.agents/config.json`:
1. Run `pnpm run agents:sync`
2. Run `pnpm run agents:check`
3. Verify you did not stage any generated files under `.claude/skills/` or the
generated MCP/runtime config paths
4. Update `AGENTS.md` or `CONTRIBUTING.md` if the shared workflow materially
changed
`pnpm install` also runs the sync/check flow via `postinstall`.
## Adding Shared Skills
Shared skills live under `.agents/skills/`.
Use them for durable, reusable guidance such as:
- backend implementation patterns
- provider-specific maintenance workflows
- repeated repo-specific review checklists
Do not use skills for one-off task notes or tool runtime configuration.
Use `skills/skill-creator/SKILL.md` when creating or editing shared skills.
`pnpm run agents:sync` projects the shared skills into `.claude/skills/` so
Claude can discover the same repo-owned skills.
-59
View File
@@ -1,59 +0,0 @@
{
"shared": {
"setupScript": "bash scripts/codex/setup.sh",
"devCommand": "pnpm run dev",
"devTerminalDescription": "Main development terminal running the development server"
},
"mcpServers": {
"playwright": {
"transport": "stdio",
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest",
"--isolated",
"--save-session",
"--output-dir",
"/tmp/playwright-mcp",
"--test-id-attribute",
"data-testid"
]
},
"langfuse-docs": {
"transport": "http",
"url": "https://langfuse.com/api/mcp"
},
"linear": {
"transport": "http",
"url": "https://mcp.linear.app/mcp"
}
},
"claude": {
"settings": {
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)"
],
"deny": []
},
"enableAllProjectMcpServers": true
}
},
"codex": {
"environment": {
"version": 1,
"name": "langfuse"
}
},
"cursor": {
"environment": {
"agentCanUpdateSnapshot": false
}
}
}
-60
View File
@@ -1,60 +0,0 @@
---
name: add-model-price
description: Use when editing worker/src/constants/default-model-prices.json, packages/shared/src/server/llm/types.ts, pricing tiers, tokenizer IDs, or matchPattern regexes for OpenAI, Anthropic, Bedrock, Vertex, Azure, or Gemini model pricing.
---
# Add Model Price
Use this skill for model pricing changes in `worker/` and shared LLM type
updates in `packages/shared/`.
## When to Apply
- Editing `worker/src/constants/default-model-prices.json`
- Editing `packages/shared/src/server/llm/types.ts`
- Adding a new priced model
- Updating provider prices, cache pricing, or tier conditions
- Expanding regex coverage for Bedrock, Vertex, Azure, or provider-prefixed
model names
## How to Read This Skill
- Use this `SKILL.md` as the high-level workflow and helper index.
- Open only the specific reference file that matches the task.
## Quick Start Checklist
### Adding a New Model
- Gather official pricing from the provider documentation.
- Generate a lowercase UUID for the model entry.
- Create a `matchPattern` that covers supported provider formats.
- Add at least one default pricing tier.
- Insert the pricing entry into `worker/src/constants/default-model-prices.json`.
- Update `packages/shared/src/server/llm/types.ts` if the model should be
selectable in playground or evaluation flows.
- Validate the JSON after editing.
### Updating an Existing Model
- Update the relevant prices, keys, tiers, or regexes.
- Refresh `updatedAt` to today's ISO-8601 timestamp.
- Validate the JSON after editing.
## Reference Map
| Topic | Read this when | File |
| ------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Schema and tier rules | You need the entry shape or pricing-tier invariants | [references/schema-and-tiers.md](references/schema-and-tiers.md) |
| Provider sources and price keys | You need official pricing URLs, per-token conversion, or provider-specific usage keys | [references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md) |
| Match patterns | You are editing `matchPattern` regexes or provider coverage | [references/match-patterns.md](references/match-patterns.md) |
| Workflow and validation | You are applying the end-to-end edit process or checking common mistakes | [references/workflow-and-validation.md](references/workflow-and-validation.md) |
## Deterministic Helpers
- Pricing file validator:
`node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs`
- Match-pattern tester:
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model <modelName> --accept <sample...> --reject <sample...>`
- Direct regex tester:
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --pattern '(?i)^(openai/)?(gpt-4o)$' --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini`
@@ -1,51 +0,0 @@
# Match Patterns
## Anthropic Claude: API + Bedrock + Vertex
```regex
(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$
```
Matches:
- `claude-opus-4-6`
- `anthropic/claude-opus-4-6`
- `anthropic.claude-opus-4-6-v1:0`
- `us.anthropic.claude-opus-4-6-v1:0`
## With Version Date
```regex
(?i)^(anthropic\/)?(claude-opus-4-5-20251101|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-5-20251101-v1:0|claude-opus-4-5@20251101)$
```
## OpenAI
```regex
(?i)^(openai\/)?(gpt-4o)$
```
## Google Gemini
```regex
(?i)^(google\/)?(gemini-2.5-pro)$
```
## Pattern Components
| Component | Purpose | Example |
| --- | --- | --- |
| `(?i)` | Case-insensitive match | `gpt-4o` and `GPT-4O` |
| `^...$` | Full-string match | Avoids partial matches |
| `(provider\/)?` | Optional provider prefix | `openai/gpt-4o` |
| `(eu\\.|us\\.|apac\\.)?` | Optional AWS region prefix | `us.anthropic.model` |
| `(:0)?` | Optional version suffix | Bedrock model versions |
| `@date` | Vertex AI version format | `claude-3-5-sonnet@20240620` |
## Testing Patterns
Use the bundled helper script:
```bash
node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model gpt-4o --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini
```
@@ -1,84 +0,0 @@
# Provider Sources and Price Keys
## Official Pricing Sources
Always fetch pricing from the provider's official docs before editing.
| Provider | Source |
| --- | --- |
| Anthropic Claude | `https://platform.claude.com/docs/en/about-claude/pricing` |
| OpenAI | `https://openai.com/api/pricing/` |
| Google Gemini | `https://ai.google.dev/pricing` |
| AWS Bedrock | `https://aws.amazon.com/bedrock/pricing/` |
| Azure OpenAI | `https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/` |
Capture:
1. Base input token price per million tokens
2. Output token price per million tokens
3. Cache write price when supported
4. Cache read price when supported
5. Any long-context or conditional pricing
6. All model ID variants that Langfuse should match
## Price Conversion
Values in `default-model-prices.json` are per token, not per million tokens.
| Provider Price | JSON Value |
| --- | --- |
| `$5 / MTok` | `5e-6` |
| `$25 / MTok` | `25e-6` |
| `$0.50 / MTok` | `0.5e-6` |
| `$6.25 / MTok` | `6.25e-6` |
Formula:
```text
price_per_token = price_per_mtok / 1_000_000
```
## Common Price Keys by Provider
### Anthropic Claude
```json
{
"input": "<base_input_price>",
"input_tokens": "<base_input_price>",
"output": "<output_price>",
"output_tokens": "<output_price>",
"cache_creation_input_tokens": "<cache_write_price>",
"input_cache_creation": "<cache_write_price>",
"cache_read_input_tokens": "<cache_read_price>",
"input_cache_read": "<cache_read_price>"
}
```
### OpenAI
```json
{
"input": "<input_price>",
"input_cached_tokens": "<cached_input_price>",
"input_cache_read": "<cached_input_price>",
"output": "<output_price>"
}
```
### Google Gemini
```json
{
"input": "<input_price>",
"input_modality_1": "<input_price>",
"prompt_token_count": "<input_price>",
"promptTokenCount": "<input_price>",
"input_cached_tokens": "<cached_price>",
"cached_content_token_count": "<cached_price>",
"output": "<output_price>",
"output_modality_1": "<output_price>",
"candidates_token_count": "<output_price>",
"candidatesTokenCount": "<output_price>"
}
```
@@ -1,138 +0,0 @@
# Schema and Tiers
## Target Files
- Pricing data: `worker/src/constants/default-model-prices.json`
- Shared model types: `packages/shared/src/server/llm/types.ts`
## Complete Model Entry Schema
```json
{
"id": "uuid-generated-with-uuidgen",
"modelName": "model-name-identifier",
"matchPattern": "(?i)^regex-pattern$",
"createdAt": "ISO-8601-timestamp",
"updatedAt": "ISO-8601-timestamp",
"tokenizerConfig": null,
"tokenizerId": "claude|openai|null",
"pricingTiers": [
{
"id": "model-uuid_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.000005,
"output": 0.000025
}
}
]
}
```
## Required Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Unique lowercase ID used by the pricing file |
| `modelName` | string | Primary model identifier |
| `matchPattern` | string | Regex used to match provider model names |
| `createdAt` | string | ISO-8601 timestamp set on creation |
| `updatedAt` | string | ISO-8601 timestamp refreshed whenever the entry changes |
| `pricingTiers` | array | At least one pricing tier |
## Optional Fields
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `tokenizerId` | string | `null` | Usually `"claude"`, `"openai"`, or `null` |
| `tokenizerConfig` | object | `null` | Custom tokenizer settings |
## Default Tier
Every model must have exactly one default tier:
```json
{
"id": "{model-id}_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {}
}
```
Rules:
- `isDefault` must be `true`
- `priority` must be `0`
- `conditions` must be `[]`
## Additional Tiers
Use extra tiers for context-window or usage-based pricing:
```json
{
"id": "uuid-for-tier",
"name": "Large Context (>200K)",
"isDefault": false,
"priority": 1,
"conditions": [
{
"usageDetailPattern": "(input|prompt|cached)",
"operator": "gt",
"value": 200000,
"caseSensitive": false
}
],
"prices": {}
}
```
Supported operators: `gt`, `gte`, `lt`, `lte`, `eq`, `neq`
## Multi-Tier Example
Use multiple tiers when a provider changes pricing by context window or usage
class. Keep exactly one default tier and assign higher priorities to conditional
tiers:
```json
{
"pricingTiers": [
{
"id": "{model-id}_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 0.000001,
"output": 0.000002
}
},
{
"id": "uuid-for-large-context-tier",
"name": "Large Context (>200K)",
"isDefault": false,
"priority": 1,
"conditions": [
{
"usageDetailPattern": "(input|prompt|cached)",
"operator": "gt",
"value": 200000,
"caseSensitive": false
}
],
"prices": {
"input": 0.000002,
"output": 0.000004
}
}
]
}
```
@@ -1,128 +0,0 @@
# Workflow and Validation
## Step-by-Step Workflow
### 1. Fetch Official Pricing
Open the provider's official pricing page and collect input, output, cache
write, and cache read prices.
### 2. Generate a Lowercase ID
```bash
uuidgen
```
Convert the output to lowercase before using it.
### 3. Create or Update the Entry
Use nearby models in `worker/src/constants/default-model-prices.json` as the
template, then:
- add the new entry near related models
- refresh `updatedAt` when editing an existing entry
Example for a model with `$5` input, `$25` output, `$6.25` cache write, and
`$0.50` cache read per million tokens:
```json
{
"id": "13458bc0-1c20-44c2-8753-172f54b67647",
"modelName": "claude-opus-4-6",
"matchPattern": "(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?)$",
"createdAt": "2026-03-09T00:00:00.000Z",
"updatedAt": "2026-03-09T00:00:00.000Z",
"tokenizerConfig": null,
"tokenizerId": "claude",
"pricingTiers": [
{
"id": "13458bc0-1c20-44c2-8753-172f54b67647_tier_default",
"name": "Standard",
"isDefault": true,
"priority": 0,
"conditions": [],
"prices": {
"input": 5e-6,
"input_tokens": 5e-6,
"output": 25e-6,
"output_tokens": 25e-6,
"cache_creation_input_tokens": 6.25e-6,
"input_cache_creation": 6.25e-6,
"cache_read_input_tokens": 0.5e-6,
"input_cache_read": 0.5e-6
}
}
]
}
```
### 4. Update Shared Model Types When Needed
If the model should be available in playground or LLM-as-judge flows, add it to
the correct array in `packages/shared/src/server/llm/types.ts`.
Common arrays include:
- `anthropicModels`
- `openAIModels`
- `vertexAIModels`
- `googleAIStudioModels`
Do not add a new model as the first entry in one of these arrays. The first
entry is used as a default model in some test or evaluation paths, and newer
models may not be available to all users yet.
### 5. Validate the Result
Run the bundled validator:
```bash
node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs
```
For quick manual inspection, use `jq`:
```bash
jq '.[] | select(.modelName == "claude-opus-4-6")' worker/src/constants/default-model-prices.json
```
## Validation Rules
1. Exactly one default tier must have `isDefault: true`
2. The default tier must have `priority: 0`
3. The default tier must have `conditions: []`
4. Non-default tiers must have `priority > 0`
5. Non-default tiers must have at least one condition
6. Priorities must be unique within a model
7. Tier names must be unique within a model
8. Each tier must contain at least one price
9. All tiers must expose the same usage-type keys
10. Regex patterns must be valid
## Testing Model Matching
Use the bundled tester before finishing any `matchPattern` change:
```bash
node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model <modelName> --accept <sample...> --reject <sample...>
```
Use representative accepted and rejected model IDs for every provider format the
regex is intended to cover.
## Common Mistakes
- Guessing prices instead of using official provider docs
- Using MTok values directly instead of per-token values
- Forgetting the `_tier_default` suffix on the default tier ID
- Forgetting to escape regex metacharacters such as `.`
- Forgetting to refresh `updatedAt`
## Existing Model Templates
Use nearby entries as templates:
- `claude-opus-4-5-20251101` for Anthropic multi-provider patterns
- `gpt-4o` for a simple OpenAI pattern
- `gemini-2.5-pro` for a multi-tier Gemini entry
@@ -1,115 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
const repoRoot = process.cwd();
const defaultFile = path.resolve(
repoRoot,
"worker/src/constants/default-model-prices.json",
);
const args = process.argv.slice(2);
function readOption(name) {
const index = args.indexOf(name);
if (index === -1) {
return null;
}
return args[index + 1] ?? null;
}
function readListOption(name) {
const index = args.indexOf(name);
if (index === -1) {
return [];
}
const values = [];
for (let i = index + 1; i < args.length; i += 1) {
if (args[i].startsWith("--")) {
break;
}
values.push(args[i]);
}
return values;
}
function compilePattern(rawPattern) {
let source = rawPattern;
let flags = "";
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
if (inlineFlags) {
flags = inlineFlags[1];
source = rawPattern.slice(inlineFlags[0].length);
}
return new RegExp(source, flags);
}
let pattern = readOption("--pattern");
const modelName = readOption("--model");
const accepted = readListOption("--accept");
const rejected = readListOption("--reject");
if (!pattern && !modelName) {
console.error("Pass either --pattern <regex> or --model <modelName>.");
process.exit(1);
}
if (accepted.length === 0 && rejected.length === 0) {
console.error("Provide samples with --accept and/or --reject.");
process.exit(1);
}
if (!pattern && modelName) {
const models = JSON.parse(await fs.readFile(defaultFile, "utf8"));
const model = models.find((entry) => entry.modelName === modelName);
if (!model) {
console.error(`Model not found in pricing file: ${modelName}`);
process.exit(1);
}
pattern = model.matchPattern;
}
let regex;
try {
regex = compilePattern(pattern);
} catch (error) {
console.error(`Invalid pattern: ${error.message}`);
process.exit(1);
}
const failures = [];
for (const sample of accepted) {
const matched = regex.test(sample);
console.log(`${matched ? "PASS" : "FAIL"} accept ${sample}`);
if (!matched) {
failures.push(`Expected pattern to match: ${sample}`);
}
}
for (const sample of rejected) {
const matched = regex.test(sample);
console.log(`${!matched ? "PASS" : "FAIL"} reject ${sample}`);
if (matched) {
failures.push(`Expected pattern to reject: ${sample}`);
}
}
if (failures.length > 0) {
console.error("");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("");
console.log(
`Pattern is valid for ${accepted.length + rejected.length} sample(s).`,
);
@@ -1,150 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
const defaultFile = "worker/src/constants/default-model-prices.json";
const repoRoot = process.cwd();
const filePath = path.resolve(repoRoot, process.argv[2] ?? defaultFile);
const failures = [];
function compileMatchPattern(rawPattern, label) {
let source = rawPattern;
let flags = "";
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
if (inlineFlags) {
flags = inlineFlags[1];
source = rawPattern.slice(inlineFlags[0].length);
}
try {
return new RegExp(source, flags);
} catch (error) {
failures.push(`${label}: invalid matchPattern (${error.message})`);
return null;
}
}
function keysOfPrices(prices) {
return Object.keys(prices).sort();
}
const raw = await fs.readFile(filePath, "utf8");
const models = JSON.parse(raw);
if (!Array.isArray(models)) {
throw new Error("Expected the pricing file to be a JSON array.");
}
for (const model of models) {
const label = model.modelName ?? model.id ?? "<unknown-model>";
if (!model.id || typeof model.id !== "string") {
failures.push(`${label}: missing string id`);
}
if (!model.modelName || typeof model.modelName !== "string") {
failures.push(`${label}: missing string modelName`);
}
if (!model.matchPattern || typeof model.matchPattern !== "string") {
failures.push(`${label}: missing string matchPattern`);
} else {
compileMatchPattern(model.matchPattern, label);
}
if (Number.isNaN(Date.parse(model.createdAt ?? ""))) {
failures.push(`${label}: invalid createdAt timestamp`);
}
if (Number.isNaN(Date.parse(model.updatedAt ?? ""))) {
failures.push(`${label}: invalid updatedAt timestamp`);
}
if (!Array.isArray(model.pricingTiers) || model.pricingTiers.length === 0) {
failures.push(`${label}: pricingTiers must be a non-empty array`);
continue;
}
const defaultTiers = model.pricingTiers.filter((tier) => tier.isDefault);
if (defaultTiers.length !== 1) {
failures.push(`${label}: must have exactly one default tier`);
}
const seenPriorities = new Set();
const seenNames = new Set();
let expectedPriceKeys = null;
for (const tier of model.pricingTiers) {
const tierLabel = `${label}/${tier.name ?? tier.id ?? "<unknown-tier>"}`;
if (seenPriorities.has(tier.priority)) {
failures.push(`${tierLabel}: duplicate tier priority ${tier.priority}`);
} else {
seenPriorities.add(tier.priority);
}
if (seenNames.has(tier.name)) {
failures.push(`${tierLabel}: duplicate tier name ${tier.name}`);
} else {
seenNames.add(tier.name);
}
if (!tier.prices || typeof tier.prices !== "object") {
failures.push(`${tierLabel}: missing prices object`);
continue;
}
const priceKeys = keysOfPrices(tier.prices);
if (priceKeys.length === 0) {
failures.push(`${tierLabel}: prices object must not be empty`);
}
for (const [usageType, price] of Object.entries(tier.prices)) {
if (typeof price !== "number" || Number.isNaN(price) || price < 0) {
failures.push(`${tierLabel}: invalid price for ${usageType}`);
}
}
if (tier.isDefault) {
if (tier.priority !== 0) {
failures.push(`${tierLabel}: default tier priority must be 0`);
}
if (!Array.isArray(tier.conditions) || tier.conditions.length !== 0) {
failures.push(`${tierLabel}: default tier conditions must be []`);
}
} else {
if (!(tier.priority > 0)) {
failures.push(`${tierLabel}: non-default tier priority must be > 0`);
}
if (!Array.isArray(tier.conditions) || tier.conditions.length === 0) {
failures.push(
`${tierLabel}: non-default tiers must define at least one condition`,
);
}
}
if (!expectedPriceKeys) {
expectedPriceKeys = priceKeys.join(",");
} else if (expectedPriceKeys !== priceKeys.join(",")) {
failures.push(
`${tierLabel}: price keys must match the other tiers for ${label}`,
);
}
}
}
if (failures.length > 0) {
console.error("Pricing validation failed:\n");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`Validated ${models.length} pricing entries in ${path.relative(repoRoot, filePath)}.`,
);
@@ -1,71 +0,0 @@
---
name: agent-setup-maintenance
description: |
Shared workflow for editing Langfuse's repo-owned agent setup under `.agents/`.
Use when changing AGENTS files, shared skills, `.agents/config.json`,
generated shim behavior, provider discovery paths, or install-time agent sync.
---
# Agent Setup Maintenance
Use this skill when changing the shared agent setup for the repository.
## Start Here
- Read [`../../README.md`](../../README.md) for the shared config and shim model.
- Read root [`../../AGENTS.md`](../../AGENTS.md) for repo-level expectations.
- When adding or editing shared skills, use
[`../skill-creator/SKILL.md`](../skill-creator/SKILL.md), then apply the
repo-specific checks in this skill.
- Inspect [`../../../scripts/agents/sync-agent-shims.mjs`](../../../scripts/agents/sync-agent-shims.mjs)
before changing generated outputs or provider discovery behavior.
- Inspect [`../../../scripts/postinstall.sh`](../../../scripts/postinstall.sh)
and [`../../../package.json`](../../../package.json) when changing install-time
sync behavior.
## Workflow
1. Edit the canonical files under `.agents/`, not generated provider outputs.
2. Keep root `AGENTS.md` and `CLAUDE.md` as discovery symlinks; do not turn
them back into manually maintained copies.
3. Treat tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`,
`.vscode/`, and `.mcp.json` as generated discovery surfaces unless the tool
requires a truly tool-specific feature.
4. Keep root `AGENTS.md` concise. Move detailed or conditional workflows into
shared skills or package `AGENTS.md` files.
5. Treat developer feedback as a learning loop: when a task reveals a durable
repo convention, recurring pitfall, reusable workflow, or verification
pattern, update the smallest relevant `AGENTS.md` or shared skill.
6. When adding or changing a shared skill, keep `SKILL.md` as the entrypoint; do
not add skill-by-skill links to root `AGENTS.md`.
7. When shared setup behavior changes materially, update `README.md` and
contributor-facing docs in the same PR.
## Docker / Install-Time Constraint
- `pnpm install` runs in environments that may not contain the full repo source
tree.
- In Docker builds, Turbo's pruned install stage can run root `postinstall`
before `scripts/` and `.agents/` are available in the image.
- Keep install-time agent setup logic robust in those pruned contexts: skip
cleanly when the required repo-owned files are not present.
## Required Verification
Run after changing shared agent setup:
- `pnpm run agents:sync`
- `pnpm run agents:check`
Run additional verification when relevant:
- `pnpm run postinstall` when install-time behavior changes
- targeted tests for any scripts you changed
## Design Rules
- Prefer one repo-owned source of truth over duplicated provider-specific files.
- Keep shared setup tool-neutral where possible.
- Only keep provider-specific files in source control when the provider requires
a fixed discovery path or feature that cannot be expressed through the shared
setup model.
@@ -1,62 +0,0 @@
---
name: analyze-cloud-costs
description: |
Analyze Langfuse Cloud infrastructure cost structure using Metabase cost
marts. Use when asked about cloud spend, AWS versus ClickHouse cost splits,
cost drivers by provider/service/usage type/account, daily cost per tracing
event, infra cost dashboards, or cost regressions visible in Metabase.
---
# Analyze Cloud Costs
## Overview
Use this skill for evidence-backed Langfuse Cloud cost analysis. The primary
source is the Metabase infra cost dashboard and its production cost marts; the
deliverable should name the time window, query grain, top drivers, and caveats.
## Workflow
1. Clarify the question and choose the grain:
- Headline daily totals: total, AWS, ClickHouse, tracing events, and cost per
100k events.
- Cost structure: provider, service, usage type, operation, account, and day.
- Driver or regression analysis: compare a recent complete-day window against
a prior baseline.
2. Load [`references/cost-marts.md`](references/cost-marts.md) for table IDs,
field IDs, query examples, and caveats.
3. Use the Metabase MCP. If the Metabase tools are not visible, discover them
with tool search before falling back to manual interpretation.
4. Prefer complete UTC days. Avoid treating current-day AWS cost as final
because AWS CUR rows can arrive late.
5. Start broad, then drill down:
- Provider split.
- Service split within the dominant provider.
- Usage type, operation, and account split for the top services.
- Daily trend when explaining change over time.
6. Report only what the queried data supports. If a requested slice is absent,
say that no rows were found for that slice instead of inventing a driver.
## Query Rules
- Use `mcp__metabase__.query` for quick reads. Use
`construct_query` plus `execute_query` when you need to inspect or reuse the
opaque query.
- Pass `filters`, `aggregations`, `group_by`, and `fields` as JSON arrays. Some
tool schemas may display these as strings; if that happens, serialize the same
arrays without changing their shape.
- Keep limits explicit and small enough for analysis. Use pagination only when
the continuation token is needed.
- Include the Metabase dashboard link or query result context in the final
answer when useful.
## Output Expectations
Summarize:
- Time window and whether it uses complete UTC days.
- Total cost and provider split when relevant.
- Top cost drivers by service, usage type, operation, or account.
- Trend or baseline comparison when the user asks "why did this change?"
- Caveats, especially incomplete current-day AWS data and ClickHouse credit
labeling in the unified mart.
@@ -1,4 +0,0 @@
interface:
display_name: "Analyze Cloud Costs"
short_description: "Analyze Langfuse Cloud cost structure"
default_prompt: "Use $analyze-cloud-costs to explain recent Langfuse Cloud cost drivers from Metabase."
@@ -1,137 +0,0 @@
# Langfuse Cloud Cost Marts
Use this reference when querying or explaining Langfuse Cloud cost structure.
Dashboard:
- https://langfuse.metabaseapp.com/dashboard/22-infra-cost?account=&date=past90days&tab=20-tab-1
## Primary Tables
| Purpose | Table | ID |
| --- | --- | --- |
| Unified AWS and ClickHouse cost rows by provider, service, usage type, account, and day | `langfuse_prod.mart_daily_cost_chart` | `739` |
| Daily headline totals plus tracing event counts and cost per 100k events | `langfuse_prod.mart_daily_cost_with_events` | `784` |
| Detailed AWS CUR summary by product, operation, account, and usage type | `langfuse_prod.mart_aws_cost_daily_by_service` | `610` |
| Detailed ClickHouse costs by entity and metric | `langfuse_prod.mart_clickhouse_daily_cost` | `689` |
Prefer table `739` for structural breakdowns. Prefer table `784` for daily
headline totals.
## Field IDs
### `mart_daily_cost_chart` (`739`)
| Field ID | Field |
| --- | --- |
| `t739-0` | `usage_date` |
| `t739-1` | `service_provider` |
| `t739-2` | `service_name` |
| `t739-3` | `operation` |
| `t739-4` | `usage_type` |
| `t739-5` | `account_name` |
| `t739-6` | `cost_usd` |
### `mart_daily_cost_with_events` (`784`)
| Field ID | Field |
| --- | --- |
| `t784-0` | `usage_date` |
| `t784-1` | `total_cost_usd` |
| `t784-2` | `clickhouse_cost_usd` |
| `t784-3` | `aws_cost_usd` |
| `t784-5` | `s3_api_operations_cost_usd` |
| `t784-6` | `total_tracing_events` |
| `t784-7` | `total_cost_per_100k_events` |
## Metabase MCP Patterns
The Metabase MCP supports `query` for direct reads and
`construct_query` plus `execute_query` for reusable opaque queries. In practice,
pass `filters`, `aggregations`, `group_by`, and `fields` as JSON arrays:
```json
{
"table_id": 739,
"filters": [
{
"field_id": "t739-0",
"operation": "greater-than-or-equal",
"value": "2026-05-09"
}
],
"aggregations": [
{
"function": "sum",
"field_id": "t739-6"
}
],
"group_by": [
{ "field_id": "t739-1" },
{ "field_id": "t739-2" }
],
"limit": "200"
}
```
If a tool surface insists on strings for those parameters, serialize the same
arrays as JSON strings.
## Common Breakdowns
Provider split:
- Table `739`
- Aggregate `sum(t739-6)`
- Group by `t739-1`
Service split:
- Table `739`
- Aggregate `sum(t739-6)`
- Group by `t739-1`, `t739-2`
Usage type split:
- Table `739`
- Aggregate `sum(t739-6)`
- Group by `t739-1`, `t739-4`
Environment/account split:
- Table `739`
- Aggregate `sum(t739-6)`
- Group by `t739-1`, `t739-5`
Daily headline totals:
- Table `784`
- Filter `t784-0` by date.
- Read `total_cost_usd`, `clickhouse_cost_usd`, `aws_cost_usd`,
`total_tracing_events`, and `total_cost_per_100k_events`.
Daily trend by provider:
- Table `739`
- Aggregate `sum(t739-6)`
- Group by `t739-0`, `t739-1`
Drilldown sequence for a cost spike:
1. Compare total daily cost in table `784`.
2. Split the same days by provider in table `739`.
3. Split the dominant provider by service.
4. Split the dominant service by usage type, operation, and account.
## Caveats
- Current-day AWS cost can be incomplete because AWS CUR data may not have
landed yet.
- For stable recent analysis, prefer the last complete UTC days rather than
including today.
- ClickHouse cost rows are labeled `cost_usd` in the unified mart, but the
source metric is ClickHouse credits. Mention this when precision or billing
interpretation matters.
- Field IDs can change if Metabase models are rebuilt. If a query fails, search
Metabase for the table name and inspect the returned metadata before
changing the analysis.
@@ -1,116 +0,0 @@
---
name: backend-dev-guidelines
description: Shared backend guide for Langfuse's Next.js, tRPC, BullMQ, and TypeScript monorepo. Use when creating or reviewing tRPC routers, public REST endpoints, BullMQ queue processors, backend services, middleware, Prisma or ClickHouse data access, OpenTelemetry instrumentation, Zod validation, env configuration, or backend tests across web, worker, or packages/shared.
---
# Backend Development Guidelines
Use this skill for backend and API work across `web/`, `worker/`, and
`packages/shared/`.
## When to Apply
- Creating or modifying tRPC routers and procedures
- Creating or modifying public API endpoints
- Creating or modifying queue processors, producers, or queue-backed workflows
- Building or refactoring backend services and repositories
- Working on backend auth, middleware, validation, or observability
- Updating Prisma or ClickHouse access patterns
- Adding or fixing backend tests
## How to Read This Skill
- Use this `SKILL.md` when the task spans multiple backend areas or you need the
end-to-end reference map.
- Read only the specific reference file that matches the work when the scope is
narrower.
- If the task introduces a user-supplied URL, an outbound HTTP request, a new
integration, or touches secrets, RBAC, or redirect handling, also load the
shared [`security-review`](../security-review/SKILL.md) skill before
designing or implementing the change.
## Quick Start Checklists
### UI: New tRPC Feature
- Define the router in `features/[feature]/server/*Router.ts`.
- Use the appropriate protected or public procedure.
- Authenticate with JWT-aware middleware.
- Check project/resource access and entitlements.
- Validate input with Zod v4.
- Put business logic in a service file.
- Use `traceException` for error handling where relevant.
- Add unit or integration tests in `__tests__/`.
- Access config via `env.mjs`.
### SDKs: New Public API Endpoint
- Create the route in `pages/api/public/`.
- Wrap it with `withMiddlewares` and `createAuthedProjectAPIRoute`.
- Define types in `features/public-api/types/`.
- Authenticate with basic auth.
- Validate query, body, and response with Zod schemas.
- Include API versioning in paths and schemas.
- Update Fern API definitions to match TypeScript types.
- Add end-to-end tests in `__tests__/async/`.
### Worker: New Queue Processor
- Create the processor in `worker/src/queues/`.
- Define queue types in `packages/shared/src/server/queues`.
- Place business logic in `features/` or `worker/src/features/`.
- Distinguish failed jobs from jobs that should succeed with a recorded error.
- Register the queue in `WorkerManager` in `app.ts`.
- Add worker vitest coverage.
## Core Principles
- tRPC procedures, public API routes, and queue processors delegate business
logic to services.
- Access configuration through `env.mjs`; do not read `process.env` directly
outside env setup.
- Validate all external input with Zod v4.
- Use Prisma directly for simple CRUD and repositories for complex query access.
- Use OpenTelemetry and DataDog for backend observability.
- Always filter project-scoped database queries by `projectId`.
- Keep Fern API definitions in sync with public TypeScript API contracts.
- Keep backend tests independent and parallel-safe.
## Live Examples
- tRPC router with project auth and Zod input:
`web/src/features/events/server/eventsRouter.ts`.
- Public API route with middleware and typed request/response schemas:
`web/src/pages/api/public/datasets/index.ts`.
- Worker queue processor with typed jobs, logging, and retry behavior:
`worker/src/queues/evalQueue.ts`.
- Tenant filters for Prisma and ClickHouse:
`references/database-patterns.md`.
## Naming Conventions
- tRPC routers: `camelCaseRouter.ts`, for example `datasetRouter.ts`.
- Services: `service.ts` in the feature server directory.
- Queue processors: `camelCaseQueue.ts`, for example `evalQueue.ts`.
- Public API routes: kebab-case filenames, for example `dataset-items.ts`.
## Anti-Patterns to Avoid
- Business logic in routes or procedures.
- Direct `process.env` usage instead of `env.mjs` / `env.ts`.
- Missing error handling.
- Missing input validation.
- Missing `projectId` filters on tenant-scoped queries.
- `console.log` instead of `logger` / `traceException`.
## Reference Map
| Topic | Read this when | File |
| ----------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Architecture and package boundaries | You need the web/worker/shared split, request flow, or queue lifecycle | [references/architecture-overview.md](references/architecture-overview.md) |
| Routing and controllers | You are writing tRPC procedures, public API routes, or queue entrypoints | [references/routing-and-controllers.md](references/routing-and-controllers.md) |
| Middleware and auth | You are changing request auth, permissions, or middleware composition | [references/middleware-guide.md](references/middleware-guide.md) |
| Services and repositories | You are placing business logic, repository code, or DI patterns | [references/services-and-repositories.md](references/services-and-repositories.md) |
| Database access | You are touching Prisma, ClickHouse, tenant filters, or query patterns | [references/database-patterns.md](references/database-patterns.md) |
| Configuration | You are adding env vars, startup config, or runtime toggles | [references/configuration.md](references/configuration.md) |
| Testing | You are adding or updating backend tests | [references/testing-guide.md](references/testing-guide.md) |
@@ -1,870 +0,0 @@
# Architecture Overview - Langfuse Backend
Complete guide to the layered architecture pattern used in Langfuse's Next.js/tRPC/Express monorepo. Check package manifests such as `web/package.json` for current framework versions before version-sensitive work.
## Table of Contents
- [Layered Architecture Pattern](#layered-architecture-pattern)
- [Request Lifecycle](#request-lifecycle)
- [Directory Structure](#directory-structure)
- [Module Organization](#module-organization)
- [Separation of Concerns](#separation-of-concerns)
- [Database Architecture](#database-architecture)
---
## Layered Architecture Pattern
Langfuse uses a **three-layer architecture** with two primary entry points (tRPC and Public API) plus async processing via Worker.
### The Three Layers
```
# Web Package (Next.js)
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
│ │ │ │
│ HTTP Request │ │ HTTP Request │
│ ↓ │ │ ↓ │
│ tRPC Procedure │ │ withMiddlewares + │
│ (protectedProjectProcedure)│ │ createAuthedProjectAPIRoute│
│ ↓ │ │ ↓ │
│ Service (business logic) │ │ Service (business logic) │
│ ↓ │ │ ↓ │
│ Prisma / ClickHouse │ │ Prisma / ClickHouse │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
[optional]: Publish to Redis BullMQ queue
┌─ Worker Package (Express) ──────────────────────────────────┐
│ │
│ BullMQ Queue Job │
│ ↓ │
│ Queue Processor (handles job) │
│ ↓ │
│ Service (business logic) │
│ ↓ │
│ Prisma / ClickHouse │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Layer Breakdown
**Layer 1: API Entry Points**
Two types of entry points:
- **tRPC Procedures** - Type-safe RPC for UI
- Located in `features/[feature]/server/*Router.ts`
- Uses middleware for auth/validation
- Types shared between client/server
- **Public REST APIs** - REST endpoints for SDKs
- Located in `pages/api/public/`
- Uses `withMiddlewares` + `createAuthedProjectAPIRoute`
- Versioned with Zod schemas
**Layer 2: Services**
- Business logic and orchestration
- Shared between tRPC, Public API, and Worker
- Located in `features/[feature]/server/service.ts`
- No HTTP/Request/Response knowledge
- Use repositories for complex queries or Prisma directly for simple CRUD
**Layer 3: Data Access**
- **Repositories** for complex data access patterns (traces, observations, scores, events)
- **Direct Prisma** for simple CRUD operations in services
- PostgreSQL for transactional data
- ClickHouse for analytics/traces (accessed via repositories)
- Redis for caching/queues
**Async Processing Layer: Worker**
- BullMQ queue processors
- Same service layer as Web
- Handles long-running operations
### Why This Architecture?
**Testability:**
- tRPC procedures easily testable with type-safe callers
- Services tested independently with mocked DB
- Queue processors tested with vitest
- Clear test boundaries
**Maintainability:**
- Business logic isolated in services
- tRPC provides type safety end-to-end
- Changes to API don't affect service layer
- Easy to locate and fix bugs
**Reusability:**
- Services used by tRPC, Public API, Worker, and scripts
- Business logic not tied to HTTP or tRPC
- Consistent patterns across packages
**Scalability:**
- Worker handles async operations separately
- Easy to add new tRPC procedures
- Clear patterns to follow
- Shared code in packages/shared
---
## Request Lifecycle
### tRPC Request Flow (UI)
```typescript
1. HTTP POST /api/trpc/datasets.create
2. Next.js API route catches request (pages/api/trpc/[trpc].ts)
3. tRPC router resolves procedure:
- Match route to procedure in datasetRouter.ts
4. tRPC middleware chain executes:
- protectedProjectProcedure (authentication)
- hasEntitlement checks
- Input validation with Zod v4
5. Procedure handler calls service:
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createDatasetSchema)
.mutation(async ({ input, ctx }) => {
return await createDataset(input, ctx.session);
}),
})
6. Service executes business logic:
- Validate business rules
- Use repositories for complex queries or Prisma directly
- ClickHouse queries via repositories if needed
7. Database operations:
- prisma.dataset.create({ data })
- clickhouse queries via getTracesTable()
8. Response flows back:
Database Service Procedure tRPC Client
```
### Public API Request Flow (SDKs)
```typescript
1. HTTP POST /api/public/datasets
2. Next.js API route handler (pages/api/public/datasets/index.ts)
3. withMiddlewares wrapper executes:
- Basic auth verification
- Rate limiting
- CORS handling
4. createAuthedProjectAPIRoute handler:
- Parse and validate request with Zod v4
- Extract auth context (project, user)
5. Handler calls service function:
const dataset = await createDataset({
name: req.body.name,
projectId: req.auth.projectId,
});
6. Service executes (same as tRPC path)
7. Response formatted and returned:
res.status(201).json(dataset);
```
### Worker/Queue Processing Flow
```typescript
1. Job added to Redis BullMQ queue:
await evalQueue.add("eval-job", {
evalId, projectId
});
2. Worker picks up job from Redis
3. Queue processor handles job:
// worker/src/queues/evalQueue.ts
async process(job: Job<EvalJobType>) {
await processEvaluation(job.data);
}
4. Processor calls service:
- Same service layer as Web
- Business logic execution
5. Service performs operations:
- Prisma transactions
- ClickHouse queries
- External API calls (LLMs)
6. Job completes or fails:
- Success: job.updateProgress(100)
- Failure: throw error for retry
```
---
## Directory Structure
### Web Package (`/web/src/`)
```
web/src/
├── features/ # Feature-organized code
│ ├── datasets/
│ │ ├── server/ # Backend logic
│ │ │ ├── datasetRouter.ts # tRPC router
│ │ │ └── datasetService.ts # Business logic
│ │ ├── components/ # React components
│ │ └── types/ # Feature types
│ │
│ ├── public-api/
│ │ ├── server/
│ │ │ ├── withMiddlewares.ts
│ │ │ └── createAuthedProjectAPIRoute.ts
│ │ └── types/ # API schemas
│ │
│ └── [feature-name]/
│ ├── server/
│ │ ├── *Router.ts # tRPC router
│ │ └── service.ts # Business logic
│ ├── components/
│ └── types/
├── server/
│ ├── api/
│ │ ├── routers/ # tRPC routers
│ │ ├── trpc.ts # tRPC setup & middleware
│ │ └── root.ts # Main router combining all
│ ├── auth.ts # NextAuth.js config
│ └── db.ts # Database utilities
├── pages/
│ ├── api/
│ │ ├── public/ # Public REST APIs
│ │ │ ├── datasets.ts
│ │ │ └── traces.ts
│ │ └── trpc/
│ │ └── [trpc].ts # tRPC endpoint
│ └── [routes].tsx # Next.js pages
├── __tests__/ # Jest tests
│ ├── async/ # Integration tests
│ └── sync/ # Unit tests
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
└── env.mjs # Environment config
```
### Worker Package (`/worker/src/`)
```
worker/src/
├── queues/ # BullMQ processors
│ ├── evalQueue.ts # Evaluation jobs
│ ├── ingestionQueue.ts # Data ingestion
│ ├── batchExportQueue.ts # Batch exports
│ └── workerManager.ts # Queue registration
├── features/ # Business logic
│ └── [feature]/
│ └── service.ts
├── __tests__/ # Vitest tests
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
├── app.ts # Express setup + queue registration
├── env.ts # Environment config
└── index.ts # Server start
```
### Shared Package (`/packages/shared/`)
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
| Import Path | Usage | What's Included |
| ------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
| `@langfuse/shared` | ✅ Frontend + Backend | Prisma types, Zod schemas, constants, table definitions, domain models, utilities |
| `@langfuse/shared/src/db` | 🔒 Backend only | Prisma client instance |
| `@langfuse/shared/src/server` | 🔒 Backend only | Services, repositories, queues, auth, ClickHouse, LLM integration, instrumentation |
| `@langfuse/shared/src/server/auth/apiKeys` | 🔒 Backend only | API key management (separated to avoid circular deps) |
| `@langfuse/shared/encryption` | 🔒 Backend only | Database field encryption/decryption |
**Key Structure:**
```
packages/shared/src/
├── server/ # 🔒 All server-only code
│ ├── auth/ # Authentication & authorization
│ ├── clickhouse/ # ClickHouse client & queries
│ ├── redis/ # Redis client & 30+ queue types
│ ├── repositories/ # Data access (traces, observations, scores, events)
│ ├── services/ # Business services (Storage, Email, Slack, etc.)
│ ├── llm/ # LLM integration
│ ├── instrumentation/ # OpenTelemetry
│ └── queues.ts, logger.ts, filterToPrisma.ts, etc.
├── features/ # ✅ Feature types (evals, scores, prompts, datasets)
├── domain/ # ✅ Domain models (automations, webhooks, etc.)
├── tableDefinitions/ # ✅ Table schemas
├── interfaces/ # ✅ Shared interfaces (filters, orderBy)
├── utils/ # ✅ Utilities (JSON, Zod, string checks)
├── encryption/ # 🔒 Encryption utilities
└── db.ts, constants.ts, types.ts, etc.
```
**Common Import Patterns:**
```typescript
// ✅ Main export - Safe for frontend + backend
import {
Prisma,
Role,
type Dataset,
CloudConfigSchema,
} from "@langfuse/shared";
// 🔒 Database - Backend only
import { prisma } from "@langfuse/shared/src/db";
// 🔒 Server utilities - Backend only
import {
logger,
instrumentAsync,
traceException,
redis,
clickhouseClient,
StorageService,
fetchLLMCompletion,
filterToPrisma,
} from "@langfuse/shared/src/server";
// 🔒 API keys - Backend only
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
// 🔒 Encryption - Backend only
import { encrypt, decrypt } from "@langfuse/shared/encryption";
```
---
## Module Organization
### Feature-Based Organization (Recommended)
For most features, organize by domain within `features/`:
```
src/features/datasets/
├── server/ # Backend code
│ ├── datasetRouter.ts # tRPC procedures
│ └── service.ts # Business logic
├── components/ # React components
│ ├── DatasetTable.tsx
│ └── DatasetForm.tsx
├── types/ # Feature types
│ └── index.ts
└── utils/ # Feature utilities
```
**When to use:**
- Any feature with UI + API
- Clear domain boundary
- Multiple related procedures
### Subdomain Organization
For complex features with multiple subdomains:
```
src/features/evaluations/
├── server/
│ ├── evalRouter.ts # Main router
│ ├── evalService.ts # Core service
│ ├── templates/ # Template subdomain
│ │ ├── templateRouter.ts
│ │ └── templateService.ts
│ └── configs/ # Config subdomain
│ ├── configRouter.ts
│ └── configService.ts
├── components/
│ ├── templates/
│ └── configs/
└── types/
```
**When to use:**
- Feature has 10+ files
- Clear subdomains exist
- Logical grouping improves clarity
### Flat Organization (Rare)
For small, standalone features:
```
src/server/api/routers/
├── healthRouter.ts # Simple health check
└── versionRouter.ts # Version info
```
**When to use:**
- Simple features (1-2 procedures)
- No UI components
- Standalone utilities
---
## Separation of Concerns
### What Goes Where
**tRPC Procedures (Entry Layer):**
- ✅ Procedure definitions (query/mutation)
- ✅ Middleware application (auth, validation)
- ✅ Input schemas (Zod v4)
- ✅ Service delegation
- ✅ Error transformation (TRPCError)
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
- ❌ Complex validation (belongs in services)
**Public API Routes (Entry Layer):**
- ✅ Route registration
- ✅ Middleware wrapper application
- ✅ Input validation (Zod v4)
- ✅ Service delegation
- ✅ Response formatting
- ✅ HTTP status codes
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
**Services Layer:**
- ✅ Business logic
- ✅ Business rules enforcement
- ✅ Transaction orchestration
- ✅ Repository calls for complex queries
- ✅ Direct Prisma operations for simple CRUD
- ✅ ClickHouse queries (via repositories)
- ✅ Redis cache access
- ✅ External API calls (LLMs, etc.)
- ❌ HTTP concerns (Request/Response)
- ❌ tRPC-specific types (TRPCError in entry layer)
- ❌ NextAuth session handling (passed as parameter)
**Queue Processors (Worker):**
- ✅ Job registration and configuration
- ✅ Job data extraction
- ✅ Service delegation
- ✅ Progress updates
- ✅ Error handling (retry logic)
- ❌ Business logic (belongs in services)
- ❌ Database operations (belongs in services)
### Example: Dataset Creation
**tRPC Procedure (Entry Point):**
```typescript
// web/src/features/datasets/server/datasetRouter.ts
import { z } from "zod/v4";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { createDataset } from "./service";
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(
z.object({
name: z.string(),
description: z.string().optional(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
return await createDataset({
...input,
userId: ctx.session.user.id,
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create dataset",
cause: error,
});
}
}),
});
```
**Service (Business Logic):**
```typescript
// web/src/features/datasets/server/service.ts
import { prisma } from "@langfuse/shared/src/db";
import { instrumentAsync, traceException } from "@langfuse/shared/src/server";
export async function createDataset(data: {
name: string;
description?: string;
projectId: string;
userId: string;
}) {
return await instrumentAsync({ name: "dataset.create" }, async (span) => {
// Business rule: Check for duplicate names in project
const existing = await prisma.dataset.findFirst({
where: {
name: data.name,
projectId: data.projectId,
},
});
if (existing) {
throw new Error(`Dataset with name "${data.name}" already exists`);
}
// Create dataset
const dataset = await prisma.dataset.create({
data: {
name: data.name,
description: data.description,
projectId: data.projectId,
createdById: data.userId,
},
});
span.setAttributes({
datasetId: dataset.id,
projectId: dataset.projectId,
});
return dataset;
});
}
```
**Public API (Alternative Entry Point):**
```typescript
// web/src/pages/api/public/datasets/index.ts
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { createDataset } from "@/src/features/datasets/server/service";
import { z } from "zod/v4";
const createDatasetSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
export default withMiddlewares({
POST: createAuthedProjectAPIRoute({
name: "Create Dataset",
bodySchema: createDatasetSchema,
fn: async ({ body, auth, res }) => {
const dataset = await createDataset({
name: body.name,
description: body.description,
projectId: auth.scope.projectId,
userId: auth.scope.userId,
});
return res.status(201).json(dataset);
},
}),
});
```
**Queue Processor (Async Processing):**
```typescript
// worker/src/queues/datasetExportQueue.ts
import { Job } from "bullmq";
import { exportDataset } from "../features/datasets/exportService";
export async function processDatasetExport(
job: Job<{ datasetId: string; projectId: string; format: string }>,
) {
const { datasetId, projectId, format } = job.data;
await job.updateProgress(10);
// Delegate to service
const exportUrl = await exportDataset({
datasetId,
projectId,
format,
onProgress: (percent) => job.updateProgress(percent),
});
await job.updateProgress(100);
return { exportUrl };
}
```
**Notice:** Each layer has clear, distinct responsibilities!
- **Entry layers** (tRPC/Public API/Queue) handle protocol concerns
- **Service layer** contains all business logic
- **Data layer** accessed via repositories (complex queries) or Prisma directly (simple CRUD)
---
## Database Architecture
### Dual Database System
Langfuse uses two databases with different purposes:
```
┌─────────────────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ ClickHouse │ │
│ │ │ │ │ │
│ │ Transactional│ │ Analytics │ │
│ │ Data │ │ Data │ │
│ └──────────────┘ └──────────────┘ │
│ ↑ ↑ │
│ │ │ │
│ Prisma ORM Direct SQL │
│ (schema migrations) (via client) │
└─────────────────────────────────────────────────────────────┘
```
**PostgreSQL (Primary Database):**
- Accessed via Prisma ORM
- Transactional data (users, projects, datasets, etc.)
- ACID guarantees
- Schema managed via `prisma migrate`
- Located in `packages/shared/prisma/`
**ClickHouse (Analytics Database):**
- Accessed via direct SQL queries
- High-volume trace/observation data
- Columnar storage for analytics
- Optimized for aggregations
- Schema in `packages/shared/src/server/clickhouse/`
- Schema managed via `golang-migrate`
**Redis (Cache & Queues):**
- BullMQ job queues
- Caching layer
- Session storage
- Rate limiting
### Data Access Pattern
**Services access databases directly:**
```typescript
// PostgreSQL via Prisma
import { prisma } from "@langfuse/shared/src/db";
const dataset = await prisma.dataset.create({ data });
// ClickHouse via helper functions
import { getTracesTable } from "@langfuse/shared/src/server";
const traces = await getTracesTable({
projectId,
filter: [...],
limit: 1000,
});
// Redis via queue/cache utilities
import { redis } from "@langfuse/shared/src/server";
await redis.set(`cache:${key}`, value, "EX", 3600);
```
**Repository Pattern:**
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns. Repositories provide:
- Abstraction over complex queries (traces, observations, scores, events)
- Data converters for transforming database models to application models
- ClickHouse query builders and stream processing
- Reusable query logic across services
Services can use repositories for complex operations OR Prisma directly for simple CRUD operations.
---
## Best Practices
### 1. Keep Procedures Thin
tRPC procedures should only handle protocol concerns:
```typescript
// ❌ BAD: Business logic in procedure
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createSchema)
.mutation(async ({ input, ctx }) => {
// 200 lines of business logic here
const existing = await prisma.dataset.findFirst(...);
if (existing) throw new Error(...);
const dataset = await prisma.dataset.create(...);
await sendNotification(...);
return dataset;
}),
});
// ✅ GOOD: Delegate to service
export const datasetRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(createSchema)
.mutation(async ({ input, ctx }) => {
return await createDataset(input, ctx.session);
}),
});
```
### 2. Services Should Be Protocol-Agnostic
Services should work regardless of entry point:
```typescript
// ✅ GOOD: No HTTP/tRPC knowledge
export async function createDataset(data: CreateDatasetInput) {
// Pure business logic
return await prisma.dataset.create({ data });
}
// ❌ BAD: tRPC-specific
export async function createDataset(ctx: TRPCContext) {
// Coupled to tRPC
}
```
### 3. Observability with OpenTelemetry + DataDog
**Langfuse uses OpenTelemetry for backend observability, with traces and logs sent to DataDog.**
Use structured logging and instrumentation:
```typescript
import {
logger,
traceException,
instrumentAsync,
} from "@langfuse/shared/src/server";
export async function processEvaluation(evalId: string) {
return await instrumentAsync(
{ name: "evaluation.process", attributes: { evalId } },
async (span) => {
// Structured logging (includes trace_id, span_id, dd.trace_id)
logger.info("Starting evaluation", { evalId });
try {
// Operation here
const result = await runEvaluation(evalId);
span.setAttributes({
score: result.score,
status: "success",
});
return result;
} catch (error) {
// Record exception to OpenTelemetry span (sent to DataDog)
traceException(error, span);
logger.error("Evaluation failed", { evalId, error: error.message });
throw error;
}
},
);
}
```
**Note**: Frontend uses Sentry for error tracking, but backend (tRPC, API routes, services, worker) uses OpenTelemetry + DataDog.
### 4. Use Proper Error Handling
Transform errors at entry points:
```typescript
// tRPC procedure
try {
return await service();
} catch (error) {
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "User-friendly message",
cause: error,
});
}
// Public API
try {
return await service();
} catch (error) {
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
return res.status(500).json({
error: "User-friendly message",
});
}
```
### 5. Validate at Entry Points
Use Zod v4 for all input validation:
```typescript
import { z } from "zod/v4";
// tRPC
.input(z.object({
name: z.string().min(1).max(255),
projectId: z.string(),
}))
// Public API
const bodySchema = z.object({
name: z.string().min(1).max(255),
});
const validated = bodySchema.parse(req.body);
```
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main guide
- [routing-and-controllers.md](routing-and-controllers.md) - tRPC and Public API details
- [services-and-repositories.md](services-and-repositories.md) - Service patterns
- [testing-guide.md](testing-guide.md) - Testing strategies
@@ -1,563 +0,0 @@
# Configuration Management - Environment Variables
Complete guide to managing configuration across Langfuse's monorepo packages.
## Table of Contents
- [Environment Variable Pattern](#environment-variable-pattern)
- [Package-Specific Configuration](#package-specific-configuration)
- [Special Environment Variables](#special-environment-variables)
- [Best Practices](#best-practices)
---
## Environment Variable Pattern
### Why Zod-Validated Environment Variables?
**Problems with raw process.env:**
- ❌ No type safety
- ❌ No validation
- ❌ Hard to test
- ❌ Runtime errors for typos
- ❌ No default values
**Benefits of Zod validation:**
- ✅ Type-safe configuration
- ✅ Validated at startup
- ✅ Clear error messages
- ✅ Default values
- ✅ Environment-specific transformation
---
## Package-Specific Configuration
Each package has its own `env.ts` or `env.mjs` file that validates and exports environment variables:
```
langfuse/
├── web/src/env.mjs # Next.js app (t3-env pattern)
├── worker/src/env.ts # Worker service (Zod schema)
├── packages/shared/src/env.ts # Shared config (Zod schema)
└── ee/src/env.ts # Enterprise Edition (Zod schema)
```
### Web Package (`web/src/env.mjs`)
Uses **t3-oss/env-nextjs** for Next.js-specific validation with server/client separation.
**Key Features:**
- Separates server-side and client-side environment variables
- Client variables must be prefixed with `NEXT_PUBLIC_`
- Validates at build time (unless `DOCKER_BUILD=1`)
- `runtimeEnv` section manually maps all variables
**Structure:**
```typescript
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
// Server-side only variables (never exposed to client)
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(1),
SALT: z.string(),
CLICKHOUSE_URL: z.string().url(),
// ... 100+ server variables
},
// Client-side variables (exposed to browser)
client: {
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z
.enum(["US", "EU", "STAGING", "DEV", "HIPAA", "JP"])
.optional(),
NEXT_PUBLIC_SIGN_UP_DISABLED: z.enum(["true", "false"]).default("false"),
// ... client variables
},
// Runtime mapping (required for Next.js edge runtime)
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION:
process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
// ... must map ALL variables
},
// Skip validation in Docker builds
skipValidation: process.env.DOCKER_BUILD === "1",
emptyStringAsUndefined: true,
});
```
**Usage:**
```typescript
// In server-side code (tRPC, API routes)
import { env } from "@/src/env.mjs";
const dbUrl = env.DATABASE_URL;
const salt = env.SALT;
// In client-side code (React components)
import { env } from "@/src/env.mjs";
const region = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
```
### Worker Package (`worker/src/env.ts`)
Uses **plain Zod schema** for Express.js worker service.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
BUILD_ID: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string(),
PORT: z.coerce.number().positive().max(65536).default(3030),
// ClickHouse
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
// S3 Event Upload (required)
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
error: "Langfuse requires a bucket name for S3 Event Uploads.",
}),
// Queue concurrency settings
LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY: z.coerce
.number()
.positive()
.default(20),
LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY: z.coerce
.number()
.positive()
.default(5),
// Queue consumer toggles
QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
// ... 150+ worker-specific variables
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "./env";
const concurrency = env.LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY;
const s3Bucket = env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET;
```
### Shared Package (`packages/shared/src/env.ts`)
Uses **plain Zod schema** for configuration shared between web and worker.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "./utils/environment";
const EnvSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
// Redis configuration
REDIS_HOST: z.string().nullish(),
REDIS_PORT: z.coerce.number().positive().max(65536).default(6379).nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
// ClickHouse
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(25),
// S3 Event Upload
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string(),
LANGFUSE_S3_EVENT_UPLOAD_REGION: z.string().optional(),
// Logging
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.optional(),
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
// Encryption
ENCRYPTION_KEY: z
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
// ... 80+ shared variables
});
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "@langfuse/shared/src/env";
const redisHost = env.REDIS_HOST;
const clickhouseUrl = env.CLICKHOUSE_URL;
```
### Enterprise Edition Package (`ee/src/env.ts`)
Minimal Zod schema for EE-specific variables.
**Structure:**
```typescript
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
});
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
**Usage:**
```typescript
import { env } from "@langfuse/ee/src/env";
const licenseKey = env.LANGFUSE_EE_LICENSE_KEY;
```
---
## Special Environment Variables
### NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
**Purpose:** Identifies the cloud deployment region for Langfuse Cloud.
**Type:** `"US" | "EU" | "STAGING" | "DEV" | "HIPAA" | "JP" | undefined`
**Where Used:**
- **web/src/env.mjs** - Client-side accessible (prefixed with `NEXT_PUBLIC_`)
- **ee/src/env.ts** - Enterprise features
- **packages/shared/src/env.ts** - Shared logic
- **worker/src/env.ts** - Worker processing
**When Set:**
| Environment | Value | Purpose |
| ------------------------ | ---------------------- | ---------------------------------------------- |
| **Developer Laptop** | `"DEV"` or `"STAGING"` | Local development against cloud infrastructure |
| **Langfuse Cloud US** | `"US"` | Production US region |
| **Langfuse Cloud EU** | `"EU"` | Production EU region |
| **Langfuse Cloud HIPAA** | `"HIPAA"` | HIPAA-compliant region |
| **Langfuse Cloud JP** | `"JP"` | Production JP region |
| **OSS Self-Hosted** | `undefined` (not set) | Self-hosted deployments don't have region |
**Use Cases:**
```typescript
// Check if running in cloud
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
// Enable cloud-specific features
- Usage metering and billing
- Cloud spend alerts
- Free tier enforcement
- Stripe integration
- PostHog analytics
}
// Region-specific behavior
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "HIPAA") {
// HIPAA compliance features
}
// Development/staging checks
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV") {
// Enable debug features
}
```
**Example Configuration:**
```bash
# .env file on developer laptop
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=DEV
# Cloud US deployment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
# Self-hosted OSS deployment
# (variable not set)
```
### LANGFUSE_EE_LICENSE_KEY
**Purpose:** Enables Enterprise Edition features in self-hosted deployments.
**Type:** `string | undefined`
**Where Used:**
- **web/src/env.mjs** - Web app EE features
- **ee/src/env.ts** - EE package
**When Set:**
| Deployment | Value | Features Enabled |
| ------------------- | ------------------ | ---------------------------------------------------------------- |
| **Langfuse Cloud** | Not set | Cloud features controlled by `NEXT_PUBLIC_LANGFUSE_CLOUD_REGION` |
| **OSS Self-Hosted** | Not set | Core open-source features only |
| **EE Self-Hosted** | License key string | Enterprise features enabled |
**Enterprise Features Controlled:**
When `LANGFUSE_EE_LICENSE_KEY` is set and valid:
- SSO integrations (custom OIDC, SAML)
- Advanced RBAC
- Audit logging
- Custom branding
- SLA support
- Advanced security features
**Usage Pattern:**
```typescript
import { env } from "@/src/env.mjs";
// Check if EE license is present
if (env.LANGFUSE_EE_LICENSE_KEY) {
// Validate license
const isValidLicense = await validateEELicense(env.LANGFUSE_EE_LICENSE_KEY);
if (isValidLicense) {
// Enable EE features
enableCustomSSO();
enableAdvancedRBAC();
}
}
```
**Example Configuration:**
```bash
# OSS self-hosted (no license)
# LANGFUSE_EE_LICENSE_KEY not set
# EE self-hosted
LANGFUSE_EE_LICENSE_KEY=ee_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Langfuse Cloud (uses region instead)
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
# LANGFUSE_EE_LICENSE_KEY not used
```
### Other Important Variables
**DOCKER_BUILD**
```typescript
// Skip validation during Docker builds
skipValidation: process.env.DOCKER_BUILD === "1";
```
**Purpose:** Docker builds happen before runtime env vars are available, so validation must be skipped.
**SALT**
```typescript
SALT: z.string({
required_error: "A strong Salt is required to encrypt API keys securely.",
});
```
**Purpose:** Required for encrypting API keys in database. Must be set in production.
**ENCRYPTION_KEY**
```typescript
ENCRYPTION_KEY: z.string().length(64, "Must be 256 bits, 64 hex characters");
```
**Purpose:** Optional 256-bit key for encrypting sensitive database fields.
**Generate:** `openssl rand -hex 32`
---
## Best Practices
### 1. Always Import from env.mjs/env.ts
```typescript
// ❌ NEVER DO THIS
const dbUrl = process.env.DATABASE_URL;
// ✅ ALWAYS DO THIS
import { env } from "@/src/env.mjs";
const dbUrl = env.DATABASE_URL; // Type-safe, validated
```
### 2. Use Appropriate Import Path
```typescript
// In web package
import { env } from "@/src/env.mjs";
// In worker package
import { env } from "./env";
// In shared package
import { env } from "@langfuse/shared/src/env";
```
### 3. Client Variables Must Start with NEXT*PUBLIC*
```typescript
// ❌ Won't work in browser
API_KEY: z.string(); // in server config
// ✅ Accessible in browser
NEXT_PUBLIC_API_KEY: z.string(); // in client config
```
### 4. Provide Sensible Defaults for Development
```typescript
PORT: z.coerce.number().positive().default(3030),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
REDIS_PORT: z.coerce.number().positive().default(6379),
```
### 5. Use Coercion for Numbers
```typescript
// .env files are always strings
PORT: z.coerce.number(); // Converts "3000" to 3000
```
### 6. Transform Complex Values
```typescript
// Split comma-separated values
LANGFUSE_LOG_PROPAGATED_HEADERS: z.string().optional().transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : []
),
// Parse project:rate pairs
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z.string().optional().transform((val) => {
const map = new Map<string, number>();
val?.split(",").forEach(part => {
const [projectId, rate] = part.split(":");
map.set(projectId, parseFloat(rate));
});
return map;
}),
```
### 7. Validation at Startup
All environment variables are validated when the application starts. Invalid configuration will cause immediate failure with clear error messages:
```bash
❌ Validation error:
- SALT: Required
- CLICKHOUSE_URL: Invalid url
- PORT: Number must be less than or equal to 65536
```
### 8. Skip Validation in Docker Builds
Always include the Docker build escape hatch:
```typescript
export const env =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
### 9. Use removeEmptyEnvVariables Helper
Treats empty strings as undefined:
```typescript
import { removeEmptyEnvVariables } from "@langfuse/shared";
EnvSchema.parse(removeEmptyEnvVariables(process.env));
```
This prevents errors from `.env` files with empty values:
```bash
# .env
OPTIONAL_VAR= # Treated as undefined, not empty string
```
---
## Configuration File Locations
```
langfuse/
├── .env # Local development overrides
├── .env.dev.example # Example dev configuration
├── web/src/env.mjs # Web app env validation
├── worker/src/env.ts # Worker env validation
├── packages/shared/src/env.ts # Shared env validation
└── ee/src/env.ts # EE env validation
```
**DO NOT commit:**
- `.env`
- `.env.local`
- `.env.production`
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main guide
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
@@ -1,691 +0,0 @@
# Database Patterns - PostgreSQL & ClickHouse
Complete guide to database access patterns in Langfuse using PostgreSQL (Prisma ORM) and ClickHouse (direct client).
## Table of Contents
- [Database Architecture Overview](#database-architecture-overview)
- [PostgreSQL with Prisma](#postgresql-with-prisma)
- [ClickHouse with Direct Client](#clickhouse-with-direct-client)
- [Repository Pattern](#repository-pattern)
- [When to Use Which Database](#when-to-use-which-database)
- [Error Handling](#error-handling)
---
## Database Architecture Overview
Langfuse uses a **dual database architecture**:
| Database | Technology | Purpose | Access Pattern |
| -------------- | ----------------- | ------------------------------------------------------------- | -------------------------------------- |
| **PostgreSQL** | Prisma ORM | Transactional data, relational data, CRUD operations | Type-safe ORM with migrations |
| **ClickHouse** | Direct SQL client | Analytics data, high-volume traces/observations, aggregations | Raw SQL queries with streaming support |
| **Redis** | ioredis | Queues (BullMQ), caching, rate limiting | Direct client access |
**Key Principle**: Use PostgreSQL for transactional data and relationships. Use ClickHouse for high-volume analytics and time-series data.
**⚠️ Important**: All queries must filter by `project_id` (or `projectId`) to ensure proper data isolation between tenants. This is essential for the multi-tenant architecture.
---
## PostgreSQL with Prisma
### Import Pattern
```typescript
import { prisma } from "@langfuse/shared/src/db";
// Direct access to Prisma client
const user = await prisma.user.findUnique({ where: { id } });
```
**Important**: Always import from `@langfuse/shared/src/db`, not `@prisma/client` directly.
### Common CRUD Operations
**⚠️ ALWAYS include `projectId` in WHERE clauses** for project-scoped data:
```typescript
// Create
const project = await prisma.project.create({
data: {
name: "My Project",
orgId: organizationId,
},
});
// ✅ GOOD: Read with projectId filter
const trace = await prisma.trace.findUnique({
where: { id: traceId, projectId }, // ← Always include projectId for tenant isolation
include: {
scores: true,
project: { select: { id: true, name: true } },
},
});
// ❌ BAD: Missing projectId filter
// const trace = await prisma.trace.findUnique({
// where: { id: traceId }, // ← Missing projectId!
// });
// Update
await prisma.user.update({
where: { id: userId },
data: { lastLogin: new Date() },
});
// ✅ GOOD: Delete with projectId
await prisma.apiKey.delete({
where: { id: apiKeyId, projectId }, // ← Always include projectId
});
// ✅ GOOD: Count with projectId
const traceCount = await prisma.trace.count({
where: { projectId, userId }, // ← Always include projectId
});
```
### Transactions
Use Prisma interactive transactions for operations that must be atomic:
```typescript
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
const project = await tx.project.create({
data: {
name: "Default Project",
orgId: user.id,
},
});
await tx.projectMembership.create({
data: {
userId: user.id,
projectId: project.id,
role: "OWNER",
},
});
return { user, project };
});
```
**Transaction options:**
```typescript
await prisma.$transaction(
async (tx) => {
// Transaction logic
},
{
maxWait: 5000, // Max time to wait for transaction to start (ms)
timeout: 10000, // Max time transaction can run (ms)
},
);
```
### Query Optimization
**Use `select` to limit fields:**
```typescript
// ❌ Fetches all fields (including large JSON columns)
const traces = await prisma.trace.findMany({ where: { projectId } });
// ✅ Only fetch needed fields
const traces = await prisma.trace.findMany({
where: { projectId },
select: {
id: true,
name: true,
timestamp: true,
userId: true,
},
});
```
**Prevent N+1 queries with `include`:**
```typescript
// ❌ N+1 Query Problem
const projects = await prisma.project.findMany();
for (const project of projects) {
// N additional queries
const memberCount = await prisma.projectMembership.count({
where: { projectId: project.id },
});
}
// ✅ Use include or aggregation
const projects = await prisma.project.findMany({
include: {
members: { select: { userId: true, role: true } },
},
});
```
**Pagination:**
```typescript
const PAGE_SIZE = 50;
const traces = await prisma.trace.findMany({
where: { projectId },
orderBy: { timestamp: "desc" },
take: PAGE_SIZE,
skip: page * PAGE_SIZE,
});
```
## ClickHouse with Direct Client
### Import Pattern
```typescript
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
```
### ClickHouse Client Singleton
ClickHouse uses a singleton client manager that reuses connections:
```typescript
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
// Get client (automatically reuses existing connection)
const client = clickhouseClient();
// For read-only queries (uses read replica if configured)
const client = clickhouseClient(undefined, "ReadOnly");
```
### Query Patterns
ClickHouse queries use **raw SQL** with parameterized queries. Parameters use `{paramName: Type}` syntax:
**⚠️ Important**: All ClickHouse queries must include `project_id` filter to ensure proper tenant isolation.
**Simple query:**
```typescript
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
// ✅ GOOD: Always filter by project_id
const rows = await queryClickhouse<{ id: string; name: string }>({
query: `
SELECT id, name, timestamp
FROM traces
WHERE project_id = {projectId: String} -- ← REQUIRED: Always filter by project_id
AND timestamp >= {startTime: DateTime64(3)}
ORDER BY timestamp DESC
LIMIT {limit: UInt32}
`,
params: {
projectId, // ← Required for tenant isolation
startTime: convertDateToClickhouseDateTime(startDate),
limit: 100,
},
tags: { feature: "tracing", type: "trace" },
});
// ❌ BAD: Missing project_id filter
// const rows = await queryClickhouse({
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
// params: { startTime },
// });
```
**Streaming query (for large result sets):**
```typescript
import { queryClickhouseStream } from "@langfuse/shared/src/server/repositories/clickhouse";
// Stream results to avoid loading all rows in memory
for await (const row of queryClickhouseStream<ObservationRecordReadType>({
query: `
SELECT *
FROM observations
WHERE project_id = {projectId: String}
AND start_time >= {startTime: DateTime64(3)}
`,
params: { projectId, startTime },
})) {
// Process row by row
await processObservation(row);
}
```
**Upsert (insert) operation:**
```typescript
import { upsertClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
await upsertClickhouse({
table: "traces",
records: [
{
id: traceId,
project_id: projectId,
timestamp: new Date(),
name: "API Call",
user_id: userId,
// ... other fields
},
],
eventBodyMapper: (record) => ({
// Transform record for event log
id: record.id,
name: record.name,
// ... other fields
}),
tags: { feature: "ingestion", type: "trace" },
});
```
**DDL/Administrative commands:**
```typescript
import { commandClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
// Create table, alter schema, etc.
await commandClickhouse({
query: `
ALTER TABLE traces
ADD COLUMN IF NOT EXISTS new_field String
`,
tags: { feature: "migration" },
});
```
### ClickHouse Type Mapping
| JavaScript Type | ClickHouse Param Type |
| --------------- | --------------------------------------------------------- |
| `string` | `String` |
| `number` | `UInt32`, `Int64`, `Float64` |
| `Date` | `DateTime64(3)` (use `convertDateToClickhouseDateTime()`) |
| `boolean` | `UInt8` (0 or 1) |
| `string[]` | `Array(String)` |
**Date handling:**
```typescript
import { convertDateToClickhouseDateTime } from "@langfuse/shared/src/server/clickhouse/client";
const params = {
startTime: convertDateToClickhouseDateTime(new Date()),
};
```
### ClickHouse Query Best Practices
**1. Always filter by `project_id` for tenant isolation:**
```typescript
// ✅ CORRECT: project_id filter is required
const query = `
SELECT *
FROM traces
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
AND timestamp >= {startTime: DateTime64(3)}
`;
// ❌ WRONG: Missing project_id filter
// const query = `
// SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}
// `;
```
**Why this is important:**
- Langfuse is multi-tenant - each project's data must be isolated
- The `project_id` filter ensures queries only access data from the intended tenant
- All queries on project-scoped tables (traces, observations, scores, sessions, etc.) must filter by `project_id`
**2. Use LIMIT BY for deduplication:**
```typescript
// Get latest version of each trace
const query = `
SELECT *
FROM traces
WHERE project_id = {projectId: String} -- ← Always include project_id
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`;
```
**`is_deleted` on `traces`, `observations`, and `scores` is dormant — avoid new filters.**
These three tables are declared as
`ReplacingMergeTree(event_ts, is_deleted)`, but no production
code writes `is_deleted = 1` for them — all deletes use
ClickHouse's lightweight `DELETE FROM` mutation (e.g.
`deleteObservationsByTraceIds`,
`deleteObservationsByProjectId`,
`deleteObservationsOlderThanDays`), which marks rows via the
engine-managed `_row_exists` column. `_row_exists` is handled
transparently by the read path; no special query handling is
needed.
What this means for query authors:
- **`WHERE is_deleted = 0` filters on these three tables are
dead weight in practice.** A few legacy reads still carry
them (e.g. `web/src/features/score-analytics/server/`); new
code should not add them unless soft-delete writes have
actually been introduced.
**Separate case: `blob_storage_file_log`.** This table is also
a `ReplacingMergeTree` but **does** use soft-delete
intentionally — `ingestionFileDeletion.ts` writes
`is_deleted: "1"`, `batch-project-blob-cleaner` reads with
`countIf(is_deleted = 1)`. The guidance above does not apply
to it.
**3. Use time-based filtering for performance:**
```typescript
// Combine project_id filter with timestamp for optimal performance
const query = `
SELECT *
FROM observations
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
AND start_time >= {startTime: DateTime64(3)} -- ← Improves performance
AND start_time < {endTime: DateTime64(3)}
`;
```
**4. Use CTEs for complex queries (still require `project_id`):**
```typescript
const query = `
WITH observations_agg AS (
SELECT
trace_id,
count() as observation_count,
sum(total_cost) as total_cost
FROM observations
WHERE project_id = {projectId: String} -- ← Filter in CTE
GROUP BY trace_id
)
SELECT
t.id,
t.name,
o.observation_count,
o.total_cost
FROM traces t
LEFT JOIN observations_agg o ON t.id = o.trace_id
WHERE t.project_id = {projectId: String} -- ← Filter in main query
`;
```
**Note**: When using CTEs or subqueries, ensure `project_id` filter is applied at each level.
**Error handling with retries:**
ClickHouse queries automatically retry on network errors (socket hang up). Custom error handling for resource limits:
```typescript
import {
queryClickhouse,
ClickHouseResourceError,
} from "@langfuse/shared/src/server/repositories/clickhouse";
try {
const rows = await queryClickhouse({ query, params });
} catch (error) {
if (error instanceof ClickHouseResourceError) {
// Memory limit, timeout, or overcommit error
throw new Error(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
}
throw error;
}
```
---
## Repository Pattern
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns.
### When to Use Repositories
**Use repositories when:**
- Complex ClickHouse queries with CTEs, aggregations, or joins
- Query used in multiple places (DRY principle)
- Need data transformation/converters (DB → domain models)
- Building reusable query logic with filters
**Use direct Prisma/ClickHouse for:**
- Simple CRUD operations
- One-off queries
- Prototyping (refactor to repository later)
### Repository Examples
**Trace repository (ClickHouse):**
```typescript
// packages/shared/src/server/repositories/traces.ts
export const getTracesByIds = async (
projectId: string,
traceIds: string[],
): Promise<TraceRecordReadType[]> => {
const rows = await queryClickhouse<TraceRecordReadType>({
query: `
SELECT *
FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`,
params: { projectId, traceIds },
tags: { feature: "tracing", type: "trace" },
});
return rows.map(convertClickhouseToDomain);
};
```
**Score repository (PostgreSQL + ClickHouse):**
```typescript
// Repositories can query both databases
export const getScoresByTraceId = async (
projectId: string,
traceId: string,
) => {
// Use ClickHouse for analytics
const clickhouseScores = await queryClickhouse<ScoreRecordReadType>({
query: `
SELECT *
FROM scores
WHERE project_id = {projectId: String}
AND trace_id = {traceId: String}
`,
params: { projectId, traceId },
});
// Use Prisma for config data
const scoreConfigs = await prisma.scoreConfig.findMany({
where: { projectId },
});
return enrichScoresWithConfigs(clickhouseScores, scoreConfigs);
};
```
---
## When to Use Which Database
| Use Case | Database | Reasoning |
| -------------------------------------- | ---------- | ------------------------------------------ |
| User accounts, projects, API keys | PostgreSQL | Transactional data with strong consistency |
| Prompt management, dataset definitions | PostgreSQL | Configuration data with relations |
| Project settings, RBAC permissions | PostgreSQL | Small, frequently updated data |
| Traces, observations, events | ClickHouse | High-volume time-series data |
| Score aggregations, analytics queries | ClickHouse | Fast aggregations over millions of rows |
| Usage metrics, cost calculations | ClickHouse | Analytical queries with GROUP BY |
| Exports, large dataset queries | ClickHouse | Streaming support for large result sets |
**Decision flow:**
1. Is it high-volume time-series data? → **ClickHouse**
2. Does it need aggregation over millions of rows? → **ClickHouse**
3. Is it transactional data with relationships? → **PostgreSQL**
4. Is it configuration or user data? → **PostgreSQL**
5. Is it frequently updated? → **PostgreSQL**
6. Is it append-only analytics data? → **ClickHouse**
### Project-Scoped vs Global Tables
**Project-scoped tables (MUST filter by `project_id`):**
- `traces` - All trace queries require `project_id`
- `observations` - All observation queries require `project_id`
- `scores` - All score queries require `project_id`
- `events` - All event queries require `project_id`
- `dataset_run_items_rmt` - All dataset run queries require `project_id`
**Global tables (no `project_id` filter needed):**
- `users` - User management (use `id` for filtering)
- `organizations` - Organization data (use `id` for filtering)
- System configuration tables
**Example of correct filtering:**
```typescript
// ✅ CORRECT: Project-scoped query
const traces = await queryClickhouse({
query: `
SELECT * FROM traces
WHERE project_id = {projectId: String}
AND timestamp >= {startTime: DateTime64(3)}
`,
params: { projectId, startTime },
});
// ✅ CORRECT: Global table query (no project_id needed)
const user = await prisma.user.findUnique({
where: { id: userId },
});
// ❌ WRONG: Project-scoped query without project_id filter
// const traces = await queryClickhouse({
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
// });
```
---
## Error Handling
### PostgreSQL (Prisma) Errors
```typescript
import { Prisma } from "@prisma/client";
import { prisma } from "@langfuse/shared/src/db";
try {
await prisma.user.create({ data: userData });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Unique constraint violation
if (error.code === "P2002") {
const target = error.meta?.target as string[];
throw new ConflictError(`${target?.join(", ")} already exists`);
}
// Foreign key constraint
if (error.code === "P2003") {
throw new ValidationError("Invalid reference");
}
// Record not found
if (error.code === "P2025") {
throw new NotFoundError("Record not found");
}
// Record required to connect not found
if (error.code === "P2018") {
throw new ValidationError("Related record not found");
}
}
// Unknown error
logger.error("Prisma error", { error });
throw error;
}
```
**Common Prisma error codes:**
| Code | Meaning | Typical Cause |
| ------- | --------------------------- | -------------------------------------- |
| `P2002` | Unique constraint violation | Duplicate email, API key, etc. |
| `P2003` | Foreign key constraint | Referenced record doesn't exist |
| `P2025` | Record not found | Update/delete of non-existent record |
| `P2018` | Required relation not found | Connect to non-existent related record |
### ClickHouse Errors
```typescript
import {
queryClickhouse,
ClickHouseResourceError,
} from "@langfuse/shared/src/server/repositories/clickhouse";
try {
const rows = await queryClickhouse({ query, params });
} catch (error) {
// ClickHouse resource errors (memory limit, timeout, overcommit)
if (error instanceof ClickHouseResourceError) {
logger.warn("ClickHouse resource error", {
errorType: error.errorType, // "MEMORY_LIMIT" | "OVERCOMMIT" | "TIMEOUT"
message: error.message,
});
// User-friendly error message
throw new BadRequestError(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
}
// Network/connection errors are automatically retried
logger.error("ClickHouse error", { error });
throw error;
}
```
**ClickHouse error types:**
| Error Type | Discriminator | Meaning | Solution |
| -------------- | ----------------------- | --------------------------- | ------------------------------------------------- |
| `MEMORY_LIMIT` | "memory limit exceeded" | Query used too much memory | Use more specific filters or shorter time range |
| `OVERCOMMIT` | "OvercommitTracker" | Memory overcommit limit hit | Reduce query complexity or result set size |
| `TIMEOUT` | "Timeout", "timed out" | Query took too long | Add filters, reduce time range, or optimize query |
**ClickHouse retries:**
ClickHouse queries automatically retry network errors (socket hang up) with exponential backoff. Configure retry behavior:
```typescript
// In packages/shared/src/env.ts
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().positive().default(3);
```
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
- [configuration.md](configuration.md) - Environment variable configuration
@@ -1,793 +0,0 @@
# Middleware Guide - tRPC & Public API Patterns
Complete guide to middleware patterns in Langfuse's Next.js + tRPC architecture.
## Table of Contents
- [tRPC Middleware](#trpc-middleware)
- [Public API Middleware](#public-api-middleware)
- [Authentication Patterns](#authentication-patterns)
- [Error Handling Middleware](#error-handling-middleware)
- [OpenTelemetry Instrumentation](#opentelemetry-instrumentation)
- [Composable Procedures](#composable-procedures)
---
## tRPC Middleware
**File:** `web/src/server/api/trpc.ts`
tRPC middleware in Langfuse is composable and type-safe. Each middleware enriches the context and provides guarantees to subsequent middleware.
### Core tRPC Middlewares
**1. Error Handling Middleware (`withErrorHandling`)**
Intercepts all errors and transforms them into user-friendly tRPC errors:
```typescript
const withErrorHandling = t.middleware(async ({ ctx, next }) => {
const res = await next({ ctx });
if (!res.ok) {
if (res.error.cause instanceof ClickHouseResourceError) {
// Surface ClickHouse resource errors with advice message
res.error = new TRPCError({
code: "SERVICE_UNAVAILABLE",
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
});
} else {
// Transform 5xx errors to not expose internals
const { code, httpStatus } = resolveError(res.error);
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
res.error = new TRPCError({
code,
cause: null, // do not expose stack traces
message: isSafeToExpose
? res.error.message
: "Internal error. We have been notified and are working on it.",
});
}
}
return res;
});
```
**2. OpenTelemetry Instrumentation (`withOtelInstrumentation`)**
Propagates OpenTelemetry context with Langfuse-specific baggage:
```typescript
const withOtelInstrumentation = t.middleware(async (opts) => {
const actualInput = await opts.getRawInput();
const baggageCtx = contextWithLangfuseProps({
headers: opts.ctx.headers,
userId: opts.ctx.session?.user?.id,
projectId: (actualInput as Record<string, string>)?.projectId,
});
return opentelemetry.context.with(baggageCtx, () => opts.next());
});
```
**3. Authentication Middleware (`enforceUserIsAuthed`)**
Ensures user is logged in via NextAuth session:
```typescript
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
```
**4. Project Membership Middleware (`enforceUserIsAuthedAndProjectMember`)**
Validates that the user is a member of the project specified in input:
```typescript
const enforceUserIsAuthedAndProjectMember = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const actualInput = await opts.getRawInput();
const parsedInput = inputProjectSchema.safeParse(actualInput);
if (!parsedInput.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input, projectId is required",
});
}
const projectId = parsedInput.data.projectId;
const sessionProject = ctx.session.user.organizations
.flatMap((org) =>
org.projects.map((project) => ({ ...project, organization: org })),
)
.find((project) => project.id === projectId);
if (!sessionProject) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User is not a member of this project",
});
}
return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
orgId: sessionProject.organization.id,
orgRole: sessionProject.organization.role,
projectId: projectId,
projectRole: sessionProject.role,
},
},
});
});
```
**5. Organization Membership Middleware (`enforceIsAuthedAndOrgMember`)**
Validates organization membership:
```typescript
const enforceIsAuthedAndOrgMember = t.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const actualInput = await opts.getRawInput();
const result = inputOrganizationSchema.safeParse(actualInput);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input, orgId is required",
});
}
const orgId = result.data.orgId;
const sessionOrg = ctx.session.user.organizations.find(
(org) => org.id === orgId,
);
if (!sessionOrg && ctx.session.user.admin !== true) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User is not a member of this organization",
});
}
return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
orgId: orgId,
orgRole:
ctx.session.user.admin === true ? Role.OWNER : sessionOrg!.role,
},
},
});
});
```
**6. Trace Access Middleware (`enforceTraceAccess`)**
Special middleware for trace-level routes that supports public traces:
```typescript
const enforceTraceAccess = t.middleware(async (opts) => {
const { ctx, next } = opts;
const actualInput = await opts.getRawInput();
const result = inputTraceSchema.safeParse(actualInput);
if (!result.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
}
const trace = await getTraceById({
traceId: result.data.traceId,
projectId: result.data.projectId,
timestamp: result.data.timestamp ?? undefined,
});
if (!trace) {
throw new TRPCError({ code: "NOT_FOUND", message: "Trace not found" });
}
const sessionProject = ctx.session?.user?.organizations
.flatMap((org) => org.projects)
.find(({ id }) => id === result.data.projectId);
// Allow access if:
// 1. User is a project member
// 2. Trace is public
// 3. User is admin
if (!trace.public && !sessionProject && ctx.session?.user?.admin !== true) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"User is not a member of this project and this trace is not public",
});
}
return next({
ctx: {
session: { ...ctx.session, projectRole: sessionProject?.role },
trace: trace, // pass the trace to avoid refetching
},
});
});
```
### tRPC Procedure Types
Langfuse exports composed procedures with middleware chains:
```typescript
// 1. Public procedure (no auth required)
export const publicProcedure = withOtelTracingProcedure.use(withErrorHandling);
// 2. Authenticated procedure (NextAuth session required)
export const authenticatedProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceUserIsAuthed);
// 3. Project-scoped procedure (project membership required)
export const protectedProjectProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceUserIsAuthedAndProjectMember);
// 4. Organization-scoped procedure
export const protectedOrganizationProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceIsAuthedAndOrgMember);
// 5. Trace access procedure (public traces supported)
export const protectedGetTraceProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceTraceAccess);
// 6. Session access procedure
export const protectedGetSessionProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceSessionAccess);
// 7. Admin API key procedure (for admin operations)
export const adminProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceAdminAuth);
```
---
## Public API Middleware
**Files:** `web/src/features/public-api/server/`
### withMiddlewares Pattern
Wraps all public API routes with CORS, error handling, and OpenTelemetry:
```typescript
export function withMiddlewares(handlers: Handlers) {
return async (req: NextApiRequest, res: NextApiResponse) => {
const ctx = contextWithLangfuseProps({ headers: req.headers });
return opentelemetry.context.with(ctx, async () => {
try {
// 1. CORS middleware
await runMiddleware(req, res, cors);
// 2. HTTP method routing
const method = req.method as HttpMethod;
if (!handlers[method]) throw new MethodNotAllowedError();
// 3. Execute handler
return await handlers[method](req, res);
} catch (error) {
// 4. Error handling
if (error instanceof BaseError) {
if (error.httpCode >= 500) traceException(error);
return res.status(error.httpCode).json({
message: error.message,
error: error.name,
});
}
if (error instanceof ClickHouseResourceError) {
return res.status(524).json({
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
error: "Request is taking too long to process.",
});
}
if (isZodError(error)) {
return res.status(400).json({
message: "Invalid request data",
error: error.issues,
});
}
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
});
};
}
```
**Usage:**
```typescript
// web/src/pages/api/public/datasets/[datasetName]/items.ts
export default withMiddlewares({
GET: getDatasetItemsHandler,
POST: createDatasetItemHandler,
});
```
### createAuthedProjectAPIRoute Pattern
Factory function for authenticated public API routes with:
- Authentication (Basic auth or Admin API key)
- Rate limiting
- Input/output validation (Zod)
- OpenTelemetry context
```typescript
export const createAuthedProjectAPIRoute = <TQuery, TBody, TResponse>(
routeConfig: RouteConfig<TQuery, TBody, TResponse>,
) => {
return async (req: NextApiRequest, res: NextApiResponse) => {
// 1. Authentication (verifyAuth)
const auth = await verifyAuth(
req,
routeConfig.isAdminApiKeyAuthAllowed || false,
);
// 2. Rate limiting
const rateLimitResponse =
await RateLimitService.getInstance().rateLimitRequest(
auth.scope,
routeConfig.rateLimitResource || "public-api",
);
if (rateLimitResponse?.isRateLimited()) {
return rateLimitResponse.sendRestResponseIfLimited(res);
}
// 3. Input validation
const query = routeConfig.querySchema
? routeConfig.querySchema.parse(req.query)
: {};
const body = routeConfig.bodySchema
? routeConfig.bodySchema.parse(req.body)
: {};
// 4. Execute with OpenTelemetry context
const ctx = contextWithLangfuseProps({
headers: req.headers,
projectId: auth.scope.projectId,
});
return opentelemetry.context.with(ctx, async () => {
const response = await routeConfig.fn({ query, body, req, res, auth });
// 5. Response validation (dev only)
if (env.NODE_ENV === "development" && routeConfig.responseSchema) {
const parsingResult = routeConfig.responseSchema.safeParse(response);
if (!parsingResult.success) {
logger.error("Response validation failed:", parsingResult.error);
}
}
res.status(routeConfig.successStatusCode || 200).json(response);
});
};
};
```
**Usage:**
```typescript
// web/src/pages/api/public/traces/[traceId].ts
export default createAuthedProjectAPIRoute({
name: "Get Trace",
querySchema: GetTraceV1Query,
responseSchema: GetTraceV1Response,
fn: async ({ query, auth }) => {
const trace = await getTraceById({
traceId: query.traceId,
projectId: auth.scope.projectId,
});
return transformTraceToApiResponse(trace);
},
});
```
---
## Authentication Patterns
### tRPC Authentication (NextAuth)
tRPC uses NextAuth sessions stored in JWT cookies:
```typescript
// Context creation with session
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const session = await getServerAuthSession({ req, res });
addUserToSpan({
userId: session?.user?.id,
email: session?.user?.email ?? undefined,
});
return {
session,
headers: req.headers,
prisma,
DB,
};
};
```
**Session types:**
```typescript
// Base authenticated context
export type AuthedContext = {
session: { user: NonNullable<Session["user"]> };
};
// Project-scoped context
export type ProjectAuthedContext = {
session: AuthedContext["session"] & {
orgId: string;
orgRole: Role;
projectId: string;
projectRole: Role;
};
};
```
### Public API Authentication
Public APIs use **Basic Auth** with API keys:
```typescript
async function verifyBasicAuth(authHeader: string | undefined) {
const regularAuth = await new ApiAuthService(
prisma,
redis,
).verifyAuthHeaderAndReturnScope(authHeader);
if (!regularAuth.validKey) {
throw { status: 401, message: regularAuth.error };
}
if (regularAuth.scope.accessLevel !== "project") {
throw {
status: 401,
message: "Access denied - need basic auth with secret key",
};
}
return regularAuth;
}
```
**Admin API Key Authentication** (self-hosted only):
```typescript
async function verifyAdminApiKeyAuth(req: NextApiRequest) {
// Requires:
// 1. Authorization: Bearer <ADMIN_API_KEY>
// 2. x-langfuse-admin-api-key: <ADMIN_API_KEY>
// 3. x-langfuse-project-id: <project-id>
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
throw {
status: 403,
message: "Admin API key auth not available on Langfuse Cloud",
};
}
const adminApiKey = env.ADMIN_API_KEY;
const bearerToken = req.headers.authorization?.replace("Bearer ", "");
const adminApiKeyHeader = req.headers["x-langfuse-admin-api-key"];
// Timing-safe comparison
const isValid =
crypto.timingSafeEqual(
Buffer.from(bearerToken),
Buffer.from(adminApiKey),
) &&
crypto.timingSafeEqual(
Buffer.from(adminApiKeyHeader),
Buffer.from(adminApiKey),
);
if (!isValid) throw { status: 401, message: "Invalid admin API key" };
const projectId = req.headers["x-langfuse-project-id"];
const project = await prisma.project.findUnique({ where: { id: projectId } });
if (!project) throw { status: 404, message: "Project not found" };
return { validKey: true, scope: { projectId, accessLevel: "project" } };
}
```
---
## Error Handling Middleware
### tRPC Error Transformation
All tRPC errors go through `withErrorHandling` middleware:
**Error types handled:**
1. **ClickHouseResourceError**`SERVICE_UNAVAILABLE` (524)
2. **BaseError** → Preserves httpCode and message
3. **5xx errors** → Sanitized as "Internal error" (hides stack traces)
4. **4xx errors** → Original error message preserved
**Example:**
```typescript
if (!res.ok) {
if (res.error.cause instanceof ClickHouseResourceError) {
res.error = new TRPCError({
code: "SERVICE_UNAVAILABLE",
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
});
} else {
const { code, httpStatus } = resolveError(res.error);
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
res.error = new TRPCError({
code,
cause: null,
message: isSafeToExpose ? res.error.message : "Internal error.",
});
}
}
```
### Public API Error Handling
Public API uses `withMiddlewares` for error handling:
```typescript
catch (error) {
// 1. BaseError (custom application errors)
if (error instanceof BaseError) {
if (error.httpCode >= 500) traceException(error);
return res.status(error.httpCode).json({
message: error.message,
error: error.name,
});
}
// 2. ClickHouseResourceError (query timeouts, memory limits)
if (error instanceof ClickHouseResourceError) {
return res.status(524).json({
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
error: "Request is taking too long to process.",
});
}
// 3. Zod validation errors
if (isZodError(error)) {
return res.status(400).json({
message: "Invalid request data",
error: error.issues,
});
}
// 4. Prisma errors
if (isPrismaException(error)) {
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: "An unknown error occurred",
});
}
// 5. Unknown errors
traceException(error);
return res.status(500).json({
message: "Internal Server Error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
```
---
## OpenTelemetry Instrumentation
All requests (tRPC and public API) propagate OpenTelemetry context with Langfuse-specific baggage.
### Context Propagation Pattern
```typescript
import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
import * as opentelemetry from "@opentelemetry/api";
// Create context with Langfuse baggage
const ctx = contextWithLangfuseProps({
headers: req.headers,
userId: session?.user?.id,
projectId: input?.projectId,
});
// Execute with context
return opentelemetry.context.with(ctx, async () => {
// All instrumented code inside here will have access to baggage
return await handler();
});
```
**Baggage includes:**
- `userId` - User ID from session
- `projectId` - Project ID from input
- `headers` - Request headers for trace propagation
### tRPC Instrumentation
```typescript
const withOtelInstrumentation = t.middleware(async (opts) => {
const actualInput = await opts.getRawInput();
const baggageCtx = contextWithLangfuseProps({
headers: opts.ctx.headers,
userId: opts.ctx.session?.user?.id,
projectId: (actualInput as Record<string, string>)?.projectId,
});
return opentelemetry.context.with(baggageCtx, () => opts.next());
});
// Used with Baselime tracing
const withOtelTracingProcedure = t.procedure
.use(withOtelInstrumentation)
.use(tracing({ collectInput: true, collectResult: true }));
```
---
## Composable Procedures
tRPC procedures are composed by chaining middleware:
### Composition Pattern
```typescript
// Base procedure with tracing + error handling
const baseProcedure = withOtelTracingProcedure.use(withErrorHandling);
// Add authentication
const authedProcedure = baseProcedure.use(enforceUserIsAuthed);
// Add project scoping
const projectProcedure = authedProcedure.use(
enforceUserIsAuthedAndProjectMember,
);
```
### Using Procedures in Routers
```typescript
import { protectedProjectProcedure } from "@/src/server/api/trpc";
export const tracesRouter = createTRPCRouter({
// Input automatically validated against Zod schema
all: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
page: z.number().optional(),
limit: z.number().optional(),
}),
)
.query(async ({ input, ctx }) => {
// ctx.session.projectId is guaranteed to exist
// ctx.session.projectRole contains user's role
const traces = await getTraces({
projectId: input.projectId,
page: input.page ?? 0,
limit: input.limit ?? 50,
});
return traces;
}),
byId: protectedGetTraceProcedure
.input(
z.object({
traceId: z.string(),
projectId: z.string(),
timestamp: z.date().nullish(),
}),
)
.query(async ({ input, ctx }) => {
// ctx.trace is guaranteed to exist (fetched by middleware)
// No need to refetch
return ctx.trace;
}),
});
```
### Middleware Execution Order
Middleware executes in the order it's chained:
```typescript
protectedProjectProcedure
.use(withErrorHandling) // 1. Wraps entire execution
.use(enforceUserIsAuthed) // 2. Validates session exists
.use(enforceUserIsAuthedAndProjectMember) // 3. Validates project membership
.input(schema) // 4. Validates input
.query(async ({ input, ctx }) => {
// 5. Executes query
// ...
});
```
**Context enrichment:**
Each middleware can enrich the context:
```typescript
// After enforceUserIsAuthed:
ctx.session.user; // NonNullable<User>
// After enforceUserIsAuthedAndProjectMember:
ctx.session.projectId; // string
ctx.session.projectRole; // Role (OWNER | ADMIN | MEMBER | VIEWER)
ctx.session.orgId; // string
ctx.session.orgRole; // Role
// After enforceTraceAccess:
ctx.trace; // TraceRecord (pre-fetched)
```
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
@@ -1,908 +0,0 @@
# Routing Patterns - Next.js & tRPC
Complete guide to routing and separation of concerns in Langfuse's Next.js + tRPC architecture.
## Table of Contents
- [Architecture Overview](#architecture-overview)
- [tRPC Routers](#trpc-routers)
- [Public REST API Routes](#public-rest-api-routes)
- [Fern API Definitions](#fern-api-definitions)
- [Service Layer](#service-layer)
- [Repository Layer](#repository-layer)
- [Separation of Concerns](#separation-of-concerns)
- [Anti-Patterns](#anti-patterns)
---
## Architecture Overview
Langfuse uses a **layered architecture** with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────┐
│ ENTRY POINTS │
│ ┌──────────────────────┐ ┌─────────────────────────┐ │
│ │ tRPC Procedures │ │ Public REST API Routes │ │
│ │ (Internal UI API) │ │ (SDK/External API) │ │
│ └──────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
│ Business logic, orchestration, validation │
│ web/src/features/*/server/ or packages/shared/services/ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ REPOSITORY LAYER │
│ Complex queries, data transformation │
│ packages/shared/src/server/repositories/ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ DATABASE LAYER │
│ PostgreSQL (Prisma) + ClickHouse (Direct Client) │
└─────────────────────────────────────────────────────────────┘
```
### Key Principles
**Entry Points (Routes/Procedures):**
- ✅ Define routing and procedure signatures
- ✅ Handle authentication/authorization (via middleware)
- ✅ Validate input (Zod schemas)
- ✅ Delegate to services
- ✅ Return responses
**Entry Points should NEVER:**
- ❌ Contain business logic
- ❌ Access database directly
- ❌ Perform complex data transformations
- ❌ Make direct repository calls (use services)
**Services:**
- ✅ Contain business logic
- ✅ Orchestrate multiple operations
- ✅ Call repositories or Prisma/ClickHouse
- ✅ Handle complex workflows
- ❌ Should NOT know about HTTP, tRPC, or request/response objects
**Repositories:**
- ✅ Complex database queries
- ✅ Data transformation (DB → domain models)
- ✅ ClickHouse query builders
- ✅ Reusable query logic
- ❌ Should NOT contain business logic
---
## tRPC Routers
**Location:** `web/src/server/api/routers/`
tRPC routers define type-safe procedures for the internal UI. Each router groups related operations.
### Router Structure
**File:** `web/src/server/api/routers/scores.ts`
```typescript
import { z } from "zod/v4";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { paginationZod, singleFilter, orderBy } from "@langfuse/shared";
import {
getScoresUiTable,
getScoresUiCount,
upsertScore,
} from "@langfuse/shared/src/server";
const ScoreAllOptions = z.object({
projectId: z.string(),
filter: z.array(singleFilter),
orderBy: orderBy,
...paginationZod,
});
export const scoresRouter = createTRPCRouter({
/**
* Get all scores for a project
*/
all: protectedProjectProcedure
.input(ScoreAllOptions)
.query(async ({ input, ctx }) => {
// Delegate to repository for data fetching
const clickhouseScoreData = await getScoresUiTable({
projectId: input.projectId,
filter: input.filter ?? [],
orderBy: input.orderBy,
limit: input.limit,
offset: input.page * input.limit,
});
// Delegate to Prisma for related data
const [jobExecutions, users] = await Promise.all([
ctx.prisma.jobExecution.findMany({
where: {
jobOutputScoreId: {
in: clickhouseScoreData.map((score) => score.id),
},
},
}),
ctx.prisma.user.findMany({
where: {
id: {
in: clickhouseScoreData
.map((s) => s.authorUserId)
.filter((id): id is string => id !== null),
},
},
}),
]);
// Transform and combine data
return clickhouseScoreData.map((score) => ({
...score,
jobConfigurationId:
jobExecutions.find((j) => j.jobOutputScoreId === score.id)
?.jobConfigurationId ?? null,
authorUserImage:
users.find((u) => u.id === score.authorUserId)?.image ?? null,
authorUserName:
users.find((u) => u.id === score.authorUserId)?.name ?? null,
}));
}),
/**
* Create or update score
*/
createAnnotationScore: protectedProjectProcedure
.input(CreateAnnotationScoreData)
.mutation(async ({ input, ctx }) => {
// Validation
validateConfigAgainstBody(input);
// Delegate to repository
await upsertScore({
id: input.id ?? randomUUID(),
traceId: input.traceId,
projectId: input.projectId,
name: input.name,
value: input.value,
source: ScoreSource.ANNOTATION,
authorUserId: ctx.session.user.id,
comment: input.comment,
});
// Audit log
await auditLog({
session: ctx.session,
resourceType: "score",
resourceId: input.id,
action: "create",
});
return { success: true };
}),
});
```
**Key Points:**
- Use appropriate procedure type (`protectedProjectProcedure`, `authenticatedProcedure`, etc.)
- Define input schema with Zod (`.input()`)
- Use `.query()` for reads, `.mutation()` for writes
- Delegate to services/repositories for data access
- Keep procedures thin - no business logic
- Type-safe throughout (TypeScript infers types from Zod schemas)
### Registering Routers
**File:** `web/src/server/api/root.ts`
```typescript
import { createTRPCRouter } from "@/src/server/api/trpc";
import { scoresRouter } from "./routers/scores";
import { tracesRouter } from "./routers/traces";
import { dashboardRouter } from "@/src/features/dashboard/server/dashboard-router";
export const appRouter = createTRPCRouter({
scores: scoresRouter,
traces: tracesRouter,
dashboard: dashboardRouter,
// ... other routers
});
export type AppRouter = typeof appRouter;
```
**Calling from frontend:**
```typescript
// Type-safe client call
const { data, isLoading } = api.scores.all.useQuery({
projectId: "proj_123",
page: 0,
limit: 50,
filter: [],
orderBy: null,
});
```
---
## Public REST API Routes
**Location:** `web/src/pages/api/public/`
Public API routes use **Next.js file-based routing** and provide REST endpoints for SDKs and external integrations.
### File-based Routing
Next.js uses file system for routing:
```
web/src/pages/api/public/
├── scores/
│ ├── index.ts → GET/POST /api/public/scores
│ └── [scoreId].ts → GET/PATCH/DELETE /api/public/scores/:scoreId
├── traces/
│ ├── index.ts → GET /api/public/traces
│ └── [traceId].ts → GET /api/public/traces/:traceId
└── datasets/
└── [name]/
├── index.ts → GET/POST /api/public/datasets/:name
└── items/
└── index.ts → GET /api/public/datasets/:name/items
```
**Dynamic routes:**
- `[param].ts` → Single dynamic segment (e.g., `/api/public/scores/[scoreId].ts`)
- `[...param].ts` → Catch-all route (e.g., `/api/public/[...path].ts`)
### REST API Pattern
**File:** `web/src/pages/api/public/scores/index.ts`
```typescript
import { v4 } from "uuid";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import {
GetScoresQueryV1,
GetScoresResponseV1,
PostScoresBodyV1,
PostScoresResponseV1,
} from "@langfuse/shared";
import { eventTypes, processEventBatch } from "@langfuse/shared/src/server";
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
export default withMiddlewares({
// POST /api/public/scores
POST: createAuthedProjectAPIRoute({
name: "Create Score",
bodySchema: PostScoresBodyV1,
responseSchema: PostScoresResponseV1,
fn: async ({ body, auth, res }) => {
const event = {
id: v4(),
type: eventTypes.SCORE_CREATE,
timestamp: new Date().toISOString(),
body,
};
if (!event.body.id) {
event.body.id = v4();
}
const result = await processEventBatch([event], auth);
if (result.errors.length > 0) {
const error = result.errors[0];
res.status(error.status).json({
message: error.error ?? error.message,
});
return { id: "" };
}
return { id: event.body.id };
},
}),
// GET /api/public/scores
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
querySchema: GetScoresQueryV1,
responseSchema: GetScoresResponseV1,
fn: async ({ query, auth }) => {
const scoresApiService = new ScoresApiService("v1");
const [items, count] = await Promise.all([
scoresApiService.generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page,
limit: query.limit,
userId: query.userId,
name: query.name,
}),
scoresApiService.getScoresCountForPublicApi({
projectId: auth.scope.projectId,
userId: query.userId,
name: query.name,
}),
]);
return {
data: items,
meta: {
page: query.page,
limit: query.limit,
totalItems: count,
totalPages: Math.ceil(count / query.limit),
},
};
},
}),
});
```
**Key Points:**
- Use `withMiddlewares` for all public API routes (provides CORS, error handling, OpenTelemetry)
- Use `createAuthedProjectAPIRoute` for authenticated endpoints (handles auth, rate limiting, validation)
- Define separate handlers for each HTTP method
- Input/output validated with Zod schemas
- Delegate to services for business logic
### Versioned API Type Location
When a feature has versioned public API types, place them in `packages/shared/`
under the feature's `interfaces/api/` folder, one subdirectory per version.
The scores feature (`packages/shared/src/features/scores/interfaces/api/`) is
the canonical example — the `GetScoresQueryV1` / `GetScoresResponseV1` symbols
imported from `@langfuse/shared` originate there.
Do not create a flat file (e.g. `<domain>-api-v2.ts`) and do not place
versioned types in `web/src/features/public-api/types/`.
```
packages/shared/src/features/<domain>/interfaces/api/
├── v1/
│ ├── schemas.ts # Zod schemas for request/response shapes
│ ├── endpoints.ts # Composed request/response types
│ └── validation.ts # Cross-field validation helpers
├── v2/
│ └── ...
└── vN/
└── ...
```
### Fern API Definitions
When modifying public API types in `web/src/features/public-api/types/` or under
`packages/shared/src/features/<domain>/interfaces/api/`, update the matching
Fern API definitions in `fern/apis/server/definition/`.
**Zod to Fern Type Mapping:**
| Zod Type | Fern Type | Example |
| -------------- | ----------------------- | --------------------------------------------------------- |
| `.nullish()` | `optional<nullable<T>>` | `z.string().nullish()` -> `optional<nullable<string>>` |
| `.nullable()` | `nullable<T>` | `z.string().nullable()` -> `nullable<string>` |
| `.optional()` | `optional<T>` | `z.string().optional()` -> `optional<string>` |
| Always present | `T` | `z.string()` -> `string` |
Add a source comment at the top of each Fern type that references the
TypeScript source:
```yaml
# Source: web/src/features/public-api/types/traces.ts - APITrace
Trace:
properties:
id: string
name:
type: nullable<string>
```
### Simple Public Routes
For routes that don't need authentication:
```typescript
// web/src/pages/api/public/health.ts
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
export default withMiddlewares({
GET: async (req, res) => {
res.status(200).json({ status: "ok" });
},
});
```
---
## Service Layer
**Location:** `web/src/features/*/server/` or `packages/shared/src/server/services/`
Services contain business logic and orchestrate operations. They're called by tRPC procedures and API routes.
### Service Pattern
**File:** `web/src/features/public-api/server/scores-api-service.ts`
```typescript
import {
_handleGenerateScoresForPublicApi,
_handleGetScoresCountForPublicApi,
type ScoreQueryType,
} from "@/src/features/public-api/server/scores";
import { _handleGetScoreById } from "@langfuse/shared/src/server";
export class ScoresApiService {
constructor(private readonly apiVersion: "v1" | "v2") {}
/**
* Get a specific score by ID
*/
async getScoreById({
projectId,
scoreId,
source,
}: {
projectId: string;
scoreId: string;
source?: ScoreSourceType;
}) {
return _handleGetScoreById({
projectId,
scoreId,
source,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
preferredClickhouseService: "ReadOnly",
});
}
/**
* Get list of scores with version-aware filtering
*/
async generateScoresForPublicApi(props: ScoreQueryType) {
return _handleGenerateScoresForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
});
}
/**
* Get count of scores with version-aware filtering
*/
async getScoresCountForPublicApi(props: ScoreQueryType) {
return _handleGetScoresCountForPublicApi({
props,
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
});
}
}
```
**Key Points:**
- Services contain business logic, not routing logic
- Services should NOT import tRPC or Next.js types
- Services can call repositories, Prisma, ClickHouse directly
- Services orchestrate multiple operations
- Services are reusable across tRPC and public API
### Where to Put Services
**Feature-specific services:**
```
web/src/features/
├── datasets/
│ └── server/
│ └── dataset-service.ts
├── evals/
│ └── server/
│ └── eval-service.ts
└── public-api/
└── server/
└── scores-api-service.ts
```
**Shared services:**
```
packages/shared/src/server/services/
├── SlackService.ts
├── DashboardService/
├── StorageService.ts
└── DefaultEvaluationModelService/
```
---
## Repository Layer
**Location:** `packages/shared/src/server/repositories/`
Repositories handle complex database queries, data transformation, and provide reusable query logic.
### Repository Structure
```
packages/shared/src/server/repositories/
├── traces.ts # Trace queries (ClickHouse)
├── observations.ts # Observation queries (ClickHouse)
├── scores.ts # Score queries (ClickHouse)
├── clickhouse.ts # Core ClickHouse helpers
└── definitions.ts # Type definitions
```
### Repository Pattern
**File:** `packages/shared/src/server/repositories/traces.ts`
```typescript
import { queryClickhouse, upsertClickhouse } from "./clickhouse";
import { TraceRecordReadType } from "./definitions";
import { convertClickhouseToDomain } from "./traces_converters";
/**
* Get traces by IDs
*/
export const getTracesByIds = async (
projectId: string,
traceIds: string[],
): Promise<TraceRecordReadType[]> => {
const rows = await queryClickhouse<TraceRecordReadType>({
query: `
SELECT *
FROM traces
WHERE project_id = {projectId: String}
AND id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`,
params: { projectId, traceIds },
tags: { feature: "tracing", type: "trace" },
});
return rows.map(convertClickhouseToDomain);
};
/**
* Upsert trace to ClickHouse
*/
export const upsertTrace = async (
trace: TraceRecordInsertType,
): Promise<void> => {
await upsertClickhouse({
table: "traces",
records: [trace],
eventBodyMapper: (body) => ({
id: body.id,
name: body.name,
user_id: body.user_id,
// ... map fields
}),
tags: { feature: "ingestion", type: "trace" },
});
};
```
**Key Points:**
- Use `queryClickhouse` for SELECT queries
- Use `upsertClickhouse` for INSERT/UPDATE
- Use `commandClickhouse` for DDL (ALTER TABLE, etc.)
- Include data converters (`convertClickhouseToDomain`)
- Add OpenTelemetry tags for observability
- Repositories should NOT contain business logic
### When to Use Repositories
**Use repositories for:**
- Complex ClickHouse queries with CTEs, joins, aggregations
- Queries used in multiple places (DRY principle)
- Data transformation from DB types to domain models
- Streaming large result sets
**Use direct Prisma/ClickHouse for:**
- Simple CRUD operations
- One-off queries
- Prototyping (can refactor to repository later)
---
## Separation of Concerns
### ✅ Good Example: Proper Layering
**tRPC Procedure (Entry Point):**
```typescript
// web/src/server/api/routers/scores.ts
export const scoresRouter = createTRPCRouter({
all: protectedProjectProcedure
.input(ScoreFilterOptions)
.query(async ({ input }) => {
// ✅ Thin procedure - delegates to repository
return await getScoresUiTable({
projectId: input.projectId,
filter: input.filter,
orderBy: input.orderBy,
});
}),
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
// ✅ Delegates to service for orchestration
return await createScoreWithValidation({
scoreData: input,
userId: ctx.session.user.id,
projectId: ctx.session.projectId,
});
}),
});
```
**Service (Business Logic):**
```typescript
// web/src/features/scores/server/score-service.ts
export async function createScoreWithValidation({
scoreData,
userId,
projectId,
}: {
scoreData: CreateScoreInput;
userId: string;
projectId: string;
}) {
// ✅ Business logic: validation
const config = await prisma.scoreConfig.findUnique({
where: { id: scoreData.configId },
});
if (!config) {
throw new LangfuseNotFoundError("Score config not found");
}
validateConfigAgainstBody(config, scoreData);
// ✅ Business logic: orchestration
const scoreId = randomUUID();
await Promise.all([
// Create score in ClickHouse
upsertScore({
id: scoreId,
projectId,
traceId: scoreData.traceId,
name: scoreData.name,
value: scoreData.value,
authorUserId: userId,
}),
// Audit log in PostgreSQL
auditLog({
userId,
resourceType: "score",
resourceId: scoreId,
action: "create",
}),
]);
return { id: scoreId };
}
```
**Repository (Data Access):**
```typescript
// packages/shared/src/server/repositories/scores.ts
export const upsertScore = async (score: ScoreInsertType): Promise<void> => {
// ✅ Pure data access - no business logic
await upsertClickhouse({
table: "scores",
records: [score],
eventBodyMapper: (body) => ({
id: body.id,
trace_id: body.traceId,
name: body.name,
value: body.value,
author_user_id: body.authorUserId,
}),
tags: { feature: "scoring" },
});
};
```
### Why This Works
1. **tRPC Procedure**: Thin, delegates to service
2. **Service**: Contains all business logic (validation, orchestration)
3. **Repository**: Pure data access, reusable
4. **Service is protocol-agnostic**: Can be called from tRPC, public API, or worker
5. **Clear separation**: Easy to test, maintain, extend
---
## Anti-Patterns
### ❌ Anti-Pattern 1: Business Logic in Routes
**Bad:**
```typescript
// ❌ BAD: Business logic in tRPC procedure
export const scoresRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
// ❌ Validation logic in route
const config = await ctx.prisma.scoreConfig.findUnique({
where: { id: input.configId },
});
if (!config) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (config.dataType === "NUMERIC" && typeof input.value !== "number") {
throw new TRPCError({ code: "BAD_REQUEST" });
}
// ❌ Direct database access
await ctx.prisma.score.create({
data: {
id: randomUUID(),
projectId: ctx.session.projectId,
traceId: input.traceId,
name: input.name,
value: input.value,
},
});
// ❌ More business logic
await auditLog({ ... });
return { success: true };
}),
});
```
**Why it's bad:**
- Business logic tied to tRPC (can't reuse in public API)
- Hard to test (need to mock tRPC context)
- No separation of concerns
- Difficult to maintain
**Good:**
```typescript
// ✅ GOOD: Thin procedure, delegates to service
export const scoresRouter = createTRPCRouter({
create: protectedProjectProcedure
.input(CreateScoreInput)
.mutation(async ({ input, ctx }) => {
return await createScoreWithValidation({
scoreData: input,
userId: ctx.session.user.id,
projectId: ctx.session.projectId,
});
}),
});
```
### ❌ Anti-Pattern 2: Database Calls in Routes
**Bad:**
```typescript
// ❌ BAD: Direct database access in route
export default withMiddlewares({
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
fn: async ({ auth }) => {
// ❌ Direct ClickHouse query in route
const scores = await queryClickhouse({
query: "SELECT * FROM scores WHERE project_id = {projectId: String}",
params: { projectId: auth.scope.projectId },
});
return { data: scores };
},
}),
});
```
**Good:**
```typescript
// ✅ GOOD: Delegates to service or repository
export default withMiddlewares({
GET: createAuthedProjectAPIRoute({
name: "Get Scores",
fn: async ({ auth, query }) => {
const scoresService = new ScoresApiService("v1");
return await scoresService.generateScoresForPublicApi({
projectId: auth.scope.projectId,
page: query.page,
limit: query.limit,
});
},
}),
});
```
### ❌ Anti-Pattern 3: Business Logic in Repositories
**Bad:**
```typescript
// ❌ BAD: Business logic in repository
export const upsertScore = async (
score: ScoreInsertType
): Promise<void> => {
// ❌ Validation in repository
if (!score.name) {
throw new Error("Score name is required");
}
// ❌ Authorization check in repository
const project = await prisma.project.findUnique({
where: { id: score.projectId },
});
if (!project) {
throw new Error("Project not found");
}
// ❌ Side effects in repository
await auditLog({ ... });
await upsertClickhouse({ ... });
};
```
**Good:**
```typescript
// ✅ GOOD: Pure data access, no business logic
export const upsertScore = async (score: ScoreInsertType): Promise<void> => {
await upsertClickhouse({
table: "scores",
records: [score],
eventBodyMapper: (body) => ({
id: body.id,
trace_id: body.traceId,
name: body.name,
value: body.value,
}),
tags: { feature: "scoring" },
});
};
```
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main backend development guidelines
- [architecture-overview.md](architecture-overview.md) - System architecture
- [middleware-guide.md](middleware-guide.md) - Middleware patterns
- [database-patterns.md](database-patterns.md) - Database access patterns
@@ -1,650 +0,0 @@
# Services and Repositories - Business Logic Layer
Complete guide to organizing business logic with services and data access with repositories.
## Table of Contents
- [Service Layer Overview](#service-layer-overview)
- [Dependency Injection Pattern](#dependency-injection-pattern)
- [Singleton Pattern](#singleton-pattern)
- [Repository Pattern](#repository-pattern)
- [Service Design Principles](#service-design-principles)
- [Caching Strategies](#caching-strategies)
- [Testing Services](#testing-services)
---
## Service Layer Overview
### Purpose of Services
**Services contain business logic** - the 'what' and 'why' of your application:
```
Controller asks: "Should I do this?"
Service answers: "Yes/No, here's why, and here's what happens"
Repository executes: "Here's the data you requested"
```
**Services are responsible for:**
- ✅ Business rules enforcement
- ✅ Orchestrating multiple repositories
- ✅ Transaction management
- ✅ Complex calculations
- ✅ External service integration
- ✅ Business validations
**Services should NOT:**
- ❌ Know about HTTP (Request/Response)
- ❌ Direct Prisma access (use repositories)
- ❌ Handle route-specific logic
- ❌ Format HTTP responses
---
## Dependency Injection Pattern
### Why Dependency Injection?
**Benefits:**
- Easy to test (inject mocks)
- Clear dependencies
- Flexible configuration
- Promotes loose coupling
### Excellent Example: NotificationService
**File:** `/blog-api/src/services/NotificationService.ts`
```typescript
// Define dependencies interface for clarity
export interface NotificationServiceDependencies {
prisma: PrismaClient;
batchingService: BatchingService;
emailComposer: EmailComposer;
}
// Service with dependency injection
export class NotificationService {
private prisma: PrismaClient;
private batchingService: BatchingService;
private emailComposer: EmailComposer;
private preferencesCache: Map<
string,
{ preferences: UserPreference; timestamp: number }
> = new Map();
private CACHE_TTL =
(notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;
// Dependencies injected via constructor
constructor(dependencies: NotificationServiceDependencies) {
this.prisma = dependencies.prisma;
this.batchingService = dependencies.batchingService;
this.emailComposer = dependencies.emailComposer;
}
/**
* Create a notification and route it appropriately
*/
async createNotification(params: CreateNotificationParams) {
const {
recipientID,
type,
title,
message,
link,
context = {},
channel = "both",
priority = NotificationPriority.NORMAL,
} = params;
try {
// Get template and render content
const template = getNotificationTemplate(type);
const rendered = renderNotificationContent(template, context);
// Create in-app notification record
const notificationId = await createNotificationRecord({
instanceId: parseInt(context.instanceId || "0", 10),
template: type,
recipientUserId: recipientID,
channel: channel === "email" ? "email" : "inApp",
contextData: context,
title: finalTitle,
message: finalMessage,
link: finalLink,
});
// Route notification based on channel
if (channel === "email" || channel === "both") {
await this.routeNotification({
notificationId,
userId: recipientID,
type,
priority,
title: finalTitle,
message: finalMessage,
link: finalLink,
context,
});
}
return notification;
} catch (error) {
ErrorLogger.log(error, {
context: {
"[NotificationService] createNotification": {
type: params.type,
recipientID: params.recipientID,
},
},
});
throw error;
}
}
/**
* Route notification based on user preferences
*/
private async routeNotification(params: {
notificationId: number;
userId: string;
type: string;
priority: NotificationPriority;
title: string;
message: string;
link?: string;
context?: Record<string, any>;
}) {
// Get user preferences with caching
const preferences = await this.getUserPreferences(params.userId);
// Check if we should batch or send immediately
if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
await this.batchingService.queueNotificationForBatch({
notificationId: params.notificationId,
userId: params.userId,
userPreference: preferences,
priority: params.priority,
});
} else {
// Send immediately via EmailComposer
await this.sendImmediateEmail({
userId: params.userId,
title: params.title,
message: params.message,
link: params.link,
context: params.context,
type: params.type,
});
}
}
/**
* Determine if email should be batched
*/
shouldBatchEmail(
preferences: UserPreference,
notificationType: string,
priority: NotificationPriority,
): boolean {
// HIGH priority always immediate
if (priority === NotificationPriority.HIGH) {
return false;
}
// Check batch mode
const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
return batchMode !== BatchMode.IMMEDIATE;
}
/**
* Get user preferences with caching
*/
async getUserPreferences(userId: string): Promise<UserPreference> {
// Check cache first
const cached = this.preferencesCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.preferences;
}
const preference = await this.prisma.userPreference.findUnique({
where: { userID: userId },
});
const finalPreferences = preference || DEFAULT_PREFERENCES;
// Update cache
this.preferencesCache.set(userId, {
preferences: finalPreferences,
timestamp: Date.now(),
});
return finalPreferences;
}
}
```
**Usage in Controller:**
```typescript
// Instantiate with dependencies
const notificationService = new NotificationService({
prisma: PrismaService.main,
batchingService: new BatchingService(PrismaService.main),
emailComposer: new EmailComposer(),
});
// Use in controller
const notification = await notificationService.createNotification({
recipientID: "user-123",
type: "AFRLWorkflowNotification",
context: { workflowName: "AFRL Monthly Report" },
});
```
**Key Takeaways:**
- Dependencies passed via constructor
- Clear interface defines required dependencies
- Easy to test (inject mocks)
- Encapsulated caching logic
- Business rules isolated from HTTP
---
## Singleton Pattern
### When to Use Singletons
**Use for:**
- Services with expensive initialization
- Services with shared state (caching)
- Services accessed from many places
- Permission services
- Configuration services
### Example: PermissionService (Singleton)
**File:** `/blog-api/src/services/permissionService.ts`
```typescript
import { PrismaClient } from "@prisma/client";
class PermissionService {
private static instance: PermissionService;
private prisma: PrismaClient;
private permissionCache: Map<
string,
{ canAccess: boolean; timestamp: number }
> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Private constructor prevents direct instantiation
private constructor() {
this.prisma = PrismaService.main;
}
// Get singleton instance
public static getInstance(): PermissionService {
if (!PermissionService.instance) {
PermissionService.instance = new PermissionService();
}
return PermissionService.instance;
}
/**
* Check if user can complete a workflow step
*/
async canCompleteStep(
userId: string,
stepInstanceId: number,
): Promise<boolean> {
const cacheKey = `${userId}:${stepInstanceId}`;
// Check cache
const cached = this.permissionCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.canAccess;
}
try {
const post = await this.prisma.post.findUnique({
where: { id: postId },
include: {
author: true,
comments: {
include: {
user: true,
},
},
},
});
if (!post) {
return false;
}
// Check if user has permission
const canEdit =
post.authorId === userId || (await this.isUserAdmin(userId));
// Cache result
this.permissionCache.set(cacheKey, {
canAccess: isAssigned,
timestamp: Date.now(),
});
return isAssigned;
} catch (error) {
console.error(
"[PermissionService] Error checking step permission:",
error,
);
return false;
}
}
/**
* Clear cache for user
*/
clearUserCache(userId: string): void {
for (const [key] of this.permissionCache) {
if (key.startsWith(`${userId}:`)) {
this.permissionCache.delete(key);
}
}
}
/**
* Clear all cache
*/
clearCache(): void {
this.permissionCache.clear();
}
}
// Export singleton instance
export const permissionService = PermissionService.getInstance();
```
**Usage:**
```typescript
import { permissionService } from "../services/permissionService";
// Use anywhere in the codebase
const canComplete = await permissionService.canCompleteStep(userId, stepId);
if (!canComplete) {
throw new ForbiddenError("You do not have permission to complete this step");
}
```
---
## Repository Pattern
### Purpose of Repositories
**Repositories abstract data access** - the 'how' of data operations:
```
Service: "Get me all active users sorted by name"
Repository: "Here's the Prisma query that does that"
```
**Repositories are responsible for:**
- ✅ All Prisma operations
- ✅ Query construction
- ✅ Query optimization (select, include)
- ✅ Database error handling
- ✅ Caching database results
**Repositories should NOT:**
- ❌ Contain business logic
- ❌ Know about HTTP
- ❌ Make decisions (that's service layer)
### Langfuse Repository Examples
Use these current Langfuse files as repository templates:
- PostgreSQL repository with project-scoped filters:
`packages/shared/src/server/repositories/comments.ts`
- ClickHouse repository with project-scoped filters and query helpers:
`packages/shared/src/server/repositories/traces.ts`
- Repository tests:
`web/src/__tests__/server/repositories/event-repository.servertest.ts`
Keep data-access concerns in repositories and business decisions in services.
Project-scoped queries must include `projectId` or `project_id` filters.
---
## Service Design Principles
### 1. Single Responsibility
Each service should have ONE clear purpose:
```typescript
// ✅ GOOD - Single responsibility
class UserService {
async createUser() {}
async updateUser() {}
async deleteUser() {}
}
class EmailService {
async sendEmail() {}
async sendBulkEmails() {}
}
// ❌ BAD - Too many responsibilities
class UserService {
async createUser() {}
async sendWelcomeEmail() {} // Should be EmailService
async logUserActivity() {} // Should be AuditService
async processPayment() {} // Should be PaymentService
}
```
### 2. Clear Method Names
Method names should describe WHAT they do:
```typescript
// ✅ GOOD - Clear intent
async createNotification()
async getUserPreferences()
async shouldBatchEmail()
async routeNotification()
// ❌ BAD - Vague or misleading
async process()
async handle()
async doIt()
async execute()
```
### 3. Use Params Objects for Multiple Arguments
When a function receives multiple arguments, use a single params object instead of positional arguments:
```typescript
// ❌ BAD - Positional arguments are unclear and can be swapped
async function createTrace(
projectId: string,
userId: string,
sessionId: string,
name: string,
) {}
// Call site - which string is which?
await createTrace(projectId, userId, sessionId, name);
// ✅ GOOD - Params object makes intent clear
async function createTrace(params: {
projectId: string;
userId: string;
sessionId: string;
name: string;
}) {}
// Call site - clear and prevents argument swapping bugs
await createTrace({ projectId, userId, sessionId, name });
```
**Benefits:**
- More readable at call sites
- Prevents bugs when positional arguments of the same type are accidentally swapped
- Easier to add optional parameters later
- Self-documenting code
### 4. Return Types
Always use explicit return types:
```typescript
// ✅ GOOD - Explicit types
async createUser(data: CreateUserDTO): Promise<User> {}
async findUsers(): Promise<User[]> {}
async deleteUser(id: string): Promise<void> {}
// ❌ BAD - Implicit any
async createUser(data) {} // No types!
```
### 5. Error Handling
Services should throw meaningful errors:
```typescript
// ✅ GOOD - Meaningful errors
if (!user) {
throw new NotFoundError(`User not found: ${userId}`);
}
if (emailExists) {
throw new ConflictError("Email already exists");
}
// ❌ BAD - Generic errors
if (!user) {
throw new Error("Error"); // What error?
}
```
### 6. Avoid God Services
Don't create services that do everything:
```typescript
// ❌ BAD - God service
class WorkflowService {
async startWorkflow() {}
async completeStep() {}
async assignRoles() {}
async sendNotifications() {} // Should be NotificationService
async validatePermissions() {} // Should be PermissionService
async logAuditTrail() {} // Should be AuditService
// ... 50 more methods
}
// ✅ GOOD - Focused services
class WorkflowService {
constructor(
private notificationService: NotificationService,
private permissionService: PermissionService,
private auditService: AuditService,
) {}
async startWorkflow() {
// Orchestrate other services
await this.permissionService.checkPermission();
await this.workflowRepository.create();
await this.notificationService.notify();
await this.auditService.log();
}
}
```
---
## Caching Strategies
### 1. In-Memory Caching
```typescript
class UserService {
private cache: Map<string, { user: User; timestamp: number }> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async getUser(userId: string): Promise<User> {
// Check cache
const cached = this.cache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.user;
}
// Fetch from database
const user = await userRepository.findById(userId);
// Update cache
if (user) {
this.cache.set(userId, { user, timestamp: Date.now() });
}
return user;
}
clearUserCache(userId: string): void {
this.cache.delete(userId);
}
}
```
### 2. Cache Invalidation
```typescript
class UserService {
async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
// Update in database
const user = await userRepository.update(userId, data);
// Invalidate cache
this.clearUserCache(userId);
return user;
}
}
```
---
## Testing Services
Use `testing-guide.md` for backend test patterns. Prefer current Langfuse tests
over invented examples:
- Repository tests:
`web/src/__tests__/server/repositories/event-repository.servertest.ts`
- Pure service unit tests:
`web/src/__tests__/server/unit/`
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main guide
- [routing-and-controllers.md](routing-and-controllers.md) - Controllers that use services
- [database-patterns.md](database-patterns.md) - Prisma and repository patterns
- [testing-guide.md](testing-guide.md) - Testing service and repository code
@@ -1,543 +0,0 @@
# Testing Guide - Backend Testing Strategies
Complete guide to testing Langfuse backend services across web, worker, and shared packages.
## Table of Contents
- [Key Testing Principles](#key-testing-principles)
- [Test Types Overview](#test-types-overview)
- [Integration Tests (Public API)](#integration-tests-public-api)
- [Service-Level Tests (Repository/Service)](#service-level-tests-repositoryservice)
- [tRPC Tests (Procedure Testing)](#trpc-tests-procedure-testing)
- [Worker Tests (Queue Processing)](#worker-tests-queue-processing)
- [Running Tests](#running-tests)
---
## Key Testing Principles
### General Principles
1. **Test Isolation**: Each test should be independent and runnable in any order
2. **Unique IDs**: Use `randomUUID()` or unique project IDs to avoid test interference
3. **Cleanup**: Always clean up test data in service tests (or use unique project IDs)
4. **Avoid Global Resets**: Prefer scoped cleanup or unique project IDs over global reset helpers
5. **Flags and Fallbacks**: When code branches on env flags, feature flags, or fallback data paths, test both branches and ensure fixtures are written to the same store the branch reads from
### By Test Type
| Test Type | Key Principles |
| --------------- | -------------------------------------------------------------------------------- |
| **Integration** | Test HTTP endpoints, validate status codes and response shapes |
| **tRPC** | Use `createInnerTRPCContext` and `appRouter.createCaller`, test auth/permissions |
| **Service** | Test individual functions with isolated data, always cleanup |
| **Worker** | Use vitest, test streams with async iteration, test filtering logic |
### Test Data Management
```typescript
// ✅ GOOD: Use unique IDs
const projectId = randomUUID();
const traceId = randomUUID();
// ✅ GOOD: Cleanup in service tests
afterAll(async () => {
await prisma.model.delete({ where: { id: modelId } });
});
// ✅ GOOD: Use unique projects (no cleanup needed)
const { projectId } = await createOrgProjectAndApiKey();
// ❌ BAD: Shared test data between tests
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
```
---
## Test Types Overview
Langfuse uses multiple testing strategies for different layers:
| Test Type | Framework | Location | Purpose |
| ----------- | --------- | ---------------------------------------- | ----------------------------------- |
| Integration | Vitest | `web/src/__tests__/server/` | Full API endpoint testing |
| tRPC | Vitest | `web/src/__tests__/server/` | tRPC procedure testing with auth |
| Service | Vitest | `web/src/__tests__/server/repositories/` | Repository/service function testing |
| Worker | Vitest | `worker/src/__tests__/` | Queue processors and streams |
---
## Integration Tests (Public API)
Test full REST API endpoints end-to-end using HTTP requests.
**File location:** `web/src/__tests__/server/datasets-api.servertest.ts`
```typescript
import { makeZodVerifiedAPICall } from "../helpers";
import { PostDatasetsV1Response } from "@/src/features/public-api/types/datasets";
describe("Dataset API", () => {
it("should create dataset", async () => {
const res = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: "test-dataset" },
auth,
);
expect(res.status).toBe(200);
});
it("should validate input", async () => {
const res = await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: "" }, // Invalid empty name
auth,
);
expect(res.status).toBe(400);
});
});
```
**Key Points:**
- Uses `makeZodVerifiedAPICall` for type-safe API testing
- Tests HTTP status codes and response validation
- Tests both success and error cases
---
## Service-Level Tests (Repository/Service)
Test individual repository/service functions with isolated data.
**File location:** `web/src/__tests__/server/repositories/event-repository.servertest.ts`
```typescript
import {
createEvent,
createEventsCh,
getObservationsWithModelDataFromEventsTable,
} from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import { randomUUID } from "crypto";
describe("Event Repository Tests", () => {
it("should return observations with model data", async () => {
const traceId = randomUUID();
const generationId = randomUUID();
const modelId = randomUUID();
// Create test data
await prisma.model.create({
data: {
id: modelId,
projectId,
modelName: `gpt-4-${modelId}`,
matchPattern: `(?i)^(gpt-?4-${modelId})$`,
startDate: new Date("2023-01-01"),
unit: "TOKENS",
Price: {
create: [
{ usageType: "input", price: 0.03 },
{ usageType: "output", price: 0.06 },
],
},
},
});
const event = createEvent({
id: generationId,
span_id: generationId,
project_id: projectId,
trace_id: traceId,
type: "GENERATION",
name: `test-generation-${generationId}`,
model_id: modelId,
});
await createEventsCh([event]);
// Test the service function
const result = await getObservationsWithModelDataFromEventsTable({
projectId,
filter: [
{ type: "string", column: "id", operator: "=", value: generationId },
],
limit: 1000,
offset: 0,
});
expect(result.length).toBeGreaterThan(0);
const observation = result.find((o) => o.id === generationId);
expect(observation?.internalModelId).toBe(modelId);
expect(Number(observation?.inputPrice)).toBeCloseTo(0.03, 5);
// Cleanup
await prisma.model.delete({ where: { id: modelId } });
});
it("should handle filters correctly", async () => {
const projectId = randomUUID();
const traceId = randomUUID();
const observations = [
createEvent({
id: randomUUID(),
project_id: projectId,
trace_id: traceId,
type: "GENERATION",
name: "test1",
}),
createEvent({
id: randomUUID(),
project_id: projectId,
trace_id: traceId,
type: "SPAN",
name: "test2",
}),
];
await createEventsCh(observations);
const result = await getObservationsWithModelDataFromEventsTable({
projectId,
filter: [
{
type: "stringOptions",
column: "type",
operator: "any of",
value: ["GENERATION"],
},
],
limit: 1000,
offset: 0,
});
expect(result.every((o) => o.type === "GENERATION")).toBe(true);
});
});
```
**Key Points:**
- Tests service/repository functions directly
- Uses ClickHouse and Prisma test data
- Always cleanup test data after tests
- Use unique IDs to avoid test interference
---
## tRPC Tests (Procedure Testing)
Test tRPC procedures with caller pattern and auth context.
**File location:** `web/src/__tests__/server/automations-trpc.servertest.ts`
```typescript
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { prisma } from "@langfuse/shared/src/db";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import type { Session } from "next-auth";
import { v4 } from "uuid";
import { JobConfigState } from "@langfuse/shared";
async function prepare() {
const { project, org } = await createOrgProjectAndApiKey();
const session: Session = {
expires: "1",
user: {
id: "user-1",
name: "Demo User",
organizations: [
{
id: org.id,
name: org.name,
role: "OWNER",
projects: [
{
id: project.id,
role: "ADMIN",
name: project.name,
},
],
},
],
},
};
const ctx = createInnerTRPCContext({ session, headers: {} });
const caller = appRouter.createCaller({ ...ctx, prisma });
return { project, org, session, ctx, caller };
}
describe("automations trpc", () => {
it("should retrieve all automations for a project", async () => {
const { project, caller } = await prepare();
// Create test trigger
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
},
});
// Create test action
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
headers: { "Content-Type": "application/json" },
},
},
});
// Link trigger to action
await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Call tRPC procedure
const response = await caller.automations.getAutomations({
projectId: project.id,
});
expect(response).toHaveLength(1);
expect(response[0]).toMatchObject({
name: "Test Automation",
trigger: expect.objectContaining({
id: trigger.id,
eventSource: "prompt",
}),
});
});
it("should throw error when user lacks permissions", async () => {
const { project, session } = await prepare();
// Create limited session
const limitedSession: Session = {
...session,
user: {
...session.user!,
organizations: [
{
...session.user!.organizations[0],
projects: [
{
...session.user!.organizations[0].projects[0],
role: "VIEWER", // VIEWER can't create automations
},
],
},
],
},
};
const limitedCtx = createInnerTRPCContext({
session: limitedSession,
headers: {},
});
const limitedCaller = appRouter.createCaller({ ...limitedCtx, prisma });
await expect(
limitedCaller.automations.createAutomation({
projectId: project.id,
name: "Unauthorized",
eventSource: "prompt",
eventAction: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
actionType: "WEBHOOK",
actionConfig: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {},
apiVersion: { prompt: "v1" },
},
}),
).rejects.toThrow("User does not have access");
});
});
```
**Key Points:**
- Uses `prepare()` helper to set up test context
- Creates authenticated caller with `appRouter.createCaller`
- Tests both success and permission error cases
- Can test different user roles and permissions
---
## Worker Tests (Queue Processing)
Test queue processors and stream functions using vitest.
**File location:** `worker/src/__tests__/batchExport.test.ts`
```typescript
import { randomUUID } from "crypto";
import { expect, describe, it } from "vitest";
import {
createObservation,
createObservationsCh,
createOrgProjectAndApiKey,
createTraceScore,
createScoresCh,
createTrace,
createTracesCh,
} from "@langfuse/shared/src/server";
import { getObservationStream } from "../features/database-read-stream/observation-stream";
describe("batch export test suite", () => {
it("should export observations", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const traceId = randomUUID();
const trace = createTrace({
project_id: projectId,
id: traceId,
});
await createTracesCh([trace]);
const observations = [
createObservation({
project_id: projectId,
trace_id: traceId,
type: "SPAN",
}),
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "GENERATION",
}),
];
const score = createTraceScore({
project_id: projectId,
trace_id: traceId,
observation_id: observations[0].id,
name: "test",
value: 123,
});
await createScoresCh([score]);
await createObservationsCh(observations);
// Test the stream function
const stream = await getObservationStream({
projectId: projectId,
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
filter: [],
});
const rows: any[] = [];
for await (const chunk of stream) {
rows.push(chunk);
}
expect(rows).toHaveLength(2);
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: observations[0].id,
type: observations[0].type,
test: [score.value],
}),
]),
);
});
it("should export with filters", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const observations = [
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "GENERATION",
name: "test1",
}),
createObservation({
project_id: projectId,
trace_id: randomUUID(),
type: "SPAN",
name: "test2",
}),
];
await createObservationsCh(observations);
const stream = await getObservationStream({
projectId: projectId,
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
filter: [
{
type: "stringOptions",
operator: "any of",
column: "name",
value: ["test1"],
},
],
});
const rows: any[] = [];
for await (const chunk of stream) {
rows.push(chunk);
}
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe("test1");
});
});
```
**Key Points:**
- Uses vitest (not Jest) for worker tests
- Tests stream functions with async iteration
- Creates isolated test data per test
- Use unique project IDs to avoid interference
---
## Running Tests
Use the nearest package `AGENTS.md` as the source of truth for current test
commands.
Common targeted forms:
- Web server tests: `pnpm --filter web run test <file-or-pattern>`
- Web client tests: `pnpm --filter web run test-client <file-or-pattern>`
- Worker tests: `pnpm --filter worker run test <file-or-pattern>`
---
**Related Files:**
- [../SKILL.md](../SKILL.md) - Main backend guidelines
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
- [services-and-repositories.md](services-and-repositories.md) - Service and repository examples
-48
View File
@@ -1,48 +0,0 @@
---
name: changelog-writing
description: |
Shared workflow for writing Langfuse changelog entries after a feature is complete.
Use when a branch is ready for merge and a changelog entry or changelog draft is needed.
---
# Changelog Writing
Use this skill when a completed feature branch needs a changelog entry.
## Workflow
1. Understand the change set.
2. Study recent changelog patterns in `../langfuse-docs/pages/changelog`.
3. Find related documentation links in `../langfuse-docs/pages`.
4. Draft a user-focused changelog entry.
5. Recommend whether an image or screenshot should be added.
## What To Gather
- The branch diff relative to `main`
- The Linear issue, if the branch name includes an `lfe-XXXX` identifier
- The affected product areas
- Relevant docs pages to link or create
## Writing Rules
- Write for users, not internal implementation detail
- Prefer second person: "you can now..."
- Focus on what changed, why it matters, and how to use it
- Match the structure and tone of recent changelog posts
- Keep technical detail only where it improves user understanding
## Output Format
Provide:
1. A short summary of what changed
2. The complete changelog post content
3. Whether an image should be added and what it should show
4. Any docs pages that should be linked or created
## Reference Files
- Changelog destination: `../langfuse-docs/pages/changelog`
- Recent changelog examples: inspect 3-5 recent files in that directory
- Existing docs: `../langfuse-docs/pages`
@@ -1,51 +0,0 @@
# ClickHouse Best Practices
Agent skill providing comprehensive ClickHouse guidance for schema design, query optimization, and data ingestion.
## Installation
```bash
npx skills add ClickHouse/clickhouse-agent-skills
```
## What's Included
**28 atomic rules** organized by prefix:
| Prefix | Count | Coverage |
| -------------------- | ----- | ------------------------------------------- |
| `schema-pk-*` | 4 | PRIMARY KEY selection, cardinality ordering |
| `schema-types-*` | 5 | Data types, LowCardinality, Nullable |
| `schema-partition-*` | 4 | Partitioning strategy, lifecycle management |
| `schema-json-*` | 1 | JSON type usage |
| `query-join-*` | 5 | JOIN algorithms, filtering, alternatives |
| `query-index-*` | 1 | Data skipping indices |
| `query-mv-*` | 2 | Incremental and refreshable MVs |
| `insert-batch-*` | 1 | Batch sizing (10K-100K rows) |
| `insert-async-*` | 2 | Async inserts, data formats |
| `insert-mutation-*` | 2 | Mutation avoidance |
| `insert-optimize-*` | 1 | OPTIMIZE FINAL avoidance |
## Trigger Phrases
This skill activates when you:
- "Create a table for..."
- "Optimize this query..."
- "Design a schema for..."
- "Why is this query slow?"
- "How should I insert data into..."
- "Should I use UPDATE or..."
## Files
| File | Purpose |
| ------------ | -------------------------------------------------------- |
| `SKILL.md` | Review workflow, quick reference, and rule-selection entrypoint |
| `rules/*.md` | Individual rule definitions |
## Related Documentation
All rules link to official ClickHouse documentation:
- [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
@@ -1,241 +0,0 @@
---
name: clickhouse-best-practices
description: MUST USE when reviewing ClickHouse schemas, queries, or configurations. Contains 28 rules that MUST be checked before providing recommendations. Always read relevant rule files and cite specific rules in responses.
license: Apache-2.0
metadata:
author: ClickHouse Inc
version: "0.3.0"
---
# ClickHouse Best Practices
Comprehensive guidance for ClickHouse covering schema design, query optimization, and data ingestion. Contains 28 rules across 3 main categories (schema, query, insert), prioritized by impact.
> **Official docs:** [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
## IMPORTANT: How to Apply This Skill
**Before answering ClickHouse questions, follow this priority order:**
1. **Check for applicable rules** in the `rules/` directory
2. **If rules exist:** Apply them and cite them in your response using "Per `rule-name`..."
3. **If no rule exists:** Use the LLM's ClickHouse knowledge or search documentation
4. **If uncertain:** Use web search for current best practices
5. **Always cite your source:** rule name, "general ClickHouse guidance", or URL
**Why rules take priority:** ClickHouse has specific behaviors (columnar storage, sparse indexes, merge tree mechanics) where general database intuition can be misleading. The rules encode validated, ClickHouse-specific guidance.
## Langfuse-Specific Rules
- Use `packages/shared/src/server/queries/clickhouse-sql/event-query-builder.ts`
for queries against the `events` table. Do not hand-roll `events` SQL unless
you first confirm the query builder cannot express the query.
- Never use `FINAL` on the `events` table; it is designed so `FINAL` is not
required and the keyword hurts performance.
- Any migration in `packages/shared/clickhouse/migrations/clustered/**` with
more than one `ALTER` on the same table must end every metadata `ALTER`
(`ADD/DROP/MODIFY COLUMN`, `ADD/DROP INDEX`) with `SETTINGS alter_sync = 2`,
and every mutation-creating `ALTER` (`MATERIALIZE …`, `UPDATE`, `DELETE`)
with `SETTINGS mutations_sync = 2`. The matching `unclustered/` file runs
against plain `MergeTree` and does not need (and should not duplicate)
these settings.
---
## Review Procedures
### For Schema Reviews (CREATE TABLE, ALTER TABLE)
**Read these rule files in order:**
1. `rules/schema-pk-plan-before-creation.md` - ORDER BY is immutable
2. `rules/schema-pk-cardinality-order.md` - Column ordering in keys
3. `rules/schema-pk-prioritize-filters.md` - Filter column inclusion
4. `rules/schema-types-native-types.md` - Proper type selection
5. `rules/schema-types-minimize-bitwidth.md` - Numeric type sizing
6. `rules/schema-types-lowcardinality.md` - LowCardinality usage
7. `rules/schema-types-avoid-nullable.md` - Nullable vs DEFAULT
8. `rules/schema-partition-low-cardinality.md` - Partition count limits
9. `rules/schema-partition-lifecycle.md` - Partitioning purpose
**Check for:**
- [ ] PRIMARY KEY / ORDER BY column order (low-to-high cardinality)
- [ ] Data types match actual data ranges
- [ ] LowCardinality applied to appropriate string columns
- [ ] Partition key cardinality bounded (100-1,000 values)
- [ ] ReplacingMergeTree has version column if used
- [ ] Clustered migration files with multiple ALTERs on the same table use `SETTINGS alter_sync = 2` (metadata) and `SETTINGS mutations_sync = 2` (`MATERIALIZE …`, `UPDATE`, `DELETE`); unclustered mirror has none
### For Query Reviews (SELECT, JOIN, aggregations)
**Read these rule files:**
1. `rules/query-join-choose-algorithm.md` - Algorithm selection
2. `rules/query-join-filter-before.md` - Pre-join filtering
3. `rules/query-join-use-any.md` - ANY vs regular JOIN
4. `rules/query-index-skipping-indices.md` - Secondary index usage
5. `rules/schema-pk-filter-on-orderby.md` - Filter alignment with ORDER BY
**Check for:**
- [ ] Filters use ORDER BY prefix columns
- [ ] JOINs filter tables before joining (not after)
- [ ] Correct JOIN algorithm for table sizes
- [ ] Skipping indices for non-ORDER BY filter columns
### For Insert Strategy Reviews (data ingestion, updates, deletes)
**Read these rule files:**
1. `rules/insert-batch-size.md` - Batch sizing requirements
2. `rules/insert-mutation-avoid-update.md` - UPDATE alternatives
3. `rules/insert-mutation-avoid-delete.md` - DELETE alternatives
4. `rules/insert-async-small-batches.md` - Async insert usage
5. `rules/insert-optimize-avoid-final.md` - OPTIMIZE TABLE risks
**Check for:**
- [ ] Batch size 10K-100K rows per INSERT
- [ ] No ALTER TABLE UPDATE for frequent changes
- [ ] ReplacingMergeTree or CollapsingMergeTree for update patterns
- [ ] Async inserts enabled for high-frequency small batches
---
## Output Format
Structure your response as follows:
```
## Rules Checked
- `rule-name-1` - Compliant / Violation found
- `rule-name-2` - Compliant / Violation found
...
## Findings
### Violations
- **`rule-name`**: Description of the issue
- Current: [what the code does]
- Required: [what it should do]
- Fix: [specific correction]
### Compliant
- `rule-name`: Brief note on why it's correct
## Recommendations
[Prioritized list of changes, citing rules]
```
---
## Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rule Count |
| -------- | --------------------- | -------- | ------------------- | ---------- |
| 1 | Primary Key Selection | CRITICAL | `schema-pk-` | 4 |
| 2 | Data Type Selection | CRITICAL | `schema-types-` | 5 |
| 3 | JOIN Optimization | CRITICAL | `query-join-` | 5 |
| 4 | Insert Batching | CRITICAL | `insert-batch-` | 1 |
| 5 | Mutation Avoidance | CRITICAL | `insert-mutation-` | 2 |
| 6 | Partitioning Strategy | HIGH | `schema-partition-` | 4 |
| 7 | Skipping Indices | HIGH | `query-index-` | 1 |
| 8 | Materialized Views | HIGH | `query-mv-` | 2 |
| 9 | Async Inserts | HIGH | `insert-async-` | 2 |
| 10 | OPTIMIZE Avoidance | HIGH | `insert-optimize-` | 1 |
| 11 | JSON Usage | MEDIUM | `schema-json-` | 1 |
---
## Quick Reference
### Schema Design - Primary Key (CRITICAL)
- `schema-pk-plan-before-creation` - Plan ORDER BY before table creation (immutable)
- `schema-pk-cardinality-order` - Order columns low-to-high cardinality
- `schema-pk-prioritize-filters` - Include frequently filtered columns
- `schema-pk-filter-on-orderby` - Query filters must use ORDER BY prefix
### Schema Design - Data Types (CRITICAL)
- `schema-types-native-types` - Use native types, not String for everything
- `schema-types-minimize-bitwidth` - Use smallest numeric type that fits
- `schema-types-lowcardinality` - LowCardinality for <10K unique strings
- `schema-types-enum` - Enum for finite value sets with validation
- `schema-types-avoid-nullable` - Avoid Nullable; use DEFAULT instead
### Schema Design - Partitioning (HIGH)
- `schema-partition-low-cardinality` - Keep partition count 100-1,000
- `schema-partition-lifecycle` - Use partitioning for data lifecycle, not queries
- `schema-partition-query-tradeoffs` - Understand partition pruning trade-offs
- `schema-partition-start-without` - Consider starting without partitioning
### Schema Design - JSON (MEDIUM)
- `schema-json-when-to-use` - JSON for dynamic schemas; typed columns for known
### Query Optimization - JOINs (CRITICAL)
- `query-join-choose-algorithm` - Select algorithm based on table sizes
- `query-join-use-any` - ANY JOIN when only one match needed
- `query-join-filter-before` - Filter tables before joining
- `query-join-consider-alternatives` - Dictionaries/denormalization vs JOIN
- `query-join-null-handling` - join_use_nulls=0 for default values
### Query Optimization - Indices (HIGH)
- `query-index-skipping-indices` - Skipping indices for non-ORDER BY filters
### Query Optimization - Materialized Views (HIGH)
- `query-mv-incremental` - Incremental MVs for real-time aggregations
- `query-mv-refreshable` - Refreshable MVs for complex joins
### Insert Strategy - Batching (CRITICAL)
- `insert-batch-size` - Batch 10K-100K rows per INSERT
### Insert Strategy - Async (HIGH)
- `insert-async-small-batches` - Async inserts for high-frequency small batches
- `insert-format-native` - Native format for best performance
### Insert Strategy - Mutations (CRITICAL)
- `insert-mutation-avoid-update` - ReplacingMergeTree instead of ALTER UPDATE
- `insert-mutation-avoid-delete` - Lightweight DELETE or DROP PARTITION
### Insert Strategy - Optimization (HIGH)
- `insert-optimize-avoid-final` - Let background merges work
---
## When to Apply
This skill activates when you encounter:
- `CREATE TABLE` statements
- `ALTER TABLE` modifications
- `ORDER BY` or `PRIMARY KEY` discussions
- Data type selection questions
- Slow query troubleshooting
- JOIN optimization requests
- Data ingestion pipeline design
- Update/delete strategy questions
- ReplacingMergeTree or other specialized engine usage
- Partitioning strategy decisions
---
## Rule File Structure
Each rule file in `rules/` contains:
- **YAML frontmatter**: title, impact level, tags
- **Brief explanation**: Why this rule matters
- **Incorrect example**: Anti-pattern with explanation
- **Correct example**: Best practice with explanation
- **Additional context**: Trade-offs, when to apply, references
@@ -1,24 +0,0 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Schema Design (schema)
**Impact:** CRITICAL
**Description:** Proper schema design is foundational to ClickHouse performance. ORDER BY is immutable after table creation; wrong choices require full data migration. Includes primary key selection, data types, partitioning strategy, and JSON usage. Column types and ordering can impact query speed by orders of magnitude.
## 2. Query Optimization (query)
**Impact:** CRITICAL
**Description:** Query patterns dramatically affect performance. JOIN algorithms, filtering strategies, skipping indices, and materialized views can reduce query time from minutes to milliseconds. Pre-computed aggregations read thousands of rows instead of billions.
## 3. Insert Strategy (insert)
**Impact:** CRITICAL
**Description:** Each INSERT creates a data part. Single-row inserts overwhelm the merge process. Proper batching (10K-100K rows), async inserts for high-frequency writes, mutation avoidance, and letting background merges work are essential for stable cluster performance.
@@ -1,28 +0,0 @@
---
title: Rule Title Here
impact: CRITICAL | HIGH | MEDIUM | LOW
impactDescription: "Quantified improvement (e.g., 10x faster queries)"
tags: [tag1, tag2]
---
## Rule Title Here
**Impact: CRITICAL** (optional description)
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```sql
-- Bad: description
SELECT * FROM table;
```
**Correct (description of what's right):**
```sql
-- Good: description
SELECT * FROM table;
```
Reference: [Official Docs](https://clickhouse.com/docs/best-practices/...)
@@ -1,55 +0,0 @@
---
title: Use Async Inserts for High-Frequency Small Batches
impact: HIGH
impactDescription: "Server-side buffering when client batching isn't practical"
tags: [insert, async, buffering, small-batches]
---
## Use Async Inserts for High-Frequency Small Batches
**Impact: HIGH**
When client-side batching isn't practical, async inserts buffer server-side and create larger parts automatically.
**Incorrect (small batches without async):**
```python
# Small batches without async_insert - creates too many parts
for batch in chunks(events, 100):
client.execute("INSERT INTO events VALUES", batch)
```
**Correct (enable async inserts):**
```python
# Enable async_insert with safe defaults
client.execute("SET async_insert = 1")
client.execute("SET wait_for_async_insert = 1") # Confirms durability
for batch in chunks(events, 100):
client.execute("INSERT INTO events VALUES", batch)
# Server buffers and creates larger parts automatically
```
```sql
-- Configure server-side for specific users
ALTER USER my_app_user SETTINGS
async_insert = 1,
wait_for_async_insert = 1,
async_insert_max_data_size = 10000000, -- Flush at 10MB
async_insert_busy_timeout_ms = 1000; -- Flush after 1s
```
**Flush conditions (whichever occurs first):**
- Buffer reaches `async_insert_max_data_size`
- Time threshold `async_insert_busy_timeout_ms` elapses
- Maximum insert queries accumulate
**Return modes:**
| Setting | Behavior | Use Case |
|---------|----------|----------|
| `wait_for_async_insert=1` | Waits for flush, confirms durability | **Recommended** |
| `wait_for_async_insert=0` | Fire-and-forget, unaware of errors | **Risky** - only if you accept data loss |
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,54 +0,0 @@
---
title: Batch Inserts Appropriately (10K-100K rows)
impact: CRITICAL
impactDescription: "Each INSERT creates a part; single-row inserts overwhelm merge process"
tags: [insert, batching, parts, performance]
---
## Batch Inserts Appropriately (10K-100K rows)
**Impact: CRITICAL**
Each INSERT creates a new data part. Single-row or small-batch inserts create thousands of tiny parts, overwhelming the merge process and causing cluster instability.
**Incorrect (single-row or tiny batches):**
```python
# Single-row inserts - creates 10,000 parts!
for event in events:
client.execute("INSERT INTO events VALUES", [event])
# Tiny batches - still too many parts
for batch in chunks(events, 100): # 100 rows per INSERT
client.execute("INSERT INTO events VALUES", batch)
```
**Correct (proper batch size):**
```python
# Ideal batch size: 10,000-100,000 rows
BATCH_SIZE = 10_000
for batch in chunks(events, BATCH_SIZE):
client.execute("INSERT INTO events VALUES", batch)
```
**Recommended batch sizes:**
| Threshold | Value |
|-----------|-------|
| Minimum | 1,000 rows |
| Ideal range | 10,000-100,000 rows |
| Insert rate (sync) | ~1 insert per second |
**Validation:**
```sql
-- Monitor part count (>3000 per partition blocks inserts)
SELECT table, count() as parts, sum(rows) as total_rows
FROM system.parts
WHERE active AND database = 'default'
GROUP BY table
ORDER BY parts DESC;
```
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,29 +0,0 @@
---
title: Use Native Format for Best Insert Performance
impact: MEDIUM
impactDescription: "Native format is most efficient; JSONEachRow is expensive to parse"
tags: [insert, format, Native, performance]
---
## Use Native Format for Best Insert Performance
**Impact: MEDIUM**
Data format affects insert performance. Native format is column-oriented with minimal parsing overhead.
**Performance Ranking (fastest to slowest):**
| Format | Notes |
|--------|-------|
| **Native** | Most efficient. Column-oriented, minimal parsing. Recommended. |
| **RowBinary** | Efficient row-based alternative |
| **JSONEachRow** | Easier to use but expensive to parse |
**Example:**
```python
# Use Native format for best performance
client.execute("INSERT INTO events VALUES", data, settings={'input_format': 'Native'})
```
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
@@ -1,74 +0,0 @@
---
title: Avoid ALTER TABLE DELETE
impact: CRITICAL
impactDescription: "Use lightweight DELETE, CollapsingMergeTree, or DROP PARTITION instead"
tags: [insert, mutation, DELETE, CollapsingMergeTree]
---
## Avoid ALTER TABLE DELETE
**Impact: CRITICAL**
`ALTER TABLE DELETE` is a mutation that rewrites entire data parts. Use alternatives like lightweight DELETE, CollapsingMergeTree, or DROP PARTITION.
**Incorrect (mutation delete):**
```sql
-- Mutation delete for cleanup
ALTER TABLE orders DELETE WHERE status = 'cancelled';
-- Time-based cleanup via mutation (very expensive)
ALTER TABLE sessions DELETE WHERE created_at < now() - INTERVAL 7 DAY;
```
**Correct - CollapsingMergeTree:**
```sql
CREATE TABLE orders (
order_id UInt64,
customer_id UInt64,
total Decimal(10,2),
sign Int8 -- 1 = active, -1 = deleted
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY order_id;
-- Insert order
INSERT INTO orders VALUES (123, 456, 99.99, 1);
-- "Delete" by inserting with sign = -1
INSERT INTO orders VALUES (123, 456, 99.99, -1);
-- Query collapses +1 and -1 pairs
SELECT order_id, sum(total * sign) as total
FROM orders GROUP BY order_id HAVING sum(sign) > 0;
```
**Correct - Lightweight Deletes (23.3+):**
```sql
-- Marks rows, doesn't rewrite immediately
DELETE FROM orders WHERE status = 'cancelled';
-- Physical deletion happens during normal merges
```
**Correct - DROP PARTITION for Bulk Deletion:**
```sql
-- Instant deletion of old data
ALTER TABLE events DROP PARTITION '202301';
-- Much faster than:
ALTER TABLE events DELETE WHERE toYYYYMM(timestamp) = 202301;
```
**Delete strategy comparison:**
| Method | Speed | When to Use |
|--------|-------|-------------|
| ALTER DELETE | Slow | Rare corrections only |
| CollapsingMergeTree | Fast | Frequent soft deletes |
| Lightweight DELETE | Medium | Occasional deletes |
| DROP PARTITION | Instant | Bulk deletion by partition |
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
@@ -1,58 +0,0 @@
---
title: Avoid ALTER TABLE UPDATE
impact: CRITICAL
impactDescription: "Mutations rewrite entire parts; use ReplacingMergeTree instead"
tags: [insert, mutation, UPDATE, ReplacingMergeTree]
---
## Avoid ALTER TABLE UPDATE
**Impact: CRITICAL**
`ALTER TABLE UPDATE` is a mutation - an asynchronous background process that rewrites entire data parts affected by the change. This is extremely expensive for frequent or large-scale operations.
**Why mutations are problematic:**
- **Write amplification:** Rewrite complete parts even for minor changes
- **Disk I/O spike:** Degrades overall cluster performance
- **No rollback:** Cannot be rolled back after submission
- **Inconsistent reads:** SELECT may read mix of mutated and unmutated parts
**Incorrect (mutation for updates):**
```sql
-- Rewrites potentially huge amounts of data
ALTER TABLE users UPDATE status = 'inactive'
WHERE last_login < now() - INTERVAL 90 DAY;
-- Frequent row updates via mutation
ALTER TABLE inventory UPDATE quantity = quantity - 1
WHERE product_id = 123;
-- If product exists across 100 parts, rewrites ALL 100 parts
```
**Correct (ReplacingMergeTree):**
```sql
-- Table design for updates
CREATE TABLE users (
user_id UInt64,
name String,
status LowCardinality(String),
updated_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;
-- "Update" by inserting new version
INSERT INTO users (user_id, name, status)
VALUES (123, 'John', 'inactive');
-- Query with FINAL to get latest version
SELECT * FROM users FINAL WHERE user_id = 123;
-- Or use aggregation
SELECT user_id, argMax(status, updated_at) as status
FROM users GROUP BY user_id;
```
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
@@ -1,57 +0,0 @@
---
title: Avoid OPTIMIZE TABLE FINAL
impact: HIGH
impactDescription: "Forces expensive merge of all parts; let background merges work"
tags: [insert, OPTIMIZE, merge, performance]
---
## Avoid OPTIMIZE TABLE FINAL
**Impact: HIGH**
`OPTIMIZE TABLE ... FINAL` forces immediate merge of all parts into one part per partition. This is resource-intensive and rarely necessary. ClickHouse already performs smart background merges.
**Note:** `OPTIMIZE FINAL` is not the same as `FINAL`. The `FINAL` modifier in SELECT queries may be necessary for deduplicated results in ReplacingMergeTree and is generally fine to use.
**Incorrect (OPTIMIZE FINAL after inserts):**
```sql
-- Running OPTIMIZE FINAL after every batch insert
INSERT INTO events SELECT * FROM staging_events;
OPTIMIZE TABLE events FINAL; -- Expensive and unnecessary!
-- Scheduled OPTIMIZE FINAL jobs
-- Cron: 0 * * * * clickhouse-client -q "OPTIMIZE TABLE events FINAL"
```
**Correct (let background merges work):**
```sql
-- Let background merges handle optimization
INSERT INTO events SELECT * FROM staging_events;
-- Done! ClickHouse merges automatically
-- For ReplacingMergeTree deduplication, use FINAL in queries
SELECT * FROM events FINAL WHERE user_id = 123;
-- Instead of running OPTIMIZE FINAL to deduplicate
```
**Problems with OPTIMIZE FINAL:**
- Rewrites entire partition regardless of need
- Ignores the ~150 GB part size safeguard
- Can cause memory pressure or OOM errors
- Lengthy execution time for large datasets
**When OPTIMIZE FINAL may be acceptable:**
- Finalizing data before table freezing
- Preparing data for export operations
- One-time operations, not regular workflows
**Better alternatives:**
| Need | Alternative |
|------|-------------|
| Deduplicate ReplacingMergeTree | Use `FINAL` modifier in SELECT |
| Reduce part count | Rely on background merges |
Reference: [Avoid OPTIMIZE FINAL](https://clickhouse.com/docs/best-practices/avoid-optimize-final)
@@ -1,77 +0,0 @@
---
title: Use Data Skipping Indices for Non-ORDER BY Filters
impact: HIGH
impactDescription: "Up to 60x faster queries by skipping irrelevant granules"
tags: [query, index, skipping, bloom_filter]
---
## Use Data Skipping Indices for Non-ORDER BY Filters
**Impact: HIGH**
Queries filtering on columns not in ORDER BY cannot use the primary index and result in full scans. Data skipping indices store metadata about blocks and skip granules that definitely don't match.
**Important:** Skip indices should be considered **after** optimizing data types, primary key selection, and materialized views.
**When to use:**
- High overall cardinality but low cardinality within blocks
- Rare values critical for search (error codes, specific IDs)
- Column correlates with primary key
**When NOT to use:**
- As a first optimization step
- Matching values scattered across many blocks
- Without testing on real data
**Incorrect (filtering on non-ORDER BY column):**
```sql
CREATE TABLE events (
event_type LowCardinality(String),
timestamp DateTime,
user_id UInt64 -- Not in ORDER BY
)
ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp));
-- Query filters on user_id - scans all matching event_type
SELECT * FROM events
WHERE event_type = 'click' AND user_id = 12345;
```
**Correct (add skipping index):**
```sql
CREATE TABLE events (
event_type LowCardinality(String),
timestamp DateTime,
user_id UInt64,
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4
)
ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp));
-- Or add to existing table
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_user_id;
```
**Index types:**
| Type | Best For | Example Filter |
|------|----------|----------------|
| `bloom_filter` | Equality on high-cardinality | `WHERE user_id = 123` |
| `set(N)` | Low cardinality (N unique values) | `WHERE status IN ('a','b')` |
| `minmax` | Range queries | `WHERE amount > 1000` |
| `ngrambf_v1` | Text search | `WHERE text LIKE '%term%'` |
| `tokenbf_v1` | Token search | `WHERE hasToken(text, 'word')` |
**Validation:**
```sql
EXPLAIN indexes = 1
SELECT * FROM events WHERE user_id = 12345;
-- Look for "Skip" in output showing granules skipped
```
Reference: [Use Data Skipping Indices Where Appropriate](https://clickhouse.com/docs/best-practices/use-data-skipping-indices-where-appropriate)
@@ -1,43 +0,0 @@
---
title: Choose the Right JOIN Algorithm
impact: CRITICAL
impactDescription: "Wrong algorithm causes OOM; right algorithm handles large tables efficiently"
tags: [query, JOIN, algorithm, memory]
---
## Choose the Right JOIN Algorithm
**Impact: CRITICAL**
ClickHouse's default hash join loads the RIGHT table entirely into memory. Choose the right algorithm based on table sizes and constraints.
**Algorithm selection:**
| Algorithm | Best For | Trade-off |
|-----------|----------|-----------|
| `parallel_hash` | Small-to-medium in-memory tables | Default since 24.11; fast, concurrent |
| `hash` | General purpose, all join types | Single-threaded hash table build |
| `direct` | Dictionary lookups (INNER/LEFT only) | Fastest; no hash table construction |
| `full_sorting_merge` | Tables already sorted on join key | Skips sort if pre-ordered; low memory |
| `partial_merge` | Large tables, memory-constrained | Minimized memory; slower execution |
| `grace_hash` | Large datasets, tunable memory | Flexible; disk-spilling capability |
| `auto` | Adaptive algorithm selection | Tries hash first, falls back on memory pressure |
**Example usage:**
```sql
-- Let ClickHouse choose automatically
SET join_algorithm = 'auto';
-- For large-to-large joins where memory is constrained
SET join_algorithm = 'partial_merge';
SELECT * FROM large_a JOIN large_b ON large_b.id = large_a.id;
-- When joining by primary key columns, sort-merge skips sorting step
SET join_algorithm = 'full_sorting_merge';
SELECT * FROM table_a a JOIN table_b b ON b.pk_col = a.pk_col;
```
**Note:** ClickHouse 24.12+ automatically positions smaller tables on the right side. For earlier versions, manually ensure the smaller table is on the RIGHT.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,72 +0,0 @@
---
title: Consider Alternatives to JOINs
impact: CRITICAL
impactDescription: "Dictionaries and denormalization shift work from query time to insert time"
tags: [query, JOIN, dictionary, denormalization]
---
## Consider Alternatives to JOINs
**Impact: CRITICAL**
Repeated JOINs to dimension tables add overhead. Dictionaries or denormalization shift computational work from query time to insert/pre-processing time.
**Incorrect (JOIN on every query):**
```sql
-- JOIN on every query
SELECT o.order_id, c.name, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2024-01-01';
```
**Correct - Dictionary Lookup:**
```sql
-- Create dictionary
CREATE DICTIONARY customer_dict (
id UInt64,
name String,
email String
)
PRIMARY KEY id
SOURCE(CLICKHOUSE(TABLE 'customers'))
LAYOUT(HASHED())
LIFETIME(MIN 300 MAX 360);
-- Use dictGet instead of JOIN (uses direct join algorithm - fastest)
SELECT
order_id,
dictGet('customer_dict', 'name', customer_id) as customer_name,
dictGet('customer_dict', 'email', customer_id) as customer_email
FROM orders
WHERE created_at > '2024-01-01';
```
**Correct - Denormalization:**
```sql
-- Denormalized table with materialized view
CREATE MATERIALIZED VIEW orders_enriched_mv TO orders_enriched AS
SELECT
o.order_id, o.customer_id,
c.name as customer_name,
c.email as customer_email,
o.total, o.created_at
FROM orders o
JOIN customers c ON c.id = o.customer_id;
```
**Approach comparison:**
| Approach | Use Case | Performance |
|----------|----------|-------------|
| Dictionary | Frequent lookups to small dimension | Fastest (in-memory) |
| Denormalization | Analytics always need enriched data | Fast (no join at query) |
| IN subquery | Existence filtering | Often faster than JOIN |
| JOIN | Infrequent or complex joins | Acceptable |
**Critical dictionary caveat:** Dictionaries silently deduplicate duplicate keys, retaining only the final value. Only use when source has unique keys.
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,54 +0,0 @@
---
title: Filter Tables Before Joining
impact: CRITICAL
impactDescription: "Joining full tables then filtering wastes resources"
tags: [query, JOIN, filtering, subquery]
---
## Filter Tables Before Joining
**Impact: CRITICAL**
Joining full tables then filtering wastes resources. Add filtering in `WHERE` or `JOIN ON` clauses. If automatic pushdown fails, restructure as a subquery.
**Incorrect (join then filter):**
```sql
-- Joins entire tables, then filters
SELECT o.order_id, c.name, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2024-01-01' AND c.country = 'US';
```
**Correct (filter in subqueries before joining):**
```sql
-- Filter in subqueries before joining
SELECT o.order_id, c.name, o.total
FROM (
SELECT order_id, customer_id, total
FROM orders
WHERE created_at > '2024-01-01'
) o
JOIN (
SELECT id, name
FROM customers
WHERE country = 'US'
) c ON c.id = o.customer_id;
```
**Even better - aggregate before joining:**
```sql
SELECT c.country, o.total_revenue
FROM (
SELECT customer_id, sum(total) as total_revenue
FROM orders
WHERE created_at > '2024-01-01'
GROUP BY customer_id
) o
JOIN customers c ON c.id = o.customer_id;
```
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,33 +0,0 @@
---
title: Optimize NULL Handling in Outer JOINs
impact: MEDIUM
impactDescription: "Default values instead of NULL reduces memory overhead"
tags: [query, JOIN, NULL, memory]
---
## Optimize NULL Handling in Outer JOINs
**Impact: MEDIUM**
Set `join_use_nulls = 0` to use default column values instead of NULL markers, reducing memory overhead compared to Nullable wrappers.
**Example:**
```sql
-- Use default values instead of NULLs for non-matching rows
SET join_use_nulls = 0;
SELECT o.order_id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
-- Non-matching rows get '' for name instead of NULL
```
**When to use:**
| Setting | Behavior | Use Case |
|---------|----------|----------|
| `join_use_nulls = 0` | Default values (empty string, 0) for non-matches | When you can handle default values |
| `join_use_nulls = 1` (default) | NULL for non-matches | When you need to distinguish "no match" from "matched with default" |
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,40 +0,0 @@
---
title: Use ANY JOIN When Only One Match Needed
impact: HIGH
impactDescription: "Returns first match only; less memory and faster execution"
tags: [query, JOIN, ANY, performance]
---
## Use ANY JOIN When Only One Match Needed
**Impact: HIGH**
Use `ANY` JOINs when you only need a single match rather than all matches. They consume less memory and execute faster.
**Incorrect (returns all matches):**
```sql
-- Returns all matching rows, uses more memory
SELECT o.order_id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
```
**Correct (returns first match only):**
```sql
-- Returns only first match per row, faster and less memory
SELECT o.order_id, c.name
FROM orders o
LEFT ANY JOIN customers c ON c.id = o.customer_id;
```
**ANY JOIN types:**
| Type | Behavior |
|------|----------|
| `LEFT ANY JOIN` | At most one match from right table |
| `INNER ANY JOIN` | At most one match, only matching rows |
| `RIGHT ANY JOIN` | At most one match from left table |
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
@@ -1,68 +0,0 @@
---
title: Use Incremental MVs for Real-Time Aggregations
impact: HIGH
impactDescription: "Read thousands of rows instead of billions; minimal cluster overhead"
tags: [query, materialized-view, aggregation, real-time]
---
## Use Incremental MVs for Real-Time Aggregations
**Impact: HIGH**
Incremental MVs automatically apply the view's query to new data blocks at insert time. Results are written to a target table and partial results merge over time.
**Incorrect (full aggregation on every query):**
```sql
-- Full aggregation on every dashboard load
SELECT
event_type,
toStartOfHour(timestamp) as hour,
count() as events,
uniq(user_id) as unique_users
FROM events
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY event_type, hour;
-- Scans 7 days of data every time (billions of rows)
```
**Correct (incremental MV with pre-aggregation):**
```sql
-- Create target table for aggregated data
CREATE TABLE events_hourly (
event_type LowCardinality(String),
hour DateTime,
events AggregateFunction(count),
unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, hour);
-- Create materialized view to populate incrementally
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
event_type,
toStartOfHour(timestamp) as hour,
countState() as events,
uniqState(user_id) as unique_users
FROM events
GROUP BY event_type, hour;
-- Query the pre-aggregated data
SELECT
event_type, hour,
countMerge(events) as events,
uniqMerge(unique_users) as unique_users
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY event_type, hour;
-- Reads thousands of rows instead of billions
```
**Key points:**
- Use `-State` functions in MV, `-Merge` functions in query
- Incremental - existing data not automatically included (backfill separately)
- Minimal cluster overhead at insert time
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
@@ -1,64 +0,0 @@
---
title: Use Refreshable MVs for Complex Joins and Batch Workflows
impact: HIGH
impactDescription: "Sub-millisecond queries with periodic refresh; ideal for complex joins"
tags: [query, materialized-view, refresh, batch]
---
## Use Refreshable MVs for Complex Joins and Batch Workflows
**Impact: HIGH**
Refreshable MVs execute queries periodically on a schedule. The full query re-executes and overwrites (or appends to) the target table.
**Best for:**
- Sub-millisecond latency where minor staleness is acceptable
- Caching "top N" results or lookup tables
- Complex multi-table joins requiring denormalization
- Batch workflows and DAG dependencies
**Incorrect (expensive join on every request):**
```sql
-- Complex join executed on every request
SELECT
o.order_id, o.total,
c.name as customer_name,
p.name as product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.created_at >= now() - INTERVAL 1 DAY;
```
**Correct (refreshable MV):**
```sql
-- Create refreshable MV that runs every 5 minutes
CREATE MATERIALIZED VIEW orders_denormalized
REFRESH EVERY 5 MINUTE
ENGINE = MergeTree()
ORDER BY (created_at, order_id)
AS SELECT
o.order_id, o.created_at, o.total,
c.name as customer_name, c.segment,
p.name as product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.created_at >= now() - INTERVAL 1 DAY;
-- Query the pre-joined data (sub-millisecond)
SELECT * FROM orders_denormalized WHERE segment = 'enterprise';
```
**APPEND vs REPLACE modes:**
| Mode | Behavior | Use Case |
|------|----------|----------|
| `REPLACE` (default) | Overwrites previous contents | Current state, lookup tables |
| `APPEND` | Adds new rows to existing data | Periodic snapshots, historical accumulation |
**Critical warning:** Query should run quickly compared to refresh interval. Don't schedule every 10 seconds if the query takes 10+ seconds.
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
@@ -1,76 +0,0 @@
---
title: Use JSON Type for Dynamic Schemas
impact: MEDIUM
impactDescription: "Field-level querying for semi-structured data; use typed columns for known schemas"
tags: [schema, JSON, semi-structured, flexibility]
---
## Use JSON Type for Dynamic Schemas
**Impact: MEDIUM**
ClickHouse's JSON type splits JSON objects into separate sub-columns, enabling field-level query optimization. Use it for truly dynamic data, not everything.
**Incorrect (schema bloat or opaque String):**
```sql
-- BAD: Hundreds of nullable columns for event properties
CREATE TABLE events (
event_id UUID,
prop_page_url Nullable(String),
prop_button_id Nullable(String),
-- ... 100 more nullable columns
)
-- BAD: JSON as String when you need field queries
CREATE TABLE events (
event_id UUID,
properties String -- No field-level optimization
)
```
**Correct (JSON for dynamic, typed for known):**
```sql
-- Use JSON type for dynamic properties
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(),
event_type LowCardinality(String),
timestamp DateTime DEFAULT now(),
properties JSON -- Flexible schema with type inference
)
ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
-- Query JSON paths directly
SELECT
event_type,
properties.url as page_url,
properties.amount as purchase_amount
FROM events
WHERE event_type = 'page_view' AND properties.url = '/home';
```
**When to use JSON:**
| Scenario | Use JSON? |
|----------|-----------|
| Data structure varies unpredictably | Yes |
| Field types/schemas change over time | Yes |
| Need field-level querying | Yes |
| Fixed, known schema | No (use typed columns) |
| JSON as opaque blob (no field queries) | No (use String) |
**Optimization: specify types for known paths:**
```sql
CREATE TABLE events (
properties JSON(
url String,
amount Float64,
product_id UInt64
)
)
```
Reference: [Use JSON Where Appropriate](https://clickhouse.com/docs/best-practices/use-json-where-appropriate)
@@ -1,50 +0,0 @@
---
title: Use Partitioning for Data Lifecycle Management
impact: HIGH
impactDescription: "DROP PARTITION is instant; DELETE is expensive row-by-row scan"
tags: [schema, partitioning, TTL, data-management]
---
## Use Partitioning for Data Lifecycle Management
**Impact: HIGH**
Partitioning is **primarily a data management technique, not a query optimization tool**. It excels at:
- **Dropping data**: Remove entire partitions as single metadata operations
- **TTL retention**: Implement time-based retention policies efficiently
- **Tiered storage**: Move old partitions to cold storage
- **Archiving**: Move partitions between tables
**Incorrect (no time alignment for lifecycle):**
```sql
-- Cannot efficiently drop old data by time
CREATE TABLE events (...)
ENGINE = MergeTree()
PARTITION BY event_type -- No time alignment
ORDER BY (timestamp);
-- Slow: must scan and delete row by row
DELETE FROM events WHERE timestamp < '2023-01-01';
```
**Correct (time-based for lifecycle):**
```sql
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, timestamp)
TTL timestamp + INTERVAL 1 YEAR DELETE; -- Drops whole partitions
-- Fast: metadata-only operation
ALTER TABLE events DROP PARTITION '202301';
-- Archive to cold storage
ALTER TABLE events_archive ATTACH PARTITION '202301' FROM events;
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,61 +0,0 @@
---
title: Keep Partition Cardinality Low (100-1,000 Values)
impact: HIGH
impactDescription: "Too many partitions cause part explosion and 'too many parts' errors"
tags: [schema, partitioning, parts]
---
## Keep Partition Cardinality Low (100-1,000 Values)
**Impact: HIGH**
Too many distinct partition values create excessive data parts, eventually triggering "too many parts" errors. ClickHouse enforces limits via `max_parts_in_total` and `parts_to_throw_insert` settings.
**Incorrect (high cardinality partitioning):**
```sql
-- High cardinality = too many partitions
CREATE TABLE events (...)
ENGINE = MergeTree()
PARTITION BY user_id -- Millions of partitions!
ORDER BY (timestamp);
-- Daily partitions can grow unbounded over years
CREATE TABLE logs (...)
ENGINE = MergeTree()
PARTITION BY toDate(timestamp) -- 3650 partitions over 10 years
ORDER BY (service, timestamp);
```
**Correct (bounded cardinality):**
```sql
-- Monthly partitions = 12 per year, bounded cardinality
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
)
ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, timestamp);
```
**Validation:**
```sql
-- Check partition count and health
SELECT
partition,
count() as parts,
sum(rows) as rows,
formatReadableSize(sum(bytes_on_disk)) as size
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition
ORDER BY partition;
-- Warning signs: hundreds or thousands of partitions
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,35 +0,0 @@
---
title: Understand Partition Query Performance Trade-offs
impact: MEDIUM
impactDescription: "Partition pruning helps some queries; spanning many partitions hurts others"
tags: [schema, partitioning, query, performance]
---
## Understand Partition Query Performance Trade-offs
**Impact: MEDIUM**
Partitioning can help or hurt query performance:
- **Potential improvement**: Queries filtering by partition key may benefit from partition pruning
- **Potential degradation**: Queries spanning many partitions increase total parts scanned
ClickHouse automatically builds **MinMax indexes** on partition columns. Data merges occur **within partitions only**, not across them.
**Incorrect (query scans all partitions):**
```sql
-- Query must scan all partitions
SELECT count(*) FROM events
WHERE event_type = 'click'; -- No partition pruning
```
**Correct (query prunes to single partition):**
```sql
-- Query prunes to single partition
SELECT count(*) FROM events
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
AND event_type = 'click';
```
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,42 +0,0 @@
---
title: Consider Starting Without Partitioning
impact: MEDIUM
impactDescription: "Add partitioning later when you have clear lifecycle requirements"
tags: [schema, partitioning, simplicity]
---
## Consider Starting Without Partitioning
**Impact: MEDIUM**
Start without partitioning and add it later only if:
- You have clear data lifecycle requirements (retention, archiving)
- Your access patterns clearly benefit from partition pruning
- You understand the cardinality implications
**Example (start simple):**
```sql
-- Start simple, no partitioning
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
)
ENGINE = MergeTree()
ORDER BY (event_type, timestamp);
-- Add partitioning later if needed for lifecycle management
-- (requires table recreation or materialized view migration)
```
**When to add partitioning:**
| Need | Add Partitioning? |
|------|-------------------|
| Time-based data retention | Yes |
| Archive old data to cold storage | Yes |
| Query performance on time ranges | Maybe (test first) |
| No specific lifecycle needs | No |
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
@@ -1,45 +0,0 @@
---
title: Order Columns by Cardinality (Low to High)
impact: CRITICAL
impactDescription: "Enables granule skipping; high-cardinality first prevents index pruning"
tags: [schema, primary-key, cardinality, ORDER BY]
---
## Order Columns by Cardinality (Low to High)
**Impact: CRITICAL**
Since the sparse primary index operates on data blocks (granules) rather than individual rows, low-cardinality leading columns create more useful index entries that can skip entire blocks. Place lower-cardinality columns before higher-cardinality ones in the ordering key.
**Incorrect (high cardinality first):**
```sql
-- UUID first means no pruning benefit
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_id, event_type, timestamp);
-- Every granule has different event_id values, index can't skip anything
```
**Correct (low cardinality first):**
```sql
-- Low cardinality first enables pruning
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_type, event_date, event_id);
-- Index can skip entire event_type groups
```
**Column Order Guidelines:**
| Position | Cardinality | Examples |
|----------|-------------|----------|
| 1st | Low (few distinct values) | event_type, status, country |
| 2nd | Date (coarse granularity) | toDate(timestamp) |
| 3rd+ | Medium-High | user_id, session_id |
| Last | High (if needed) | event_id, uuid |
**Tip:** Use `toDate(timestamp)` instead of raw `DateTime` columns when day-level filtering suffices - this reduces index size from 32-bit to 16-bit representations.
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,52 +0,0 @@
---
title: Filter on ORDER BY Columns in Queries
impact: CRITICAL
impactDescription: "Skipping prefix columns prevents index usage"
tags: [schema, primary-key, WHERE, query]
---
## Filter on ORDER BY Columns in Queries
**Impact: CRITICAL**
Even with good schema design, queries must use ORDER BY columns to benefit. Skipping prefix columns or filtering on non-ORDER BY columns prevents index usage.
**Incorrect (skips prefix or uses non-ORDER BY columns):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Skips prefix columns - can't use index effectively
SELECT * FROM events WHERE event_type = 'click';
-- Filter on column not in ORDER BY - full table scan
SELECT * FROM events WHERE user_agent LIKE '%Chrome%';
```
**Correct (uses ORDER BY prefix):**
```sql
-- Given: ORDER BY (tenant_id, event_type, timestamp)
-- Full prefix match - best performance
SELECT * FROM events
WHERE tenant_id = 123 AND event_type = 'click';
-- Partial prefix - still uses index
SELECT * FROM events WHERE tenant_id = 123;
-- Range on later column after equality on earlier
SELECT * FROM events
WHERE tenant_id = 123 AND event_type = 'click' AND timestamp >= '2024-01-01';
```
**Index usage reference:**
| Filter | Index Used? |
|--------|-------------|
| `WHERE tenant_id = 123` | Full |
| `WHERE tenant_id = 123 AND event_type = 'click'` | Full |
| `WHERE event_type = 'click'` | None (skipped prefix) |
| `WHERE timestamp > '2024-01-01'` | None (skipped both) |
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,64 +0,0 @@
---
title: Plan PRIMARY KEY Before Table Creation
impact: CRITICAL
impactDescription: "ORDER BY is immutable; wrong choice requires full data migration"
tags: [schema, primary-key, ORDER BY]
---
## Plan PRIMARY KEY Before Table Creation
**Impact: CRITICAL** (immutable after creation)
ClickHouse's ORDER BY clause defines physical data ordering and the sparse index. Unlike other databases, **ORDER BY cannot be modified after table creation**. A wrong choice requires creating a new table and migrating all data.
**Incorrect (arbitrary ORDER BY without query analysis):**
```sql
-- Creating table without analyzing query patterns
CREATE TABLE events (
event_id UUID,
user_id UInt64,
timestamp DateTime
)
ENGINE = MergeTree()
ORDER BY (event_id); -- Chosen arbitrarily
-- Later: "Most queries filter by user_id!"
-- Cannot fix with: ALTER TABLE events MODIFY ORDER BY (user_id, timestamp)
-- ERROR: Cannot modify ORDER BY
```
**Correct (query-driven ORDER BY selection):**
```sql
-- Step 1: Document query patterns BEFORE creating table
/*
Query Analysis:
- 60% of queries: WHERE user_id = ? AND timestamp BETWEEN ? AND ?
- 25% of queries: WHERE event_type = ? AND timestamp > ?
- 15% of queries: WHERE event_id = ?
Conclusion: user_id and event_type are primary filters
*/
-- Step 2: Create table with correct ORDER BY
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_type LowCardinality(String),
timestamp DateTime,
event_date Date DEFAULT toDate(timestamp)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date, event_id);
```
**Pre-creation checklist:**
- [ ] Listed top 5-10 query patterns
- [ ] Identified columns in WHERE clauses with frequency
- [ ] Prioritized columns that exclude large numbers of rows
- [ ] Ordered columns by cardinality (low first, high last)
- [ ] Limited to 4-5 key columns (typically sufficient)
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,44 +0,0 @@
---
title: Prioritize Filter Columns in ORDER BY
impact: CRITICAL
impactDescription: "Columns not in ORDER BY cause full table scans"
tags: [schema, primary-key, WHERE, filtering]
---
## Prioritize Filter Columns in ORDER BY
**Impact: CRITICAL**
Prioritize columns frequently used in query filters (WHERE clause), especially those that exclude large numbers of rows. Queries filtering on columns not in ORDER BY result in full table scans.
**Incorrect (ORDER BY doesn't match query patterns):**
```sql
-- If most queries filter by tenant_id:
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (event_id); -- Queries by tenant_id will full-scan!
```
**Correct (ORDER BY matches filter patterns):**
```sql
-- ORDER BY matches query filter patterns
CREATE TABLE events (...)
ENGINE = MergeTree()
ORDER BY (tenant_id, event_date, event_id);
-- Query now uses primary index:
SELECT * FROM events WHERE tenant_id = 123 AND event_date >= '2024-01-01';
```
**Validation:**
```sql
-- Verify index usage
EXPLAIN indexes = 1
SELECT * FROM events WHERE tenant_id = 123;
-- Look for "PrimaryKey" with Key Condition
```
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
@@ -1,55 +0,0 @@
---
title: Avoid Nullable Unless Semantically Required
impact: HIGH
impactDescription: "Nullable adds storage overhead; use DEFAULT values instead"
tags: [schema, data-types, Nullable, DEFAULT]
---
## Avoid Nullable Unless Semantically Required
**Impact: HIGH**
Nullable columns maintain a separate UInt8 column for tracking null values, increasing storage and degrading performance. Use DEFAULT values instead when feasible.
**Incorrect (Nullable everywhere):**
```sql
CREATE TABLE users (
id Nullable(UInt64), -- IDs should never be null
name Nullable(String), -- Empty string is fine
age Nullable(UInt8), -- 0 is a valid default
login_count Nullable(UInt32) -- 0 is a valid default
)
```
**Correct (DEFAULT values, Nullable only when semantic):**
```sql
CREATE TABLE users (
id UInt64, -- Never null
name String DEFAULT '', -- Empty = unknown
age UInt8 DEFAULT 0, -- 0 = unknown
login_count UInt32 DEFAULT 0, -- 0 = never logged in
deleted_at Nullable(DateTime), -- NULL = not deleted (semantic!)
parent_id Nullable(UInt64) -- NULL = no parent (semantic!)
)
```
**When Nullable IS appropriate:**
| Use Case | Why |
|----------|-----|
| `deleted_at` | NULL = "not deleted", timestamp = "deleted at X" |
| `parent_id` | NULL = "no parent", value = "has parent" |
| `discount_percent` | NULL = "no discount", 0 = "0% discount" |
**Defaults instead of Nullable:**
| Type | Default |
|------|---------|
| String | `''` (empty string) |
| UInt*/Int* | `0` |
| DateTime | `now()` or `toDateTime(0)` |
| UUID | `generateUUIDv4()` |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,58 +0,0 @@
---
title: Use Enum for Finite Value Sets
impact: MEDIUM
impactDescription: "Insert-time validation and natural ordering; 1-2 bytes storage"
tags: [schema, data-types, Enum, validation]
---
## Use Enum for Finite Value Sets
**Impact: MEDIUM**
Enum types provide validation at insert time and enable queries that exploit natural ordering. Use Enum8 (up to 256 values) or Enum16 (up to 65,536 values).
**Incorrect (String without validation):**
```sql
CREATE TABLE orders (
status String -- No validation, typos like "shiped" allowed
)
-- Ordering requires CASE statements
SELECT * FROM orders ORDER BY
CASE status
WHEN 'pending' THEN 1
WHEN 'processing' THEN 2
WHEN 'shipped' THEN 3
END;
```
**Correct (Enum with validation and ordering):**
```sql
CREATE TABLE orders (
status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4)
)
-- Insert validation: invalid values rejected
INSERT INTO orders VALUES ('shiped'); -- ERROR: Unknown element 'shiped'
-- Natural ordering works automatically
SELECT * FROM orders ORDER BY status; -- Orders by enum value (1, 2, 3, 4)
-- Comparisons use natural order
SELECT * FROM orders WHERE status > 'processing'; -- shipped and delivered
```
**Enum Guidelines:**
| Scenario | Use |
|----------|-----|
| Fixed set of values known at schema time | Enum8/Enum16 |
| Values may change frequently | LowCardinality(String) |
| Need insert-time validation | Enum |
| Need natural ordering in queries | Enum |
| < 256 distinct values | Enum8 (1 byte) |
| 256-65,536 distinct values | Enum16 (2 bytes) |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,58 +0,0 @@
---
title: Use LowCardinality for Repeated Strings
impact: HIGH
impactDescription: "Dictionary encoding for <10K unique values; significant storage reduction"
tags: [schema, data-types, LowCardinality, storage]
---
## Use LowCardinality for Repeated Strings
**Impact: HIGH**
String columns with repeated values store each value repeatedly. LowCardinality uses dictionary encoding for significant storage reduction.
**Incorrect (plain String for repeated values):**
```sql
CREATE TABLE events (
country String, -- "United States" stored 500M times
browser String, -- "Chrome" stored 300M times
event_type String -- "page_view" stored 800M times
)
```
**Correct (LowCardinality for low unique counts):**
```sql
CREATE TABLE events (
country LowCardinality(String), -- ~200 unique values
browser LowCardinality(String), -- ~50 unique values
event_type LowCardinality(String) -- ~100 unique values
)
```
**When to use LowCardinality:**
| Unique Values | Recommendation |
|---------------|----------------|
| < 10,000 | Use LowCardinality |
| > 10,000 | Use regular String |
```sql
-- Check cardinality before deciding
SELECT uniq(column_name) FROM table_name;
```
**LowCardinality vs FixedString:**
Reserve `FixedString` for strictly fixed-length data (e.g., 2-char country codes). For most low-cardinality text, `LowCardinality(String)` outperforms `FixedString`.
```sql
-- FixedString: Only for truly fixed-length data
country_code FixedString(2), -- "US", "DE", "JP" - always 2 chars
-- LowCardinality: For variable-length low-cardinality strings
country_name LowCardinality(String), -- "United States", "Germany"
```
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,49 +0,0 @@
---
title: Minimize Bit-Width for Numeric Types
impact: HIGH
impactDescription: "Smaller types reduce storage and improve cache efficiency"
tags: [schema, data-types, numeric, storage]
---
## Minimize Bit-Width for Numeric Types
**Impact: HIGH**
Select the smallest numeric type that accommodates your data range. Prefer unsigned types when negative values aren't needed.
**Incorrect (oversized types):**
```sql
CREATE TABLE metrics (
status_code Int64, -- HTTP codes are 100-599
age Int64, -- Human age fits in UInt8
year Int64, -- Years fit in UInt16
item_count Int64 -- Often small numbers
)
```
**Correct (right-sized types):**
```sql
CREATE TABLE metrics (
status_code UInt16, -- 0-65,535 (HTTP codes fit easily)
age UInt8, -- 0-255 (sufficient for age)
year UInt16, -- 0-65,535 (sufficient for years)
item_count UInt32 -- 0-4 billion (adjust based on actual max)
)
```
**Numeric Type Reference:**
| Type | Range | Bytes |
|------|-------|-------|
| UInt8 | 0 to 255 | 1 |
| UInt16 | 0 to 65,535 | 2 |
| UInt32 | 0 to 4.3 billion | 4 |
| UInt64 | 0 to 18 quintillion | 8 |
| Int8 | -128 to 127 | 1 |
| Int16 | -32,768 to 32,767 | 2 |
| Int32 | -2.1 billion to 2.1 billion | 4 |
| Int64 | -9 quintillion to 9 quintillion | 8 |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
@@ -1,51 +0,0 @@
---
title: Use Native Types Instead of String
impact: CRITICAL
impactDescription: "2-10x storage reduction; enables compression and correct semantics"
tags: [schema, data-types, storage]
---
## Use Native Types Instead of String
**Impact: CRITICAL**
Using String for all data wastes storage, prevents compression optimization, and makes comparisons slower. ClickHouse's column-oriented architecture benefits directly from optimal type selection.
**Incorrect (String for everything):**
```sql
CREATE TABLE events (
event_id String, -- "550e8400-e29b-41d4-a716-446655440000" = 36 bytes
user_id String, -- "12345" = 5 bytes (no numeric operations)
created_at String, -- "2024-01-15 10:30:00" = 19 bytes
count String, -- "42" - can't do math!
is_active String -- "true" = 4 bytes
)
```
**Correct (native types):**
```sql
CREATE TABLE events (
event_id UUID DEFAULT generateUUIDv4(), -- 16 bytes (vs 36)
user_id UInt64, -- 8 bytes, numeric ops
created_at DateTime DEFAULT now(), -- 4 bytes (vs 19)
count UInt32 DEFAULT 0, -- 4 bytes, math works
is_active Bool DEFAULT true -- 1 byte (vs 4)
)
```
**Type Selection Quick Reference:**
| Data | Use | Avoid |
|------|-----|-------|
| Sequential IDs | UInt32/UInt64 | String |
| UUIDs | UUID | String |
| Status/Category | Enum8 or LowCardinality(String) | String |
| Timestamps | DateTime | DateTime64, String |
| Dates only | Date or Date32 | DateTime, String |
| Counts | UInt8/16/32 (smallest that fits) | Int64, String |
| Money | Decimal(P,S) or Int64 (cents) | Float64, String |
| Booleans | Bool or UInt8 | String |
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
-59
View File
@@ -1,59 +0,0 @@
---
name: code-review
description: |
Shared code review workflow for Langfuse. Use when reviewing a PR, branch, diff,
or local changes for correctness, regressions, risk, and missing tests.
Start with references/review-checklist.md for repo-specific review rules and
use package AGENTS.md files plus any matching shared skills when the change
touches those areas.
---
# Code Review
Use this skill when the task is to review code changes rather than implement a
feature.
## Start Here
- Read [`references/review-checklist.md`](references/review-checklist.md) for
the repo's canonical review rules.
- Read root [`AGENTS.md`](../../../AGENTS.md) and the nearest package
`AGENTS.md` for the files under review.
- If the review touches ClickHouse, also use the shared
`clickhouse-best-practices` skill.
- If the review touches backend code, also use the shared
`backend-dev-guidelines` skill where relevant.
- If the change accepts a user-supplied URL, adds outbound HTTP, introduces a
new integration, or touches secrets, RBAC, or redirect handling, also use
the shared [`security-review`](../security-review/SKILL.md) skill. Run its
[`references/checklist.md`](../security-review/references/checklist.md)
before signoff.
## Review Priorities
Focus on:
- correctness bugs
- behavioral regressions
- security and tenant-isolation risks
- performance issues with real impact
- missing or weak tests for risky changes
## Output Expectations
- Findings first, ordered by severity
- File and line references for each finding
- Short summary only after findings
- If no findings, say so explicitly and mention any residual risk or coverage gaps
## Scope Guidance
Use `references/review-checklist.md` for Langfuse-specific checks such as:
- ClickHouse and Postgres migration expectations
- project-scoped tenant isolation checks
- API/Fern consistency
- banner-offset UI positioning
- environment variable access patterns
Do not duplicate those rules in ad hoc prompts or tool-specific command files.
@@ -1,73 +0,0 @@
# Langfuse Review Checklist
This is the canonical shared review checklist for Langfuse.
## Database Migrations
### ClickHouse
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/clustered` directory should include `ON CLUSTER default` and should use `Replicated` merge tree table types.
- E.g. `ReplacingMergeTree` is likely an error while `ReplicatedReplacingMergeTree` would be correct in most cases.
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/unclustered` directory must not include `ON CLUSTER` statements and must not use `Replicated` merge tree table types.
- Migrations in `packages/shared/clickhouse/migrations/clustered` should match their counterparts in `packages/shared/clickhouse/migrations/unclustered` aside from the restrictions listed above.
- When adding new indexes on ClickHouse, ensure that there is a corresponding `MATERIALIZE INDEX` statement in the same migration. The materialization can use `SETTINGS mutations_sync = 2` if they operate on smaller tables, but may timeout otherwise.
- All ClickHouse queries on project-scoped tables (traces, observations, scores, events, sessions, etc.) must include `WHERE project_id = {projectId: String}` filter to ensure proper tenant isolation and that queries only access data from the intended project.
- For operations on the `events` table, you must never use the `FINAL` keyword as it kills performance. `events` is built so that `FINAL` is never required.
### Postgres
- Most `schema.prisma` changes should produce a change in `packages/shared/prisma/migrations`.
- All Prisma queries on project-scoped tables must include `projectId` in the WHERE clause (e.g., `where: { id: traceId, projectId }`) to ensure proper tenant isolation and that queries only access data from the intended project.
### Environment Variables
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
## Redis Invocations
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
## Langfuse Cloud
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
## Banner Height System
- Use `top-banner-offset` instead of `top-0` for any elements that are positioned `sticky`, `fixed`, or `absolute` with a global reference point (e.g., `top-0`). This ensures proper spacing when system banners (payment, maintenance, etc.) are displayed.
- The banner height is managed through CSS variables (`--banner-height` and `--banner-offset`) defined in `web/src/styles/globals.css`.
- Banner components (like PaymentBanner) dynamically update `--banner-height` using ResizeObserver to track their actual height, ensuring accurate positioning even when banners resize (e.g., on mobile wrapping).
- Available Tailwind utilities:
- `top-banner-offset` / `pt-banner-offset` - For sticky/fixed/absolute positioning and padding
- `h-screen-with-banner` / `min-h-screen-with-banner` - For full-height containers accounting for banners
## Security
- For changes that accept a user-supplied URL, host, `endpoint`, `baseURL`,
or webhook target, or that issue a new outbound HTTP request, run the
shared [`security-review`](../../security-review/SKILL.md) skill and treat
its [`outbound-url-validation.md`](../../security-review/references/outbound-url-validation.md)
defenses (save-time validation + use-time / connection-time validation +
redirect-time validation) as required. Plain `fetch(<userUrl>)` or SDK
init with `endpoint: <userUrl>` without one of the canonical validators
(`validateLlmConnectionBaseURL`, `validateWebhookURL`,
`validateBlobStorageEndpoint`, or a new wrapper around
`validateOutboundUrlHost`) is a finding.
- For changes that add a new integration, secret-bearing field, redirect
follower, or RBAC scope, run the rest of the
[`security-review/references/checklist.md`](../../security-review/references/checklist.md).
## JavaScript / TypeScript Style
- use concat instead of spread to avoid stack overflow with large arrays
## Seeder
- make sure that for new features with data model changes, the database seeder is adjusted.
## API Documentation
- Whenever a file in `web/src/features/public-api/types` changes, the `fern/apis` definition probably needs to be adjusted, too.
- `nullish` types should map to `optional<nullable<T>>` in fern.
- `nullable` types should map to `nullable<T>` in fern.
- `optional` types should map to `optional<T>` in fern.
@@ -1,74 +0,0 @@
---
name: datadog-query-recipes
description: |
Langfuse-specific Datadog query recipes for production telemetry research.
Use when asked to investigate tenant or project activity, public API endpoint
usage, queue consumer behavior, spans, logs, metrics, or ad hoc production
questions across prod-us, prod-eu, prod-hipaa, and prod-jp. This skill is for
reusable query shapes and measured research; pair it with
debug-issue-with-datadog when the task is an incident or root-cause analysis.
---
# Datadog Query Recipes
Use this skill for Langfuse production telemetry research where the main work is
finding the right Datadog data path. Keep findings evidence-based and include
the exact Datadog links or query shapes that support the answer.
## Required Scope
Unless the user explicitly narrows the scope, cover every production
environment:
- `prod-us`
- `prod-eu`
- `prod-hipaa`
- `prod-jp`
Query both Datadog sites when needed. Default to the EU site for `prod-eu` and
the US site for the other prod environments, but verify with a small count or
facet query before concluding an environment has no data.
Before querying live Datadog, load the relevant Datadog MCP guidance for the
data domain you need: traces, logs, metrics, and visualizations.
## Workflow
1. Identify the entity and signal: tenant ID, org ID, project ID, route, queue,
service, error class, or metric.
2. Read only the relevant reference:
- Prod environment/site routing:
[`references/environments.md`](references/environments.md)
- Public API tenant or legacy endpoint usage:
[`references/public-api-tenant-usage.md`](references/public-api-tenant-usage.md)
- Queue inventory, queue consumers, and queue metrics:
[`references/queue-consumers.md`](references/queue-consumers.md)
3. Start with aggregate queries, grouped by environment, service, route,
queue, project, org, status, or error facets as appropriate.
4. Fetch raw spans, logs, or traces only after aggregation identifies the
cluster or sample you need.
5. For tenant-specific HTTP usage, prefer trace correlation over single-span
queries when tenant tags and route tags live on different spans.
6. Report the windows, environments, sites, query links, and any sampling or
missing-data caveats.
## When To Use Other Skills
- Use [`debug-issue-with-datadog`](../debug-issue-with-datadog/SKILL.md) when a
Linear issue, GitHub issue, incident report, or monitor needs root-cause
analysis and patch recommendations.
- Use [`weekly-production-review`](../weekly-production-review/SKILL.md) when
the user asks for a weekly engineering overview of production bugs, pages,
and incidents.
- Use [`linear-bug-triage`](../linear-bug-triage/SKILL.md) only after a human
approves sharing measured findings in Linear.
## Output Expectations
Summarize what was checked, including:
- Datadog site and `env` values covered.
- Time windows.
- Core filters or metrics used.
- Count, rate, latency, queue depth, trace sample, or "No measurements found".
- Datadog links or trace IDs that let the human rerun the query.
@@ -1,4 +0,0 @@
interface:
display_name: "Datadog Query Recipes"
short_description: "Langfuse production telemetry queries"
default_prompt: "Use $datadog-query-recipes to investigate Langfuse production telemetry for a tenant, endpoint, queue, or regression."
@@ -1,45 +0,0 @@
# Production Environments
Langfuse production deploys cover these environments and services. The deploy
matrix is defined in `.github/workflows/deploy.yml`.
| Environment | Primary Datadog site | Common services |
| --- | --- | --- |
| `prod-us` | US, `datadoghq.com` | `web`, `web-ingestion`, `web-iso`, `worker`, `worker-cpu` |
| `prod-eu` | EU, `datadoghq.eu` | `web`, `web-ingestion`, `web-iso`, `worker`, `worker-cpu` |
| `prod-hipaa` | US, `datadoghq.com` | `web`, `web-ingestion`, `web-iso`, `worker`, `worker-cpu` |
| `prod-jp` | US, `datadoghq.com` | `web`, `web-ingestion`, `web-iso`, `worker`, `worker-cpu` |
The site mapping is a starting point, not proof. For cross-region research, run
a small count or facet query on both Datadog sites before saying an environment
has no data.
## Starter Filters
Use these as first-pass filters, then add the subsystem-specific route, queue,
tenant, or error facets.
```text
env:prod-us
env:prod-eu
env:prod-hipaa
env:prod-jp
```
```text
env:<env> (service:web OR service:web-ingestion OR service:web-iso)
env:<env> (service:worker OR service:worker-cpu)
```
For HTTP routes, start with `service:web` or `service:web-ingestion` depending
on the endpoint. For queue consumers, start with `service:worker` and
`service:worker-cpu`.
## Cross-Site Rule
When a query returns zero:
1. Check the time window and spelling of `env`, `service`, and route/resource.
2. Query facets on the same site for `env` and `service`.
3. Repeat a small count query on the other Datadog site.
4. Only then report "No measurements found".
@@ -1,99 +0,0 @@
# Public API Tenant Usage
Use this recipe when checking whether an org or project calls a public API
route, including legacy endpoints such as:
- `/api/public/traces`
- `/api/public/traces/<traceId>` (`/api/public/traces/[traceId]` in Next.js)
- `/api/public/observations`
- `/api/public/observations/<observationId>`
(`/api/public/observations/[observationId]` in Next.js)
- `/api/public/metrics`
Migrated endpoints commonly include:
- `/api/public/v2/observations`
- `/api/public/v2/metrics`
- `/api/public/otel/v1/traces`
## Why Correlation Is Needed
Public API auth attaches tenant attributes to the `api-auth-verify` child span:
- `@langfuse.project.id`: project-scoped API key target.
- `@langfuse.org.id`: owning org or org-scoped key target.
- `@langfuse.org.plan`: plan when present.
The HTTP route is on the request root span, usually in `http.path_group`,
`http.route`, `http.target`, or `resource_name`.
Because tenant tags and route tags often live on different spans in the same
trace, do not expect this single-span query to work:
```text
@langfuse.project.id:<id> @http.path_group:/api/public/observations
```
## Per-Tenant Endpoint Recipe
1. Aggregate auth spans for the tenant to confirm the identifier and active
projects.
```text
env:<env> @langfuse.org.id:<orgId> resource_name:api-auth-verify
env:<env> @langfuse.project.id:<projectId> resource_name:api-auth-verify
```
Group by `service`, `@langfuse.project.id`, and optionally a daily interval.
2. Fetch representative matching auth spans with `search_datadog_spans`.
Include custom attributes such as `langfuse.*`, then copy representative
`traceid` values.
3. Open those traces with `get_datadog_trace`. Request service-entry spans and
include HTTP and Langfuse attributes:
```text
only_service_entry_spans: true
extra_fields: ["http.*", "next.*", "langfuse.*"]
```
4. Read the request root span's `http.path_group`, `http.route`,
`http.target`, and `resource_name` to identify the endpoint.
ID lookup routes may appear with the concrete ID in `http.target` or with
the normalized Next.js route, such as
`/api/public/traces/[traceId]` or
`/api/public/observations/[observationId]`.
5. Repeat across all relevant prod environments and both Datadog sites when the
user asks for a global answer.
Treat per-tenant endpoint results as sampled unless you have an unsampled
metric or log source with tenant and route on the same event.
## Fleet-Level Endpoint Volume
For endpoint volume without tenant scoping, aggregate request spans directly:
```text
env:<env> service:web resource_name:"GET /api/public/observations*"
env:<env> service:web resource_name:"GET /api/public/observations/*"
env:<env> service:web resource_name:"POST /api/public/traces*"
env:<env> service:web resource_name:"GET /api/public/traces/*"
env:<env> service:web resource_name:"POST /api/public/metrics*"
```
Use route facets where available:
```text
env:<env> service:web @http.path_group:/api/public/observations
env:<env> service:web @http.path_group:/api/public/observations/*
env:<env> service:web @http.path_group:/api/public/observations/[observationId]
env:<env> service:web @http.route:/api/public/observations
env:<env> service:web @http.route:/api/public/observations/[observationId]
env:<env> service:web @http.path_group:/api/public/traces/[traceId]
env:<env> service:web @http.route:/api/public/traces/[traceId]
```
If route facets and resource names disagree, fetch a few traces and inspect the
root span before reporting the result.
@@ -1,234 +0,0 @@
# Queue Consumers
Use this reference when a task asks which Langfuse queues exist, whether a
consumer is running, how much work a queue has, or how to query queue processor
spans.
## Source Of Truth
- Queue names and job names:
`packages/shared/src/server/queues.ts` (`QueueName`, `QueueJobs`).
- Queue producer classes and shard naming:
`packages/shared/src/server/redis/*.ts`.
- Worker consumer registration and feature gates:
`worker/src/app.ts`.
- Worker consumer env vars:
`worker/src/env.ts` (`QUEUE_CONSUMER_*_IS_ENABLED` plus feature-specific
gates).
- Worker registration, request/error counters, wait/processing time, and
sampled old-style depth metrics:
`worker/src/queues/workerManager.ts`.
- Queue depth background reporter:
`worker/src/features/queue-metrics-runner/index.ts`.
- Metric name conversion:
`packages/shared/src/server/instrumentation/index.ts`
(`convertQueueNameToMetricName`).
- Sharded queue registry:
`worker/src/queues/shardedQueueRegistry.ts`.
- BullMQ tracing setup:
`worker/src/instrumentation.ts` (`BullMQInstrumentation`).
## Queue Inventory
Current `QueueName` values:
| Queue | Notes |
| --- | --- |
| `trace-upsert` | Sharded. Registers all `TraceUpsertQueue` shards. |
| `trace-delete` | Delete traces from storage. |
| `project-delete` | Project deletion cleanup. |
| `evaluation-execution-queue` | Sharded eval execution. |
| `secondary-evaluation-execution-queue` | Sharded secondary eval execution. |
| `llm-as-a-judge-execution-queue` | Sharded observation-based eval execution. |
| `dataset-run-item-upsert-queue` | Dataset run item upserts. |
| `batch-export-queue` | Batch exports. |
| `otel-ingestion-queue` | Sharded OTel ingestion. |
| `secondary-otel-ingestion-queue` | Sharded secondary OTel ingestion. |
| `ingestion-queue` | Sharded single-event ingestion. |
| `secondary-ingestion-queue` | Sharded secondary single-event ingestion. |
| `cloud-usage-metering-queue` | Cloud-only, Stripe-gated. |
| `cloud-spend-alert-queue` | Cloud-only, Stripe-gated. |
| `cloud-free-tier-usage-threshold-queue` | Cloud-only, Stripe-gated. |
| `experiment-create-queue` | Experiment creation. |
| `posthog-integration-queue` | Schedules PostHog integration jobs. |
| `posthog-integration-processing-queue` | Processes PostHog projects. |
| `mixpanel-integration-queue` | Schedules Mixpanel integration jobs. |
| `mixpanel-integration-processing-queue` | Processes Mixpanel projects. |
| `blobstorage-integration-queue` | Schedules blob storage jobs. |
| `blobstorage-integration-processing-queue` | Processes blob storage projects. |
| `core-data-s3-export-queue` | Cloud export feature gate. |
| `metering-data-postgres-export-queue` | Cloud export feature gate. |
| `data-retention-queue` | Schedules data retention jobs. |
| `data-retention-processing-queue` | Processes data retention projects. |
| `batch-action-queue` | Batch actions. |
| `create-eval-queue` | Eval job creation. |
| `score-delete` | Score deletion cleanup. |
| `dataset-delete-queue` | Dataset deletion cleanup. |
| `dead-letter-retry-queue` | Dead letter retry worker. |
| `webhook-queue` | Webhook delivery. |
| `entity-change-queue` | Entity change propagation. |
| `event-propagation-queue` | Experiment event propagation gate. |
| `notification-queue` | Notifications. |
Sharded queues use the base queue for shard 0 and append `-1`, `-2`, etc. for
additional shards. The sharded base queues are:
- `trace-upsert`
- `evaluation-execution-queue`
- `secondary-evaluation-execution-queue`
- `llm-as-a-judge-execution-queue`
- `otel-ingestion-queue`
- `secondary-otel-ingestion-queue`
- `ingestion-queue`
- `secondary-ingestion-queue`
## Consumer Gates
Consumer registration is in `worker/src/app.ts`. Some gates register multiple
queues or every shard for a sharded queue.
| Gate | Queues registered |
| --- | --- |
| `QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED` | `trace-upsert` shards |
| `QUEUE_CONSUMER_CREATE_EVAL_QUEUE_IS_ENABLED` | `create-eval-queue` |
| `LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED` | `core-data-s3-export-queue` |
| `LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED` | `metering-data-postgres-export-queue` |
| `QUEUE_CONSUMER_TRACE_DELETE_QUEUE_IS_ENABLED` | `trace-delete` |
| `QUEUE_CONSUMER_SCORE_DELETE_QUEUE_IS_ENABLED` | `score-delete` |
| `QUEUE_CONSUMER_DATASET_DELETE_QUEUE_IS_ENABLED` | `dataset-delete-queue` |
| `QUEUE_CONSUMER_PROJECT_DELETE_QUEUE_IS_ENABLED` | `project-delete` |
| `QUEUE_CONSUMER_DATASET_RUN_ITEM_UPSERT_QUEUE_IS_ENABLED` | `dataset-run-item-upsert-queue` |
| `QUEUE_CONSUMER_EVAL_EXECUTION_QUEUE_IS_ENABLED` | `evaluation-execution-queue` shards, `llm-as-a-judge-execution-queue` shards |
| `QUEUE_CONSUMER_EVAL_EXECUTION_SECONDARY_QUEUE_IS_ENABLED` | `secondary-evaluation-execution-queue` shards |
| `QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED` | `batch-export-queue` |
| `QUEUE_CONSUMER_BATCH_ACTION_QUEUE_IS_ENABLED` | `batch-action-queue` |
| `QUEUE_CONSUMER_OTEL_INGESTION_QUEUE_IS_ENABLED` | `otel-ingestion-queue` shards |
| `QUEUE_CONSUMER_OTEL_INGESTION_SECONDARY_QUEUE_IS_ENABLED` | `secondary-otel-ingestion-queue` shards |
| `QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED` | `ingestion-queue` shards |
| `QUEUE_CONSUMER_INGESTION_SECONDARY_QUEUE_IS_ENABLED` | `secondary-ingestion-queue` shards |
| `QUEUE_CONSUMER_CLOUD_USAGE_METERING_QUEUE_IS_ENABLED` plus `STRIPE_SECRET_KEY` | `cloud-usage-metering-queue` |
| `QUEUE_CONSUMER_CLOUD_SPEND_ALERT_QUEUE_IS_ENABLED` plus `STRIPE_SECRET_KEY` | `cloud-spend-alert-queue` |
| `QUEUE_CONSUMER_FREE_TIER_USAGE_THRESHOLD_QUEUE_IS_ENABLED` plus cloud region and Stripe gates | `cloud-free-tier-usage-threshold-queue` |
| `QUEUE_CONSUMER_EXPERIMENT_CREATE_QUEUE_IS_ENABLED` | `experiment-create-queue` |
| `QUEUE_CONSUMER_POSTHOG_INTEGRATION_QUEUE_IS_ENABLED` | `posthog-integration-queue`, `posthog-integration-processing-queue` |
| `QUEUE_CONSUMER_MIXPANEL_INTEGRATION_QUEUE_IS_ENABLED` | `mixpanel-integration-queue`, `mixpanel-integration-processing-queue` |
| `QUEUE_CONSUMER_BLOB_STORAGE_INTEGRATION_QUEUE_IS_ENABLED` | `blobstorage-integration-queue`, `blobstorage-integration-processing-queue` |
| `QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED` | `data-retention-queue`, `data-retention-processing-queue` |
| `QUEUE_CONSUMER_DEAD_LETTER_RETRY_QUEUE_IS_ENABLED` | `dead-letter-retry-queue` |
| `QUEUE_CONSUMER_WEBHOOK_QUEUE_IS_ENABLED` | `webhook-queue` |
| `QUEUE_CONSUMER_ENTITY_CHANGE_QUEUE_IS_ENABLED` | `entity-change-queue` |
| `QUEUE_CONSUMER_EVENT_PROPAGATION_QUEUE_IS_ENABLED` plus events-table experiment gate | `event-propagation-queue` |
| `QUEUE_CONSUMER_NOTIFICATION_QUEUE_IS_ENABLED` | `notification-queue` |
## Query Consumer Spans
Start with aggregate spans on worker services:
```text
env:<env> (service:worker OR service:worker-cpu) operation_name:bullmq.process
```
Then group by `resource_name`, queue facets such as `bullmq.queue` or
`messaging.*`, and error fields. Facet names can differ between Datadog sites,
so inspect one sample span before relying on a specific facet.
Queue-specific starter query:
```text
env:<env> (service:worker OR service:worker-cpu) operation_name:bullmq.process (resource_name:"process otel-ingestion-queue" OR resource_name:"Worker.run otel-ingestion-queue" OR bullmq.queue:otel-ingestion-queue)
```
For sharded queues, query the base queue and shard suffixes:
```text
env:<env> (service:worker OR service:worker-cpu) operation_name:bullmq.process resource_name:"*otel-ingestion-queue*"
```
If a queue file wraps the handler with `instrumentAsync`, also search the
domain-specific resource name. Examples:
| Subsystem | Resource name |
| --- | --- |
| PostHog project processing | `process posthog-integration-project` |
| Mixpanel project processing | `process mixpanel-integration-project` |
| Blob storage project processing | `process blob-storage-project` |
| Data retention project processing | `process data-retention-project` |
| Event propagation | `process event-propagation` |
| Cloud usage metering | `process cloud-usage-metering` |
| Free-tier usage threshold | `process cloud-free-tier-usage-threshold` |
Useful aggregations:
- Count by `env`, `service`, and `resource_name`.
- Count by queue facet and status.
- Count by `error.type` and `error.message`.
- p50, p95, and p99 duration by queue or shard.
- Count by `messaging.bullmq.job.input.projectId` when the processor attaches
project IDs to the current span.
## Query Queue Metrics
Metric base names come from `convertQueueNameToMetricName(queueName)`:
```text
langfuse.queue.<queue-name-with-hyphens-replaced-by-underscores-and-trailing-_queue-removed>
```
Examples:
| Queue | Metric base |
| --- | --- |
| `ingestion-queue` | `langfuse.queue.ingestion` |
| `otel-ingestion-queue` | `langfuse.queue.otel_ingestion` |
| `secondary-otel-ingestion-queue` | `langfuse.queue.secondary_otel_ingestion` |
| `evaluation-execution-queue` | `langfuse.queue.evaluation_execution` |
| `trace-upsert` | `langfuse.queue.trace_upsert` |
| `batch-export-queue` | `langfuse.queue.batch_export` |
Prefer the newer tagged metrics:
```text
<metric_base>.depth{env:<env>,type:waiting}
<metric_base>.depth{env:<env>,type:failed}
<metric_base>.depth{env:<env>,type:active}
<metric_base>.rate{env:<env>,type:request}
<metric_base>.rate{env:<env>,type:failed}
<metric_base>.rate{env:<env>,type:error}
<metric_base>.time{env:<env>,type:wait}
<metric_base>.time{env:<env>,type:processing}
```
For sharded queues, use the `shard` tag when present. `shard:all` is emitted by
the depth runner for aggregate depth across shards.
Backward-compatible metrics may still appear:
```text
<metric_base>.length
<metric_base>.dlq_length
<metric_base>.active
<metric_base>.request
<metric_base>.failed
<metric_base>.error
<metric_base>.wait_time
<metric_base>.processing_time
```
For non-BullMQ internal write buffering, `ClickhouseWriter` emits
`langfuse.queue.clickhouse_writer.*` metrics, but it is not a `QueueName`
consumer.
## Consumer Running Checklist
To establish whether a consumer is running in production:
1. Check queue depth metrics for waiting, failed, and active counts.
2. Check `rate{type:request}` or old `.request` metrics for recent processing.
3. Search BullMQ processor spans on `worker` and `worker-cpu`.
4. Search worker logs for the queue name or processor-specific log prefix.
5. If all signals are empty, verify the relevant `QUEUE_CONSUMER_*_IS_ENABLED`
gate and any feature-specific gates in `worker/src/app.ts`.
The queue metrics runner only polls queues with registered workers. Missing
depth metrics can mean the consumer is not registered on that worker, queue
metrics are disabled, or the data is on the other Datadog site.
@@ -1,121 +0,0 @@
---
name: debug-issue-with-datadog
description: |
Debug a user-reported issue, Linear ticket, or incident report by combining
Datadog (APM, logs, metrics) with the Langfuse repo to establish a
root cause. Use when given a Linear issue URL/ID (e.g. LFE-XXXX), a GitHub
issue, or a pasted error/report and asked to investigate, root-cause, or
triage. Produces a structured analysis — error breakdown, hypothesis-by-class,
suggested patches with code references.
---
# Debug Issue with Datadog
Use this skill whenever the task is **investigative** rather than
implementational: a user, customer, or oncall has surfaced a problem and you
need to figure out *what is actually happening in production* and *where in the
code it lives*. The deliverable is an analysis, not a patch — though the
analysis should make the right patch obvious.
## When to Apply
- A Linear issue (typically with an `LFE-XXXX` ID) describes a production
failure, error spike, or customer report.
- A GitHub issue or pasted incident/error report needs triage.
- A monitor alerted and you need to understand *why* before deciding what to
fix.
- Existing tickets under the "Make monitoring useful again" project (parent
`LFE-8837`) and similar — these expect the structured analysis output below.
If the task is "implement this fix" rather than "figure out what's broken",
this is the wrong skill — go to `backend-dev-guidelines` or the relevant
package guide.
## Workflow
Read the inputs first, then plan the Datadog sweep, then read the code, then
write the analysis. Do not skip ahead to suggested patches before the data
supports them.
1. **Intake.** Pull every signal already available in the report. See
[`references/intake.md`](references/intake.md). For a Linear URL/ID, fetch
the issue *and* its comments via the Linear MCP — the description is often
updated inline as triage proceeds. For a GitHub issue, use `gh issue view`.
For pasted text, treat it as the description.
2. **Scope the sweep.** From the intake, pick the affected subsystem and time
window. Use [`references/repo-debug-map.md`](references/repo-debug-map.md)
to translate "PostHog integration", "ingestion failures", "evals stuck",
etc. into the Datadog filters and source files you should be looking at.
3. **Run the broad Datadog sweep.** Default to the full sweep in
[`references/datadog-playbook.md`](references/datadog-playbook.md): APM
spans, error logs, metrics, and monitors — split across `prod-eu`
and `prod-us` (and `prod-hipaa` / `prod-jp` when relevant). Always check
regional disparity first; it usually rules whole hypotheses in or out.
Use [`datadog-query-recipes`](../datadog-query-recipes/SKILL.md) for
reusable tenant, public API, queue consumer, and cross-environment query
shapes.
4. **Cluster the errors.** Group by `(projectId, error.message)` or
`(error.type, error.message)`. Treat each distinct cluster as its own
hypothesis — Langfuse incidents commonly have *multiple* coexisting root
causes, not one.
5. **Map clusters to code.** For each cluster, open the relevant handler file
from the repo-debug map and read enough of it to confirm or refute the
hypothesis. Cite specific files and line ranges in the output.
6. **Write the analysis** using
[`references/output-template.md`](references/output-template.md).
7. **Deliver.** Default: print the analysis in chat. If the user asked for it,
also save under the workflow they specified (file, Linear comment via, etc.).
## Datadog MCP Usage Notes
Two Datadog MCP servers are typically available — one bound to the EU site
(`datadoghq.eu`) and one to the US site (`datadoghq.com`). Always run
region-relevant queries against **both** unless intake clearly localizes the
incident. The `prod-eu` / `prod-us` env tags live on each side respectively.
- Span search filter pattern:
`service:worker resource_name:"process posthog-integration-project" status:error`
- Log search filter pattern:
`service:worker env:prod-eu @langfuse.project.id:cm1r6u… status:error`
- For high-volume queries, prefer `aggregate_spans` / `aggregate_events`
grouped by `(error.message, projectId)` over fetching individual traces.
- Always link to the Datadog UI for the queries you ran (final section of the
output template).
See [`references/datadog-playbook.md`](references/datadog-playbook.md) for the
full set of starter queries and parameter shapes.
## Output Expectations
From the output template:
- Header: data source, time window, region split (EU vs US table).
- Hotspots: per-`projectId` (or per-cluster) error counts.
- Root cause by error class: each cluster gets a short hypothesis with
reasoning, distinguishing primary causes from symptoms.
- Suggested patches: P0/P1/P2 grouped, with concrete file paths and short code
sketches. Reference the actual handler in `worker/src/features/**` or
`web/src/**`.
- Dashboards: paste the Datadog query URLs at the end.
Findings come first, recommendations last. If the data is thin, say so
explicitly and propose what would need to be true to confirm each hypothesis —
do not invent root causes.
## Cross-References
- Production telemetry query recipes, tenant/public API usage, and queue
consumer measurements:
[`datadog-query-recipes`](../datadog-query-recipes/SKILL.md)
- Backend layout, queue contracts, instrumentation patterns:
[`backend-dev-guidelines`](../backend-dev-guidelines/SKILL.md)
- ClickHouse-related findings (memory ceilings, JOIN spills, slow queries):
[`clickhouse-best-practices`](../clickhouse-best-practices/SKILL.md)
- Once a fix is identified and you switch to implementation, hand off to the
package `AGENTS.md` for the affected directory.
@@ -1,141 +0,0 @@
# Datadog Query Playbook
The default for this skill is the **broad sweep**: APM spans + logs + metrics
+ monitors + incidents, run against both EU and US sites unless intake clearly
localizes. Cluster results before drilling in.
Two MCP servers are typically connected — one for `datadoghq.eu`, one for
`datadoghq.com`. Run the same query on both and compare. The contrast itself
is often the most informative finding (e.g. LFE-9475's 23.5% EU vs 0.7% US
error rate immediately ruled out PostHog Cloud as the global cause).
## Tag Vocabulary
- **Site:** `datadoghq.eu` for EU, `datadoghq.com` for US.
- **Region tag (`env`):** `prod-eu`, `prod-us`, `prod-hipaa`, `prod-jp`.
- **Service:** `worker` (default in `worker/src/env.ts`) or `web` (default in
`web/src/env.mjs`). Some deployments override to `langfuse`.
- **Span resource names** for worker async jobs follow the pattern
`process <queue-name>` — see `repo-debug-map.md`.
## 1. APM Span Sweep — find the failing handler
Use `aggregate_spans` first; only fetch individual traces once a cluster is
identified.
Starter shape (rename the resource for the relevant subsystem):
```text
service:worker resource_name:"process posthog-integration-project" status:error
```
Aggregations to run, in order:
1. Count by `env` — confirms region split.
2. Count by `error.message` — primary error classes.
3. Count by `(projectId, error.message)` — which tenants are affected, by
class. `projectId` lives on span tags as `@projectId` for log search and as
a tag for spans (depends on instrumentation site).
4. p50 / p95 / p99 duration by region — fingerprints timeouts vs. crashes vs.
slow successes.
If `aggregate_spans` returns no results, check:
- the resource name is right (case-sensitive, see `repo-debug-map.md`);
- the time window covers when the issue was actually firing;
- the region tag matches reality (the EU MCP only sees EU traces).
### Public API tenant / legacy-endpoint usage
For tenant-specific public API route usage, use
[`../../datadog-query-recipes/references/public-api-tenant-usage.md`](../../datadog-query-recipes/references/public-api-tenant-usage.md).
The key gotcha is that tenant tags usually live on the `api-auth-verify` child
span while the HTTP route lives on the request root span, so correlate by
`traceid` rather than relying on one combined span filter.
## 2. Log Sweep — read what the handler said
Logs are the right tool for *messages* the handler emitted. Spans are the
right tool for *which handler invocations failed*.
Starter shapes:
```text
service:worker env:prod-eu @projectId:cm1r6u1iq00ccfvrkoy8vg3ms status:error
service:worker env:prod-eu "[POSTHOG]" status:error
```
Useful log facets:
- `@projectId` or `@langfuse.project.id` — Langfuse project (cuid).
- `@error.kind` / `@error.message` / `@error.stack` — when the Winston logger
serialized an Error.
- `@queue` / `@jobName` — when set by BullMQ instrumentation.
For high-volume subsystems (`ingestion-queue`, `otel-ingestion-queue`),
prefer `analyze_datadog_logs` with grouping over `search_datadog_logs` — the
raw matches are too noisy.
## 3. Metric Sweep — confirm the trend
Pick 23 metrics that match the subsystem. Common ones:
- `trace.bullmq.process.errors` and `trace.bullmq.process.duration`
per-queue health from the BullMQ OTel instrumentation. Filter by
`resource_name:"process <queue-name>"`.
- `trace.http_request.errors` and `trace.http_request.duration` for HTTP
handlers (`service:web`).
- ClickHouse: cluster-level `clickhouse.query.duration`,
`clickhouse.memory_usage` — the worker doesn't emit these directly, they
come from the `clickhouse` integration in the infra repo.
- Postgres: `aurora.databaseconnections`, `aurora.deadlocks` — relevant when
the symptom is `connection_limit` / `connection pool` errors.
If the subsystem isn't already known, run `search_datadog_metrics` for the
subsystem name and pick the obvious counter / gauge / histogram triplet.
## 4. Monitors & Incidents
- `search_datadog_monitors` for the subsystem name — tells you what alerts
*would* have fired and what their thresholds are. A muted monitor on the
affected subsystem is itself a finding (see LFE-9475: "EU alert muted for
a week").
- `search_datadog_incidents` for the time window — links any pre-existing
incident the user may not have referenced.
## 5. RUM / Frontend (only when the symptom is user-facing)
Skip unless the issue is "page broken" / "slow load". Then:
- `search_datadog_rum_events` filtered by `@view.url:` patterns matching the
affected route.
- Cross-reference with `service:web` API errors at the same time.
## 6. Trace Drill-Down
Once a cluster is identified, fetch one or two representative traces with
`get_datadog_trace` to read the actual stack and confirm where in the handler
the throw originates. This is what lets you point at a specific file and
line range in the analysis.
## Anti-Patterns
- Don't fetch individual logs/traces before aggregating. You'll burn context
on noise and miss the cluster pattern.
- Don't trust a single-region query as global. Always compare EU and US.
- Don't read an `error.message` literally if it goes through a custom error
wrapper — `validateWebhookURL` rejections, for example, are re-logged as
"DNS lookup failed" but are actually validator rejections.
- Don't assume monitors are firing just because errors exist — check if the
monitor is muted.
## Linking Out
End the analysis with the actual Datadog UI URLs you queried, e.g.:
```text
https://app.datadoghq.eu/apm/traces?query=resource_name%3A%22process+posthog-integration-project%22+status%3Aerror
https://app.datadoghq.com/apm/traces?query=resource_name%3A%22process+posthog-integration-project%22+status%3Aerror
```
so the human reader can re-run the same query.
@@ -1,72 +0,0 @@
# Intake — What Are We Actually Investigating?
Goal: extract every signal from the input *before* you touch Datadog. Cheap
to do, expensive to skip — the wrong time window or wrong service tag can
hide the real cause.
## Source-by-Source Recipes
### Linear issue (URL or `LFE-XXXX` ID)
1. Fetch the issue via the Linear MCP:
`mcp__d39d26f2-…__get_issue` with `id: "LFE-XXXX"` and
`includeRelations: true`.
2. Fetch comments separately:
`mcp__d39d26f2-…__list_comments` with the same `issueId`.
3. Note: Langfuse triages **inside the issue description**. The description
often grows reaction sections (`projectId: <one-line note>`) as oncall
investigates. Treat both description and comments as authoritative state.
4. Pull `attachments` for linked PRs/commits — these show what's already been
tried. Reading the diff of a half-merged fix often reveals the original
theory of the bug.
5. Pull labels (`integration-posthog`, `feat-exports`, etc.) — they map
directly to the subsystem clusters in `repo-debug-map.md`.
### GitHub issue
1. `gh issue view <url-or-number> --json title,body,labels,comments,assignees`.
2. If there's a stack trace in the issue body, copy the top frames into your
notes — those frames usually map straight to a handler file.
### Pasted error / incident text
1. Treat as the issue description. If it's a stack trace, identify:
- the throwing module (handler vs. SDK vs. infra),
- whether it looks like a *user-input* failure (HTTP 4xx, validation, auth)
or an *infra* failure (5xx, timeout, OOM, DNS, pool exhaustion),
- the error class (`Error`, `TypeError`, `PrismaClientKnownRequestError`,
etc.).
2. If a `projectId` appears, that's gold — anchor every Datadog query on it.
3. If a `traceId` (Datadog's, not Langfuse's) appears, jump straight to
`get_datadog_trace`.
## Extract The Following Before Querying
Build a small notes block. If a value is missing, mark it `?` rather than
guessing — Datadog will tell you what's missing.
- **Subsystem.** PostHog integration, blob-storage export, evaluation
execution, OTel ingestion, batch action, webhook delivery, etc. Map to
`repo-debug-map.md`.
- **Region(s).** `prod-eu` / `prod-us` / `prod-hipaa` / `prod-jp`. If unknown,
query both EU and US.
- **Service.** Most issues are `service:worker`; UI/API timeouts are
`service:web`. Check for both when unsure.
- **Time window.** Default to 7 days back from the issue's `createdAt`. If
the issue references a specific incident or alert, use that window ±1 day.
- **`projectId`(s).** Project IDs in Langfuse are `cuid`-shaped
(`cl…` / `cm…`, 25 chars). The reaction blocks in Linear descriptions
often *are* lists of affected project IDs.
- **Error message fragments.** Exact substrings to grep for in DD logs:
`Header overflow`, `Timeout error.`, `HTTP 403`, `DNS lookup failed`,
`Cannot write to canceled buffer`, `connection pool`, etc.
- **Already-attempted fixes.** Linked PRs/commits on the issue. Read their
diffs — your analysis must not re-recommend something that's already
shipped.
## Output Of The Intake Step
A short bullet list (not yet formatted as the final analysis) with each of
the above filled in. The remaining steps key off this — `datadog-playbook.md`
expects subsystem + region + window, `repo-debug-map.md` expects subsystem,
and the output template wants the affected projects.
@@ -1,127 +0,0 @@
# Output Template
The analysis should be structured so it can be pasted directly as the first
investigative comment on the Linear issue. The example to anchor on is the
first comment on `LFE-9475` (PostHog Integration Processing Failures).
Findings come first, recommendations last. If the data doesn't support a
hypothesis, say so — do not invent root causes to fill the template.
## Section Order
1. **Header** — data source, time window, scope of sweep.
2. **Volume & error-rate split** — table by region (always).
3. **Hotspots** — table by `(projectId, dominant cause)` or by cluster.
4. **Root cause by error class** — one numbered subsection per cluster.
5. **Suggested patches** — P0 / P1 / P2, each with file paths and a short
code sketch.
6. **Dashboards** — Datadog UI URLs for the queries you ran.
## Skeleton (fill in with your findings)
````markdown
## Datadog APM + log analysis (<N>-day window, <YYYY-MM-DD> → <YYYY-MM-DD>)
Source: APM spans with `resource_name:"process <queue-name>"` across EU and US.
### Volume & error rate — <one-line summary of regional split>
| Region | Total spans | Errors | Error rate |
|---|---|---|---|
| EU (`prod-eu`) | <n> | <n> | **<pct>%** |
| US (`prod-us`) | <n> | <n> | **<pct>%** |
<One sentence explaining where the noise actually lives.>
### Hotspots — concentrated on ~<N> <region> projects
<Region> errors break down by `(projectId, error.message)`:
| ProjectId | Errors | Dominant cause |
|---|---|---|
| `<projectId>` | <n> | `<error message>` (<n>) + others |
| ... | ... | ... |
<Optional: contrast with another subsystem if relevant — e.g.
"Unlike blob storage, PostHog has multiple distinct root causes — not one
hotspot pattern.">
## Root cause by error class
### 1. `<error message>` — <n> errors, <n> projects
<24 sentences explaining what this error class actually is at the
implementation level (which library, which call site). Then list candidate
causes in order of likelihood. Mark which ones are confirmed by the data
vs. speculative.>
### 2. `<error message>` — <n> errors, mostly <n> projects
<Same pattern.>
### 3. <next class>
<...>
### <N>. <Symptom of upstream failure>
<Use this slot when a class is a *symptom* of another class rather than an
independent bug — call it out so suggested patches don't double-count.>
## Suggested patches
### P0 — <one-line summary, e.g. "Auto-disable integrations on persistent
auth failures">
<Why this is P0 — what noise it kills, what data it stops corrupting, what
unblocks downstream work.>
```ts
// <relative path from repo root>
// Short code sketch (520 lines). It does not need to compile —
// it must communicate the shape of the change.
```
### P0 — <next P0>
<...>
### P1 — <smaller / less urgent fix>
<Same shape.>
### P2 — <separate-but-surfaced finding>
<E.g. a Prisma pool sizing issue surfaced incidentally by this analysis but
not the original bug. Call it out with its own section so it doesn't get
lost.>
### Regional split explanation (only if relevant)
<One paragraph explaining why EU vs. US asymmetry exists — usually not an
infra bug, just where the affected tenants happen to live.>
Dashboards:
- EU APM: <url>
- US APM: <url>
- (logs / metrics / monitor links as relevant)
````
## Style Rules
- Lead with numbers, not adjectives. "23.5% error rate" beats "very noisy".
- Distinguish **primary causes** from **symptoms** explicitly. Symptoms
shouldn't get their own P0 patch.
- Always cite specific files when proposing a code change. A patch
recommendation without `worker/src/features/<…>/<file>.ts` is unfinished.
- Code sketches are illustrative — clearly mark them as sketches if they
hand-wave types. The next agent / human will write the real diff.
- If the analysis surfaces a finding *outside* the original ticket scope
(e.g. a Prisma pool issue while debugging PostHog), include it as a P2
with a sentence explaining it's separate.
- If the data refuses to converge on a single root cause, say so. The
template handles N classes — use as many subsections as the data warrants.
## When To Skip Sections
- **Single-region deployments:** if the issue clearly affects only one
region, you can replace the "Volume & error rate" table with a single-row
variant, but still note that the other region was checked and clean.
- **No code change recommended:** if the only finding is "the affected
tenants have misconfigured credentials and we should reach out", the
Suggested-patches section can be a single sentence — but still include
the dashboards.
- **Aborted investigation:** if Datadog access fails or the data is
insufficient, write what you tried, what was missing, and what would let
the next investigator pick it up.
@@ -1,100 +0,0 @@
# Repo Debug Map — Subsystem → Code → Datadog Filters
For each subsystem we ship monitors and incidents on, this is the canonical
map between the symptom, the Datadog query that surfaces it, and the source
files where the bug almost certainly lives.
When intake gives you a subsystem (PostHog, evals, exports, etc.), start
here to pick the right Datadog filters and the right files to read.
## Worker Async Jobs
Worker handlers are wrapped by `instrumentAsync` in their queue file. The
span resource name follows the pattern `process <queue-name>`. Queue and job
name constants live in
`packages/shared/src/server/queues.ts`
(`QueueName` and `QueueJobs` enums).
| Subsystem | Queue file | Handler dir | Span `resource_name` | Log prefix |
| --- | --- | --- | --- | --- |
| PostHog integration | `worker/src/queues/postHogIntegrationQueue.ts` | `worker/src/features/posthog/` | `process posthog-integration-project` | `[POSTHOG]` |
| Mixpanel integration | `worker/src/queues/mixpanelIntegrationQueue.ts` | `worker/src/features/mixpanel/` | `process mixpanel-integration-project` | `[MIXPANEL]` |
| Blob storage export | `worker/src/queues/blobStorageIntegrationQueue.ts` | `worker/src/features/blobstorage/` | `process blob-storage-project` | `[BLOBSTORAGE]` |
| Data retention | `worker/src/queues/dataRetentionQueue.ts` | `worker/src/features/batch-data-retention-cleaner/` | `process data-retention-project` | n/a |
| Event propagation | `worker/src/queues/eventPropagationQueue.ts` | `worker/src/features/eventPropagation/` | `process event-propagation` | n/a |
| Cloud usage metering | `worker/src/queues/cloudUsageMeteringQueue.ts` | `worker/src/ee/` (cloud-only) | `process cloud-usage-metering` | n/a |
| Free-tier usage threshold | `worker/src/queues/cloudFreeTierUsageThresholdQueue.ts` | `worker/src/ee/usageThresholds/` | `process cloud-free-tier-usage-threshold` | n/a |
| Ingestion (single event) | `worker/src/queues/ingestionQueue.ts` | `worker/src/features/ingestion/` (and `IngestionService`) | BullMQ default span | n/a |
| OTel ingestion | `worker/src/queues/otelIngestionQueue.ts` | `worker/src/features/otel/` | BullMQ default span | n/a |
| Evaluation execution | `worker/src/queues/evalQueue.ts` | `worker/src/features/evaluation/` | BullMQ default span | n/a |
| Batch export | `worker/src/queues/batchExportQueue.ts` | `worker/src/features/batchExport/` | BullMQ default span | n/a |
| Webhook delivery | `worker/src/queues/webhooks.ts` | `worker/src/features/webhooks/` | BullMQ default span | n/a |
| Trace / score / dataset / project delete | `worker/src/queues/{traceDelete,scoreDelete,datasetDelete,projectDelete}.ts` | `worker/src/features/traces/`, `…/scores/`, `…/datasets/` | BullMQ default span | n/a |
For queues using BullMQ default spans (no `instrumentAsync` wrapper), search
APM with `service:worker operation_name:bullmq.process` filtered by
`bullmq.queue:<queue-name>`. For queue inventory, sharded queue naming, and
queue metric recipes, use
[`../../datadog-query-recipes/references/queue-consumers.md`](../../datadog-query-recipes/references/queue-consumers.md).
## Web (Next.js / tRPC / public API)
| Subsystem | Code | Span / log filter |
| --- | --- | --- |
| Public REST API | `web/src/pages/api/public/**` | Request span: `service:web resource_name:"GET /api/public/<path>"`; tenant span: `resource_name:api-auth-verify` with `@langfuse.project.id` / `@langfuse.org.id` |
| tRPC procedures | `web/src/server/api/routers/**` | `service:web resource_name:"POST /api/trpc/<router>.<proc>"` |
| Auth / API key verification | `web/src/features/public-api/server/apiAuth.ts` | look for `verifyAuthHeaderAndReturnScope` spans |
| Stripe billing | `web/src/ee/features/billing/server/stripeBillingService.ts` | wrapped in `instrumentAsync`; spans named after the method |
For tenant-specific public API usage questions, first query
`resource_name:api-auth-verify` by `@langfuse.project.id` or
`@langfuse.org.id`, then open representative trace IDs and inspect the request
root span for `http.path_group`, `http.route`, and `http.target`. The tenant
tags and endpoint path are usually on different spans, so a single-span query
combining both may return no results even when the trace proves usage.
For the full reusable recipe, use
[`../../datadog-query-recipes/references/public-api-tenant-usage.md`](../../datadog-query-recipes/references/public-api-tenant-usage.md).
## Shared Layers
These are not subsystems on their own, but are *frequently the actual cause*
behind a worker subsystem failure.
| Layer | Location | Common failure modes |
| --- | --- | --- |
| ClickHouse access | `packages/shared/src/server/clickhouse/`, `packages/shared/src/server/repositories/` | OOM (`Code: 241`), buffer cancel (`Code: 734`), JOIN spills, slow queries on un-pre-filtered traces |
| Prisma access | `packages/shared/src/db.ts` and per-feature repos | `connection pool timeout` (worker default `connection_limit=5`), N+1 queries |
| Queue contracts | `packages/shared/src/server/queues.ts` | wrong queue name, missing schema validation |
| Logger / instrumentation | `packages/shared/src/server/logger.ts`, `packages/shared/src/server/instrumentation.ts` | log silently dropped because `LANGFUSE_LOG_LEVEL` set wrong, or span missing because handler doesn't call `instrumentAsync` |
| Webhook URL validation | `packages/shared/src/server/validateWebhookURL.ts` | rejects with messages that *look* like DNS errors but are SSRF guard rejections |
| Encryption | `packages/shared/encryption` | bad keys → 403/auth-style failures masquerading as upstream errors |
## Common Symptoms → First Files To Read
- **"403 from upstream":** check the per-integration credentials table in
Postgres (`PostHogIntegration`, `BlobStorageIntegration`, `WebhookConfig`,
etc.) and the encryption layer.
- **"Timeout":** check the SDK timeout default and the per-stream
flush/batch size in the handler. Worker async jobs default to long-running
but the upstream SDK does not.
- **"DNS lookup failed":** distinguish actual DNS from `validateWebhookURL`
rejection. The error message wrapping is misleading on purpose.
- **"Cannot write to canceled buffer" (CH):** ClickHouse stream wasn't
aborted when the downstream consumer threw. Look for an `AbortController`
threaded through the handler.
- **"Connection pool timeout" (Prisma):** worker `connection_limit` is set
in the connection string; jobs doing per-row `findFirst()` exhaust it.
Check whether the integration row could be cached in closure scope.
- **"memory limit exceeded" (CH):** look for unbounded JOINs without a
pre-filter CTE, especially in analytics integrations.
- **"Header overflow":** Node HTTP parser's default 80 KB ceiling. Either
raise `--max-http-header-size` for the worker, or replace the SDK's HTTP
client.
## Where to Look for Already-Shipped Fixes
Before recommending a patch, confirm it isn't already merged or in flight:
- `attachments` on the Linear issue (PRs and commits are auto-linked).
- `git log --oneline --since=<recent-window> -- <handler-path>`.
- Open PRs touching the file via `gh pr list --search "<filename>"`.
@@ -1,60 +0,0 @@
---
name: frontend-browser-review
description: |
Shared workflow for browser-based review of user-visible frontend changes in Langfuse.
Use when a change affects UI behavior, layout, styling, navigation, or browser-visible
regressions and should be checked with the Playwright MCP server before signoff.
---
# Frontend Browser Review
Use this skill when a change affects what users see or do in the browser.
## Start Here
- Read [`../../../web/AGENTS.md`](../../../web/AGENTS.md) for web-specific
entry points and test commands.
- Use the workspace `playwright` MCP server configured from the repo-owned
shared agent setup.
## When To Use It
- UI changes in `web/**`
- Layout, styling, or responsive behavior changes
- Changes to navigation or page flows
- Bug fixes where the failure mode is visible in the browser
- Final signoff for user-visible frontend work
## Review Loop
1. Start the app with `pnpm run dev:web` unless an existing local server is
already running.
2. Install Chromium with `pnpm run playwright:install` if Playwright has not
been set up on the machine yet.
3. Open the primary changed flow with the Playwright MCP server.
4. Exercise the main happy path affected by the change.
5. Check for obvious visual regressions:
- broken layout or spacing
- banner overlap or viewport anchoring issues
- missing loading, empty, or error states
- broken responsive behavior on narrow widths
6. If the page changed materially, inspect the resulting UI state and compare
it against the intended behavior from the task or existing patterns.
7. If the browser session fails, inspect traces and artifacts under
`/tmp/playwright-mcp`.
## Output Expectations
Report:
1. What flow you reviewed
2. Whether the primary flow worked
3. Any visible regressions or follow-up risks
4. If review was blocked, exactly what prevented browser verification
## Scope Notes
- This skill complements, not replaces, targeted tests and linting.
- For implementation details, stay in `web/AGENTS.md` and package-local skills.
- Use this as the browser-signoff workflow, not as a generic frontend coding
guide.
@@ -1,74 +0,0 @@
---
name: frontend-large-feature-architecture
description: |
Use when building, changing, or refactoring large Langfuse frontend features,
virtualized lists, large tables, controller components, local feature state,
Zustand stores, row selection, high-frequency UI state, or
rendering-performance issues.
---
# Frontend Large Feature Architecture
Use this skill when building, changing, or refactoring a large frontend
surface.
In this skill, "controller" means a component or hook that owns feature logic:
data fetching, view state, table/list state, effects, actions, and expensive
rendering. The problem is not the name; it is one place owning too many
changing responsibilities.
## Big Feature Rules
When a feature grows, split rendering from logic. Most components should be
view-only. Data preparation should be pure. Complex user actions should live in
external async functions or local-store actions. Effects are integration
boundaries, not the normal way to derive state.
For the full rules, read
[`references/big-feature-rules.md`](references/big-feature-rules.md).
## Required Model
- The page/view owns lifecycle and creates feature-scoped dependencies.
- Server/query state stays in tRPC/React Query; route state stays in the
router/filter hooks.
- High-frequency feature UI state belongs in a per-mount local vanilla Zustand
store. Create it with lazy `useState`, not `useMemo`.
- Global stores are only for truly cross-feature, cross-route product state.
- React context may provide a stable store or action owner. Do not put
frequently changing state directly in provider values.
- Rendered rows/cells/items should be view-only or narrow containers. Put
effects, subscriptions, data loading, and workflows outside expensive views.
- Shared `src/components/*` exports should stay context-free or receive
explicit props. Put view-scoped Zustand consumers in `src/features/*`.
- Large feature folders should have a concise `README.md` owner map.
- Migrate real features through small PRs that improve one state boundary,
action workflow, data-preparation seam, or render boundary.
## Local Store Default
Prefer a local vanilla Zustand store for large or high-frequency feature state.
The store is created by the page/view and destroyed on unmount.
Use selectors that return primitives or stable references. If a component needs
multiple values, use shallow selector helpers or split subscriptions so one
changing field does not rerender unrelated UI.
Complex user workflows should live in `actions/*.ts` files or store actions.
The component wires hooks and passes dependencies; the action owns the workflow.
## When To Read References
- For the core big-feature rules and migration reality, read
[`references/big-feature-rules.md`](references/big-feature-rules.md).
- For virtualized lists, translated DOM, row measurement, and scroll rerenders,
read [`references/virtualized-lists.md`](references/virtualized-lists.md).
- For local feature stores and splitting large components, read
[`references/local-feature-state.md`](references/local-feature-state.md).
- For feature `README.md` owner maps, read
[`references/feature-readmes.md`](references/feature-readmes.md).
- For a step-by-step migration from a controller component to a managed feature
pattern, read
[`references/controller-migration.md`](references/controller-migration.md).
This is the default reference for traces/observations tables, sessions,
experiments, prompts, evals, datasets, and other controller-heavy surfaces.
@@ -1,7 +0,0 @@
interface:
display_name: "Frontend Feature Architecture"
short_description: "Build, change, or refactor React features"
default_prompt: "Use $frontend-large-feature-architecture to build, change, or refactor this large frontend surface with clear state ownership, stable subscriptions, and view-only render boundaries."
policy:
allow_implicit_invocation: true
@@ -1,98 +0,0 @@
# Big Feature Rules
Use these rules when a feature keeps growing and the instinct is to add one
more state variable, prop, callback, or effect to the same component.
Here, "controller" means a component or hook that owns feature logic: state,
effects, data loading, subscriptions, actions, and derived data. Some
controller code is necessary. The failure mode is one controller owning too many
changing responsibilities and waking too much UI.
## Ownership Baseline
- The page/view owns lifecycle and creates feature-scoped dependencies.
- Server/query state stays in tRPC/React Query.
- Route state stays in router/filter hooks.
- High-frequency local UI state belongs in a per-mount feature store.
- Global stores are only for product state shared across routes or features.
- Pure data preparation turns fetched data into UI data before rendering.
- Complex workflows live in `actions/*.ts` or store actions.
- Effects are integration boundaries, not ordinary state derivation.
- Expensive rows/cells/items should be view-only behind narrow containers.
## Hard Rules
1. Growth usually means React rendering and feature logic need to be separated.
A bigger component is not an architecture.
2. Most components should be view-only. They render what they are given, or
they read a small selected value from a context-available feature store.
3. Complicated data preparation belongs in separate pure functions. Backend
data should flow one way: fetched data -> compiled UI data -> render.
4. User actions that trigger complex logic should live outside render
components as named async functions or store actions. If the action needs a
lot of context, pass the feature store instance and let the action read what
it needs.
5. A feature with both local store state and React Query data needs an explicit
bridge in a custom hook or named action.
6. Prefer view-scoped local feature stores over global stores for page-instance
state. Filters, selected rows, expanded rows, lazy load state, drawers, and
local actions usually belong to one mounted page instance.
7. Create local store instances with a lazy `useState` initializer, not
`useMemo`. The store is a per-mount instance, not a render-time derived
value.
8. Effects should not be normal state mutators. Before adding an effect, try
pure data preparation, a store action, or an explicit event handler.
9. Do not copy existing large Langfuse feature components as examples. Treat
them as legacy unless they follow this skill.
10. Prefer small reliable PRs over a large rewrite. Each PR should improve one
state boundary, action workflow, data-preparation seam, or render boundary,
then update the feature README or migration note with the next slice.
## Migration Reality
Most large frontend features are not yet in the ideal shape. Traces,
observations, experiments, prompts, evals, datasets, and session views all have
some controller-heavy surfaces. Do not copy an existing large component just
because it works today. Treat it as a migration candidate and move it one
state/action/data-preparation boundary at a time.
For the step-by-step path, read `controller-migration.md`.
## Small PR Policy
A good big-feature PR is intentionally incomplete. It should change one
semantic interaction and keep behavior stable: one selection boundary, one
action workflow, one pure data-preparation helper, or one render boundary.
Do not bundle file reorganization, state extraction, visual changes, and
behavior fixes in the same PR unless the user explicitly asks for that scope.
When a reorganization is needed, prefer a rename-only PR first or a later
follow-up PR.
## Effects And Actions
Effects are for subscriptions, observers, imperative third-party APIs,
one-time initialization, and cleanup. Keep them in containers or feature hooks,
not view components. If an effect writes state repeatedly, make the store action
idempotent.
Complex actions should be callable without rendering a component. Put workflows
in `actions/*.ts` or named store actions. Components wire hooks and pass
dependencies; actions own the workflow.
```ts
await applyBulkAction({
store,
queryClient,
projectId,
});
```
Actions must not call React hooks. Pass hook results, query helpers, the local
store instance, or narrow callback dependencies into the action. If an action
needs substantial data preparation, export a pure helper next to it so the
transformation can be tested independently.
Do not pass twenty props through the tree so a button can do feature-level work,
and do not leave complex workflows inline in a page controller because that
controller happened to have all dependencies in scope.
@@ -1,123 +0,0 @@
# Controller Migration Guide
Use this when a frontend surface has grown into one component or hook that owns
data fetching, route glue, filters, table/list state, selection, drawers,
actions, and expensive rendering.
The target is clear ownership, not "everything in a store." Start from the
ownership baseline in `big-feature-rules.md`.
Most existing features are not there yet. Migrate in narrow slices and keep
behavior stable.
## Realistic Migration Strategy
Do not start by designing the perfect final feature. Start by making the next
change safer than the previous one.
For each migration PR, write down:
- the current controller problem being targeted
- the single state/action/data-preparation/render boundary being improved
- what behavior must remain unchanged
- what instrumentation or tests prove the slice worked
- what still remains spread across the feature
- the next recommended atomic slice
This is how large features become managed features without review-hostile
rewrites. The feature README is the living owner map; update it in place as the
feature moves forward.
## Step-by-Step Path
1. **Map the controller.** List every state group, query, derived value, effect,
callback, action, and expensive child render owned by the component.
2. **Classify state.** Separate server/query, route, persisted browser,
high-frequency local UI, derived view data, imperative integration, and
one-off modal/form state.
3. **Instrument one symptom.** Pick a concrete interaction such as row
selection, scroll, filter change, drawer open, form step change, or
saved-view change. Measure what rerenders, remounts, refetches, or
recalculates.
4. **Choose one boundary.** Start with the smallest high-value boundary:
selection, lazy-row state, batch action workflow, filter-target mapping,
column/view state, wizard step state, or pure data preparation. Do not
migrate everything at once.
5. **Choose the lightest tool.** A pure helper or action extraction may be the
right first PR. Add a local store only when selective subscriptions or
per-mount persistence are needed.
6. **Create a local store instance when needed.** Use lazy `useState` in the
page/view:
```ts
const [store] = useState(() => createFeatureStore(initialState));
```
Provide only this stable store instance through context.
7. **Move mutations into named actions.** Put state-changing logic in store
actions or external action functions. Keep components responsible for user
events, not workflows.
8. **Split containers from views.** Containers may subscribe, call hooks, or
fetch data. Views should render props or tiny selected values.
9. **Move data preparation out of render.** Put expensive or complicated
transformations in pure functions. Backend data should flow into compiled UI
data, then into rendering.
10. **Bridge query state explicitly.** If local store decisions depend on React
Query data, use a named feature hook or action to express that relationship.
11. **Isolate imperative integration.** Virtualizers, observers, keyboard
listeners, third-party DOM mutation handling, and timers belong in narrow
integration hooks.
12. **Update the feature README.** Record what this PR improved, desired
boundaries, known spread state, and the next extraction target.
13. **Remove debug instrumentation.** Temporary logs are useful during
migration, but should not survive the slice.
14. **Repeat.** Each slice should make one semantic interaction narrower and
easier to reason about.
## Feature-Specific First Slices
Use the feature's current shape to pick the first slice:
- **Traces and observations tables**: start with row selection, select-all,
batch actions, or expensive cell wrappers.
- **Session detail and session events**: isolate virtualization, lazy-row load
state, and dynamic measurement from rendered row content.
- **Experiment result tables**: start with selected-row state, filter-target
mapping, run/evaluation batch actions, or pure helpers for comparison column
construction.
- **Experiment creation wizards**: separate submitted form data from display
state such as active step, selected prompt labels, schema display names, and
evaluator selection.
- **Prompt management**: split prompt detail route/query state, label/version
selection, prompt history data preparation, and mutation workflows.
- **Eval template and evaluator forms**: extract form defaults, model/provider
preparation, validation helpers, and submit workflows before adding a store.
- **Datasets and dataset runs**: isolate active-cell/compare-field state,
table selection, run comparison preparation, and upload/import workflows.
If a feature already has hooks for part of this work, treat them as partial
migration, not proof that the whole surface is healthy.
## Acceptance Criteria
- A local state change wakes only components that selected that state.
- Page components no longer rebuild columns, filters, row wrappers, and action
callbacks for unrelated row-level changes.
- Expensive rows/cells are view-only behind narrow containers.
- Effects are integration boundaries or one-time initialization, not ordinary
data derivation.
- Complex workflows can be called without rendering the page.
- The feature README tells the next developer what has improved, what remains
spread, and what the next small PR should target.
Avoid:
- Replacing a giant component with a giant global store.
- Moving all state at once without measured acceptance criteria.
- Treating memoization as the architecture.
- Putting provider-coupled store hooks into shared `src/components/*` exports.
- Leaving a workflow inline in the page because the page had every dependency in
scope.
- Using the README as a victory statement while hiding remaining controller
state. Be explicit about remaining debt.
@@ -1,89 +0,0 @@
# Feature READMEs
Large frontend feature folders should have a short `README.md` that acts as an
owner map for humans and agents. Prefer `README.md` over `FEATURE.md` to match
the existing `web/src/features/*` convention. Use `FEATURE.md` only if a folder
already has a user-facing or generated README.
The README is not a changelog. It should describe durable boundaries and point
to deeper migration notes.
## Required Sections
- **Surface**: what product surface the folder owns.
- **Entry Points**: route files or parent components that mount the feature,
and the page/view lifecycle owner files they call.
- **Structure**: what each subfolder owns. Use root `components/` only for
components reused across surfaces inside the feature. Use surface folders such
as `detail/` for page controllers, local stores, `actions/`, integration
hooks, and surface-private containers.
- **External Consumers**: other features that import these components. This
keeps shared exports context-free and prevents accidental provider coupling.
- **State Ownership**: where server/query state, route state, local feature
state, global product state, DOM integration state, and view-only props live.
- **Performance And Stability Boundaries**: which interactions are
high-frequency or externally unstable, and which components are allowed to
rerender or measure because of them.
- **Migration State**: what has already been improved, what state/actions are
still spread, and the next one or two atomic slices.
- **Development Context**: agent skills, migration notes, or issue docs to read
before extending the feature.
## State Ownership Rules
Use the ownership baseline in `big-feature-rules.md`. The README only needs to
name where each state category lives in this feature and where known spread
state remains.
## Performance And Stability Map
Every large feature README should name the high-frequency interactions that
must stay narrow. Typical examples:
- scroll and virtualization updates
- row selection, hover, expansion, and lazy-loading
- filter, saved-view, and column-state changes
- drawers, peek navigation, and keyboard navigation
- browser translation or other third-party DOM mutation
- resize and dynamic row measurement
For each interaction, state which boundary should update. The page component
should not rerun expensive data preparation, recreate column/config objects, or
rerender unchanged expensive cells for unrelated state changes.
## Current-State Honesty
If a feature is mid-migration, say so. The README should make the desired
boundaries clear while naming known spread state as debt. Do not present a
partially migrated feature as the final pattern.
Use an update-in-place style rather than a changelog. For example:
- **Improved in current shape**: local store owns row selection; export action
moved to `actions/exportFeatureData.ts`; row view no longer subscribes to
filter state.
- **Still spread**: saved-view state remains in the page controller; filter
option preparation is still inline; mutation workflows still close over page
hooks.
- **Next slice**: extract filter-option preparation into pure helpers; move
batch action workflow into an action file; split route/query glue from view
components.
This makes small PRs reviewable while keeping the feature migration plan
visible. Do not wait for a perfect reorganization before documenting the
current state.
## PR-Scale Guidance
Feature README updates should match the PR size:
- For a small state/action extraction, add or update only the relevant
migration-state bullets.
- For a new feature folder structure, include the owner map and external
consumers before moving logic into the folder.
- For a rename-only PR, document intended structure but avoid changing behavior.
- For behavior PRs, avoid unrelated file moves unless they are required for the
boundary being improved.
The README should help the next contributor avoid falling back into the same
large component, not argue that the migration is complete.
@@ -1,162 +0,0 @@
# Local Feature State
Large frontend features should not let one component or hook own everything.
That shape makes one checkbox, hover, or row-selection change rerun the code
that also builds filters, columns, data wrappers, expensive cells, drawers,
actions, and routing glue.
The goal is to make each state change wake only the UI and actions that
semantically depend on it.
## Default Pattern
Start from the ownership baseline in `big-feature-rules.md`, then add a local
vanilla Zustand store only when state is high-frequency, shared across multiple
subtrees in the mounted feature, or must survive row/item remounts.
Use this shape:
1. The page/view creates one store instance with lazy `useState`.
2. Context provides only the stable store instance.
3. Components subscribe to the smallest useful slice.
4. Mutations live in named store actions.
5. Complex user workflows live in `actions/*.ts` or store actions.
6. Expensive cells/rows stay view-only behind narrow containers.
7. Large feature roots have a short `README.md` owner map.
## Feature README
For a concrete owner-map template, read `feature-readmes.md`. Do not use the
README as a changelog; record durable ownership facts and the next migration
slice.
## Why Local, Not Global
Langfuse pages are often local-state heavy: filters, saved views, selected rows,
expanded rows, drawers, peek navigation, lazy row load state, and view-local
actions. Those states usually belong to one mounted page instance, not the whole
application.
Use local feature stores for this state. Create the store in the page/view and
destroy it on unmount. This keeps multiple mounted instances independent, avoids
cross-route state leaks, and makes ownership visible.
Global state is reserved for product state that is genuinely shared across
features or routes. Do not promote state globally to avoid prop drilling or to
make a large component smaller.
Do not add a store just to make a PR look architectural. If the immediate
problem is an inline export workflow, duplicated filter option shaping, or a
large column builder, first extract an action or pure helper. Use the store when
state needs selective subscriptions or must survive row remounts within one
mounted feature instance.
## Creating The Store
Prefer lazy `useState` for local store instances:
```ts
const [store] = useState(() =>
createFeatureStore({
initialProjectId: projectId,
}),
);
```
This expresses the real lifecycle: one store instance for the committed mounted
view. The unused setter is acceptable. `useMemo` is the wrong default because it
is a render-time cache for derived values, not an ownership boundary for an
external store instance. A local store is stateful infrastructure, so treat its
identity as state.
`useRef` can also hold a stable instance, but prefer `useState` unless a ref is
needed for imperative setup. Keep store creation pure; sync changing route/query
inputs into named store actions such as `resetForFeature(...)` or `init(...)`.
## Independent Actions
Complex workflows should be independent functions. Put surface-specific actions
in `actions/*.ts`, or make them named store actions when they are tightly coupled
to local feature state.
The component wires hooks, route params, stores, analytics, and query helpers.
The action owns the workflow: refetching through callbacks, reading the passed
store if needed, calling pure helpers, performing browser side effects, and
emitting analytics.
Actions must not call React hooks. If a workflow needs a lot of context, pass
the local store instance or a small dependency object rather than threading long
prop chains through view components.
Example:
```ts
await exportFeatureData({
capture,
fetchDetails,
projectId,
refetchSummary,
selectedIds,
});
```
For substantial data shaping, export a pure helper next to the action so the
transformation can be tested without rendering the page.
## Store Shape
Use immutable plain objects for keyed state that must be selector-friendly:
```ts
type FeatureStoreState = {
selectedIds: Record<string, true>;
activeId: string | null;
actions: {
toggleSelected: (id: string, selected: boolean) => void;
setActiveId: (id: string | null) => void;
};
};
```
Avoid mutating `Set` or `Map` in place. If you use them, replace the whole
instance when updating.
## Anti-Patterns
- A table/list component owns selection, filters, columns, routing, peek state,
batch actions, local dialogs, and expensive rendered cells.
- Context provider `value` changes on row selection, hover, scroll, active row,
expanded row, or other high-frequency state.
- Local feature state is promoted to a global store even though it only belongs
to one mounted page instance.
- `useMemo` is used to own a local external store instance.
- A shared `src/components/*` component calls a feature-scoped store hook. That
silently breaks other callers that do not mount the feature provider.
- Memoization is the only fix. `memo` helps, but it does not fix a bad state
boundary.
- A callback depends on an inline config object and changes identity on every
render.
- Components subscribe to large objects when they only need a boolean.
- `useEffect` derives ordinary UI state from fetched data.
- A component passes a long chain of feature context through props just so a
button can perform an action.
- A page component keeps a complex async workflow inline because all the hooks
happen to be in scope there.
## Migration Steps
1. Instrument first: identify which semantic state change causes broad renders.
2. Choose the smallest useful boundary: store state, action workflow, pure data
preparation, or imperative integration hook.
3. Extract that one state group into a local store only when selective
subscriptions are needed.
4. Replace broad props with selector subscriptions at the smallest UI boundary.
5. Move related mutations into named actions.
6. Stabilize callbacks and data wrappers.
7. Move expensive data preparation into pure functions.
8. Move complex user actions into store actions or external functions under
`actions/*.ts`.
9. Update the feature README with what improved, what remains spread, and the
next atomic slice.
10. Remove debug instrumentation.
11. Repeat for the next state group.
@@ -1,81 +0,0 @@
# Virtualized Lists
Virtualized lists are render-boundary infrastructure. They should calculate
which item shells are visible and position those shells. They should not make
each row own feature state, effects, subscriptions, data loading, and workflows.
Langfuse uses `@tanstack/react-virtual` for virtualization. Find current
callsites before changing a virtualized surface, then apply the same
state-boundary rules as any large feature.
## Smartness Trap
The broken shape is:
- virtualizer rerenders on scroll
- parent recreates callbacks, config, row wrappers, or data objects
- row components receive changed props even though the semantic row did not
change
- row-local effects/load state reset or refire
- dynamic measurement observes DOM changed by an external mutator such as Google
Translate
- measurement updates virtualizer state, which rerenders the same rows again
That is not a small memoization bug. It is leaked state ownership.
The fix is to make scroll and measurement state update the smallest possible
integration boundary. If scrolling changes a virtual item offset, unchanged row
content should not receive new semantic props, refire effects, or recreate
expensive derived data.
## Google Translate DOM Behavior
Google Translate mutates rendered DOM after React has committed it. It can wrap
text nodes, replace text, and change element dimensions outside React's data
flow. React and TanStack Virtual do not know whether the changed DOM represents
stable translated content or a transient mutation.
Do not opt product UI out of translation with `translate="no"` unless product
explicitly chooses that. Langfuse must work under browser translation.
## Measurement Rules
- Always put the correct `data-index` on the row element TanStack treats as the
item.
- Do not combine live `measureElement` with externally mutated translated DOM
in text-heavy rows.
- Prefer fixed estimates plus overscan for simple rows.
- For dynamic text-heavy rows, use controlled measurement:
- `ResizeObserver` reads the row shell.
- Debounce commits.
- Do not commit while actively scrolling.
- Round heights to avoid sub-pixel churn.
- Call `virtualizer.resizeItem(index, height)`.
- If a row alternates between two heights repeatedly, clamp to a minimum
height that still allows later legitimate growth.
## Row Rules
- The virtualizer owns positioning only.
- State that must survive remounts lives outside the row instance.
- Expensive row content should be a memoized view component.
- Narrow row containers may subscribe to local store slices and queries.
- View components should receive stable props and perform no effects.
- Feature-scoped row containers belong under `src/features/*`; shared
`src/components/*` row exports should be context-free.
- Scrolling may rerender the virtualizer. It should not rerender unchanged
expensive row content.
- Do not use a global store to preserve row state across virtualization. Use a
view-scoped store owned by the mounted list/page instance.
## Migration Steps
1. Add temporary logs to identify whether scroll causes remounts, prop changes,
measurement loops, or query refetches.
2. Remove logs before shipping.
3. Move row-local state that must survive virtualization into a local store.
4. Stabilize callbacks and config objects passed to rows.
5. Replace live `measureElement` with fixed estimates or controlled measurement.
6. Move row logic into pure helpers or feature-local containers.
7. Verify with browser translation enabled, horizontal resize, and small
vertical scroll deltas.
-51
View File
@@ -1,51 +0,0 @@
---
name: git-workflow
description: |
Langfuse repo Git, GitHub, commit, branch, pull request, issue search,
release, and production-promotion workflow. Use when staging, committing,
pushing, opening PRs, searching GitHub issues, or changing release/promotion
behavior.
---
# Git Workflow
Use this skill for repo-specific Git, GitHub, pull request, and release
operations.
## Safety
- Inspect `git status` before staging or committing.
- Do not stage unrelated working-tree changes.
- Do not revert unrelated working-tree changes.
- Do not use destructive commands such as `git reset --hard` or
`git checkout --` unless explicitly requested.
- Keep commits focused and atomic.
- Never add secrets or credentials to the repo.
## Commits and Pull Requests
- Commit messages and PR titles must follow Conventional Commits:
`type(scope): description` or `type: description`.
- Use `feat` for new features and `fix` for bug fixes.
- Use a scope when it clarifies the affected area, for example
`fix(api): handle missing trace id`.
- Mark breaking changes with `!` in the type/scope or a `BREAKING CHANGE:`
footer.
- PR titles are validated by `.github/workflows/validate-pr-title.yml`.
- In PR descriptions, list impacted packages and executed verification
commands.
## GitHub
- Use `gh search issues` for GitHub issue search.
- Prefer non-interactive Git and GitHub commands where possible.
- Keep PRs narrow enough to review without unrelated refactors.
## Release
- Release workflow is managed at root with `pnpm run release`.
- Promote `main` to `production` via
`.github/workflows/promote-main-to-production.yml` or
`pnpm run release:cloud`.
- Do not change release/versioning flow without updating this skill and the
impacted package guides.
-116
View File
@@ -1,116 +0,0 @@
---
name: linear-bug-triage
description: |
Deduplicate measured bug or regression evidence against Linear, then either
add evidence comments to related existing issues or create concise Linear bug
issues in Triage. Use when Codex has confirmed evidence from Datadog,
benchmarks, traces, timings, flamegraphs, logs, or production measurements and
needs Linear search, comments, labels, Datadog links, or bug ticket creation
without fix suggestions, but only after a human has approved sharing the
findings in Linear.
---
# Linear Bug Triage
Use this skill after a bug or regression candidate has measured evidence. This
skill owns Linear search, deduplication, evidence comments, and ticket creation;
the calling skill owns deciding whether the signal is issue-worthy.
## Human Approval Gate
Before doing anything in Linear, first show the findings to the human in a
compact markdown table and ask for explicit permission to share them in Linear.
The table should include one row per candidate with:
- Candidate / cluster name.
- Environments.
- Service and route/resource.
- Recent window measurement.
- Baseline measurement.
- Delta / regression summary.
- Key Datadog evidence links.
- Proposed Linear action (`comment existing`, `create new`, or `none`).
If the human does not explicitly approve, stop after presenting the table. Do
not search Linear, do not comment on issues, and do not create issues.
If a calling workflow already showed the findings table and obtained explicit
human approval for a Linear handoff, skip this gate and proceed directly to
deduplication.
## Required Evidence
For each candidate, gather:
- Recent window and baseline window as absolute time ranges with timezone.
- Measured signal: counts, rates, p50/p95/p99 latency, trace samples,
flamegraphs, monitor thresholds, or benchmark deltas.
- Affected environments, services, routes/resources, status codes, and top error
messages.
- Datadog links for logs, spans, traces, metrics, dashboards, or flamegraphs
used as evidence.
- The exact text `No measurements found` for requested measurements that are
unavailable.
Do not create or comment based on guesses, unsupported impact claims, or missing
measurements alone.
## Deduplication
After the human explicitly approves, before creating a new issue:
1. Search Linear for related open issues using exact error text, route/resource,
service, environment, monitor name, and Datadog link keywords.
2. Search recently closed or canceled issues if the error is recurring or the
wording is distinctive.
3. If a related issue exists, add a concise evidence comment instead of creating
a duplicate.
4. If no related issue exists, create one Linear issue in the `Triage` state for
each distinct bug cluster.
## Existing Issue Comments
For related existing issues, add only:
- Recent window and baseline window.
- Measured delta or `No measurements found` for unavailable signals.
- Affected environments, services, routes/resources, and top error messages.
- Datadog links.
Do not add fix suggestions, root-cause guesses, implementation notes, owner
assignments, or next steps.
## New Issue Format
Create new issues with:
- State/status `Triage`; pass the Linear state explicitly on creation and do not
rely on workspace defaults.
- Label `bug`.
- Additional existing labels that match the evidence, such as affected service,
environment, API, ingestion, latency, ClickHouse, Postgres, integrations, or
observability labels. Query labels first and use the repository/team's exact
label names.
- Concise title: `bug: <service or route> <measured symptom> in <envs>`.
- Concise body, evidence-only:
```markdown
Recent window: <absolute time range and timezone>
Baseline: <absolute time range and timezone>
Signal:
- <count/rate/latency delta with env/service/route>
- <"No measurements found" for missing requested measurements>
Evidence:
- Datadog logs: <url>
- Datadog spans/traces: <url>
- Datadog metrics, dashboard, or latency graph: <url>
Related Linear search:
- <brief search terms used and result>
```
Do not include fix suggestions, root-cause guesses, implementation notes, owner
assignments, or next steps unless the user explicitly asks outside the Linear
issue or comment.
@@ -1,4 +0,0 @@
interface:
display_name: "Linear Bug Triage"
short_description: "Deduplicate and file Linear bug evidence"
default_prompt: "Use $linear-bug-triage to deduplicate measured bug evidence and create or comment Linear triage issues."
@@ -1,74 +0,0 @@
---
name: pnpm-upgrade-package
description: >-
Upgrade pnpm workspace dependencies to target/latest versions:
direct/transitive bumps, release-age checks, temporary overrides,
minimumReleaseAgeExclude, lockfile/dedupe verification.
---
# PNPM Upgrade Package
Use this skill for interactive dependency bumps in Langfuse.
## Read Order
- Use this `SKILL.md` for the end-to-end workflow.
- Run the main helper once at the start of the upgrade:
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion]`
## Apply This Skill
- Ask for the package name if the user did not provide one.
- Ask for the target version if the user did not provide one.
- Run the main helper once as the first analysis step and use that single
output for scope, exclusion decisions, and the final bump.
- If the target package is not directly declared anywhere, run
`pnpm why -r <package>` to find which direct dependency brings it in, then
inspect whether the current top-level parent already allows the requested
transitive version via its dependency range.
- If the current parent range already covers the requested transitive version,
prefer a lockfile refresh / reinstall path over bumping the parent manifest.
- If the current parent range does not cover the requested transitive version,
upgrade that parent dependency instead of adding the target package directly
unless the user explicitly wants that.
- If pnpm will not move an already-allowed transitive version, a scoped
`overrides` entry in `pnpm-workspace.yaml` may be used as a temporary
resolution tool. Before finishing, prove whether the override is still
required: remove it, run `pnpm install`, then run `pnpm dedupe`. Inspect the
diff after each generated change. If the target version remains without the
override, do not keep the override; keep or restore it only when pnpm reverts
or drifts from the requested version without it.
- Never manually edit `pnpm-lock.yaml`; regenerate lockfile changes with
`pnpm` commands only. If a lockfile-only refresh causes unrelated churn,
adjust the pnpm command and rerun instead of patching the lockfile by hand.
- After fixing or upgrading a package, run `pnpm dedupe`. Always inspect the
diff after dedupe and revert that generated attempt if it introduces
unrelated churn.
- Resolve the registry latest version, but do not silently upgrade to latest
unless the user asked for latest.
- Compare the target version with the latest version installable under the
current `minimumReleaseAge` window.
- Ask before adding `minimumReleaseAgeExclude` entries for the target package,
exact dependency companions from `dependencies` or `optionalDependencies`, or
locally installed exact peer dependencies.
- Finish with `pnpm why -r <package>` to confirm that only the intended version
remains in the workspace.
## Quick Commands
- Analysis pass:
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> <targetVersion>`
- Transitive provenance / final graph verification:
`pnpm why -r <package>`
- Inspect a current parent manifest on the registry:
`npm view <parent>@<installedVersion> dependencies peerDependencies optionalDependencies --json`
- Optional lockfile cleanup:
`pnpm dedupe`
- Bump in the root workspace:
`pnpm -w up <package>@<version>`
- Bump in one workspace:
`pnpm --filter web up <package>@<version>`
- Bump everywhere that should move together:
`pnpm -r up <package>@<version>`
- Verify temporary override removal:
remove the override, then run `pnpm install` and `pnpm dedupe`
@@ -1,4 +0,0 @@
interface:
display_name: "PNPM Upgrade Package"
short_description: "Interactive pnpm package bump workflow"
default_prompt: "Use $pnpm-upgrade-package to upgrade a package in this pnpm workspace, asking me for the package or version if I did not provide them."
@@ -1,418 +0,0 @@
#!/usr/bin/env node
import { join } from "node:path";
import {
entryCoversVersion,
findLocalPackageReferences,
formatWorkspaceReference,
getRootPnpmControls,
readWorkspaceConfig,
} from "./lib/workspace-utils.mjs";
const args = process.argv.slice(2);
const asJson = args.includes("--json");
const positional = args.filter((arg) => !arg.startsWith("--"));
const packageName = positional[0];
const requestedTargetVersion = positional[1] ?? null;
if (!packageName) {
console.error(
"Usage: node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion] [--json]",
);
process.exit(1);
}
const repoRoot = process.cwd();
const workspaceConfig = readWorkspaceConfig(join(repoRoot, "pnpm-workspace.yaml"));
const minimumReleaseAgeMinutes = workspaceConfig.minimumReleaseAge ?? 0;
const thresholdMs = Date.now() - minimumReleaseAgeMinutes * 60 * 1000;
const REGISTRY_FETCH_TIMEOUT_MS = 30_000;
const registryCache = new Map();
const workspaceReferenceCache = new Map();
const getWorkspaceReferences = (name) => {
if (!workspaceReferenceCache.has(name)) {
workspaceReferenceCache.set(name, findLocalPackageReferences(repoRoot, name));
}
return workspaceReferenceCache.get(name);
};
function printSectionHeader(title) {
console.log("");
console.log(title);
}
function isPrerelease(version) {
return version.includes("-");
}
function isExactVersion(spec) {
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec.trim());
}
function getMatchingExcludeEntries(name, version) {
return workspaceConfig.minimumReleaseAgeExclude.filter((entry) =>
entryCoversVersion(entry, name, version),
);
}
async function fetchRegistryPackage(name) {
if (registryCache.has(name)) return registryCache.get(name);
const abortController = new AbortController();
const timeoutId = setTimeout(
() => abortController.abort(),
REGISTRY_FETCH_TIMEOUT_MS,
);
timeoutId.unref?.();
try {
const response = await fetch(
`https://registry.npmjs.org/${encodeURIComponent(name)}`,
{
headers: {
accept: "application/json",
"user-agent": "langfuse-pnpm-upgrade-package-skill",
},
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch ${name} from npm registry: ${response.status}`,
);
}
const metadata = await response.json();
registryCache.set(name, metadata);
return metadata;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error(
`Timed out fetching ${name} from npm registry after ${REGISTRY_FETCH_TIMEOUT_MS}ms`,
{ cause: error },
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
function getInstallability(metadata, name, version) {
const publishedAt = metadata.time?.[version] ?? null;
const publishedAtMs = publishedAt ? Date.parse(publishedAt) : null;
const matchingExcludeEntries = getMatchingExcludeEntries(name, version);
const isYoungerThanMinimumReleaseAge =
publishedAtMs != null ? publishedAtMs > thresholdMs : null;
const isInstallableWithoutNewExclude =
isYoungerThanMinimumReleaseAge == null
? null
: !isYoungerThanMinimumReleaseAge || matchingExcludeEntries.length > 0;
return {
name,
version,
publishedAt,
isYoungerThanMinimumReleaseAge,
isInstallableWithoutNewExclude,
matchingExcludeEntries,
suggestedExclude:
isInstallableWithoutNewExclude === false ? `${name}@${version}` : null,
};
}
function selectLatestInstallableVersion(metadata, name) {
const times = metadata.time ?? {};
return (
Object.keys(metadata.versions ?? {})
.filter((version) => times[version] && !isPrerelease(version))
.sort((left, right) => Date.parse(times[right]) - Date.parse(times[left]))
.map((version) => getInstallability(metadata, name, version))
.find((candidate) => candidate.isInstallableWithoutNewExclude) ?? null
);
}
function collectManifestEntries(manifest, fields) {
const merged = new Map();
for (const field of fields) {
for (const [name, spec] of Object.entries(manifest[field] ?? {})) {
const key = `${name}:${spec}`;
const entry = merged.get(key);
if (entry) {
entry.fields.push(field);
continue;
}
merged.set(key, { name, spec, fields: [field] });
}
}
return [...merged.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
}
async function analyzeManifestEntries(entries, { includeWorkspace = false } = {}) {
const exact = [];
const range = [];
for (const entry of entries) {
const workspaceReferences = includeWorkspace
? getWorkspaceReferences(entry.name)
: null;
if (!isExactVersion(entry.spec)) {
range.push({
...entry,
...(includeWorkspace ? { workspaceReferences } : {}),
});
continue;
}
const metadata = await fetchRegistryPackage(entry.name);
const installability = getInstallability(metadata, entry.name, entry.spec);
exact.push({
...entry,
...installability,
...(includeWorkspace
? {
workspaceReferences,
isInstalledInWorkspace: workspaceReferences.length > 0,
}
: {}),
suggestedExclude:
includeWorkspace && workspaceReferences.length === 0
? null
: installability.suggestedExclude,
});
}
return { exact, range };
}
function printWorkspaceReferences(title, references) {
printSectionHeader(title);
if (references.length === 0) {
console.log("- none");
return;
}
for (const reference of references) {
console.log(`- ${formatWorkspaceReference(reference)}`);
}
}
function printRootPnpmControls(rootPnpm) {
printSectionHeader("Root pnpm controls:");
if (
rootPnpm.overrideMatches.length === 0 &&
rootPnpm.patchedDependencyMatches.length === 0
) {
console.log("- none");
return;
}
for (const match of rootPnpm.overrideMatches) {
console.log(`- override ${match.selector}: ${match.value}`);
}
for (const match of rootPnpm.patchedDependencyMatches) {
console.log(`- patched dependency ${match.selector}: ${match.value}`);
}
}
function printVersionEntries(title, entries, { includeWorkspace = false } = {}) {
printSectionHeader(title);
if (entries.length === 0) {
console.log("- none");
return;
}
for (const entry of entries) {
const status =
entry.isInstallableWithoutNewExclude == null
? "unknown"
: entry.isInstallableWithoutNewExclude
? "installable now"
: "needs exclude";
console.log(
`- ${entry.name}@${entry.version} (${status}; via ${entry.fields.join(", ")})`,
);
if (entry.publishedAt) {
console.log(` published at: ${entry.publishedAt}`);
}
if (includeWorkspace) {
console.log(
` installed in workspace: ${entry.isInstalledInWorkspace ? "yes" : "no"}`,
);
for (const reference of entry.workspaceReferences) {
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
}
}
if (entry.matchingExcludeEntries.length > 0) {
console.log(
` matching exclude entries: ${entry.matchingExcludeEntries.join(", ")}`,
);
}
if (entry.suggestedExclude) {
console.log(` suggested exclude: ${entry.suggestedExclude}`);
}
}
}
function printRangeEntries(title, entries) {
printSectionHeader(title);
if (entries.length === 0) {
console.log("- none");
return;
}
for (const entry of entries) {
console.log(
`- ${entry.name}: ${entry.spec} (manual review; via ${entry.fields.join(", ")})`,
);
for (const reference of entry.workspaceReferences ?? []) {
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
}
}
}
const packageMetadata = await fetchRegistryPackage(packageName);
const latestVersion = packageMetadata["dist-tags"]?.latest ?? null;
const targetVersion = requestedTargetVersion ?? latestVersion;
if (!targetVersion) {
console.error(`Could not resolve a target version for ${packageName}.`);
process.exit(1);
}
if (!packageMetadata.versions?.[targetVersion]) {
console.error(`Version ${targetVersion} was not found for ${packageName}.`);
process.exit(1);
}
const packageWorkspaceReferences = getWorkspaceReferences(packageName);
const rootPnpm = getRootPnpmControls(repoRoot, packageName);
const latestInstallableWithoutNewExclude = selectLatestInstallableVersion(
packageMetadata,
packageName,
);
const targetInstallability = getInstallability(
packageMetadata,
packageName,
targetVersion,
);
const targetManifest = packageMetadata.versions[targetVersion];
const dependencyCompanions = await analyzeManifestEntries(
collectManifestEntries(targetManifest, [
"dependencies",
"optionalDependencies",
]),
);
const peerDependencies = await analyzeManifestEntries(
collectManifestEntries(targetManifest, ["peerDependencies"]),
{ includeWorkspace: true },
);
const result = {
packageName,
targetVersion,
targetWasExplicitlyProvided: requestedTargetVersion != null,
packageWorkspaceReferences,
rootPnpm,
minimumReleaseAgeMinutes,
thresholdIso: new Date(thresholdMs).toISOString(),
latestRegistryVersion: latestVersion,
latestRegistryPublishedAt:
latestVersion != null ? packageMetadata.time?.[latestVersion] ?? null : null,
latestInstallableWithoutNewExclude,
targetPublishedAt: targetInstallability.publishedAt,
targetIsYoungerThanMinimumReleaseAge:
targetInstallability.isYoungerThanMinimumReleaseAge,
targetIsInstallableWithoutNewExclude:
targetInstallability.isInstallableWithoutNewExclude,
matchingPackageExcludeEntries: targetInstallability.matchingExcludeEntries,
suggestedPackageExclude: targetInstallability.suggestedExclude,
exactDependencyCompanions: dependencyCompanions.exact,
rangeDependencyCompanions: dependencyCompanions.range,
exactPeerDependencies: peerDependencies.exact,
rangePeerDependencies: peerDependencies.range,
};
if (asJson) {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
}
console.log(`Package: ${packageName}`);
console.log(
`Target version: ${targetVersion}${
requestedTargetVersion
? ""
: " (resolved latest; still ask before bumping if version was omitted)"
}`,
);
printWorkspaceReferences(
"Target package workspace references:",
packageWorkspaceReferences,
);
printRootPnpmControls(rootPnpm);
printSectionHeader("Release-age window:");
console.log(`minimumReleaseAge: ${minimumReleaseAgeMinutes} minutes`);
console.log(`Threshold: ${result.thresholdIso}`);
console.log(`Latest registry version: ${result.latestRegistryVersion ?? "unknown"}`);
if (result.latestRegistryPublishedAt) {
console.log(`Latest registry published at: ${result.latestRegistryPublishedAt}`);
}
if (latestInstallableWithoutNewExclude) {
console.log(
`Latest installable without new exclude: ${latestInstallableWithoutNewExclude.version} (${latestInstallableWithoutNewExclude.publishedAt})`,
);
} else {
console.log("Latest installable without new exclude: none found");
}
console.log(`Target published at: ${result.targetPublishedAt ?? "unknown"}`);
console.log(
`Target installable without new exclude: ${
result.targetIsInstallableWithoutNewExclude == null
? "unknown"
: result.targetIsInstallableWithoutNewExclude
? "yes"
: "no"
}`,
);
if (result.matchingPackageExcludeEntries.length > 0) {
console.log("Matching package exclude entries:");
for (const entry of result.matchingPackageExcludeEntries) {
console.log(`- ${entry}`);
}
} else {
console.log("Matching package exclude entries: none");
}
if (result.suggestedPackageExclude) {
console.log(`Suggested package exclude: ${result.suggestedPackageExclude}`);
}
printVersionEntries(
"Exact dependency companions (dependencies + optionalDependencies):",
result.exactDependencyCompanions,
);
printRangeEntries(
"Range dependency companions (dependencies + optionalDependencies):",
result.rangeDependencyCompanions,
);
printVersionEntries("Exact peer dependencies:", result.exactPeerDependencies, {
includeWorkspace: true,
});
printRangeEntries("Range peer dependencies:", result.rangePeerDependencies);
@@ -1,244 +0,0 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
const packageFields = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
export function formatWorkspaceReference(reference) {
const label = reference.workspaceName
? `${reference.path} (${reference.workspaceName})`
: reference.path;
const specs = reference.matches
.map((match) => `${match.field}: ${match.spec}`)
.join(", ");
return `${label} -> ${specs}`;
}
export function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function stripInlineComment(line) {
let quote = null;
let escaped = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) {
quote = null;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (char === "#") {
return line.slice(0, index).trimEnd();
}
}
return line;
}
function unquoteYamlScalar(value) {
return value.trim().replace(/^['"]|['"]$/g, "");
}
export function readWorkspaceConfig(path) {
const raw = readFileSync(path, "utf8");
const lines = raw.split(/\r?\n/);
let minimumReleaseAge = 0;
const minimumReleaseAgeExclude = [];
const overrides = {};
const patchedDependencies = {};
let activeBlock = null;
for (const line of lines) {
const uncommented = stripInlineComment(line);
const trimmed = uncommented.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const ageMatch = trimmed.match(/^minimumReleaseAge:\s*(\d+)\s*$/);
if (ageMatch) {
minimumReleaseAge = Number(ageMatch[1]);
activeBlock = null;
continue;
}
if (/^minimumReleaseAgeExclude:\s*$/.test(trimmed)) {
activeBlock = "minimumReleaseAgeExclude";
continue;
}
if (/^overrides:\s*$/.test(trimmed)) {
activeBlock = "overrides";
continue;
}
if (/^patchedDependencies:\s*$/.test(trimmed)) {
activeBlock = "patchedDependencies";
continue;
}
if (/^\S/.test(uncommented)) {
activeBlock = null;
continue;
}
if (activeBlock === "minimumReleaseAgeExclude") {
const excludeMatch = uncommented.match(/^\s*-\s+(.+?)\s*$/);
if (excludeMatch) {
minimumReleaseAgeExclude.push(unquoteYamlScalar(excludeMatch[1]));
}
continue;
}
if (activeBlock === "overrides" || activeBlock === "patchedDependencies") {
const entryMatch = uncommented.match(/^\s+(.+?):\s+(.+?)\s*$/);
if (!entryMatch) continue;
const selector = unquoteYamlScalar(entryMatch[1]);
const value = unquoteYamlScalar(entryMatch[2]);
if (activeBlock === "overrides") {
overrides[selector] = value;
} else {
patchedDependencies[selector] = value;
}
}
}
return {
minimumReleaseAge,
minimumReleaseAgeExclude,
overrides,
patchedDependencies,
};
}
export function collectPackageJsonPaths(repoRoot) {
const paths = [
"package.json",
"web/package.json",
"worker/package.json",
"ee/package.json",
];
const packagesRoot = join(repoRoot, "packages");
if (!existsSync(packagesRoot)) return paths;
const stack = [packagesRoot];
while (stack.length > 0) {
const current = stack.pop();
for (const entry of readdirSync(current, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".git"
) {
continue;
}
const nextPath = join(current, entry.name);
if (entry.isDirectory()) {
stack.push(nextPath);
continue;
}
if (entry.isFile() && entry.name === "package.json") {
paths.push(relative(repoRoot, nextPath));
}
}
}
return [...new Set(paths)];
}
export function matchesPackageSelector(selector, wantedPackage) {
if (selector === wantedPackage) return true;
if (selector.startsWith(`${wantedPackage}@`)) return true;
if (selector.endsWith(`>${wantedPackage}`)) return true;
if (selector.includes(`>${wantedPackage}@`)) return true;
if (selector.endsWith("/*")) {
const prefix = selector.slice(0, -1);
return wantedPackage.startsWith(prefix);
}
return false;
}
export function entryCoversVersion(entry, wantedPackage, wantedVersion) {
if (entry === wantedPackage) return true;
if (entry.endsWith("/*")) {
const prefix = entry.slice(0, -1);
return wantedPackage.startsWith(prefix);
}
if (!entry.startsWith(`${wantedPackage}@`)) return false;
return entry
.slice(wantedPackage.length + 1)
.split("||")
.map((part) => part.trim())
.includes(wantedVersion);
}
export function findLocalPackageReferences(repoRoot, wantedPackage) {
const results = [];
for (const packageJsonPath of collectPackageJsonPaths(repoRoot)) {
const json = readJson(join(repoRoot, packageJsonPath));
const matches = [];
for (const field of packageFields) {
if (json[field]?.[wantedPackage]) {
matches.push({ field, spec: json[field][wantedPackage] });
}
}
if (matches.length > 0) {
results.push({
path: packageJsonPath,
workspaceName: json.name ?? null,
matches,
});
}
}
return results;
}
export function getRootPnpmControls(repoRoot, packageName) {
const workspaceConfig = readWorkspaceConfig(
join(repoRoot, "pnpm-workspace.yaml"),
);
return {
overrideMatches: Object.entries(workspaceConfig.overrides)
.filter(([selector]) => matchesPackageSelector(selector, packageName))
.map(([selector, value]) => ({ selector, value })),
patchedDependencyMatches: Object.entries(
workspaceConfig.patchedDependencies,
)
.filter(([selector]) => matchesPackageSelector(selector, packageName))
.map(([selector, value]) => ({ selector, value })),
};
}
-101
View File
@@ -1,101 +0,0 @@
---
name: security-review
description: Security review patterns for Langfuse. Use during code review, design, or planning whenever a change accepts user-supplied URLs, host/endpoint/baseURL fields, secrets, cross-tenant data, new outbound HTTP requests, new integrations (webhooks, blob storage, LLM connections, image proxies), redirect-following behavior, or new auth/permission scopes. Covers SSRF/outbound URL validation today and is intentionally extensible to other recurring security findings (tenant isolation, secret handling, redirect mishandling, file upload, RBAC scope drift).
---
# Security Review
Use this skill when reviewing or planning code that touches a security-sensitive
surface in Langfuse. It collects the recurring findings the team has seen in
external security reports so that future agents catch them at design and review
time rather than after the fact.
## When to Apply
Apply this skill when the change touches any of:
- a user-supplied URL, host, endpoint, `baseURL`, or webhook target
- a new outbound HTTP request (`fetch`, `axios`, AWS SDK client init with a
custom `endpoint`, OpenAI/Anthropic/Bedrock client init with a custom
`baseURL`, etc.)
- a new integration form under Settings -> Integrations or any
admin-configurable network destination
- a new tRPC procedure or public API route that mutates project-scoped data or
changes who can access it
- secrets, API keys, signing secrets, or encryption-at-rest fields
- redirect-following or cross-origin header handling
- file uploads, image proxies, or other binary data flowing in or out
Apply this skill during **plan mode** when designing a new integration so the
correct validation surfaces land in the plan, not in a follow-up CVE.
## How to Read This Skill
1. Open [references/checklist.md](references/checklist.md) and run the mental
sweep against the change.
2. For each bullet that fires, open the matching topic reference.
| Topic | Open when | File |
| --- | --- | --- |
| SSRF and outbound URL validation | The change accepts or fetches a user-supplied URL, host, or endpoint | [references/outbound-url-validation.md](references/outbound-url-validation.md) |
The catalog is intentionally short today. New topic files are added as new
finding classes recur (see "Extending This Skill").
## Output Expectations (Review Mode)
When this skill is used during code review:
- List findings first, ordered by severity, with file and line references.
- For each finding, name the canonical helper or known-good call site the
author should copy.
- For SSRF-class findings, point at [references/outbound-url-validation.md](references/outbound-url-validation.md)
rather than re-deriving the fix.
- Call out missing **negative tests** (private-IP, cross-tenant, missing-scope)
as findings, not as nice-to-haves.
## Output Expectations (Design / Plan Mode)
When this skill is used while planning:
- Restate which surfaces the new feature exposes (forms, public API routes,
worker entrypoints).
- For each surface that matches a checklist trigger, name the validator or
helper that must be invoked and at which layer (save-time, use-time,
connection-time, redirect-time).
- Treat "we will validate later" as a design defect: validation belongs in the
same change that introduces the surface.
## Extending This Skill
Add a new `references/<topic>.md` whenever a security finding recurs across
features or PR reviews. Keep each reference narrow and concrete:
1. Threat in plain language (one paragraph).
2. Canonical helpers in this repo, with paths.
3. Known-good call sites that can be copied.
4. Required defenses (save-time, use-time, transport-time, etc.).
5. Anti-patterns to flag in review.
Then add a one-line trigger to [references/checklist.md](references/checklist.md)
pointing at the new topic file, and add a row to the table above.
Candidates for future references (do not add until a real finding recurs):
- Tenant isolation (`projectId` filters across Prisma and ClickHouse)
- Secret handling and encryption-at-rest read paths
- Redirect mishandling and sensitive-header propagation
- File upload validation and content-type sniffing
- RBAC scope drift on new tRPC/public API endpoints
- Signed URL scoping (expiry, path, method)
- Public API rate limiting and auth boundary checks
## Integration With Other Skills
- The shared `code-review` skill should defer here for any change that matches
the triggers above; see [code-review/SKILL.md](../code-review/SKILL.md).
- The shared `backend-dev-guidelines` skill should defer here when adding
outbound HTTP, integration config, or URL-accepting procedures; see
[backend-dev-guidelines/SKILL.md](../backend-dev-guidelines/SKILL.md).
- Confirmed issues with reproduction evidence go through `linear-bug-triage`
for Linear handoff.
@@ -1,74 +0,0 @@
# Security Review Checklist
Run this list mentally on every relevant change. For each bullet, either find
that it does not apply, or confirm the listed mitigation is present in the
diff. Each bullet links to the topic reference that owns the detail.
## User-Supplied URLs and Outbound Requests
- Does the change accept a URL, host, `endpoint`, `baseURL`, webhook target,
or any field that becomes the destination of an outbound HTTP request?
- If yes, see [outbound-url-validation.md](outbound-url-validation.md).
- Required: a save-time validator on the mutation/API route **and** use-time
or connection-time validation when the request is issued, including
redirects. Raw `fetch(userUrl)` or SDK init with `endpoint: userUrl`
without that wiring is a finding.
- Does the change add a new "integration" (Settings -> Integrations, new
webhook destination, new storage backend, new LLM provider, new image
proxy) that lets an admin configure a host?
- If yes, see [outbound-url-validation.md](outbound-url-validation.md).
- Required: new env-driven allowlist trio (host / IPs / IP segments) for
self-hosted, plus strict Cloud enforcement.
- Does the change follow redirects on a user-controlled URL with plain
`fetch()`?
- If yes, see [outbound-url-validation.md](outbound-url-validation.md)
(Redirect Handling). Required: `fetchWithSecureRedirects` with the
matching validator.
## Tenant Isolation
- Does every new Prisma query on a project-scoped table include `projectId`
in the `where` clause?
- Does every new ClickHouse query on a project-scoped table include
`project_id = {projectId: String}`?
- Does every new tRPC procedure use `protectedProjectProcedure` (or
equivalent) and call `throwIfNoProjectAccess` with the right scope?
- Does every new public API route go through `withMiddlewares` +
`createAuthedProjectAPIRoute` (or the equivalent organization variant) with
the right scope?
These are enforced today by the repo-wide review checklist in
[../../code-review/references/review-checklist.md](../../code-review/references/review-checklist.md);
restate them here so the security sweep stays self-contained.
## Secrets and Credentials
- Are new secrets stored encrypted at rest (e.g. via `encrypt` / `decrypt`
from `@langfuse/shared/encryption`) and never returned through `get`/list
endpoints?
- Are secrets omitted from API responses (`select` / `omit` in Prisma) and
from logs?
- Are new env vars added to `.env*.example` files and validated via the
package `env.mjs/ts`, not read from `process.env` directly?
## Audit Logging
- Do sensitive mutations (integration config changes, role or scope changes,
exports, deletions) emit `auditLog` with the right `action` and
`resourceType`? Match existing wiring in
`web/src/features/blobstorage-integration/blobstorage-integration-router.ts`
and similar routers.
## Negative Tests
- Does the change include tests that prove **blocked** inputs are rejected
(private IPs, cross-tenant IDs, missing scope, http: on Cloud)? Missing
negative coverage on a security-sensitive surface is a finding.
## Extending
When a new finding class starts to recur in PR reviews or security reports,
add a one-line bullet here pointing at a new `references/<topic>.md`. Do not
restate the detail in this checklist; keep it as the trigger surface.
@@ -1,165 +0,0 @@
# Outbound URL Validation (SSRF)
## Threat
Any Langfuse code path that issues an outbound HTTP request to a URL derived
from user input (mutation form, public API field, integration config, image
proxy) can be coerced into Server-Side Request Forgery. The high-value
internal targets in Langfuse's deployment topology include:
- Cloud instance metadata services (`169.254.169.254`, IMDSv2 endpoints)
- Internal Postgres, ClickHouse, Redis, S3/MinIO, queue admin UIs
- Loopback admin interfaces (`127.0.0.1`, `localhost`, Docker API on
`2375/2376`)
- Kubernetes API server and other in-cluster control planes
- Any RFC1918 / RFC6598 / IPv6 ULA range routable from the pod or container
Even when the surface requires `integrations:CRUD` or admin scope, the
attacker model assumes the credentialed user is malicious or compromised;
SSRF lets them pivot from app-level admin to network-level access that the
deployment topology otherwise denies.
## Canonical Helpers
All under `packages/shared/src/server/outbound-url/`:
- [`parseOutboundUrl(urlString)`](../../../../packages/shared/src/server/outbound-url/validation.ts)
— safe parse. Rejects embedded credentials, invalid encoding, and bad URL
syntax. **Use this instead of `new URL(...)` for any user-supplied URL.**
- [`validateOutboundUrlHost({ url, whitelist, logContext, shouldSkipDnsCheckForLiteralIps })`](../../../../packages/shared/src/server/outbound-url/validation.ts)
— checks hostname blocklist, IP literal blocklist, and forward DNS
resolution against blocked CIDRs (defends DNS rebinding by resolving every
A/AAAA plus the local `getaddrinfo` view).
- [`addSecureOutboundConnectionValidation(options, ...)`](../../../../packages/shared/src/server/outbound-url/connection.ts)
— attaches connect-time IP validation to a `fetch` request so the TCP peer
is re-validated after DNS resolution, not just at save time.
- [`fetchWithSecureRedirects(...)`](../../../../packages/shared/src/server/outbound-url/fetch.ts)
— manual redirect handling. Validates each `Location` hop with the
caller-supplied validator and strips sensitive headers (`Authorization`,
`Cookie`, signing headers) on cross-origin redirects.
Surface-specific wrappers (reuse these rather than rolling your own):
- LLM base URL:
[`validateLlmConnectionBaseURL`](../../../../packages/shared/src/server/llm/baseUrlValidation.ts)
- Webhook URL:
[`validateWebhookURL`](../../../../packages/shared/src/server/webhooks/validation.ts)
- Blob storage endpoint:
[`validateBlobStorageEndpoint`](../../../../packages/shared/src/server/services/blobStorageEndpointValidation.ts)
and the companion
[`blobStorageEndpointConnectionValidationOptions`](../../../../packages/shared/src/server/services/blobStorageEndpointValidation.ts)
for connect-time enforcement through `StorageServiceFactory`.
## Required Defenses
Every outbound-URL surface MUST apply **all three** of:
1. **Save-time validation** in the mutation, tRPC procedure, or public API
route that persists the URL. Reject the write if validation fails.
2. **Use-time / connection-time validation** when the request is actually
issued (worker job, lazy validation endpoint, processor). DNS can change
between save and use; the SDK that ultimately makes the call may resolve a
different IP than the save-time check did. Plumb
`addSecureOutboundConnectionValidation` (or the SDK's equivalent hook)
through the request.
3. **Redirect-time validation** if the request can be redirected. Plain
`fetch()` defaults to `redirect: 'follow'` and will silently chase a
redirect into the loopback range. Use `fetchWithSecureRedirects` with the
matching validator instead.
## Known-Good Call Sites (Copy These)
- LLM base URL save:
`web/src/features/llm-api-key/server/router.ts` (`update` mutation calls
`validateLlmConnectionBaseURL` before persisting).
- LLM base URL through public API:
`web/src/pages/api/public/llm-connections/index.ts`.
- Webhook URL save + use:
`packages/shared/src/server/webhooks/validation.ts` is wired into both the
automation form and the worker-side webhook sender.
- Blob storage endpoint:
`web/src/features/blobstorage-integration/blobstorage-integration-router.ts`
(`validate` mutation calls `validateBlobStorageEndpoint`); connection-time
enforcement flows through `StorageServiceFactory.getInstance({
connectionValidation: blobStorageEndpointConnectionValidationOptions() })`.
## When Adding a New Outbound URL Surface
1. Identify the user-input layers: form mutation, public API route, env import,
anywhere the URL can be supplied by a tenant.
2. Decide whether an existing wrapper fits. If yes, reuse it. If not, add a
new wrapper under `packages/shared/src/server/...` that delegates to
`validateOutboundUrlHost` so blocklist behavior, DNS rebinding handling,
and credential checks stay centralized.
3. Define the env allowlist trio for self-hosted users who legitimately point
at private network targets (mirroring
`LANGFUSE_WEBHOOK_WHITELISTED_HOST/IPS/IP_SEGMENTS`,
`LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST/IPS/IP_SEGMENTS`, and
`LANGFUSE_BLOB_STORAGE_ENDPOINT_WHITELISTED_HOST/IPS/IP_SEGMENTS`). Do
not share another surface's allowlist; each surface keeps its own.
4. Add the env vars to `.env*.example` and the package `env.mjs/ts`.
5. Call the wrapper from:
- every tRPC mutation that writes the URL,
- every public API route that accepts the URL,
- the worker/processor that issues the request (use connect-time
validation if the underlying SDK does not expose a host-validation hook).
6. If the request can redirect, use `fetchWithSecureRedirects` with the same
wrapper as the validator.
7. Add server-side tests that prove blocked targets fail validation:
`127.0.0.1`, `169.254.169.254`, an RFC1918 literal, an RFC1918 hostname
(DNS rebinding), `http://` on Cloud, and a URL containing
`user:pass@host`.
## Anti-Patterns to Flag in Review
- `fetch(<user-supplied-url>)` (or `axios`, `got`, etc.) without an upstream
call to a `validate*URL` helper, or without
`addSecureOutboundConnectionValidation` on the request options.
- A tRPC mutation that persists a `host` / `endpoint` / `baseURL` / `webhookUrl`
field without invoking the matching validator before the write.
- `StorageServiceFactory.getInstance({ endpoint })` (or any SDK client init
that takes a user-controlled URL) without `connectionValidation` plumbed
through.
- Custom URL parsing via `new URL(userInput)` instead of
`parseOutboundUrl(userInput)`. The latter rejects embedded credentials and
bad encoding, both of which are recurring SSRF/credential-leak vectors.
- Following redirects with `fetch(url)` (default `redirect: 'follow'`) on a
user-controlled URL. Switch to `fetchWithSecureRedirects`.
- A new integration UI that validates only on the client side. Save-time
validation must run server-side.
- Save-time validation present, use-time validation missing (or vice versa).
Both layers are required; the worker may issue the request hours after the
save and DNS will have moved.
## Env Allowlist Behavior
- Cloud (`NEXT_PUBLIC_LANGFUSE_CLOUD_REGION` set) forces strict mode.
Allowlist env vars are ignored on Cloud, and HTTPS is enforced for surfaces
that require it (LLM base URL, blob storage endpoint).
- Self-hosted reads the per-surface env trio:
- `LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST/IPS/IP_SEGMENTS`
- `LANGFUSE_WEBHOOK_WHITELISTED_HOST/IPS/IP_SEGMENTS`
- `LANGFUSE_BLOB_STORAGE_ENDPOINT_WHITELISTED_HOST/IPS/IP_SEGMENTS`
- Blob storage endpoint validation is **opt-in** today on self-hosted (the
helper is a no-op until the operator configures one of the allowlist env
vars). There is a `TODO(next major)` in
`blobStorageEndpointValidation.ts` to flip the default; until then, do not
rely on blob storage validation for new surfaces — wire your own wrapper
that defaults to strict.
## Negative Tests (Required)
A change that adds a new outbound URL surface MUST include server-side tests
that assert each of the following fails validation:
- Loopback literal (`http://127.0.0.1`, `http://[::1]`)
- Cloud metadata literal (`http://169.254.169.254`)
- RFC1918 literal (`http://10.0.0.1`)
- Hostname that resolves to a private IP (DNS rebinding sanity check)
- URL with embedded credentials (`http://user:pass@host`)
- `http://` on Cloud (where HTTPS is required)
- An empty allowlist permits no internal targets on self-hosted
Pattern reference:
`worker/src/__tests__/llm-base-url-validation.test.ts` and the test files
adjacent to the wrappers above.
-456
View File
@@ -1,456 +0,0 @@
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations.
metadata:
short-description: Create or update Codex skills
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained folders that extend Codex's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Codex from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
### Require Human Review for Ticket Writes
For skills that create Linear tickets, update Linear tickets, or add evidence to
existing tickets, require human review before any write. The skill must present
all findings in a table, ask the human which findings to create or update in
Linear, and wait for an explicit selection before making changes.
Use this table structure unless the domain needs additional columns:
| ID | Finding | Evidence | Impact / Scope | Existing Ticket Match | Proposed Linear Action | Confidence | Human Decision |
| --- | --- | --- | --- | --- | --- | --- | --- |
| F1 | Concise symptom or bug claim | Measured counts, deltas, links, traces, logs, or "No measurements found" | Affected env, service, route, customer segment, or blast radius supported by evidence | Existing issue key/link, duplicate candidate, or "None found" | Create new ticket, add evidence comment, update status/labels, or no action | High/medium/low plus one short reason | Leave blank for the human to choose |
In the skill instructions, state that Codex must not create tickets, comment on
tickets, edit ticket fields, or add evidence until the human chooses one or more
row IDs and actions. If the human asks for an automated sweep, still pause at
this review table before writing to Linear.
### Protect Validation Integrity
You may use subagents during iteration to validate whether a skill works on realistic tasks or whether a suspected problem is real. This is most useful when you want an independent pass on the skill's behavior, outputs, or failure modes after a revision. Only do this when it is possible to start new subagents.
When using subagents for validation, treat that as an evaluation surface. The goal is to learn whether the skill generalizes, not whether another agent can reconstruct the answer from leaked context.
Prefer raw artifacts such as example prompts, outputs, diffs, logs, or traces. Give the minimum task-local context needed to perform the validation. Avoid passing the intended answer, suspected bug, intended fix, or your prior conclusions unless the validation explicitly requires them.
### Require Valid Markdown Output
When a skill instructs Codex to return Markdown, require the final output to be
valid Markdown, not just Markdown-like text.
In the skill instructions, explicitly require Codex to:
- use valid Markdown syntax for headings, lists, links, tables, and code fences;
- include a space after list markers such as `-`, `*`, and `1.`;
- close every Markdown link and parenthesis correctly;
- avoid malformed tables, dangling backticks, or partially opened fenced code
blocks;
- prefer plain paragraphs over complex formatting when the structure would be
fragile.
If a skill produces structured reports, include a short output-format section
that says the response must be valid Markdown and should be checked for basic
syntax mistakes before returning it.
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
├── agents/ (recommended)
│ └── openai.yaml - UI metadata for skill lists and chips
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
Every SKILL.md consists of:
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
#### Agents metadata (recommended)
- UI-facing metadata for skill lists and chips
- Read references/openai_yaml.md before generating values and follow its descriptions and constraints
- Create: human-facing `display_name`, `short_description`, and `default_prompt` by reading the skill
- Generate deterministically by passing the values as `--interface key=value` to `scripts/generate_openai_yaml.py` or `scripts/init_skill.py`
- On updates: validate `agents/openai.yaml` still matches SKILL.md; regenerate if stale
- Only include other optional interface fields (icons, brand color) if explicitly provided
- See references/openai_yaml.md for field definitions and examples
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking.
- **When to include**: For documentation that Codex should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Codex produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context
#### What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window)
#### Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
**Pattern 1: High-level guide with references**
```markdown
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
```
Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
**Pattern 2: Domain-specific organization**
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
```
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
```
When a user asks about sales metrics, Codex only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
```
When the user chooses AWS, Codex only reads aws.md.
**Pattern 3: Conditional details**
Show basic content, link to advanced content:
```markdown
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
```
Codex reads REDLINING.md or OOXML.md only when the user needs those features.
**Important guidelines:**
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing.
## Skill Creation Process
Skill creation involves these steps:
1. Understand the skill with concrete examples
2. Plan reusable skill contents (scripts, references, assets)
3. Initialize the skill (run init_skill.py)
4. Edit the skill (implement resources and write SKILL.md)
5. Validate the skill (run quick_validate.py)
6. Iterate based on real usage and forward-test complex skills.
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
### Skill Naming
- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`).
- When generating names, generate a name under 64 characters (letters, digits, hyphens).
- Prefer short, verb-led phrases that describe the action.
- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).
- Name the skill folder exactly after the skill name.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
- "Where should I create this skill? If you do not have a preference, I will place it in `$CODEX_HOME/skills` (or `~/.codex/skills` when `CODEX_HOME` is unset) so Codex can discover it automatically."
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists. In this case, continue to the next step.
Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$CODEX_HOME/skills`; when `CODEX_HOME` is unset, fall back to `~/.codex/skills` so the skill is auto-discovered.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory> [--resources scripts,references,assets] [--examples]
```
Examples:
```bash
scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills"
scripts/init_skill.py my-skill --path "${CODEX_HOME:-$HOME/.codex}/skills" --resources scripts,references
scripts/init_skill.py my-skill --path ~/work/skills --resources scripts --examples
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates `agents/openai.yaml` using agent-generated `display_name`, `short_description`, and `default_prompt` passed via `--interface key=value`
- Optionally creates resource directories based on `--resources`
- Optionally adds example files when `--examples` is set
After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.
Generate `display_name`, `short_description`, and `default_prompt` by reading the skill, then pass them as `--interface key=value` to `init_skill.py` or regenerate with:
```bash
scripts/generate_openai_yaml.py <path/to/skill-folder> --interface key=value
```
Only include other optional interface fields when the user explicitly provides them. For full field descriptions and examples, see references/openai_yaml.md.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively.
After substantial revisions, or if the skill is particularly tricky, you should use subagents to forward-test the skill on realistic tasks or artifacts. When doing so, pass the artifact under validation rather than your diagnosis of what is wrong, and keep the prompt generic enough that success depends on transferable reasoning rather than hidden ground truth.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required.
#### Update SKILL.md
**Writing Guidelines:** Always use imperative/infinitive form.
##### Frontmatter
Write the YAML frontmatter with `name` and `description`:
- `name`: The skill name
- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill.
- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
##### Body
Write instructions for using the skill and its bundled resources.
If the skill expects Markdown output, add an explicit output-format section that
requires valid Markdown syntax in the final response.
### Step 5: Validate the Skill
Once development of the skill is complete, validate the skill folder to catch basic issues early:
```bash
scripts/quick_validate.py <path/to/skill-folder>
```
The validation script checks YAML frontmatter format, required fields, and naming rules. If validation fails, fix the reported issues and run the command again.
### Step 6: Iterate
After testing the skill, you may detect the skill is complex enough that it requires forward-testing; or users may request improvements.
User testing often this happens right after using the skill, with fresh context of how the skill performed.
**Forward-testing and iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again
5. Forward-test if it is reasonable and appropriate
## Forward-testing
To forward-test, launch subagents as a way to stress test the skill with minimal context.
Subagents should *not* know that they are being asked to test the skill. They should be treated as
an agent asked to perform a task by the user. Prompts to subagents should look like:
`Use $skill-x at /path/to/skill-x to solve problem y`
Not:
`Review the skill at /path/to/skill-x; pretend a user asks you to...`
Decision rule for forward-testing:
- Err on the side of forward-testing
- Ask for approval if you think there's a risk that forward-testing would:
* take a long time,
* require additional approvals from the user, or
* modify live production systems
In these cases, show the user your proposed prompt and request (1) a yes/no decision, and
(2) any suggested modifictions.
Considerations when forward-testing:
- use fresh threads for independent passes
- pass the skill, and a request in a similar way the user would.
- pass raw artifacts, not your conclusions
- avoid showing expected answers or intended fixes
- rebuild context from source artifacts after each iteration
- review the subagent's output and reasoning and emitted artifacts
- avoid leaving artifacts the agent can find on disk between iterations;
clean up subagents' artifacts to avoid additional contamination.
If forward-testing only succeeds when subagents see leaked context, tighten the skill or the
forward-testing setup before trusting the result.
@@ -1,6 +0,0 @@
interface:
display_name: "Skill Creator"
short_description: "Create or update Codex skills"
icon_small: "./assets/skill-creator-small.svg"
icon_large: "./assets/skill-creator.png"
default_prompt: "Use $skill-creator to create or refine a concise Codex skill."
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
<path fill="#0D0D0D" d="M12.03 4.113a3.612 3.612 0 0 1 5.108 5.108l-6.292 6.29c-.324.324-.56.561-.791.752l-.235.176c-.205.14-.422.261-.65.36l-.229.093a4.136 4.136 0 0 1-.586.16l-.764.134-2.394.4c-.142.024-.294.05-.423.06-.098.007-.232.01-.378-.026l-.149-.05a1.081 1.081 0 0 1-.521-.474l-.046-.093a1.104 1.104 0 0 1-.075-.527c.01-.129.035-.28.06-.422l.398-2.394c.1-.602.162-.987.295-1.35l.093-.23c.1-.228.22-.445.36-.65l.176-.235c.19-.232.428-.467.751-.79l6.292-6.292Zm-5.35 7.232c-.35.35-.534.535-.66.688l-.11.147a2.67 2.67 0 0 0-.24.433l-.062.154c-.08.22-.124.462-.232 1.112l-.398 2.394-.001.001h.003l2.393-.399.717-.126a2.63 2.63 0 0 0 .394-.105l.154-.063a2.65 2.65 0 0 0 .433-.24l.147-.11c.153-.126.339-.31.688-.66l4.988-4.988-3.227-3.226-4.987 4.988Zm9.517-6.291a2.281 2.281 0 0 0-3.225 0l-.364.362 3.226 3.227.363-.364c.89-.89.89-2.334 0-3.225ZM4.583 1.783a.3.3 0 0 1 .294.241c.117.585.347 1.092.707 1.48.357.385.859.668 1.549.783a.3.3 0 0 1 0 .592c-.69.115-1.192.398-1.549.783-.315.34-.53.77-.657 1.265l-.05.215a.3.3 0 0 1-.588 0c-.117-.585-.347-1.092-.707-1.48-.357-.384-.859-.668-1.549-.783a.3.3 0 0 1 0-.592c.69-.115 1.192-.398 1.549-.783.36-.388.59-.895.707-1.48l.015-.05a.3.3 0 0 1 .279-.19Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,49 +0,0 @@
# openai.yaml fields (full example + descriptions)
`agents/openai.yaml` is an extended, product-specific config intended for the machine/harness to read, not the agent. Other product-specific config can also live in the `agents/` folder.
## Full example
```yaml
interface:
display_name: "Optional user-facing name"
short_description: "Optional user-facing description"
icon_small: "./assets/small-400px.png"
icon_large: "./assets/large-logo.svg"
brand_color: "#3B82F6"
default_prompt: "Optional surrounding prompt to use the skill with"
dependencies:
tools:
- type: "mcp"
value: "github"
description: "GitHub MCP server"
transport: "streamable_http"
url: "https://api.githubcopilot.com/mcp/"
policy:
allow_implicit_invocation: true
```
## Field descriptions and constraints
Top-level constraints:
- Quote all string values.
- Keep keys unquoted.
- For `interface.default_prompt`: generate a helpful, short (typically 1 sentence) example starting prompt based on the skill. It must explicitly mention the skill as `$skill-name` (e.g., "Use $skill-name-here to draft a concise weekly status update.").
- `interface.display_name`: Human-facing title shown in UI skill lists and chips.
- `interface.short_description`: Human-facing short UI blurb (2564 chars) for quick scanning.
- `interface.icon_small`: Path to a small icon asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder.
- `interface.icon_large`: Path to a larger logo asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder.
- `interface.brand_color`: Hex color used for UI accents (e.g., badges).
- `interface.default_prompt`: Default prompt snippet inserted when invoking the skill.
- `dependencies.tools[].type`: Dependency category. Only `mcp` is supported for now.
- `dependencies.tools[].value`: Identifier of the tool or dependency.
- `dependencies.tools[].description`: Human-readable explanation of the dependency.
- `dependencies.tools[].transport`: Connection type when `type` is `mcp`.
- `dependencies.tools[].url`: MCP server URL when `type` is `mcp`.
- `policy.allow_implicit_invocation`: When false, the skill is not injected into
the model context by default, but can still be invoked explicitly via `$skill`.
Defaults to true.
@@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
OpenAI YAML Generator - Creates agents/openai.yaml for a skill folder.
Usage:
generate_openai_yaml.py <skill_dir> [--name <skill_name>] [--interface key=value]
"""
import argparse
import re
import sys
from pathlib import Path
ACRONYMS = {
"GH",
"MCP",
"API",
"CI",
"CLI",
"LLM",
"PDF",
"PR",
"UI",
"URL",
"SQL",
}
BRANDS = {
"openai": "OpenAI",
"openapi": "OpenAPI",
"github": "GitHub",
"pagerduty": "PagerDuty",
"datadog": "DataDog",
"sqlite": "SQLite",
"fastapi": "FastAPI",
}
SMALL_WORDS = {"and", "or", "to", "up", "with"}
ALLOWED_INTERFACE_KEYS = {
"display_name",
"short_description",
"icon_small",
"icon_large",
"brand_color",
"default_prompt",
}
def yaml_quote(value):
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{escaped}"'
def format_display_name(skill_name):
words = [word for word in skill_name.split("-") if word]
formatted = []
for index, word in enumerate(words):
lower = word.lower()
upper = word.upper()
if upper in ACRONYMS:
formatted.append(upper)
continue
if lower in BRANDS:
formatted.append(BRANDS[lower])
continue
if index > 0 and lower in SMALL_WORDS:
formatted.append(lower)
continue
formatted.append(word.capitalize())
return " ".join(formatted)
def generate_short_description(display_name):
description = f"Help with {display_name} tasks"
if len(description) < 25:
description = f"Help with {display_name} tasks and workflows"
if len(description) < 25:
description = f"Help with {display_name} tasks with guidance"
if len(description) > 64:
description = f"Help with {display_name}"
if len(description) > 64:
description = f"{display_name} helper"
if len(description) > 64:
description = f"{display_name} tools"
if len(description) > 64:
suffix = " helper"
max_name_length = 64 - len(suffix)
trimmed = display_name[:max_name_length].rstrip()
description = f"{trimmed}{suffix}"
if len(description) > 64:
description = description[:64].rstrip()
if len(description) < 25:
description = f"{description} workflows"
if len(description) > 64:
description = description[:64].rstrip()
return description
def read_frontmatter_name(skill_dir):
skill_md = Path(skill_dir) / "SKILL.md"
if not skill_md.exists():
print(f"[ERROR] SKILL.md not found in {skill_dir}")
return None
content = skill_md.read_text()
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
print("[ERROR] Invalid SKILL.md frontmatter format.")
return None
frontmatter_text = match.group(1)
import yaml
try:
frontmatter = yaml.safe_load(frontmatter_text)
except yaml.YAMLError as exc:
print(f"[ERROR] Invalid YAML frontmatter: {exc}")
return None
if not isinstance(frontmatter, dict):
print("[ERROR] Frontmatter must be a YAML dictionary.")
return None
name = frontmatter.get("name", "")
if not isinstance(name, str) or not name.strip():
print("[ERROR] Frontmatter 'name' is missing or invalid.")
return None
return name.strip()
def parse_interface_overrides(raw_overrides):
overrides = {}
optional_order = []
for item in raw_overrides:
if "=" not in item:
print(f"[ERROR] Invalid interface override '{item}'. Use key=value.")
return None, None
key, value = item.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
print(f"[ERROR] Invalid interface override '{item}'. Key is empty.")
return None, None
if key not in ALLOWED_INTERFACE_KEYS:
allowed = ", ".join(sorted(ALLOWED_INTERFACE_KEYS))
print(f"[ERROR] Unknown interface field '{key}'. Allowed: {allowed}")
return None, None
overrides[key] = value
if key not in ("display_name", "short_description") and key not in optional_order:
optional_order.append(key)
return overrides, optional_order
def write_openai_yaml(skill_dir, skill_name, raw_overrides):
overrides, optional_order = parse_interface_overrides(raw_overrides)
if overrides is None:
return None
display_name = overrides.get("display_name") or format_display_name(skill_name)
short_description = overrides.get("short_description") or generate_short_description(display_name)
if not (25 <= len(short_description) <= 64):
print(
"[ERROR] short_description must be 25-64 characters "
f"(got {len(short_description)})."
)
return None
interface_lines = [
"interface:",
f" display_name: {yaml_quote(display_name)}",
f" short_description: {yaml_quote(short_description)}",
]
for key in optional_order:
value = overrides.get(key)
if value is not None:
interface_lines.append(f" {key}: {yaml_quote(value)}")
agents_dir = Path(skill_dir) / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
output_path = agents_dir / "openai.yaml"
output_path.write_text("\n".join(interface_lines) + "\n")
print(f"[OK] Created agents/openai.yaml")
return output_path
def main():
parser = argparse.ArgumentParser(
description="Create agents/openai.yaml for a skill directory.",
)
parser.add_argument("skill_dir", help="Path to the skill directory")
parser.add_argument(
"--name",
help="Skill name override (defaults to SKILL.md frontmatter)",
)
parser.add_argument(
"--interface",
action="append",
default=[],
help="Interface override in key=value format (repeatable)",
)
args = parser.parse_args()
skill_dir = Path(args.skill_dir).resolve()
if not skill_dir.exists():
print(f"[ERROR] Skill directory not found: {skill_dir}")
sys.exit(1)
if not skill_dir.is_dir():
print(f"[ERROR] Path is not a directory: {skill_dir}")
sys.exit(1)
skill_name = args.name or read_frontmatter_name(skill_dir)
if not skill_name:
sys.exit(1)
result = write_openai_yaml(skill_dir, skill_name, args.interface)
if result:
sys.exit(0)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,400 +0,0 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path> [--resources scripts,references,assets] [--examples] [--interface key=value]
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-new-skill --path skills/public --resources scripts,references
init_skill.py my-api-helper --path skills/private --resources scripts --examples
init_skill.py custom-skill --path /custom/location
init_skill.py my-skill --path skills/public --interface short_description="Short UI label"
"""
import argparse
import re
import sys
from pathlib import Path
from generate_openai_yaml import write_openai_yaml
MAX_SKILL_NAME_LENGTH = 64
ALLOWED_RESOURCES = {"scripts", "references", "assets"}
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing"
- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text"
- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features"
- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" -> numbered capability list
- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources (optional)
Create only the resource directories this skill actually needs. Delete this section if no resources are required.
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Codex's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Codex produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Not every skill requires all three types of resources.**
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Codex produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def normalize_skill_name(skill_name):
"""Normalize a skill name to lowercase hyphen-case."""
normalized = skill_name.strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
return normalized
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return " ".join(word.capitalize() for word in skill_name.split("-"))
def parse_resources(raw_resources):
if not raw_resources:
return []
resources = [item.strip() for item in raw_resources.split(",") if item.strip()]
invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES})
if invalid:
allowed = ", ".join(sorted(ALLOWED_RESOURCES))
print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}")
print(f" Allowed: {allowed}")
sys.exit(1)
deduped = []
seen = set()
for resource in resources:
if resource not in seen:
deduped.append(resource)
seen.add(resource)
return deduped
def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples):
for resource in resources:
resource_dir = skill_dir / resource
resource_dir.mkdir(exist_ok=True)
if resource == "scripts":
if include_examples:
example_script = resource_dir / "example.py"
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("[OK] Created scripts/example.py")
else:
print("[OK] Created scripts/")
elif resource == "references":
if include_examples:
example_reference = resource_dir / "api_reference.md"
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("[OK] Created references/api_reference.md")
else:
print("[OK] Created references/")
elif resource == "assets":
if include_examples:
example_asset = resource_dir / "example_asset.txt"
example_asset.write_text(EXAMPLE_ASSET)
print("[OK] Created assets/example_asset.txt")
else:
print("[OK] Created assets/")
def init_skill(skill_name, path, resources, include_examples, interface_overrides):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
resources: Resource directories to create
include_examples: Whether to create example files in resource directories
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"[ERROR] Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"[OK] Created skill directory: {skill_dir}")
except Exception as e:
print(f"[ERROR] Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title)
skill_md_path = skill_dir / "SKILL.md"
try:
skill_md_path.write_text(skill_content)
print("[OK] Created SKILL.md")
except Exception as e:
print(f"[ERROR] Error creating SKILL.md: {e}")
return None
# Create agents/openai.yaml
try:
result = write_openai_yaml(skill_dir, skill_name, interface_overrides)
if not result:
return None
except Exception as e:
print(f"[ERROR] Error creating agents/openai.yaml: {e}")
return None
# Create resource directories if requested
if resources:
try:
create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples)
except Exception as e:
print(f"[ERROR] Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
if resources:
if include_examples:
print("2. Customize or delete the example files in scripts/, references/, and assets/")
else:
print("2. Add resources to scripts/, references/, and assets/ as needed")
else:
print("2. Create resource directories only if needed (scripts/, references/, assets/)")
print("3. Update agents/openai.yaml if the UI metadata should differ")
print("4. Run the validator when ready to check the skill structure")
print(
"5. Forward-test complex skills with realistic user requests to ensure they work as intended"
)
return skill_dir
def main():
parser = argparse.ArgumentParser(
description="Create a new skill directory with a SKILL.md template.",
)
parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)")
parser.add_argument("--path", required=True, help="Output directory for the skill")
parser.add_argument(
"--resources",
default="",
help="Comma-separated list: scripts,references,assets",
)
parser.add_argument(
"--examples",
action="store_true",
help="Create example files inside the selected resource directories",
)
parser.add_argument(
"--interface",
action="append",
default=[],
help="Interface override in key=value format (repeatable)",
)
args = parser.parse_args()
raw_skill_name = args.skill_name
skill_name = normalize_skill_name(raw_skill_name)
if not skill_name:
print("[ERROR] Skill name must include at least one letter or digit.")
sys.exit(1)
if len(skill_name) > MAX_SKILL_NAME_LENGTH:
print(
f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). "
f"Maximum is {MAX_SKILL_NAME_LENGTH} characters."
)
sys.exit(1)
if skill_name != raw_skill_name:
print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.")
resources = parse_resources(args.resources)
if args.examples and not resources:
print("[ERROR] --examples requires --resources to be set.")
sys.exit(1)
path = args.path
print(f"Initializing skill: {skill_name}")
print(f" Location: {path}")
if resources:
print(f" Resources: {', '.join(resources)}")
if args.examples:
print(" Examples: enabled")
else:
print(" Resources: none (create as needed)")
print()
result = init_skill(skill_name, path, resources, args.examples, args.interface)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import re
import sys
from pathlib import Path
import yaml
MAX_SKILL_NAME_LENGTH = 64
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
return False, "SKILL.md not found"
content = skill_md.read_text()
if not content.startswith("---"):
return False, "No YAML frontmatter found"
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}"
allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"}
unexpected_keys = set(frontmatter.keys()) - allowed_properties
if unexpected_keys:
allowed = ", ".join(sorted(allowed_properties))
unexpected = ", ".join(sorted(unexpected_keys))
return (
False,
f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}",
)
if "name" not in frontmatter:
return False, "Missing 'name' in frontmatter"
if "description" not in frontmatter:
return False, "Missing 'description' in frontmatter"
name = frontmatter.get("name", "")
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}"
name = name.strip()
if name:
if not re.match(r"^[a-z0-9-]+$", name):
return (
False,
f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)",
)
if name.startswith("-") or name.endswith("-") or "--" in name:
return (
False,
f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens",
)
if len(name) > MAX_SKILL_NAME_LENGTH:
return (
False,
f"Name is too long ({len(name)} characters). "
f"Maximum is {MAX_SKILL_NAME_LENGTH} characters.",
)
description = frontmatter.get("description", "")
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}"
description = description.strip()
if description:
if "<" in description or ">" in description:
return False, "Description cannot contain angle brackets (< or >)"
if len(description) > 1024:
return (
False,
f"Description is too long ({len(description)} characters). Maximum is 1024 characters.",
)
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)
-61
View File
@@ -1,61 +0,0 @@
---
name: storybook
description: Use when writing or reviewing Storybook stories (`.stories.tsx`) for React components.
---
# Storybook Component Stories
## Which Components Can Have Stories?
Only create stories for components that **do not** do any of the following:
- Depend on context.
- Fetch data via any private API, including Langfuses own tRPC API.
Stories should follow the `ComponentName.stories.tsx` filename pattern. Components covered by stories should have exactly one exported or public component.
If this is not the case, suggest splitting up the file that includes the component to be covered first.
Be mindful of the breadth a story covers. A story should show a component in isolation. Page-level compositions should be rare and intentional.
## What to Do If a Component Violates the Criteria
Suggest abstracting a presentational component that does not violate the criteria and receives relevant data via props.
Make sure the props are well-defined using TypeScript.
Keep the existing component, but update it to use the newly created component for rendering. These presentational components are easier to test and easier to reuse.
## How Stories Should Be Written
- Use "CSF Next" format by default.
- Cover only the relevant component by default.
- Avoid custom render functions by default.
- Use `satisfies` and typed Storybook metadata so invalid args, decorators, and play functions are type-checked.
- Use play functions to test user-relevant interactions after render, not to compensate for complex setup or hidden dependencies.
- Name stories after the state they represent, not the implementation. Also do not include the component name in the story name.
- Prefer: `Default`, `Empty`, `WithLongName`, `Error`, `Disabled`, `Loading`
- Avoid: `Test1`, `CustomRenderExample`, `ButtonWithLongNameAndIcon`
- Set callbacks up as Storybook Actions by default:
```ts
import { fn } from "storybook/test";
```
- Avoid large fixtures.
- Use the smallest meaningful data shape needed to render the state.
- If fixtures are required and may be shared, check whether a reusable helper function exists. Otherwise, create one for defining the fixture.
## Variant and Design Showcase Stories
If a component has many variants, and the point of the story is to showcase the design of a component rather than its functionality, stories may render the component multiple times.
For example, a `Button` with a `size: "sm" | "md" | "lg"` prop may have a story that shows three buttons side by side.
If the button also has a `variant: "primary" | "secondary"` prop, consider using a matrix-like UI that showcases all possible combinations.
These compositional stories **should not** contain Storybook play functions. They should also not allow the Storybook user to customize the predefined args, such as `size` and `variant`, via Storybook args. Having an arg for non-bound props, such as `text`, may be acceptable.
## Additional Information
- We do not use MSW and are not planning to add it.
-951
View File
@@ -1,951 +0,0 @@
---
name: turborepo
description: |
Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,
dependsOn, caching, remote cache, the "turbo" CLI, --filter, --affected, CI optimization, environment
variables, internal packages, monorepo structure/best practices, and boundaries.
Use when user: configures tasks/workflows/pipelines, creates packages, sets up
monorepo, shares code between apps, runs changed/affected packages, debugs cache,
or has apps/packages directories.
metadata:
version: 2.8.21-canary.9
---
# Turborepo Skill
Build system for JavaScript/TypeScript monorepos. Turborepo caches task outputs and runs tasks in parallel based on dependency graph.
## IMPORTANT: Package Tasks, Not Root Tasks
**DO NOT create Root Tasks. ALWAYS create package tasks.**
When creating tasks/scripts/pipelines, you MUST:
1. Add the script to each relevant package's `package.json`
2. Register the task in root `turbo.json`
3. Root `package.json` only delegates via `turbo run <task>`
**DO NOT** put task logic in root `package.json`. This defeats Turborepo's parallelization.
```json
// DO THIS: Scripts in each package
// apps/web/package.json
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
// apps/api/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// packages/ui/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
```
```json
// turbo.json - register tasks
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": { "dependsOn": ["build"] }
}
}
```
```json
// Root package.json - ONLY delegates, no task logic
{
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test"
}
}
```
```json
// DO NOT DO THIS - defeats parallelization
// Root package.json
{
"scripts": {
"build": "cd apps/web && next build && cd ../api && tsc",
"lint": "eslint apps/ packages/",
"test": "vitest"
}
}
```
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
## Secondary Rule: `turbo run` vs `turbo`
**Always use `turbo run` when the command is written into code:**
```json
// package.json - ALWAYS "turbo run"
{
"scripts": {
"build": "turbo run build"
}
}
```
```yaml
# CI workflows - ALWAYS "turbo run"
- run: turbo run build --affected
```
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
## Quick Decision Trees
### "I need to configure a task"
```
Configure a task?
├─ Define task dependencies → references/configuration/tasks.md
├─ Lint/check-types (parallel + caching) → Use Transit Nodes pattern (see below)
├─ Specify build outputs → references/configuration/tasks.md#outputs
├─ Handle environment variables → references/environment/RULE.md
├─ Set up dev/watch tasks → references/configuration/tasks.md#persistent
├─ Package-specific config → references/configuration/RULE.md#package-configurations
└─ Global settings (cacheDir, daemon) → references/configuration/global-options.md
```
### "My cache isn't working"
```
Cache problems?
├─ Tasks run but outputs not restored → Missing `outputs` key
├─ Cache misses unexpectedly → references/caching/gotchas.md
├─ Need to debug hash inputs → Use --summarize or --dry
├─ Want to skip cache entirely → Use --force or cache: false
├─ Remote cache not working → references/caching/remote-cache.md
└─ Environment causing misses → references/environment/gotchas.md
```
### "I want to run only changed packages"
```
Run only what changed?
├─ Changed packages + dependents (RECOMMENDED) → turbo run build --affected
├─ Custom base branch → --affected --affected-base=origin/develop
├─ Manual git comparison → --filter=...[origin/main]
└─ See all filter options → references/filtering/RULE.md
```
**`--affected` is the primary way to run only changed packages.** It automatically compares against the default branch and includes dependents.
### "I want to filter packages"
```
Filter packages?
├─ Only changed packages → --affected (see above)
├─ By package name → --filter=web
├─ By directory → --filter=./apps/*
├─ Package + dependencies → --filter=web...
├─ Package + dependents → --filter=...web
└─ Complex combinations → references/filtering/patterns.md
```
### "Environment variables aren't working"
```
Environment issues?
├─ Vars not available at runtime → Strict mode filtering (default)
├─ Cache hits with wrong env → Var not in `env` key
├─ .env changes not causing rebuilds → .env not in `inputs`
├─ CI variables missing → references/environment/gotchas.md
└─ Framework vars (NEXT_PUBLIC_*) → Auto-included via inference
```
### "I need to set up CI"
```
CI setup?
├─ GitHub Actions → references/ci/github-actions.md
├─ Vercel deployment → references/ci/vercel.md
├─ Remote cache in CI → references/caching/remote-cache.md
├─ Only build changed packages → --affected flag
├─ Skip unnecessary builds → turbo-ignore (references/cli/commands.md)
└─ Skip container setup when no changes → turbo-ignore
```
### "I want to watch for changes during development"
```
Watch mode?
├─ Re-run tasks on change → turbo watch (references/watch/RULE.md)
├─ Dev servers with dependencies → Use `with` key (references/configuration/tasks.md#with)
├─ Restart dev server on dep change → Use `interruptible: true`
└─ Persistent dev tasks → Use `persistent: true`
```
### "I need to create/structure a package"
```
Package creation/structure?
├─ Create an internal package → references/best-practices/packages.md
├─ Repository structure → references/best-practices/structure.md
├─ Dependency management → references/best-practices/dependencies.md
├─ Best practices overview → references/best-practices/RULE.md
├─ JIT vs Compiled packages → references/best-practices/packages.md#compilation-strategies
└─ Sharing code between apps → references/best-practices/RULE.md#package-types
```
### "How should I structure my monorepo?"
```
Monorepo structure?
├─ Standard layout (apps/, packages/) → references/best-practices/RULE.md
├─ Package types (apps vs libraries) → references/best-practices/RULE.md#package-types
├─ Creating internal packages → references/best-practices/packages.md
├─ TypeScript configuration → references/best-practices/structure.md#typescript-configuration
├─ ESLint configuration → references/best-practices/structure.md#eslint-configuration
├─ Dependency management → references/best-practices/dependencies.md
└─ Enforce package boundaries → references/boundaries/RULE.md
```
### "I want to enforce architectural boundaries"
```
Enforce boundaries?
├─ Check for violations → turbo boundaries
├─ Tag packages → references/boundaries/RULE.md#tags
├─ Restrict which packages can import others → references/boundaries/RULE.md#rule-types
└─ Prevent cross-package file imports → references/boundaries/RULE.md
```
## Critical Anti-Patterns
### Using `turbo` Shorthand in Code
**`turbo run` is recommended in package.json scripts and CI pipelines.** The shorthand `turbo <task>` is intended for interactive terminal use.
```json
// WRONG - using shorthand in package.json
{
"scripts": {
"build": "turbo build",
"dev": "turbo dev"
}
}
// CORRECT
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
```yaml
# WRONG - using shorthand in CI
- run: turbo build --affected
# CORRECT
- run: turbo run build --affected
```
### Root Scripts Bypassing Turbo
Root `package.json` scripts MUST delegate to `turbo run`, not run tasks directly.
```json
// WRONG - bypasses turbo entirely
{
"scripts": {
"build": "bun build",
"dev": "bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
### Using `&&` to Chain Turbo Tasks
Don't chain turbo tasks with `&&`. Let turbo orchestrate.
```json
// WRONG - turbo task not using turbo run
{
"scripts": {
"changeset:publish": "bun build && changeset publish"
}
}
// CORRECT
{
"scripts": {
"changeset:publish": "turbo run build && changeset publish"
}
}
```
### `prebuild` Scripts That Manually Build Dependencies
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
```json
// WRONG - manually building dependencies
{
"scripts": {
"prebuild": "cd ../../packages/types && bun run build && cd ../utils && bun run build",
"build": "next build"
}
}
```
**However, the fix depends on whether workspace dependencies are declared:**
1. **If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
2. **If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
- Then remove the `prebuild` script
```json
// CORRECT - declare dependency, let turbo handle build order
// package.json
{
"dependencies": {
"@repo/types": "workspace:*",
"@repo/utils": "workspace:*"
},
"scripts": {
"build": "next build"
}
}
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
```
**Key insight:** `^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
### Overly Broad `globalDependencies`
`globalDependencies` affects ALL tasks in ALL packages via the **global hash** — tasks cannot opt out of specific files, even with negation globs in `inputs`. Be specific.
```json
// WRONG - heavy hammer, affects all hashes
{
"globalDependencies": ["**/.env.*local"]
}
// BETTER - move to task-level inputs
{
"globalDependencies": [".env"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
```
With `futureFlags.globalConfiguration`, this problem is reduced because `global.inputs` files are folded into each task's inputs (not the global hash). Tasks can exclude specific files:
```json
// BEST - global.inputs with per-task exclusion
{
"futureFlags": { "globalConfiguration": true },
"global": {
"inputs": [".env"]
},
"tasks": {
"build": { "outputs": ["dist/**"] },
"lint": {
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env"]
}
}
}
```
### Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks": {
"build": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"test": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"dev": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"cache": false,
"persistent": true
}
}
}
// BETTER - use globalEnv and globalDependencies for shared config
{
"globalEnv": ["API_URL", "DATABASE_URL"],
"globalDependencies": [".env*"],
"tasks": {
"build": {},
"test": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
**When to use global vs task-level:**
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
### NOT an Anti-Pattern: Large `env` Arrays
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
### Using `--parallel` Flag
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
```bash
# WRONG - bypasses dependency graph
turbo run lint --parallel
# CORRECT - configure tasks to allow parallel execution
# In turbo.json, set dependsOn appropriately (or use transit nodes)
turbo run lint
```
### Package-Specific Task Overrides in Root turbo.json
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
```json
// WRONG - root turbo.json with many package-specific overrides
{
"tasks": {
"test": { "dependsOn": ["build"] },
"@repo/web#test": { "outputs": ["coverage/**"] },
"@repo/api#test": { "outputs": ["coverage/**"] },
"@repo/utils#test": { "outputs": [] },
"@repo/cli#test": { "outputs": [] },
"@repo/core#test": { "outputs": [] }
}
}
// CORRECT - use Package Configurations
// Root turbo.json - base config only
{
"tasks": {
"test": { "dependsOn": ["build"] }
}
}
// packages/web/turbo.json - package-specific override
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
// packages/api/turbo.json
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
```
**Benefits of Package Configurations:**
- Keeps configuration close to the code it affects
- Root turbo.json stays clean and focused on base patterns
- Easier to understand what's special about each package
- Works with `$TURBO_EXTENDS$` to inherit + extend arrays
**When to use `package#task` in root:**
- Single package needs a unique dependency (e.g., `"deploy": { "dependsOn": ["web#build"] }`)
- Temporary override while migrating
See `references/configuration/RULE.md#package-configurations` for full details.
### Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
```json
// WRONG - traversing out of package
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
}
}
}
// CORRECT - use $TURBO_ROOT$ for repo root
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
}
}
}
```
### Missing `outputs` for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG: build produces files but they're not cached
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
// CORRECT: build outputs are cached
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
```
Common outputs by framework:
- Next.js: `[".next/**", "!.next/cache/**"]`
- Vite/Rollup: `["dist/**"]`
- tsc: `["dist/**"]` or custom `outDir`
**TypeScript `--noEmit` can still produce cache files:**
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
```json
// If tsconfig has incremental: true, tsc --noEmit produces cache files
{
"tasks": {
"typecheck": {
"outputs": ["node_modules/.cache/tsbuildinfo.json"] // or wherever tsBuildInfoFile points
}
}
}
```
To determine correct outputs for TypeScript tasks:
1. Check if `incremental` or `composite` is enabled in tsconfig
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
3. If no incremental mode, `tsc --noEmit` produces no files
### `^build` vs `build` Confusion
```json
{
"tasks": {
// ^build = run build in DEPENDENCIES first (other packages this one imports)
"build": {
"dependsOn": ["^build"]
},
// build (no ^) = run build in SAME PACKAGE first
"test": {
"dependsOn": ["build"]
},
// pkg#task = specific package's task
"deploy": {
"dependsOn": ["web#build"]
}
}
}
```
### Environment Variables Not Hashed
```json
// WRONG: API_URL changes won't cause rebuilds
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}
// CORRECT: API_URL changes invalidate cache
{
"tasks": {
"build": {
"outputs": ["dist/**"],
"env": ["API_URL", "API_KEY"]
}
}
}
```
### `.env` Files Not in Inputs
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
```json
// WRONG: .env changes don't invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
// CORRECT: .env file changes invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.*"]
}
}
}
```
### Root `.env` File in Monorepo
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
```
// WRONG - root .env affects all packages implicitly
my-monorepo/
├── .env # Which packages use this?
├── apps/
│ ├── web/
│ └── api/
└── packages/
// CORRECT - .env files in packages that need them
my-monorepo/
├── apps/
│ ├── web/
│ │ └── .env # Clear: web needs DATABASE_URL
│ └── api/
│ └── .env # Clear: api needs API_KEY
└── packages/
```
**Problems with root `.env`:**
- Unclear which packages consume which variables
- All packages get all variables (even ones they don't need)
- Cache invalidation is coarse-grained (root .env change invalidates everything)
- Security risk: packages may accidentally access sensitive vars meant for others
- Bad habits start small — starter templates should model correct patterns
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
### Strict Mode Filtering CI Variables
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
```json
// If CI scripts need GITHUB_TOKEN but it's not in env:
{
"globalPassThroughEnv": ["GITHUB_TOKEN", "CI"],
"tasks": { ... }
}
```
Or use `--env-mode=loose` (not recommended for production).
### Shared Code in Apps (Should Be a Package)
```
// WRONG: Shared code inside an app
apps/
web/
shared/ # This breaks monorepo principles!
utils.ts
// CORRECT: Extract to a package
packages/
utils/
src/utils.ts
```
### Accessing Files Across Package Boundaries
```typescript
// WRONG: Reaching into another package's internals
import { Button } from "../../packages/ui/src/button";
// CORRECT: Install and import properly
import { Button } from "@repo/ui/button";
```
### Too Many Root Dependencies
```json
// WRONG: App dependencies in root
{
"dependencies": {
"react": "^18",
"next": "^14"
}
}
// CORRECT: Only repo tools in root
{
"devDependencies": {
"turbo": "latest"
}
}
```
## Common Task Configurations
### Standard Build Pipeline
```json
{
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
### Dev Task with `^dev` Pattern (for `turbo watch`)
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
```json
// Root turbo.json
{
"tasks": {
"dev": {
"dependsOn": ["^dev"],
"cache": false,
"persistent": false // Packages have one-shot dev scripts
}
}
}
// Package turbo.json (apps/web/turbo.json)
{
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true // Apps run long-running dev servers
}
}
}
```
**Why this works:**
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
```json
{
"tasks": {
"prepare": {
"dependsOn": ["^prepare"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["prepare"],
"cache": false,
"persistent": true
}
}
}
```
### Transit Nodes for Parallel Tasks with Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
**The problem with `dependsOn: ["^taskname"]`:**
- Forces sequential execution (slow)
**The problem with `dependsOn: []` (no dependencies):**
- Allows parallel execution (fast)
- But cache is INCORRECT - changing dependency source won't invalidate cache
**Transit Nodes solve both:**
```json
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"my-task": { "dependsOn": ["transit"] }
}
}
```
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
### With Environment Variables
```json
{
"globalEnv": ["NODE_ENV"],
"globalDependencies": [".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "DATABASE_URL"]
}
}
}
```
With `futureFlags.globalConfiguration`, the same config moves global settings under `global` — and `.env` becomes a per-task input instead of a global hash input:
```json
{
"futureFlags": { "globalConfiguration": true },
"global": {
"env": ["NODE_ENV"],
"inputs": [".env"]
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "DATABASE_URL"]
}
}
}
```
## Reference Index
### Configuration
| File | Purpose |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [configuration/RULE.md](./references/configuration/RULE.md) | turbo.json overview, Package Configurations |
| [configuration/tasks.md](./references/configuration/tasks.md) | dependsOn, outputs, inputs, env, cache, persistent |
| [configuration/global-options.md](./references/configuration/global-options.md) | globalEnv, globalDependencies, global key, futureFlags, cacheDir, envMode |
| [configuration/gotchas.md](./references/configuration/gotchas.md) | Common configuration mistakes |
### Caching
| File | Purpose |
| --------------------------------------------------------------- | -------------------------------------------- |
| [caching/RULE.md](./references/caching/RULE.md) | How caching works, hash inputs |
| [caching/remote-cache.md](./references/caching/remote-cache.md) | Vercel Remote Cache, self-hosted, login/link |
| [caching/gotchas.md](./references/caching/gotchas.md) | Debugging cache misses, --summarize, --dry |
### Environment Variables
| File | Purpose |
| ------------------------------------------------------------- | ----------------------------------------- |
| [environment/RULE.md](./references/environment/RULE.md) | env, globalEnv, passThroughEnv |
| [environment/modes.md](./references/environment/modes.md) | Strict vs Loose mode, framework inference |
| [environment/gotchas.md](./references/environment/gotchas.md) | .env files, CI issues |
### Filtering
| File | Purpose |
| ----------------------------------------------------------- | ------------------------ |
| [filtering/RULE.md](./references/filtering/RULE.md) | --filter syntax overview |
| [filtering/patterns.md](./references/filtering/patterns.md) | Common filter patterns |
### CI/CD
| File | Purpose |
| --------------------------------------------------------- | ------------------------------- |
| [ci/RULE.md](./references/ci/RULE.md) | General CI principles |
| [ci/github-actions.md](./references/ci/github-actions.md) | Complete GitHub Actions setup |
| [ci/vercel.md](./references/ci/vercel.md) | Vercel deployment, turbo-ignore |
| [ci/patterns.md](./references/ci/patterns.md) | --affected, caching strategies |
### CLI
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------- |
| [cli/RULE.md](./references/cli/RULE.md) | turbo run basics |
| [cli/commands.md](./references/cli/commands.md) | turbo run flags, turbo-ignore, other commands |
### Best Practices
| File | Purpose |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
| [best-practices/RULE.md](./references/best-practices/RULE.md) | Monorepo best practices overview |
| [best-practices/structure.md](./references/best-practices/structure.md) | Repository structure, workspace config, TypeScript/ESLint setup |
| [best-practices/packages.md](./references/best-practices/packages.md) | Creating internal packages, JIT vs Compiled, exports |
| [best-practices/dependencies.md](./references/best-practices/dependencies.md) | Dependency management, installing, version sync |
### Watch Mode
| File | Purpose |
| ------------------------------------------- | ----------------------------------------------- |
| [watch/RULE.md](./references/watch/RULE.md) | turbo watch, interruptible tasks, dev workflows |
### Boundaries (Experimental)
| File | Purpose |
| ----------------------------------------------------- | ----------------------------------------------------- |
| [boundaries/RULE.md](./references/boundaries/RULE.md) | Enforce package isolation, tag-based dependency rules |
## Source Documentation
This skill is based on the official Turborepo documentation at:
- Source: `apps/docs/content/docs/` in the Turborepo repository
- Live: https://turborepo.dev/docs
@@ -1,70 +0,0 @@
---
description: Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
---
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
## Workflow
### Step 1: Load turborepo skill
```
skill({ name: 'turborepo' })
```
### Step 2: Identify task type from user request
Analyze $ARGUMENTS to determine:
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
- **Task type**: new setup, debugging, optimization, or implementation
Use decision trees in SKILL.md to select the relevant reference files.
### Step 3: Read relevant reference files
Based on task type, read from `references/<topic>/`:
| Task | Files to Read |
| -------------------- | ------------------------------------------------------- |
| Configure turbo.json | `configuration/RULE.md` + `configuration/tasks.md` |
| Debug cache issues | `caching/gotchas.md` |
| Set up remote cache | `caching/remote-cache.md` |
| Filter packages | `filtering/RULE.md` + `filtering/patterns.md` |
| Environment problems | `environment/gotchas.md` + `environment/modes.md` |
| Set up CI | `ci/RULE.md` + `ci/github-actions.md` or `ci/vercel.md` |
| CLI usage | `cli/commands.md` |
### Step 4: Execute task
Apply Turborepo-specific patterns from references to complete the user's request.
**CRITICAL - When creating tasks/scripts/pipelines:**
1. **DO NOT create Root Tasks** - Always create package tasks
2. Add scripts to each relevant package's `package.json` (e.g., `apps/web/package.json`, `packages/ui/package.json`)
3. Register the task in root `turbo.json`
4. Root `package.json` only contains `turbo run <task>` - never actual task logic
**Other things to verify:**
- `outputs` defined for cacheable tasks
- `dependsOn` uses correct syntax (`^task` vs `task`)
- Environment variables in `env` key
- `.env` files in `inputs` if used
- Use `turbo run` (not `turbo`) in package.json and CI
### Step 5: Summarize
```
=== Turborepo Task Complete ===
Topic: <configuration|caching|filtering|environment|ci|cli>
Files referenced: <reference files consulted>
<brief summary of what was done>
```
<user-request>
$ARGUMENTS
</user-request>
@@ -1,241 +0,0 @@
# Monorepo Best Practices
Essential patterns for structuring and maintaining a healthy Turborepo monorepo.
## Repository Structure
### Standard Layout
```
my-monorepo/
├── apps/ # Application packages (deployable)
│ ├── web/
│ ├── docs/
│ └── api/
├── packages/ # Library packages (shared code)
│ ├── ui/
│ ├── utils/
│ └── config-*/ # Shared configs (eslint, typescript, etc.)
├── package.json # Root package.json (minimal deps)
├── turbo.json # Turborepo configuration
├── pnpm-workspace.yaml # (pnpm) or workspaces in package.json
└── pnpm-lock.yaml # Lockfile (required)
```
### Key Principles
1. **`apps/` for deployables**: Next.js sites, APIs, CLIs - things that get deployed
2. **`packages/` for libraries**: Shared code consumed by apps or other packages
3. **One purpose per package**: Each package should do one thing well
4. **No nested packages**: Don't put packages inside packages
## Package Types
### Application Packages (`apps/`)
- **Deployable**: These are the "endpoints" of your package graph
- **Not installed by other packages**: Apps shouldn't be dependencies of other packages
- **No shared code**: If code needs sharing, extract to `packages/`
```json
// apps/web/package.json
{
"name": "web",
"private": true,
"dependencies": {
"@repo/ui": "workspace:*",
"next": "latest"
}
}
```
### Library Packages (`packages/`)
- **Shared code**: Utilities, components, configs
- **Namespaced names**: Use `@repo/` or `@yourorg/` prefix
- **Clear exports**: Define what the package exposes
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
}
}
```
## Package Compilation Strategies
### Just-in-Time (Simplest)
Export TypeScript directly; let the app's bundler compile it.
```json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx"
}
}
```
**Pros**: Zero build config, instant changes
**Cons**: Can't cache builds, requires app bundler support
### Compiled (Recommended for Libraries)
Package compiles itself with `tsc` or bundler.
```json
{
"name": "@repo/ui",
"exports": {
"./button": {
"types": "./src/button.tsx",
"default": "./dist/button.js"
}
},
"scripts": {
"build": "tsc"
}
}
```
**Pros**: Cacheable by Turborepo, works everywhere
**Cons**: More configuration
## Dependency Management
### Install Where Used
Install dependencies in the package that uses them, not the root.
```bash
# Good: Install in the package that needs it
pnpm add lodash --filter=@repo/utils
# Avoid: Installing everything at root
pnpm add lodash -w # Only for repo-level tools
```
### Root Dependencies
Only these belong in root `package.json`:
- `turbo` - The build system
- `husky`, `lint-staged` - Git hooks
- Repository-level tooling
### Internal Dependencies
Use workspace protocol for internal packages:
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" }
// npm/yarn
{ "@repo/ui": "*" }
```
## Exports Best Practices
### Use `exports` Field (Not `main`)
```json
{
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx",
"./utils": "./src/utils.ts"
}
}
```
### Avoid Barrel Files
Don't create `index.ts` files that re-export everything:
```typescript
// BAD: packages/ui/src/index.ts
export * from './button';
export * from './card';
export * from './modal';
// ... imports everything even if you need one thing
// GOOD: Direct exports in package.json
{
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
}
}
```
### Namespace Your Packages
```json
// Good
{ "name": "@repo/ui" }
{ "name": "@acme/utils" }
// Avoid (conflicts with npm registry)
{ "name": "ui" }
{ "name": "utils" }
```
## Common Anti-Patterns
### Accessing Files Across Package Boundaries
```typescript
// BAD: Reaching into another package
import { Button } from "../../packages/ui/src/button";
// GOOD: Install and import properly
import { Button } from "@repo/ui/button";
```
### Shared Code in Apps
```
// BAD
apps/
web/
shared/ # This should be a package!
utils.ts
// GOOD
packages/
utils/ # Proper shared package
src/utils.ts
```
### Too Many Root Dependencies
```json
// BAD: Root has app dependencies
{
"dependencies": {
"react": "^18",
"next": "^14",
"lodash": "^4"
}
}
// GOOD: Root only has repo tools
{
"devDependencies": {
"turbo": "latest",
"husky": "latest"
}
}
```
## See Also
- [structure.md](./structure.md) - Detailed repository structure patterns
- [packages.md](./packages.md) - Creating and managing internal packages
- [dependencies.md](./dependencies.md) - Dependency management strategies
@@ -1,246 +0,0 @@
# Dependency Management
Best practices for managing dependencies in a Turborepo monorepo.
## Core Principle: Install Where Used
Dependencies belong in the package that uses them, not the root.
```bash
# Good: Install in specific package
pnpm add react --filter=@repo/ui
pnpm add next --filter=web
# Avoid: Installing in root
pnpm add react -w # Only for repo-level tools!
```
## Benefits of Local Installation
### 1. Clarity
Each package's `package.json` lists exactly what it needs:
```json
// packages/ui/package.json
{
"dependencies": {
"react": "^18.0.0",
"class-variance-authority": "^0.7.0"
}
}
```
### 2. Flexibility
Different packages can use different versions when needed:
```json
// packages/legacy-ui/package.json
{ "dependencies": { "react": "^17.0.0" } }
// packages/ui/package.json
{ "dependencies": { "react": "^18.0.0" } }
```
### 3. Better Caching
Installing in root changes workspace lockfile, invalidating all caches.
### 4. Pruning Support
`turbo prune` can remove unused dependencies for Docker images.
## What Belongs in Root
Only repository-level tools:
```json
// Root package.json
{
"devDependencies": {
"turbo": "latest",
"husky": "^8.0.0",
"lint-staged": "^15.0.0"
}
}
```
**NOT** application dependencies:
- react, next, express
- lodash, axios, zod
- Testing libraries (unless truly repo-wide)
## Installing Dependencies
### Single Package
```bash
# pnpm
pnpm add lodash --filter=@repo/utils
# npm
npm install lodash --workspace=@repo/utils
# yarn
yarn workspace @repo/utils add lodash
# bun
cd packages/utils && bun add lodash
```
### Multiple Packages
```bash
# pnpm
pnpm add vitest --save-dev --filter=web --filter=@repo/ui
# npm
npm install vitest --save-dev --workspace=web --workspace=@repo/ui
# yarn (v2+)
yarn workspaces foreach -R --from '{web,@repo/ui}' add vitest --dev
```
### Internal Packages
```bash
# pnpm
pnpm add @repo/ui --filter=web
# This updates package.json:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
```
## Keeping Versions in Sync
### Option 1: Tooling
```bash
# syncpack - Check and fix version mismatches
npx syncpack list-mismatches
npx syncpack fix-mismatches
# manypkg - Similar functionality
npx @manypkg/cli check
npx @manypkg/cli fix
# sherif - Rust-based, very fast
npx sherif
```
### Option 2: Package Manager Commands
```bash
# pnpm - Update everywhere
pnpm up --recursive typescript@latest
# npm - Update in all workspaces
npm install typescript@latest --workspaces
```
### Option 3: pnpm Catalogs (pnpm 9.5+)
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^18.2.0
typescript: ^5.3.0
```
```json
// Any package.json
{
"dependencies": {
"react": "catalog:" // Uses version from catalog
}
}
```
## Internal vs External Dependencies
### Internal (Workspace)
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" }
// npm/yarn
{ "@repo/ui": "*" }
```
Turborepo understands these relationships and orders builds accordingly.
### External (npm Registry)
```json
{ "lodash": "^4.17.21" }
```
Standard semver versioning from npm.
## Peer Dependencies
For library packages that expect the consumer to provide dependencies:
```json
// packages/ui/package.json
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"react": "^18.0.0", // For development/testing
"react-dom": "^18.0.0"
}
}
```
## Common Issues
### "Module not found"
1. Check the dependency is installed in the right package
2. Run `pnpm install` / `npm install` to update lockfile
3. Check exports are defined in the package
### Version Conflicts
Packages can use different versions - this is a feature, not a bug. But if you need consistency:
1. Use tooling (syncpack, manypkg)
2. Use pnpm catalogs
3. Create a lint rule
### Hoisting Issues
Some tools expect dependencies in specific locations. Use package manager config:
```yaml
# .npmrc (pnpm)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
```
## Lockfile
**Required** for:
- Reproducible builds
- Turborepo dependency analysis
- Cache correctness
```bash
# Commit your lockfile!
git add pnpm-lock.yaml # or package-lock.json, yarn.lock
```
@@ -1,335 +0,0 @@
# Creating Internal Packages
How to create and structure internal packages in your monorepo.
## Package Creation Checklist
1. Create directory in `packages/`
2. Add `package.json` with name and exports
3. Add source code in `src/`
4. Add `tsconfig.json` if using TypeScript
5. Install as dependency in consuming packages
6. Run package manager install to update lockfile
## Package Compilation Strategies
### Just-in-Time (JIT)
Export TypeScript directly. The consuming app's bundler compiles it.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
},
"scripts": {
"lint": "eslint .",
"check-types": "tsc --noEmit"
}
}
```
**When to use:**
- Apps use modern bundlers (Turbopack, webpack, Vite)
- You want minimal configuration
- Build times are acceptable without caching
**Limitations:**
- No Turborepo cache for the package itself
- Consumer must support TypeScript compilation
- Can't use TypeScript `paths` (use Node.js subpath imports instead)
### Compiled
Package handles its own compilation.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": {
"types": "./src/button.tsx",
"default": "./dist/button.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
```
```json
// packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
**When to use:**
- You want Turborepo to cache builds
- Package will be used by non-bundler tools
- You need maximum compatibility
**Remember:** Add `dist/**` to turbo.json outputs!
## Defining Exports
### Multiple Entrypoints
```json
{
"exports": {
".": "./src/index.ts", // @repo/ui
"./button": "./src/button.tsx", // @repo/ui/button
"./card": "./src/card.tsx", // @repo/ui/card
"./hooks": "./src/hooks/index.ts" // @repo/ui/hooks
}
}
```
### Conditional Exports (Compiled)
```json
{
"exports": {
"./button": {
"types": "./src/button.tsx",
"import": "./dist/button.mjs",
"require": "./dist/button.cjs",
"default": "./dist/button.js"
}
}
}
```
## Installing Internal Packages
### Add to Consuming Package
```json
// apps/web/package.json
{
"dependencies": {
"@repo/ui": "workspace:*" // pnpm/bun
// "@repo/ui": "*" // npm/yarn
}
}
```
### Run Install
```bash
pnpm install # Updates lockfile with new dependency
```
### Import and Use
```typescript
// apps/web/src/page.tsx
import { Button } from '@repo/ui/button';
export default function Page() {
return <Button>Click me</Button>;
}
```
## One Purpose Per Package
### Good Examples
```
packages/
├── ui/ # Shared UI components
├── utils/ # General utilities
├── auth/ # Authentication logic
├── database/ # Database client/schemas
├── eslint-config/ # ESLint configuration
├── typescript-config/ # TypeScript configuration
└── api-client/ # Generated API client
```
### Avoid Mega-Packages
```
// BAD: One package for everything
packages/
└── shared/
├── components/
├── utils/
├── hooks/
├── types/
└── api/
// GOOD: Separate by purpose
packages/
├── ui/ # Components
├── utils/ # Utilities
├── hooks/ # React hooks
├── types/ # Shared TypeScript types
└── api-client/ # API utilities
```
## Config Packages
### TypeScript Config
```json
// packages/typescript-config/package.json
{
"name": "@repo/typescript-config",
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./library.json": "./library.json"
}
}
```
### ESLint Config
```json
// packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"exports": {
"./base": "./base.js",
"./next": "./next.js"
},
"dependencies": {
"eslint": "^8.0.0",
"eslint-config-next": "latest"
}
}
```
## Common Mistakes
### Forgetting to Export
```json
// BAD: No exports defined
{
"name": "@repo/ui"
}
// GOOD: Clear exports
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx"
}
}
```
### Wrong Workspace Syntax
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" } // Correct
// npm/yarn
{ "@repo/ui": "*" } // Correct
{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn!
```
### Missing from turbo.json Outputs
```json
// Package builds to dist/, but turbo.json doesn't know
{
"tasks": {
"build": {
"outputs": [".next/**"] // Missing dist/**!
}
}
}
// Correct
{
"tasks": {
"build": {
"outputs": [".next/**", "dist/**"]
}
}
}
```
## TypeScript Best Practices
### Use Node.js Subpath Imports (Not `paths`)
TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+).
**JIT Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./src/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.ts"; // Uses .ts extension
```
**Compiled Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./dist/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.js"; // Uses .js extension
```
### Use `tsc` for Internal Packages
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
### Enable Go-to-Definition
For Compiled Packages, enable declaration maps:
```json
// tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true
}
}
```
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
### No Root tsconfig.json Needed
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
### Avoid TypeScript Project References
They add complexity and another caching layer. Turborepo handles dependencies better.

Some files were not shown because too many files have changed in this diff Show More