Compare commits

...
345 Commits
Author SHA1 Message Date
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
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
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
z 3595ad2644 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
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
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 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 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
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
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 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 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 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
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
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
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
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
Hanzo AI edd24e3f31 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-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 3fe306224b 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
hanzo-dev d1ae703521 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 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Hanzo Dev c8a4e2db16 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
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
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-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 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 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 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
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
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
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
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
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
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
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
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 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-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 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-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 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
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
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 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 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 f5a82a152d 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 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 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 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 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 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 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 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 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-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 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 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 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
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
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 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
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
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 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 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 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 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 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 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 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 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 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-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
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
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
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
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
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 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-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 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 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-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 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
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
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
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 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
dev 5339d28657 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
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
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 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
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
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 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 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
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
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 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 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 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 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 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
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
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 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 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
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
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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
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
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 3e7db2c7e1 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 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 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 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 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
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
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
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
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 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-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 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 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-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 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-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 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
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
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 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 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 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 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 bd2641feb4 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 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 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-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
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
Hanzo Dev 03a6167613 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
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
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
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 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 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 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
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
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
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
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
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
Hanzo AI cfef594484 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
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
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 bd68c0ee23 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 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 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 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 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 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 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
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
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
zeekay 9144dc6aba 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
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
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
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 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 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 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
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
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 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 38c035022f 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 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 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 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 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 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 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 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 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 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 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 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 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 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 69c2b3535d 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
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 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
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
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 c4ba6846b8 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 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 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 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 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
Hanzo AI b78f48e0b3 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
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
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 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 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
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
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
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 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 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 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 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 937ef0f0d8 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
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
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
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 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 d4c361f3b5 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 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 8c5d4e5902 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 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 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 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 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 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 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 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 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 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
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
zeekay 55409ef473 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
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 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 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 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
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 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 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 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 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 4cfcd1f779 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
hanzo-dev fecaf7d49d 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 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
z bc7f92665e 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
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
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 f28d79673a 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 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 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
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
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 f7a90ffe65 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 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
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
zeekay bd933e5984 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 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 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 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
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
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 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 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
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
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 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 725beb1d98 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
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 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
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 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 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 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 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
Hanzo AI b4274bda4a 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 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
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 c32bb30154 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 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 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 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 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 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 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
607 changed files with 96749 additions and 2503 deletions
+4
View File
@@ -9,6 +9,10 @@ NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
# (fork a starter → customize by prompt in the builder). Default: production.
NEXT_PUBLIC_APP_URL=https://hanzo.app
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="console2">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">console2</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+40 -13
View File
@@ -1,11 +1,21 @@
name: Build Docker Image
# Builds + pushes ghcr.io/hanzoai/console2 on the self-hosted ARC runner. ONE
# brand-agnostic image serves every brand: console2 resolves the brand at RUNTIME
# Builds + pushes ghcr.io/hanzoai/console on the self-hosted ARC runner. ONE
# brand-agnostic image serves every brand: console resolves the brand at RUNTIME
# from the request hostname (console.hanzo.ai → hanzo, console.lux.cloud → lux,
# console.zoo.cloud → zoo; src/config/index.ts), and /v1 is same-origin per host.
# So NO NEXT_PUBLIC_* are baked — baking them would pin the image to one brand.
# Tags: SEMVER ONLY (no sha, no :latest) — a `v*` git tag publishes that exact
# version; a main push publishes `v<package.json version>` (bump to release).
#
# Build muscle: RAW `docker buildx build` on the host builder — the canonical
# hanzoai/ci pattern. We deliberately do NOT use docker/setup-buildx-action +
# docker/build-push-action: that pair spins up an EPHEMERAL buildkit container
# that cannot see the host's image cache, so it re-pulled `node:22-alpine` from
# Docker Hub every run and tripped the unauthenticated 429 pull-rate limit (and
# its build-summary artifact upload hit the Actions storage quota). The host
# builder reuses the cached base layer instead — no re-pull, no artifact upload.
# The Dockerfile uses the ECR Public Docker-library mirror for the Node base image
# so a cold runner does not depend on Docker Hub's unauthenticated pull budget.
on:
push:
branches: [main]
@@ -31,21 +41,38 @@ jobs:
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
echo "tag=v$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
# node is NOT on the ARC runner PATH — resolve the version with grep/sed
# (a missing `node` silently produced the tag `:v` and a 404 deploy).
ver=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
[ -n "$ver" ] || { echo "could not resolve version from package.json"; exit 1; }
echo "tag=v${ver}" >> "$GITHUB_OUTPUT"
fi
- uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
provenance: false
sbom: false
tags: |
ghcr.io/hanzoai/console2:${{ steps.ver.outputs.tag }}
- name: Ensure base image (reuse host cache)
# No-op on a warm runner (the host builder reuses the cached base). Only a
# cold runner pulls. Use the same ECR Public Docker-library mirror as the
# Dockerfile to avoid Docker Hub's unauthenticated pull-rate limits.
run: |
set -u
base=public.ecr.aws/docker/library/node:24-alpine
if docker image inspect "$base" >/dev/null 2>&1; then
echo "base $base already cached on runner"; exit 0
fi
for i in 1 2 3 4 5; do
if docker pull "$base"; then exit 0; fi
echo "pull failed (attempt $i/5) — backing off"; sleep $((i * 30))
done
echo "could not pull $base after retries"; exit 1
- name: Build & push (host builder — reuses base cache, no artifact upload)
run: |
set -euo pipefail
docker buildx build \
--platform linux/amd64 \
--push \
-t ghcr.io/hanzoai/console:${{ steps.ver.outputs.tag }} \
-f Dockerfile .
+5
View File
@@ -15,3 +15,8 @@ next-env.d.ts
.vscode/
.idea/
*.log
e2e/screenshots/
e2e-shots/
test-results/
playwright-report/
.claude/
-23
View File
@@ -1,23 +0,0 @@
# AGENTS — console2
Read [LLM.md](./LLM.md) first; it is the canonical design doc. Highlights for
agents working here:
- **Stack:** Next.js 15 (app router) + @hanzo/gui (npm), consumed at runtime via
`transpilePackages` (the `@hanzogui/next-plugin` is broken on npm). Next 15
(not 14) because @hanzo/gui needs React 19. Pin patch versions; never lazily
major-bump.
- **Gui style props:** the v5 config is `onlyShorthandStyleProps` — use
shorthands (`p`, `px`, `bg`, `items`, `justify`, `self`, `rounded`, `minH`),
never longhands (`padding`, `backgroundColor`, …). Keep `tsc` clean.
- **One way:** all backend calls go through `src/lib/api` (never raw `fetch`);
all selects/inputs through `src/components/ui/Field.tsx`; all nav/routing
through the registry in `src/lib/products`.
- **Extensibility:** add a cloud product by appending a `ProductModule` to
`src/lib/products/registry.tsx` and writing its module component — do not add
per-product routes or touch the shell.
- **Auth:** Hanzo IAM (OIDC) via `@hanzo/iam-js-sdk`; session cookie minted by
the backend at `/v1/signin`. Never store credentials client-side.
- **Boundaries:** frontend only. No DB. No Docker builds locally (CI/CD builds
images). No secrets in the repo — config is `NEXT_PUBLIC_*` only.
- **Verify:** `npm run typecheck` and `npm run build` must pass. Show output.
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+18 -17
View File
@@ -1,29 +1,30 @@
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM node:22-alpine AS deps
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose
# platform/optional packages (e.g. react-native-worklets) resolve differently
# across npm versions, so a lockfile generated by one npm fails `npm ci` under
# another (EUSAGE "Missing: react-native-worklets@... from lock file"). install
# reconciles the tree deterministically for the build platform.
RUN npm install --no-audit --no-fund
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
# created node_modules (the 'next not found' cause — the install's own `test -f next`
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
# before install means node_modules is created by the LAST RUNs and nothing clobbers
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
COPY . .
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
RUN mkdir -p public
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose platform/
# optional packages resolve differently across npm versions, so a lockfile generated
# by one npm fails `npm ci` under another. install reconciles the tree for the
# build platform; retry-hardened against registry throttling.
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that
# so nothing brand-specific is baked.
ENV NEXT_TELEMETRY_DISABLED=1
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
# (exit 137); cap the heap generously (chat uses 4096).
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
RUN npm run build
FROM node:22-alpine AS runner
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
RUN addgroup -S app && adduser -S app -G app
@@ -34,4 +35,4 @@ COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.mjs ./next.config.mjs
USER app
EXPOSE 4000
CMD ["npm", "run", "start"]
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
+1733 -13
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
Hanzo Cloud Console (console2)
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
------------------------------------------------------------------------
Third-party attribution
------------------------------------------------------------------------
Observe surface — Langfuse (MIT License)
The console's Observe screens (Traces, Trace detail with the span-tree /
latency-waterfall, Observations, Sessions, Scores, Score Configs, Datasets,
Dataset Items, Dataset Runs / Experiments, and the observability Dashboards /
Metrics) reproduce the SCREEN LAYOUT AND USER FLOWS of Langfuse's observability
product.
This is a clean-room reimplementation in our own code (React + @hanzo/gui),
wired to the native Hanzo Cloud /v1/evals contract. No Langfuse source code is
copied. Only the MIT-licensed layout/flow concepts inform the design; the
Langfuse EE / commercial ("ee") code is neither used nor referenced.
Langfuse — https://github.com/langfuse/langfuse
Copyright (c) Langfuse GmbH
Licensed under the MIT License.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="console2" width="880"></p>
# Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all Hanzo cloud products. Built on
+54 -5
View File
@@ -3,18 +3,67 @@
import { use } from 'react'
import { notFound } from 'next/navigation'
import { matchRoute } from '~/lib/products/match'
import { resolveView, isAdminRoute } from '~/lib/products/match'
import { findEntry } from '~/lib/products/registry'
import { useIsGlobalAdmin } from '~/lib/auth/admin'
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
import { ProductSubpageModule } from '~/components/products/subpage/ProductSubpageModule'
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
import { ProductErrorBoundary } from '~/components/errors/ProductErrorBoundary'
/**
* Catch-all product route. Resolves the module + route from the registry and
* renders its component. Adding a product anywhere in the registry makes its
* routes live here — no per-product page files.
*
* Two honest gates on top of the resolver:
* - A known product SUB-PAGE (a declared specific or a uniform base sub-page:
* Overview · Settings · Status · Logs · Metrics) with no backend route yet
* renders a placeholder stub — never a 404, never a fabricated surface.
* - A CUSTOMER (non-global-admin) reaching an admin-only surface (cross-tenant
* IAM/KMS, provider + routing config) gets a graceful "managed by Hanzo" notice
* instead of the module's hostile 403 red error. Access is enforced
* server-side regardless.
*
* The resolved module renders inside `ProductErrorBoundary`: modules mount
* client-only (the authed shell renders a loader during SSR), so a throw in one
* module's first render had no boundary and white-screened the whole console
* ("Application error: a client-side exception") on a direct load / refresh. The
* boundary keeps the shell + nav and shows an honest, retryable card instead —
* one place, every product route (DRY).
*/
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = use(params)
const matched = matchRoute(slug)
if (!matched) notFound()
const showAdmin = useIsGlobalAdmin()
const view = resolveView(slug)
const Component = matched.route.component
return <Component params={matched.params} />
if (view.kind === 'notfound') notFound()
if (!showAdmin && isAdminRoute(slug)) {
const entry = findEntry(slug[0])
if (entry && entry.kind === 'module') {
const seg = slug[1]
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
return <AdminManagedNotice entry={entry} subpage={subpage} />
}
}
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
// A uniform base sub-page (Status/Logs/Metrics/Settings) → the shared per-product
// sub-page system (real feed or honest state), inside the same error boundary so
// a data fetch that throws shows the retryable card, never a white screen.
if (view.kind === 'subpage')
return (
<ProductErrorBoundary resetKey={slug.join('/')}>
<ProductSubpageModule entry={view.entry} subpage={view.subpage} />
</ProductErrorBoundary>
)
const Component = view.matched.route.component
return (
<ProductErrorBoundary resetKey={slug.join('/')}>
<Component params={view.matched.params} />
</ProductErrorBoundary>
)
}
+75
View File
@@ -0,0 +1,75 @@
'use client'
/**
* Dashboard route error backstop (Next App Router).
*
* `ProductErrorBoundary` catches throws inside a resolved product module; this
* catches anything above it in the dashboard page tree (the resolver itself, a
* non-catch-all dashboard page). It renders in the layout's content slot, so the
* shell + nav stay mounted — never a white-screened "Application error". Next's
* `reset()` re-renders the segment; `notFound()`/`redirect()` are control flow and
* do not reach here.
*/
import { useEffect } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { isChunkLoadError, shouldReloadForChunk } from '~/components/errors/boundary-logic'
/** Shared once-per-window guard key (same as ProductErrorBoundary — never double-reload). */
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const chunk = isChunkLoadError(error)
useEffect(() => {
console.error('[console] dashboard route error:', error)
// A chunk skew self-heals: reload ONCE per window to pull the fresh HTML +
// current chunks (same recovery the product boundary does), so a stale-deploy
// crash at the segment level auto-recovers instead of stranding a manual card.
if (!chunk || typeof window === 'undefined') return
try {
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
const last = raw ? Number(raw) : null
if (shouldReloadForChunk(Date.now(), last)) {
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
window.location.reload()
}
} catch {
/* sessionStorage blocked (private mode) — fall through to the manual card */
}
}, [error, chunk])
return (
<YStack p="$4">
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{chunk
? 'A newer version of the console just shipped. Reload to load the latest.'
: 'The rest of the console still works. Try again, or reload the page.'}
</Text>
<XStack gap="$2">
{!chunk ? (
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => reset()}>
Try again
</Button>
) : null}
<Button
size="$2"
chromeless={!chunk}
icon={<RefreshCw size={14} />}
onPress={() => { if (typeof window !== 'undefined') window.location.reload() }}
>
Reload
</Button>
</XStack>
</Card>
</YStack>
)
}
+27 -3
View File
@@ -1,15 +1,39 @@
import type { ReactNode } from 'react'
import { AuthGate } from '~/components/AuthGate'
import { OrgGate } from '~/components/OrgGate'
import { DashboardShell } from '~/components/DashboardShell'
import { PreferencesProvider } from '~/lib/products/preferences'
import { ScopeProvider } from '~/lib/scope-context'
import { ToastProvider } from '~/components/ui/Toast'
import { CommandPaletteProvider } from '~/components/CommandPalette'
import { AppLauncherProvider } from '~/components/AppLauncher'
import { DetailPaneProvider } from '~/components/DetailPane'
import { FloatingChatProvider } from '~/components/FloatingChat'
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<AuthGate>
<PreferencesProvider>
<DashboardShell>{children}</DashboardShell>
</PreferencesProvider>
<OrgGate>
<ScopeProvider>
<PreferencesProvider>
<ToastProvider>
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
<AppLauncherProvider>
<CommandPaletteProvider>
{/* FloatingChat floats the assistant bubble over every page. */}
<FloatingChatProvider>
{/* DetailPane hosts the ONE right-side item detail/edit pane. */}
<DetailPaneProvider>
<DashboardShell>{children}</DashboardShell>
</DetailPaneProvider>
</FloatingChatProvider>
</CommandPaletteProvider>
</AppLauncherProvider>
</ToastProvider>
</PreferencesProvider>
</ScopeProvider>
</OrgGate>
</AuthGate>
)
}
+131 -80
View File
@@ -1,48 +1,31 @@
'use client'
/**
* Product catalog — the unified console home. Every Hanzo product, grouped by
* category, with its enablement state. `enabled` products open straight in;
* `available` products offer a "Get started" onboarding affordance. Each card
* can be pinned to the sidebar (persisted to the account). Rendered entirely
* from the catalog registry.
* Product catalog — the unified console home. Every Hanzo product, grouped by the
* ten canonical categories, with its Google Cloud equivalent. Every product is
* open-for-all: each card opens straight into its native in-console surface and
* carries a "Learn more" affordance to its docs — there is no enablement gate and
* no external bounce. Each card can be pinned to the sidebar (persisted to the
* account). Rendered entirely from the catalog registry.
*/
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Star, Lock, ExternalLink, ArrowRight, Info } from '@hanzogui/lucide-icons-2'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Star, Lock, ArrowRight, BookOpen, KeyRound } from '@hanzogui/lucide-icons-2'
import { branding, config } from '~/config'
import { catalogByCategory, type CatalogEntry } from '~/lib/products/registry'
import { config } from '~/config'
import { visibleCatalogByCategory, categorySlug, type CatalogEntry } from '~/lib/products/registry'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsGlobalAdmin } from '~/lib/auth/admin'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FadeIn } from '~/components/ui/FadeIn'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
function StatusBadge({ entry }: { entry: CatalogEntry }) {
const label =
entry.status === 'enabled'
? 'Enabled'
: entry.status === 'soon'
? 'Soon'
: entry.status === 'waitlist'
? 'Waitlist'
: 'Available'
const bg =
entry.status === 'enabled'
? '$color5'
: entry.status === 'waitlist'
? '$color4'
: entry.status === 'soon'
? '$color4'
: '$color3'
return (
<XStack bg={bg} px="$2" py="$1" rounded="$10" items="center" gap="$1">
{entry.admin ? <Lock size={11} opacity={0.6} /> : null}
<Text fontSize="$1" color={entry.status === 'enabled' ? '$color12' : '$color11'} fontWeight="600">
{label}
</Text>
</XStack>
)
}
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
// sparklines, streaming activity) — the SAME component every product overview uses.
const OverviewDashboard = livingOverviewModule('overview')
function ProductCard({
entry,
@@ -58,25 +41,24 @@ function ProductCard({
onLearnMore: () => void
}) {
const Icon = entry.icon
const enabled = entry.status === 'enabled'
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" width={272}>
<XStack justify="space-between" items="flex-start">
<XStack gap="$2" items="center" flex={1}>
<Icon size={20} />
<Text fontSize="$5" fontWeight="700">
{entry.label}
</Text>
<YStack flex={1}>
<Text fontSize="$5" fontWeight="700">
{entry.label}
</Text>
{entry.gcp ? (
<Text fontSize="$1" color="$color10">
{entry.gcp}
</Text>
) : null}
</YStack>
</XStack>
<XStack gap="$1" items="center">
<Button
size="$2"
chromeless
opacity={0.4}
icon={<Info size={15} />}
onPress={onLearnMore}
aria-label={`Learn about ${entry.label}`}
/>
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
<Button
size="$2"
chromeless
@@ -93,55 +75,124 @@ function ProductCard({
</Text>
<XStack justify="space-between" items="center">
<StatusBadge entry={entry} />
<Button
size="$2"
bg={enabled ? '$color5' : 'transparent'}
chromeless
icon={<BookOpen size={14} />}
onPress={onLearnMore}
aria-label={`Learn more about ${entry.label}`}
>
Learn more
</Button>
<Button
size="$2"
bg="$color5"
borderWidth={1}
borderColor="$borderColor"
onPress={enabled ? onOpen : onLearnMore}
iconAfter={
enabled && entry.kind === 'external' ? <ExternalLink size={14} /> : <ArrowRight size={14} />
}
onPress={onOpen}
iconAfter={<ArrowRight size={14} />}
>
{enabled ? 'Open' : 'Get started'}
Open
</Button>
</XStack>
</Card>
)
}
/**
* Prominent, always-visible "Get API key" call-to-action at the top of the home.
* A cold customer must reach "New key" in one obvious click from landing — the
* api-keys page is otherwise buried in the collapsed Dev nav group. Routes to the
* real ApiKeysModule (`/api-keys`), where the `hk-` key is created/copied/rotated.
*/
function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
return (
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4">
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
<XStack items="center" gap="$3" flex={1} minW={240}>
<YStack bg="$color5" rounded="$4" p="$2.5" items="center" justify="center">
<KeyRound size={20} />
</YStack>
<YStack flex={1} minW={180}>
<Text fontSize="$5" fontWeight="800">
Get your API key
</Text>
<Text fontSize="$3" color="$color11">
Call {config.brandName} models from your apps, SDKs, and CLI with a personal key.
</Text>
</YStack>
</XStack>
<PrimaryButton size="$4" iconAfter={<ArrowRight size={16} />} onPress={onOpen}>
Get API key
</PrimaryButton>
</XStack>
</Card>
)
}
export default function DashboardHome() {
const router = useRouter()
const { toggle, isPinned } = useFavorites()
const showAdmin = useIsGlobalAdmin()
const push = (path: string) => router.push(path)
const groups = catalogByCategory()
const groups = visibleCatalogByCategory(showAdmin)
// Billing-only shell (billing.<brand> / NEXT_PUBLIC_BILLING_ONLY): the default
// route IS the Billing Center — redirect the catalog home to the billing overview
// so people who only ever see billing.hanzo.ai land straight on billing.
useEffect(() => {
if (config.billingOnly) router.replace('/billing')
}, [router])
if (config.billingOnly) {
return (
<XStack flex={1} justify="center" items="center" p="$8">
<Spinner size="large" color="$color11" />
</XStack>
)
}
return (
<>
<PageHeader
title={branding.name}
subtitle={`See, enable, and manage every ${config.brandName} product from one place.`}
/>
{groups.map((group) => (
<YStack key={group.category} gap="$3">
<Text fontSize="$5" fontWeight="800" color="$color12">
{group.category}
</Text>
<XStack flexWrap="wrap" gap="$3">
{group.entries.map((entry) => (
<ProductCard
key={entry.id}
entry={entry}
pinned={isPinned(entry.id)}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
onLearnMore={() => push(`/discover/${entry.id}`)}
/>
))}
</XStack>
</YStack>
))}
</>
<YStack gap="$7">
<GetApiKeyCta onOpen={() => push('/api-keys')} />
<OverviewDashboard params={{}} />
<YStack gap="$4">
<PageHeader
title="Explore products"
subtitle={`Open and manage every ${config.brandName} product from one place.`}
/>
{groups.map((group, i) => (
<FadeIn key={group.category} index={i} style={{ width: '100%' }}>
<YStack gap="$3">
<XStack
self="flex-start"
items="center"
gap="$2"
cursor="pointer"
hoverStyle={{ opacity: 0.75 }}
onPress={() => push(`/category/${categorySlug(group.category)}`)}
aria-label={`${group.category} overview`}
>
<Text fontSize="$5" fontWeight="800" color="$color12">
{group.category}
</Text>
<ArrowRight size={16} opacity={0.5} />
</XStack>
<XStack flexWrap="wrap" gap="$3">
{group.entries.map((entry) => (
<ProductCard
key={entry.id}
entry={entry}
pinned={isPinned(entry.id)}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
onLearnMore={() => push(`/discover/${entry.id}`)}
/>
))}
</XStack>
</YStack>
</FadeIn>
))}
</YStack>
</YStack>
)
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Server-gated GLOBAL admin aggregate proxy — the cross-tenant business/platform
* reads (`/v1/admin/{overview,usage,orgs,audit,products,finance,compute,providers}`)
* AND the few GLOBAL-admin mutations that ride the same god-view gate
* (`POST /v1/admin/providers/{toggle,primary}` — flip shared-gateway provider
* routing that affects every org).
*
* The admin business board is an ALL-ORGS god view (`?org=all`) over IAM + commerce
* + o11y. So — unlike the per-tenant `/cloud` proxy, which authorizes on the bearer
* `owner` claim and is safe for any authenticated user — this MUST be gated to a
* GLOBAL admin BEFORE anything is forwarded: a tenant customer (even one who is
* `isAdmin` of their own org) must NOT read another org's revenue/spend/customers,
* must NOT trigger the `org=all` aggregate at all, and must NOT flip a shared
* provider's enabled/primary state.
*
* Defense in depth (RED H1 — the cloud-side gate for `/v1/admin/*` is a separate
* backend contract we cannot see or test from this repo): `getAdminGate` enforces the
* SAME policy the IAM/KMS admin proxies use — a VERIFIED `@<brand.adminDomain>` email
* AND an IAM global-admin flag, fail-closed (→ 403) on any miss. Only then does the
* shared `forwardWithUserBearer` mint a short-lived user bearer and forward to
* cloud-api, applying the usual path-traversal + same-origin-CSRF hardening. On a
* mutating method (POST), that CSRF gate (Sec-Fetch-Site ≠ cross-site AND Origin/
* Referer host == Host, fail-closed 403 BEFORE resolving the user) means a
* cross-site page can never flip a provider on the victim admin's behalf. The
* browser holds no cloud credential and cannot reach this endpoint without passing
* the gate; the client-side `admin: true` nav gate + `AdminManagedNotice` is UI-only
* defense-in-depth, never the boundary.
*
* Least privilege: only the admin aggregate heads are reachable, NOT `iam`/`kms`
* (those keep their own gated proxies with their own tenant-scoping semantics) — this
* is not a general cloud-api tunnel. `allowAdminSurface` admits `v1/admin/<head>[/...]`
* (the exact forwarded upstream shape), so `providers` covers the GET list and the
* `providers/{toggle,primary}` POSTs and nothing else. `next.config.mjs` rewrites
* `/v1/admin/<head>[/...]` here for BOTH GET and POST (dropping the `/v1/` into the
* internal Next route path); this handler re-adds `v1/` for the upstream cloud call,
* and the client calls the clean same-origin `/v1/admin/*` form (unchanged).
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowAdminSurface } from '~/lib/server/admin-aggregate'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
* `|| default` (not `??`) so an env reconciled to an EMPTY string still resolves the service. */
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
const forbidden = () => NextResponse.json({ status: 'error', msg: 'forbidden' }, { status: 403 })
type Ctx = { params: Promise<{ path: string[] }> }
async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin (tenant
// customer, org-level isAdmin) gets a 403 and never triggers the org=all aggregate.
const gate = await getAdminGate(req)
if (!gate) return forbidden()
// The rewrite feeds the tail after `/v1/admin/` (e.g. `overview`, `audit`); rebuild
// the FULL cloud path, which is `/v1/admin/<head>` — cloud serves every admin route
// under `/v1/admin/*` (the beego `/v1/*` glob in hanzoai/ai + cloud's own
// `clients/admin` `app.Get("/v1/admin/…")`), and `forwardWithUserBearer` forwards to
// `target/path` VERBATIM (no `/v1` prepend), so the `v1/` MUST be part of the path
// here or the request lands on a non-existent bare `/admin/*` and 404s. The rewrite
// destination (`app/admin/aggregate/<head>`) is the internal Next route, not the
// upstream — it deliberately carries no `v1/`; this handler adds it.
// `forwardWithUserBearer` re-validates the exact forwarded path via `allow`
// (`allowAdminSurface`, keyed on the `v1/admin/<head>` shape) + `pathIsClean`.
const path = `v1/admin/${(await ctx.params).path.join('/')}`.replace(/\/+$/, '')
return forwardWithUserBearer(req, {
target: CLOUD_API_URL,
path,
allow: allowAdminSurface,
// The AdminApi client unwraps the casibase `{status,msg,data}` envelope, so this
// proxy's own 401/404 must speak the same shape (an honest state, never a throw).
errorShape: 'casibase',
unauthorizedMessage: 'Sign in as an administrator.',
})
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`). Identical path through `getAdminGate`
* (fail-closed 403) → `forwardWithUserBearer`, which applies the same-origin CSRF
* check to this mutating method BEFORE resolving the user, streams the JSON body
* through, and re-validates the path against `allowAdminSurface` (so a POST can only
* ever reach an allowed head — never `iam`/`kms`, never a traversal).
*/
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* PUT — the GLOBAL-admin enablement set (`PUT /v1/admin/enablement`, flip an item
* off|beta|ga + grant orgs). Same gate + same CSRF/traversal hardening as POST;
* `allowAdminSurface` admits only `v1/admin/enablement`, nothing else.
*/
export async function PUT(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Server-gated GLOBAL IAM admin proxy — cross-tenant IAM ops (any org).
*
* The browser holds no IAM credential. It calls this SAME-ORIGIN route with just
* its session cookie; the handler enforces the GLOBAL admin gate (`getAdminGate`:
* verified @<adminDomain> email AND a global-admin flag), then the shared
* `forwardIam` applies the allow-list + tenant scoping (a global admin may act on
* any org) and forwards to IAM as the user. A CUSTOMER managing their OWN org uses
* `/org/iam` instead — this route is global-only.
*
* Least privilege: only an explicit allow-list of admin segments is reachable
* (GET reads / POST mutations); every owner the request references — including
* the mutation BODY owner — is validated by `forwardIam`.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
import { forwardIam } from '~/lib/server/iam-proxy'
export const runtime = 'nodejs'
/** Read segments — reachable via GET only. */
const GET_SEGMENTS = new Set([
'get-organizations',
'get-organization',
'get-users',
'get-user',
'get-applications',
'get-application',
'get-providers',
'get-provider',
'get-roles',
'get-records',
])
/** Mutation segments — reachable via POST only (JSON body forwarded). */
const POST_SEGMENTS = new Set([
'add-user',
'update-user',
'delete-user',
'add-application',
'update-application',
'delete-application',
'add-provider',
'update-provider',
'delete-provider',
'add-role',
'update-role',
'delete-role',
])
/**
* Organization objects are owned by IAM's built-in `admin`, and the org
* list/get endpoints scope results to the caller's org server-side — so `admin`
* is an acceptable owner THERE (never for tenant data like users/roles).
*/
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
const gate = await getAdminGate(req)
if (!gate) return forbidden()
return forwardIam(
req,
{ user: gate.user, isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
{
segment: path.join('/'),
method,
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
orgMetaSegments: ORG_ENDPOINTS,
// The gate is already global-only; global admins may write to any org.
requireAdminForWrite: false,
},
)
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'GET')
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'POST')
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
*
* Same trust boundary as the IAM proxy: the browser sends only its session
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
* default; a global admin may target another org with `?org=`.
*
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
* body, and never derives or stores key material — it is a faithful pass-through
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
* verb + query select the operation:
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
* POST {path,name,env,value} → create/upsert → POST .../secrets
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
*
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
* renders as an honest "listing requires kmsd ≥ next release" state.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
function secretRest(path: string, name: string): string {
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
}
/** Org the operator acts on — the brand org, unless a global admin passes ?org=
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
function orgFor(gate: AdminGate, req: NextRequest): string {
return policyOrgFor(
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
req.nextUrl.searchParams.get('org'),
)
}
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
// CSRF: a cross-site page carrying the admin's auto-sent cookie must never be able
// to create / rotate / delete a KMS secret. Refuse a cross-origin MUTATION before
// the admin gate or any body read (safe GET reveals pass). Defense in depth on top
// of the session cookie's own SameSite attribute.
const csrf = csrfRefusal(req)
if (csrf) return csrf
const gate = await getAdminGate(req)
if (!gate) return forbidden()
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
const org = orgFor(gate, req)
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
const q = req.nextUrl.searchParams
const name = q.get('name') ?? ''
const path = q.get('path') ?? ''
const env = q.get('env') ?? ''
let target: string
let body: string | undefined
if (req.method === 'GET') {
if (name) {
const params = new URLSearchParams()
if (env) params.set('env', env)
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
} else {
const params = new URLSearchParams()
const prefix = q.get('prefix')
if (prefix) params.set('prefix', prefix)
if (env) params.set('env', env)
target = `${base}${params.toString() ? `?${params}` : ''}`
}
} else if (req.method === 'POST') {
target = base
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
} else if (req.method === 'PATCH') {
if (!name) return notFound()
target = `${base}/${secretRest(path, name)}`
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
} else if (req.method === 'DELETE') {
if (!name) return notFound()
const params = new URLSearchParams()
if (env) params.set('env', env)
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
} else {
return notFound()
}
let bearer: string
try {
bearer = await adminBearer(gate.user)
} catch (e) {
return NextResponse.json(
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
init.body = body
}
try {
const res = await fetchWithTimeout(target, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
})
} catch (e) {
// Surface only the transport failure — never the request body/value.
return NextResponse.json(
{ message: `KMS unreachable: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path)
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Keyless AI proxy — the ONE path the console uses to reach the model gateway.
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `hk-` key to the browser, the console calls its OWN origin (`/ai/v1/...`) with just
* the session cookie; `forwardWithUserBearer` resolves the user, mints a SHORT-LIVED,
* user-bound IAM token (shared per-user cache in identity.ts), and forwards to the
* gateway with that token. No key in the browser, no rotation on a chat turn, and
* every call is billed to the user's own org. The response STREAMS through, so
* `chat/completions` SSE (and the multi-model TTFT measurement) is preserved.
*
* Least privilege: only the read/inference AI endpoints are proxied (the ALLOWED
* allow-list); anything else 404s, so this is not a general gateway tunnel. The RAG
* retrieval switch (`X-Retrieval`/`X-Retrieval-Store`) is the ONE client-header
* passthrough (allow-listed in `ai-proxy`).
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { retrievalHeaders } from '~/lib/server/ai-proxy'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Gateway the proxied AI calls are forwarded to (gated/priced api.hanzo.ai). */
const AI_GATEWAY_URL = trim(process.env.AI_GATEWAY_URL ?? 'https://api.hanzo.ai')
/** The exact `/v1/<...>` endpoints the console is allowed to reach. */
const ALLOWED = new Set([
'v1/models',
'v1/pricing/models', // the rich model+provider catalog (context, pricing, specs, tier) for Models/Providers pages
'v1/plans', // the subscription tiers + entitlements (rpm/tpm/quota) for the catalog plan badges
'v1/chat',
'v1/chat/completions',
'v1/embeddings',
'v1/rerank',
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
'v1/images/generations', // text-to-image (JSON in → image url/b64 out) for the Playground Image tab
'v1/videos/generations', // text-to-video (JSON in → base64 MP4 out) for the Playground Video tab
])
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: AI_GATEWAY_URL,
path,
allow: (p) => ALLOWED.has(p),
// Forward the RAG retrieval switch when present; the store's org owner is still
// resolved server-side from the session (the bearer), never the browser.
extraHeaders: retrievalHeaders((h) => req.headers.get(h)),
errorShape: 'openai',
unauthorizedMessage: 'Sign in to use AI.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+4 -2
View File
@@ -2,7 +2,7 @@
/**
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
* for a backend session (`/v1/signin`) and land on the dashboard. On failure we
* for a backend session (`/v1/iam/signin`) and land on the dashboard. On failure we
* surface the error and offer a retry.
*/
import { Suspense, useEffect, useState } from 'react'
@@ -12,6 +12,7 @@ import { Button, Text, YStack } from '@hanzo/gui'
import { ApiError } from '~/lib/api'
import { Loader } from '~/components/ui/Loader'
import { useSession } from '~/lib/auth/session'
import { takeReturnTo } from '~/lib/auth/iam'
function Callback() {
const params = useSearchParams()
@@ -27,7 +28,8 @@ function Callback() {
return
}
completeSignIn(code, state)
.then(() => router.replace('/'))
// Land the user back where a mid-task expiry interrupted them (default home).
.then(() => router.replace(takeReturnTo()))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
}, [params, completeSignIn, router])
+69
View File
@@ -0,0 +1,69 @@
/**
* /auth/refresh — silently renew the console session (server-side, BFF).
*
* The browser calls this with just its httpOnly cookies (no token in the body, none
* in the URL). The route reassembles + reads the sealed refresh token, calls IAM with
* `grant_type=refresh_token`, and re-seals the ROTATED token set (IAM issues a new
* one-time-use refresh token every refresh — we always persist the NEW one). It
* returns only the new lifetime, never a token.
*
* Called two ways, both single-flight on the client (`lib/auth/refresh`): proactively
* on a timer at ~80% of the access lifetime, and reactively on a 401 from any cloud/BFF
* call. On failure (the refresh token is truly expired/revoked, or a replay of a
* rotated one) it 401s WITHOUT clearing the cookies (multi-tab rotating-token race
* safety — a lost-race 401 must not nuke another tab's freshly-rotated cookie); the
* client then re-reads the session (seeing a winner's fresh cookie if any) or falls
* through to graceful re-auth. Only explicit sign-out clears the cookies.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { readRefreshToken, refreshGrant, sealSession, setCookies, SessionError, type CookieDirective } from '~/lib/server/session'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
export const runtime = 'nodejs'
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
for (const d of dirs) {
res.cookies.set(d.name, d.value, {
httpOnly: d.httpOnly,
secure: d.secure,
sameSite: d.sameSite,
path: d.path,
maxAge: d.maxAge,
})
}
return res
}
/** 401 WITHOUT clearing the cookies (see the multi-tab note above). */
const fail = () => NextResponse.json({ error: 'refresh failed' }, { status: 401 })
export async function POST(req: NextRequest): Promise<NextResponse> {
// CSRF: uniform same-origin gate on every mutating BFF route (the hz_rt cookie is
// already SameSite=lax + Path=/auth, so this is belt-and-suspenders).
const csrf = csrfRefusal(req)
if (csrf) return csrf
const rt = readRefreshToken(req)
if (!rt) return fail()
let tokens
try {
tokens = await refreshGrant(rt)
} catch (e) {
// A 502 (endpoint unreachable) is transient — surface it distinctly so the client
// can retry, and never touch the cookies.
if (e instanceof SessionError && e.status === 502) {
return NextResponse.json({ error: 'refresh unavailable' }, { status: 502 })
}
return fail()
}
// A rotated set with no new refresh token would strand us next cycle — require it
// (fail-closed: never persist a session we cannot refresh again).
if (!tokens.refreshToken) return fail()
const sealed = sealSession(tokens)
if (!sealed) return fail()
const res = NextResponse.json({ expiresIn: Math.floor(sealed.expiresInMs / 1000) })
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
}
+149
View File
@@ -0,0 +1,149 @@
/**
* /auth/session — the console's OWN durable, refreshable OAuth session (BFF).
*
* POST establish the console session for the SIGNED-IN user (first-party
* confidential-client password grant WITH offline_access → access +
* rotating refresh token, sealed into the httpOnly cookies).
* GET the current account resolved from that session (what the AuthGate reads
* FIRST — durable + silently refreshed, so it survives the casibase
* session's own lifetime and never bounces the user mid-task).
* DELETE sign out — best-effort revoke the refresh token + clear the cookies.
*
* SECURITY. POST is GATED: it mints a console session ONLY for a caller who is
* ALREADY authenticated (a valid casibase/console session — the full login incl. any
* MFA), AND only when the password grant resolves to the SAME principal — so it can
* never be driven standalone with a stolen password, and never bypasses MFA (an MFA
* account never reaches this call). Tokens live only inside the sealed httpOnly
* cookies — never returned to the browser, never logged, never in a URL.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { type Account } from '~/lib/api/types'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { resolveUser } from '~/lib/server/identity'
import {
clearCookies,
consoleSession,
passwordGrant,
readRefreshToken,
revokeRefreshToken,
sameSubject,
sealSession,
sessionConfigured,
setCookies,
SessionError,
type ConsoleClaims,
type CookieDirective,
} from '~/lib/server/session'
export const runtime = 'nodejs'
/** Build the client-facing Account from console claims (display + admin fields only;
* never the secret material Casdoor also packs into the token). `isGlobalAdmin` is
* carried so the client nav/org gates (`isGlobalAdminAccount`) match the casibase
* path; `owner === 'admin'` also implies it. */
function accountOf(c: ConsoleClaims): Account {
return {
owner: c.owner ?? '',
name: c.name ?? '',
type: c.type,
displayName: c.displayName,
email: c.email,
avatar: c.avatar,
isAdmin: c.isAdmin,
isGlobalAdmin: c.isGlobalAdmin || c.owner === 'admin',
properties: c.properties,
}
}
/** Apply cookie directives to a NextResponse. */
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
for (const d of dirs) {
res.cookies.set(d.name, d.value, {
httpOnly: d.httpOnly,
secure: d.secure,
sameSite: d.sameSite,
path: d.path,
maxAge: d.maxAge,
})
}
return res
}
/** GET — the account + remaining access lifetime from the live console session, or
* 401 when there is none (the client then falls back to the casibase session). */
export async function GET(req: NextRequest): Promise<NextResponse> {
const sess = consoleSession(req)
if (!sess || !sess.claims.name) {
return NextResponse.json({ error: 'no session' }, { status: 401 })
}
return NextResponse.json({ account: accountOf(sess.claims), expiresIn: sess.expiresInSec })
}
/** POST { username, password } — establish the console session for the signed-in user. */
export async function POST(req: NextRequest): Promise<NextResponse> {
// CSRF: refuse a cross-origin login (login-CSRF fixes the victim into an attacker's
// session) before touching credentials.
const csrf = csrfRefusal(req)
if (csrf) return csrf
if (!sessionConfigured()) {
// No confidential client wired: the console still runs on the casibase session;
// report "not configured" so the client silently skips the console session.
return NextResponse.json({ error: 'session not configured' }, { status: 501 })
}
let body: { username?: unknown; password?: unknown }
try {
body = (await req.json()) as typeof body
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 })
}
const username = typeof body.username === 'string' ? body.username.trim() : ''
const password = typeof body.password === 'string' ? body.password : ''
if (!username || !password) {
return NextResponse.json({ error: 'missing credentials' }, { status: 400 })
}
// GATE: the caller must already be authenticated (they just completed the casibase
// login incl. any MFA). This binds the console session to a real, MFA-cleared
// session and blocks standalone password abuse.
const authed = await resolveUser(req)
if (!authed) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
}
let tokens
try {
tokens = await passwordGrant(username, password)
} catch (e) {
const status = e instanceof SessionError ? e.status : 502
return NextResponse.json({ error: 'grant failed' }, { status })
}
const sealed = sealSession(tokens)
// The grant MUST resolve to the same principal as the established session — the
// console session is for the already-authenticated user, never a third party.
const grantId =
sealed && sealed.claims.owner && sealed.claims.name ? `${sealed.claims.owner}/${sealed.claims.name}` : ''
if (!sealed || !grantId || !sameSubject(grantId, authed.id)) {
return NextResponse.json({ error: 'identity mismatch' }, { status: 401 })
}
const res = NextResponse.json({
account: accountOf(sealed.claims),
expiresIn: Math.floor(sealed.expiresInMs / 1000),
})
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
}
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
export async function DELETE(req: NextRequest): Promise<NextResponse> {
// CSRF: refuse a cross-origin forced sign-out.
const csrf = csrfRefusal(req)
if (csrf) return csrf
const rt = readRefreshToken(req)
if (rt) await revokeRefreshToken(rt)
return withCookies(NextResponse.json({ ok: true }), clearCookies())
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Email self-serve signup (HIP-0111) — create a brand-new account + its own org.
*
* This is the ONE unauthenticated BFF route (the caller has no account yet). It
* acts as the confidential `hanzo-console` client to mint, in one shot:
* 1. a personal organization (owner=`admin`, password/locale cloned from the
* brand org so the account hashes with the brand's argon2id policy), and
* 2. the user as that org's ADMIN (IAM hashes the password server-side).
* The client then signs in with the same credentials and lands as admin — no
* separate onboarding step (IAM users always belong to an org, so "create then
* onboard" is not possible against casibase; the org is minted here).
*
* Email uniqueness without a global user-lookup endpoint: the org slug is a
* DETERMINISTIC, injective function of the email (`personalOrgFromEmail`), so a
* repeat signup with the same email resolves to the same slug and is caught by
* `getOrganization` (409) — two different emails never false-collide.
*
* Honest states: 501 when the IAM client is unwired, 400 on bad input, 409 when
* the account already exists, 502 on an IAM failure.
*
* NOTE (hardening, flagged not done here): this endpoint creates accounts from the
* open internet. It validates input but has NO captcha / rate-limit / email-
* verification gate yet — those are follow-ups (email verification especially
* would add friction the go-live conversion goal explicitly avoids).
*/
import { createHash } from 'node:crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { brandFromHost } from '~/config'
import { BRANDS } from '~/lib/branding/brands'
import { createOrganization, createUser, getOrganization, mintConfigured } from '~/lib/server/identity'
import {
deriveUsername,
displayNameFromEmail,
personalOrgFromEmail,
validateSignup,
} from '~/lib/server/onboarding'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
export const runtime = 'nodejs'
export async function POST(req: NextRequest): Promise<NextResponse> {
// Same-origin gate: this route creates accounts from the open internet with no
// captcha/rate-limit yet, so refuse cross-origin scripted signups (a mild anti-abuse
// measure; the console's own signup form is same-origin).
const csrf = csrfRefusal(req)
if (csrf) return csrf
if (!mintConfigured()) {
return NextResponse.json(
{ error: 'Account creation is not configured on this deployment (IAM client unset).' },
{ status: 501 },
)
}
const body = (await req.json().catch(() => ({}))) as { email?: string; password?: string }
const v = validateSignup(body.email ?? '', body.password ?? '')
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
const brandOrg = brand.id // hanzo/lux/zoo/pars — cloned for password/locale policy
const signupApplication = `${brand.id}-cloud` // hanzo-cloud, lux-cloud, …
const digest = createHash('sha256').update(v.email).digest('hex')
const orgSlug = personalOrgFromEmail(v.email, digest)
if (await getOrganization(orgSlug)) {
return NextResponse.json(
{ error: 'An account with this email already exists. Sign in instead.' },
{ status: 409 },
)
}
const displayName = displayNameFromEmail(v.email)
try {
await createOrganization({ name: orgSlug, displayName, personal: true, sourceOwner: brandOrg })
await createUser({
org: orgSlug,
username: deriveUsername(v.email),
email: v.email,
password: v.password,
displayName,
signupApplication,
})
} catch (e) {
return NextResponse.json(
{ error: `Could not create your account: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
return NextResponse.json({ ok: true, org: orgSlug })
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Per-tenant billing DATA proxy → commerce. The browser calls console2's OWN origin
* (`/billing/v1/...`); this server handler forwards to commerce's `/v1/billing/...`,
* injecting the commerce SERVICE token from server-only env (never `NEXT_PUBLIC_`,
* never in the browser bundle) AND scoping every request to the caller's OWN org.
*
* Namespaced under `/billing/v1/` (NOT bare `/billing/`) so the data plane never
* shadows the billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …): a
* route handler always wins over the catch-all page for a matching path segment, so
* the tab slugs and the data endpoints must live in disjoint path space. The tab
* URLs now fall through to the SPA (`app/(dashboard)/[...slug]`).
*
* Same trust boundary as the `/admin/iam` + `/admin/kms` proxies, but the authz is
* PER-TENANT, not admin: any authenticated session may read/act on ITS OWN billing
* (balance / usage / invoices / credit-grants / subscriptions / payment-methods).
* The org is resolved server-side from the validated session (`resolveUser`) and
* stamped as `X-Org-Id` (the header commerce's service-token path actually reads —
* `commerce/middleware/accesstoken.go`), and the server-resolved billing subject is
* pinned onto the FULL commerce subject-key set (`user`/`userId`/`customerId`, via
* `scopedBillingSearch`) while `?org=` is dropped. The client CANNOT widen scope: a
* forged `?userId=`/`?customerId=`/`?org=` is overwritten, and because EVERY subject
* param is pinned, no billing endpoint is left unfiltered regardless of which one it
* reads (subscriptions filter `userId`, payment-methods `customerId`). So commerce's
* per-tenant isolation can never be crossed from the browser. No session → 401.
*
* Billing-subject mirrors `object.BillingSubject` (hanzoai/ai) + chat's
* `billingSubject`: a member of a PERSONAL-billing org (default the shared `hanzo`
* catch-all) bills per-user as `<org>/<name>`; a dedicated org (maxpower, …) bills
* per-org as `<org>`. The SAME subject the gateway debits — so the console shows
* the exact balance/usage that gets charged.
*
* `COMMERCE_TOKEN` unset → honest 501 (the UI shows a truthful "not configured"
* state; it never fabricates a balance).
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser } from '~/lib/server/identity'
import { billingSubject, scopedBillingSearch, scopedBillingBody } from '~/lib/server/billing-scope'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const isSafeSegment = (s: string): boolean =>
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
function commerceBaseUrl(): string {
return (process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001').replace(/\/+$/, '')
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// CSRF: a mutating billing write (spend-alert/budget) authenticates from the
// auto-sent cookie, so refuse a cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req)
if (csrf) return csrf
// Per-tenant authz: any valid session may see ITS OWN billing (no admin gate).
const user = await resolveUser(req)
if (!user) {
return NextResponse.json({ error: 'Sign in to view billing.' }, { status: 401 })
}
if (!path.every(isSafeSegment)) {
return NextResponse.json({ error: 'Invalid billing path.' }, { status: 400 })
}
const token = process.env.COMMERCE_TOKEN ?? process.env.COMMERCE_SERVICE_TOKEN ?? ''
if (!token) {
return NextResponse.json(
{ error: 'Billing is not configured (COMMERCE_TOKEN missing).' },
{ status: 501 },
)
}
// Scope to the caller's OWN org — server-resolved, never client-supplied.
const org = user.owner.trim()
const subject = billingSubject(org, user.name)
// Pin the FULL billing-subject key set to the server-resolved subject, and
// strip `org`, so the browser can never read another tenant's ledger. Commerce
// filters each endpoint on a DIFFERENT param — subscriptions on `userId`,
// payment-methods on `customerId` (or `user`), usage on `user` — so pinning
// only ONE param leaves the others unfiltered (a cross-tenant read). This
// mirrors commerce's own edge-auth `billingSubjectKeys`
// (commerce/middleware/edgeauth.go: {"user","userId","customerId"}) exactly, so
// every billing endpoint is scoped no matter which param it reads.
const qs = scopedBillingSearch(req.nextUrl.search, subject)
const url = `${commerceBaseUrl()}/v1/billing/${path.join('/')}${qs ? `?${qs}` : ''}`
const init: RequestInit = {
method: req.method,
headers: {
Authorization: `Bearer ${token}`,
// Commerce resolves the tenant namespace from `X-Org-Id` on the service-token
// path (commerce/middleware/accesstoken.go). It does NOT read `X-Hanzo-Org`,
// so sending that alone silently falls back to the service org — every tenant
// sharing one namespace. Send `X-Org-Id`, matching the `/ai` proxy.
'X-Org-Id': org,
'Content-Type': 'application/json',
Accept: 'application/json',
},
cache: 'no-store',
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
// Scope the WRITE body to the caller's OWN subject too (not just the query):
// commerce reads the subject from the JSON body on writes like create-spend-alert
// (`userId`), so pin it server-side — the browser needn't know its subject and a
// forged body subject cannot widen scope. Mirrors `scopedBillingSearch`.
init.body = scopedBillingBody(await req.text(), subject)
}
try {
const res = await fetchWithTimeout(url, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': res.headers.get('content-type') ?? 'application/json',
// A per-tenant money response (balance/usage/invoices) must NEVER be cached
// by the browser or any intermediary — otherwise the wallet shows a stale
// number after a completion or a top-up. The live-balance store still polls,
// but this guarantees each fetch hits commerce, not a cache.
'Cache-Control': 'no-store, must-revalidate',
},
})
} catch (e) {
return NextResponse.json(
{ error: `Billing upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Same-origin user-bearer proxy to the unified cloud-api `/v1/*` — the ONE path the
* browser uses to reach the cloud surfaces that authorize on a Bearer JWT.
*
* The managed data resources (vector/sql/kv/s3/docdb/datastore/search) and the
* serverless / prompt / agent surfaces resolve the org from the token's `owner`
* claim and 403 a cookie-only call ("X-Org-Id required"). So — exactly like the
* `/ai` proxy — the browser calls this OWN-origin route with just its session
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
* IAM token (shared per-user cache), and forwards to cloud-api with that Bearer. No
* credential reaches the browser, org is server-authoritative (never the browser's
* claim), and every read/write is billed + scoped to the user's own org.
*
* Least privilege: only the data + serverless HEADS are reachable (`allowCloudSurface`);
* `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a general cloud-api tunnel.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowCloudSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: CLOUD_API_URL,
path,
allow: allowCloudSurface,
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
// org-keyed, and forwarding an unvalidated project id is an attack surface
// (RED MEDIUM). A project-scoped feature must validate membership first.
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PUT(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Same-origin user-bearer proxy to commerce (`commerce.hanzo.svc`) — the store /
* merchant admin surface (products / orders / customers / collections / variants /
* discounts / store settings). The browser calls this OWN-origin route
* (`/commerce/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves
* the user, mints a short-lived user-bound IAM token, and forwards to commerce with
* that Bearer. Commerce's EdgeAuth validates the JWT and resolves the org from its
* `owner` claim (`middleware.TokenRequired` fast-paths IAM auth), so the store is
* org-scoped SERVER-SIDE — a merchant only ever sees their OWN org's catalog/orders/
* customers. No token reaches the browser, and the org is never browser-supplied.
*
* This is the TENANT store surface (any signed-in org member acts on their own org's
* store), so it is user-scoped (`resolveUser`), NOT the `/paas` god-mode service-token
* path. It is also DISTINCT from the `/billing` proxy: money (balance/usage/invoices/
* Square) stays on `/billing` with its own per-tenant subject scoping — this proxy
* carries only the store catalog/orders/customers. Least privilege on the path:
* `allowCommerceSurface` admits only the merchant REST heads (product/order/user/…),
* so `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants` are NOT reachable here.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowCommerceSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR already
* wires `COMMERCE_URL` (public egress is CF-gated). Override per-deploy with COMMERCE_URL. */
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: COMMERCE_URL,
path,
allow: allowCommerceSurface,
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
// X-Environment — the store is org-keyed and commerce re-scopes on the token.
unauthorizedMessage: 'Sign in to manage your store.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PUT(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
/**
* `/docs` → the brand documentation site (docs.hanzo.ai / docs.lux.network / …),
* resolved CLIENT-side (task #41, "True 1-binary FE").
*
* Docs are an EXTERNAL product on their own domain, never an in-app route — so a
* typed or bookmarked `<console-host>/docs` must land on the real docs, not the
* catch-all not-found. The old app/docs/route.ts issued a server 308; in the
* one-binary there is no Next runtime (the static export has no server, and a static
* export cannot rewrite), so the redirect is resolved from the per-host brand
* (`config.docsUrl`) in the browser — exactly what the sidebar "Docs" link and the
* header "?" already open. One way, both topologies (embed + standalone).
*
* The target is set in an effect (not during render) so there is no SSR/CSR
* hydration mismatch on the per-brand host between the build-time default and the
* real browser host.
*/
import { useEffect, useState } from 'react'
import { config } from '~/config'
export default function DocsRedirect() {
const [url, setUrl] = useState('')
useEffect(() => {
const target = config.docsUrl
setUrl(target)
window.location.replace(target)
}, [])
return (
<main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
Opening documentation {url ? <a href={url}>Continue</a> : null}
</main>
)
}
+324 -3
View File
@@ -1,3 +1,26 @@
/* Geist Mono — canonical Hanzo mono face (code/data). */
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css');
/* Basel Grotesk — canonical Hanzo UI/body/display/heading face (self-hosted). */
@font-face {
font-family: 'Basel';
font-style: normal;
font-weight: 400;
font-display: swap;
src:
url('/fonts/Basel-Grotesk-Book.woff2') format('woff2'),
url('/fonts/Basel-Grotesk-Book.woff') format('woff');
}
@font-face {
font-family: 'Basel';
font-style: normal;
font-weight: 500;
font-display: swap;
src:
url('/fonts/Basel-Grotesk-Medium.woff2') format('woff2'),
url('/fonts/Basel-Grotesk-Medium.woff') format('woff');
}
html,
body,
#__next {
@@ -6,12 +29,310 @@ body,
body {
margin: 0;
background-color: var(--background, #070b13);
color: var(--color, #f2f2f2);
background-color: var(--background, #000000);
color: var(--color, #ededf1);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
'Basel', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
line-height: 1.5;
}
code,
pre,
kbd,
samp {
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Tabular numerals — metrics, prices, contexts and IDs align on a fixed advance
width so columns of numbers read cleanly (the dashboard-grade detail). */
.hz-tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
* {
box-sizing: border-box;
}
/* ── Console dark theme — TRUE-BLACK canvas + calm text/borders. The ONE place the
console's dark/light palette is set. Overrides the generated @hanzo/gui (Tamagui)
theme variables; the `html:root.t_*` selector is one step more specific than the
library's runtime `:root.t_*` block so it wins regardless of stylesheet insertion
order. DRY: every surface, border and text color in the app reads from these
tokens, so this one block sets the whole product.
Design intent (dark): a TRUE-BLACK #000 canvas (matches hanzo.ai marketing +
hanzo.chat OLED) with a Linear/Vercel-caliber surface-depth ladder above it —
resting panels #050505, the next surface #0a0a0a, interactive/elevated #171717 →
#1f1f1f — so cards read with real depth, never flat voids. Above the surface
ladder the CALM neutral scale (color512) gives premium, low-glare off-white text
and quiet hairline borders (never harsh pure #fff on pure #000). Neutral grey,
monochrome-first; text contrast stays WCAG-AA+ on every surface. */
html:root.t_dark {
--background: #000000;
--backgroundStrong: #000000;
--backgroundHover: #171717;
--backgroundPress: #050505;
--backgroundFocus: #171717;
/* Surface depth ladder over the true-black canvas — panels/cards step up from
#050505 so they separate cleanly without heavy borders (Linear-grade depth). */
--color1: #050505;
--color2: #0a0a0a;
--color3: #171717;
--color4: #1f1f1f;
--color5: hsl(220 6% 16%);
--color6: hsl(220 6% 22%);
--color7: hsl(220 6% 30%);
--color8: hsl(219 6% 42%);
--color9: hsl(220 6% 55%);
--color10: hsl(219 7% 68%);
--color11: hsl(214 9% 83%);
--color12: hsl(210 12% 95%);
--color: hsl(210 12% 95%);
/* Gentle hairlines — present enough to define, quiet enough to disappear on black. */
--borderColor: hsl(220 8% 15%);
--borderColorHover: hsl(220 7% 23%);
--borderColorPress: hsl(220 8% 13%);
--borderColorFocus: hsl(220 7% 23%);
}
/* Light theme — the calm parallel: a warm off-white base (not stark #fff), soft
ink text (not pure black), and quiet hairlines. Lighter touch than dark, since
the console defaults to dark, but kept consistent for the theme toggle. */
html:root.t_light {
--background: hsl(220 20% 99%);
--color1: hsl(220 24% 100%);
--color2: hsl(220 20% 98%);
--color3: hsl(220 18% 95.5%);
--color4: hsl(220 16% 92.5%);
--color5: hsl(220 15% 89%);
--color9: hsl(220 9% 46%);
--color10: hsl(220 10% 38%);
--color11: hsl(220 14% 22%);
--color12: hsl(220 22% 12%);
--color: hsl(220 22% 12%);
--borderColor: hsl(220 16% 90%);
--borderColorHover: hsl(220 14% 82%);
}
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
~0.4s ease-out, small upward travel, staggered by the consumer). One place
defines it; <FadeIn> applies the class + per-item delay. */
@keyframes hz-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-fade-up {
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
/* Honor the user's reduced-motion preference — no entrance animation. */
@media (prefers-reduced-motion: reduce) {
.hz-fade-up {
animation: none;
}
}
/* Shell chrome motion — the sidebar collapse (width) and the Linear-style
two-level nav slide (transform). One place defines the easing; the shell
applies the class. `className` forwards to the underlying DOM node on web, so
the browser transitions the Gui-driven inline width/transform. */
.hz-collapse {
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: width;
}
.hz-slide {
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
/* Backdrop cross-fade behind a SlideOver / dialog. */
.hz-fade {
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity;
}
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
.hz-drag-item {
touch-action: none;
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-drag-item[data-dragging='true'] {
transition: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
opacity: 0.96;
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.hz-collapse,
.hz-slide,
.hz-fade,
.hz-drag-item {
transition: none;
}
}
/* Sidebar category accordion — a collapsible level-1 section. The body animates
its HEIGHT via grid-template-rows 0fr↔1fr (no magic max-height — the row
resolves to the real content height) plus a short opacity fade; the header
chevron rotates ▸→▾. Collapsed content stays in the DOM (so both directions
animate) but is `inert` (out of tab order + a11y tree). One place defines the
easing; the shell toggles `data-open`. */
.hz-acc {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-acc[data-open='true'] {
grid-template-rows: 1fr;
}
.hz-acc-inner {
overflow: hidden;
min-height: 0;
opacity: 0;
transition: opacity 180ms ease;
}
.hz-acc[data-open='true'] .hz-acc-inner {
opacity: 1;
}
.hz-chevron {
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.hz-acc,
.hz-acc-inner,
.hz-chevron {
transition: none;
}
}
/* LivingOverview motion — the "videogame-like" living dashboard. The count-up +
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
a brief highlight when a tile's number changes. One place defines the easing. */
/* Skeleton shimmer — an honest "loading", never fabricated content. */
@keyframes hz-shimmer {
0% {
background-position: -160px 0;
}
100% {
background-position: 160px 0;
}
}
.hz-skeleton {
background-color: var(--color3, rgba(148, 163, 184, 0.14));
background-image: linear-gradient(
90deg,
transparent 0%,
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
transparent 100%
);
background-size: 160px 100%;
background-repeat: no-repeat;
animation: hz-shimmer 1.2s ease-in-out infinite;
}
/* Live-feed pulse — the "Live" dot on a streaming panel. */
@keyframes hz-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
@keyframes hz-row-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-row-in {
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hz-skeleton {
animation: none;
}
.hz-row-in {
animation: none;
}
}
/* RailwayDeploy — the deployment pipeline. A smooth flowing gradient marches along the
active leg of the track (stroke-dashoffset), a soft halo pulses out from the current
station, and the status dot breathes. All reduced-motion-guarded (→ static). One place
defines the easing; RailwayDeploy applies the classes. */
@keyframes hz-rail-flow {
to {
stroke-dashoffset: -28;
}
}
.hz-rail-flow {
stroke-dasharray: 5 9;
animation: hz-rail-flow 0.85s linear infinite;
}
@keyframes hz-rail-pulse {
0% {
transform: scale(1);
opacity: 0.34;
}
70% {
transform: scale(2.1);
opacity: 0;
}
100% {
transform: scale(2.1);
opacity: 0;
}
}
.hz-rail-pulse {
transform-box: fill-box;
transform-origin: center;
animation: hz-rail-pulse 1.7s ease-out infinite;
}
.hz-rail-dot {
animation: hz-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hz-rail-flow,
.hz-rail-pulse,
.hz-rail-dot {
animation: none;
}
}
+17 -5
View File
@@ -3,13 +3,24 @@ import './globals.css'
import type { Metadata, Viewport } from 'next'
import type { ReactNode } from 'react'
import { headers } from 'next/headers'
import { Provider } from '~/components/Provider'
import { branding } from '~/config'
import { ChunkGuard } from '~/components/ChunkGuard'
import { resolveConfig } from '~/config'
export const metadata: Metadata = {
title: branding.name,
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
// brand (console.lux.cloud -> "Lux Cloud Console"), not the build-time default.
// The visible shell resolves the brand client-side from window.location, but the
// tab title is server-rendered — without reading the Host header here the browser
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
export async function generateMetadata(): Promise<Metadata> {
const host = (await headers()).get('host') ?? undefined
const { brandName } = resolveConfig(host)
return {
title: `${brandName} Console`,
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
}
}
export const viewport: Viewport = {
@@ -18,8 +29,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="t_dark" style={{ backgroundColor: '#070b13', colorScheme: 'dark' }} suppressHydrationWarning>
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
<body style={{ margin: 0 }}>
<ChunkGuard />
<Provider>{children}</Provider>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
/**
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
* The browser calls console2's OWN origin (`/nodes/v1/inventory`) with just the
* session cookie; this handler resolves the caller, resolves the BRAND from the
* request host, and fetches the allowlisted luxd RPC methods server-side for each
* network that brand may see, returning NORMALIZED per-node rows. No RPC host or
* method ever reaches the browser, and the browser can never choose either.
*
* Security (mirrors app/bootnode/[...path]/route.ts):
* - Session-gated: an unauthenticated caller gets 401 (the RPC data is public,
* but the console surface is authenticated, same as every other module).
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
* brand resolved from the host — so cloud.lux.cloud sees only Lux networks,
* console.hanzo.ai (hanzo) sees all.
* - Least privilege: the ONLY path is `v1/inventory`, and the ONLY luxd methods
* called are the four read methods below — this is not a general RPC tunnel.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { brandFromHost } from '~/config'
import { resolveUser } from '~/lib/server/identity'
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
import {
NODE_NETWORK_META,
combineInventory,
parseHeight,
type NetworkInventory,
type RawPeer,
type RawValidator,
} from '~/lib/api/nodes'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/**
* Public luxd RPC host per network — the ONLY endpoints this proxy will call.
* These are PUBLIC RPC hosts (not secrets); each is overridable per-deploy so a
* network can be repointed or disabled without a code change. A host that is
* unset/unreachable yields an honest `not-reporting` network — never fake rows.
*/
const HOSTS: Record<NodeNetworkId, string> = {
'lux-mainnet': trim(process.env.LUX_MAINNET_RPC ?? 'https://api.lux.network'),
'lux-testnet': trim(process.env.LUX_TESTNET_RPC ?? 'https://api.lux-test.network'),
'lux-devnet': trim(process.env.LUX_DEVNET_RPC ?? 'https://api.lux-dev.network'),
'pars-mainnet': trim(process.env.PARS_MAINNET_RPC ?? 'https://api.pars.network'),
// Zoo has no confirmed public primary-network host yet; the default is the
// conventional host (api.<brand>.network) and reports honestly when unreachable.
'zoo-mainnet': trim(process.env.ZOO_MAINNET_RPC ?? 'https://api.zoo.network'),
}
/** Per-network probe timeout (ms). */
const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
async function rpc<T>(
host: string,
path: '/ext/bc/P' | '/ext/info',
method: string,
signal: AbortSignal,
): Promise<T> {
const res = await fetch(`${host}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }),
cache: 'no-store',
signal,
})
if (!res.ok) throw new Error(`${method} ${res.status}`)
const json = (await res.json()) as { result?: T; error?: { message?: string } }
if (json?.error) throw new Error(json.error.message ?? `${method} error`)
return json.result as T
}
/** Probe ONE network: validators + peers + version + height, normalized. */
async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
const meta = NODE_NETWORK_META[net]
const host = HOSTS[net]
const base: NetworkInventory = {
id: net,
chain: meta.chain,
env: meta.env,
label: meta.label,
status: 'not-reporting',
validators: 0,
peers: 0,
nodes: [],
}
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const [valR, peerR, verR, hgtR] = await Promise.allSettled([
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
])
const reachable = valR.status === 'fulfilled' || peerR.status === 'fulfilled'
if (!reachable) {
const reason = valR.status === 'rejected' ? valR.reason : peerR.status === 'rejected' ? peerR.reason : null
base.error = reason instanceof Error ? reason.message : 'unreachable'
return base
}
const validators = valR.status === 'fulfilled' ? valR.value?.validators : undefined
const peers = peerR.status === 'fulfilled' ? peerR.value?.peers : undefined
const nodes = combineInventory(validators, peers, net)
base.status = 'reporting'
base.nodes = nodes
base.validators = nodes.filter((n) => n.role === 'validator').length
base.peers = nodes.filter((n) => n.role === 'peer').length
if (verR.status === 'fulfilled') base.version = verR.value?.version
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
return base
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
return base
} finally {
clearTimeout(timer)
}
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// ONE endpoint — the inventory. No arbitrary RPC pass-through.
if (path.join('/') !== 'v1/inventory') {
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
const user = await resolveUser(req)
if (!user) {
return NextResponse.json({ error: 'Sign in to view node infrastructure.' }, { status: 401 })
}
const brand = brandFromHost(req.headers.get('host'))
let networks = nodeNetworksForBrand(brand)
// Optional single-network scope, still gated by the brand's allowed set.
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
if (only) networks = networks.filter((n) => n === only)
const inventory = await Promise.all(networks.map(probe))
return NextResponse.json({ brand, networks: inventory })
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Server-gated SELF-SERVICE org member proxy — a CUSTOMER managing their OWN org.
*
* The `/admin/iam` proxy is GLOBAL-admin only, so a tenant org owner (e.g.
* Dave/maxpower) could never manage their own members through it. This proxy
* closes that: it admits ANY authenticated user with an org (`getOrgGate`), then
* the shared `forwardIam`:
* - scopes every reference (query `owner`, `id` owner, and the mutation BODY
* owner) to the caller's OWN org — a global admin may cross, a customer never;
* - guards get-organization by org NAME (no reading another org's settings);
* - requires an ORG ADMIN for writes (invite / change-role / remove), while any
* member may READ the roster.
* IAM enforces its own checks on the user-bound bearer too — this is the matching,
* fail-closed server gate, not the only one.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getOrgGate } from '~/lib/server/identity'
import { forwardIam } from '~/lib/server/iam-proxy'
export const runtime = 'nodejs'
/** Reads — any member of the org (own org only, unless global). */
const GET_SEGMENTS = new Set([
'get-users',
'get-user',
'get-roles',
'get-organization',
// Projects live under the org (IAM-served); the console host's /v1 sends /v1/iam/*
// to the cloud binary → 404, so the Projects page routes here (Bearer → IAM).
'get-organization-projects',
])
/** Writes — org admin only, own org only (unless global). */
const POST_SEGMENTS = new Set([
'add-user',
'update-user',
'delete-user',
// Org branding/settings — org-admin only, pinned to the caller's OWN org by both
// the `?id` name AND the body name (below), so a brand admin can't retarget another.
'update-organization',
// Project CRUD — org-admin only (requireAdminForWrite), pinned to the caller's org.
'add-project',
'delete-project',
])
/** Org objects are owned by the `admin` metadata org (name guarded separately). */
const ORG_META = new Set(['get-organization', 'update-organization'])
/** Segments carrying an org NAME to pin to the caller's scope (read id + write body). */
const ORG_NAME = new Set(['get-organization', 'update-organization'])
/** Segments keyed by `organization` (projects) — pin it to the caller's own org so an
* omitted/empty organization can't enumerate/pollute across tenants. */
const ORG_PARAM = new Set(['get-organization-projects', 'add-project', 'delete-project'])
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
const gate = await getOrgGate(req)
if (!gate) return forbidden()
return forwardIam(req, gate, {
segment: path.join('/'),
method,
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
orgMetaSegments: ORG_META,
orgNameSegments: ORG_NAME,
orgParamSegments: ORG_PARAM,
requireAdminForWrite: true,
})
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'GET')
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, (await ctx.params).path, 'POST')
}
+50 -1
View File
@@ -5,21 +5,67 @@
* from server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the
* browser bundle). This is the real control-plane API, not an iframe stub.
*
* SECURITY: the forwarded token is a PLATFORM SERVICE token — full control-plane
* authority, NOT tenant-scoped. So this route is gated to brand admins exactly
* like the IAM/KMS admin proxies: `getAdminGate` resolves the caller from their
* own session and requires a verified brand-admin (no gate → 403). Without this,
* any authenticated browser could drive the whole control plane through the
* service token. The gate is the control, NOT a deploy-time env toggle.
*
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
* can show a truthful "not configured" state — it never fabricates apps/deploys.
*
* SCOPE: the browser stamps the active tenant path (X-Org-Id / X-Project-Id /
* X-Environment) on every call. We forward it to the control plane so PaaS
* resources scope by org → project → environment like the rest of the console —
* but the ORG is re-resolved server-side through the admin policy (`orgFor`): a
* global admin's switched org is honored, a brand admin is PINNED to their own,
* so the forwarded X-Org-Id is authoritative and never the spoofable claim.
* Project + environment are sub-scopes the admin picks WITHIN that org, passed
* through verbatim.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const PLATFORM_URL = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
// CSRF FIRST — the service token below is control-plane god-mode, so a cross-site
// page carrying the admin's auto-sent cookie must never drive a deploy/scale/delete.
// Refuse a cross-origin MUTATION before the admin gate or any body read (safe reads
// pass). Defense in depth on top of the session cookie's own SameSite attribute.
const csrf = csrfRefusal(req)
if (csrf) return csrf
// Brand-admin gate — the service token below is control-plane god-mode.
const gate = await getAdminGate(req)
if (!gate) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
if (!TOKEN) {
return NextResponse.json(
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
{ status: 501 },
)
}
// Resolve the authoritative tenant path. Org: the admin policy honors a global
// admin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
// a brand admin to their own — so we forward the resolved org, never the raw
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
const org = policyOrgFor(
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
req.headers.get('X-Org-Id'),
)
const projectId = req.headers.get('X-Project-Id')
const environment = req.headers.get('X-Environment')
const search = req.nextUrl.search
const url = `${PLATFORM_URL}/v1/${path.join('/')}${search}`
const init: RequestInit = {
@@ -28,6 +74,9 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Org-Id': org,
...(projectId ? { 'X-Project-Id': projectId } : {}),
...(environment ? { 'X-Environment': environment } : {}),
},
// Never cache control-plane reads.
cache: 'no-store',
@@ -36,7 +85,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
init.body = await req.text()
}
try {
const res = await fetch(url, init)
const res = await fetchWithTimeout(url, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
+69
View File
@@ -0,0 +1,69 @@
'use client'
/**
* Design-reference route — renders the ProductLanding kit + the RailwayDeploy pipeline
* in its lifecycle states OFFLINE (static status props, no backend, no auth), so the
* landing/pipeline design can be reviewed and screenshotted from `next dev` without a
* live session. Data-free by construction; not linked from the product nav.
*/
import { Boxes, DollarSign, FileText, Gauge, Layers, Plus, Search, Sparkles } from '@hanzogui/lucide-icons-2'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { ProductLanding, apiBaseFromDocs, type LandingMetric, type ProductLandingConfig } from '~/components/products/landing'
import { RailwayDeploy } from '~/components/products/paas/RailwayDeploy'
import { embeddingsCodeSamples } from '~/components/products/embeddings/logic'
const metrics: LandingMetric[] = [
{ key: 'collections', label: 'Collections', value: 12, format: (n) => Math.round(n).toLocaleString(), icon: <Boxes size={14} opacity={0.6} /> },
{ key: 'documents', label: 'Documents indexed', value: 3420, format: (n) => Math.round(n).toLocaleString(), icon: <FileText size={14} opacity={0.6} /> },
{ key: 'vectors', label: 'Total vectors', value: 184213, format: (n) => Math.round(n).toLocaleString(), series: [120, 138, 150, 171, 184], deltaPct: 12, icon: <Layers size={14} opacity={0.6} /> },
{ key: 'queries', label: 'Queries (7D)', value: 8241, format: (n) => Math.round(n).toLocaleString(), series: [900, 1100, 1050, 1300, 1450], deltaPct: 8, icon: <Search size={14} opacity={0.6} /> },
{ key: 'latency', label: 'Avg latency', value: 42, format: (n) => `${Math.round(n)} ms`, series: [55, 50, 47, 44, 42], deltaPct: -6, icon: <Gauge size={14} opacity={0.6} /> },
{ key: 'cost', label: 'Cost (7D)', value: null, format: (n) => `$${(n / 100).toFixed(2)}`, icon: <DollarSign size={14} opacity={0.6} />, hint: 'Awaiting metering' },
]
const landingConfig: ProductLandingConfig = {
productId: 'embeddings',
title: 'Vector embeddings & semantic search',
tagline: 'Generate, store, and search embeddings at scale — one API for semantic search and RAG, powered by Zen embedding models.',
icon: Boxes,
docsProduct: 'embeddings',
primary: { label: 'Create collection', icon: <Plus size={16} />, onPress: () => {} },
secondary: { label: 'Try search', icon: <Search size={15} />, onPress: () => {} },
metrics,
samples: embeddingsCodeSamples(apiBaseFromDocs('https://docs.hanzo.ai'), 'zen-embedding'),
run: { label: 'Generate in console', icon: <Sparkles size={14} />, onPress: () => {} },
actions: [
{ label: 'Create collection', icon: <Plus size={15} />, onPress: () => {} },
{ label: 'Explore search', icon: <Search size={15} />, onPress: () => {} },
{ label: 'Generate embeddings', icon: <Sparkles size={15} />, onPress: () => {} },
],
}
function RailCard({ title, status }: { title: string; status: string }) {
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={320}>
<Text fontSize="$3" fontWeight="700" color="$color12">
{title}
</Text>
<RailwayDeploy status={status} />
</Card>
)
}
export default function RailwayDemoPage() {
return (
<YStack gap="$6" p="$5" maxW={1180} self="center" width="100%">
<Text fontSize="$9" fontWeight="900">RailwayDeploy pipeline</Text>
<XStack gap="$4" flexWrap="wrap">
<RailCard title="Building (in progress)" status="building" />
<RailCard title="Deploying (in progress)" status="deploying" />
<RailCard title="Live" status="live" />
<RailCard title="Failed" status="error" />
</XStack>
<Text fontSize="$9" fontWeight="900">Embeddings landing (ProductLanding kit)</Text>
<ProductLanding config={landingConfig} />
</YStack>
)
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
* Base module's ONE transport. The browser calls console2's OWN origin
* (`/superbase/v1/...`) with just the session cookie; `forwardWithUserBearer`
* resolves the user, mints a short-lived user-bound IAM token (shared per-user
* cache), and forwards to base.hanzo.ai with that token. No token ever reaches the
* browser, and the SAME @hanzo/superbase-dashboard screens render here and standalone.
*
* NOT the PaaS pattern: PaaS forwards a god-mode SERVICE token and is gated to brand
* admins. Base authorizes PER USER itself — the `tenants` collection's
* `ListRule = "owner_iam_user = @request.auth.id"` and admin-only mutations are
* enforced by Base against the forwarded user identity. So here we forward the
* USER's own minted bearer (least privilege, tenant-scoped by Base), and the only
* gate is "must be signed in" (resolveUser → 401). A non-admin simply sees their own
* tenants and gets Base's 403 on a mutation — honest, not faked.
*
* Least privilege on the path too: only the Base DATA PLANE is proxied — the
* collection schemas (read) and any collection's records (list/get/create/update/
* delete), via `allowBaseSurface`. Base's admin/settings/backup/log surfaces 404,
* so this stays a data-plane proxy, not a general Base tunnel. Base still authorizes
* every read/write per-user and per-collection itself, so a non-admin sees only what
* a collection's rules permit and gets Base's own honest 403 on a denied mutation.
* (The tenants manager rides this same proxy — records/tenants is one such path.)
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowBaseSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The Base control plane the proxied calls are forwarded to. */
const BASE_URL = trim(process.env.BASE_DASHBOARD_URL ?? 'https://base.hanzo.ai')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: BASE_URL,
path,
allow: allowBaseSurface,
unauthorizedMessage: 'Sign in to manage Base records.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Same-origin proxy to the durable task engine (hanzoai/tasks `tasksd`, the native
* Temporal-style HTTP surface at `/v1/tasks/*`).
*
* `tasksd` runs `TASKSD_REQUIRE_IDENTITY=true`: it validates an IAM **Bearer JWT**
* against the IAM JWKS, unconditionally STRIPS inbound `X-Org-Id`, and mints org +
* user from the JWT claims (`owner`→org). So — like the `/ai` proxy — the console
* calls its OWN origin (`/tasksd/...`) with just the session cookie;
* `forwardWithUserBearer` resolves the signed-in user, mints a short-lived
* user-bound IAM token (shared per-user cache), and forwards it as the Bearer. No
* key in the browser, and every read is org-scoped by the JWT server-side.
*
* READ-ONLY: only GET is proxied (the console never mutates workflows here), scoped
* to the `v1/tasks/*` subtree. When the engine is unreachable the UI shows an honest
* BackendStateCard — never fabricated workflows.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The durable task engine. Public TLS is not live yet → default to the in-cluster
* service. The tasks Service exposes REST on :7243 (http port); there is NO :80,
* so target :7243 explicitly. Override with TASKS_URL. `|| default` (not `??`) so a
* blank env still falls back to the in-cluster service. */
const TASKS_URL = trim(process.env.TASKS_URL?.trim() || 'http://tasks.hanzo.svc.cluster.local:7243')
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const rel = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: TASKS_URL,
path: `v1/tasks/${rel}`,
allow: (p) => p === 'v1/tasks' || p.startsWith('v1/tasks/'),
unauthorizedMessage: 'Sign in to view tasks.',
})
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Same-origin READ-ONLY proxy to VictoriaMetrics — the live platform telemetry
* store (Prometheus-compatible TSDB, `vmsingle-victoria-metrics-single-server`).
*
* The browser calls this OWN-origin route (`/telemetry/api/v1/query?query=up`) with
* just its first-party session cookie; this handler resolves the caller
* (`resolveUser`) and — only for a signed-in user — forwards the READ query to
* VictoriaMetrics. It powers Status (real `up{}` service health) and Metrics (real
* infra time-series), replacing the empty `/paas/apps` board and the unwired
* Metrics overview. VictoriaMetrics has no per-request auth of its own (it is an
* internal ClusterIP service), so this route IS the access boundary — hence three
* hard limits, all fail-closed:
*
* 1. Authenticated only — `resolveUser` (401 otherwise). Platform status/metrics
* is not tenant-customer data; it is the health of the Hanzo Cloud platform the
* user is signed into (a status-page concern), appropriate for any signed-in
* console user and strictly READ-ONLY — no service token, far weaker than the
* admin `/paas` control plane.
* 2. READ-only — GET only, and only the allow-listed VictoriaMetrics query
* endpoints (`allowTelemetrySurface`): `/api/v1/query`, `/query_range`,
* `/series`, `/labels`, `/label/<name>/values`, `/status/tsdb`, `/metadata`.
* Never `/api/v1/write`, `/import`, `/-/reload`, or any admin/mutating path.
* 3. Traversal-hardened — `pathIsClean` rejects `.`/`..`/`%XX`/`;` segments and the
* forward re-validates the WHATWG-normalized path, exactly like the bearer
* proxies.
*
* Honest 501 when `VM_URL` is unset, so the UI shows a truthful "telemetry not
* configured" state — never fabricated metrics.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser } from '~/lib/server/identity'
import { pathIsClean } from '~/lib/server/bearer-proxy'
import { allowTelemetrySurface } from '~/lib/server/proxy-allow'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const trimR = (s: string) => s.replace(/\/+$/, '')
const trimL = (s: string) => s.replace(/^\/+/, '')
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
/** VictoriaMetrics single-node read API. In-cluster ClusterIP :8428 (headless).
* Override with VM_URL. `|| default` (not `??`) so a blank/whitespace env still
* resolves the in-cluster service (the same env-drift guard the /vm proxy uses). */
const VM_URL = trimR(
process.env.VM_URL?.trim() || 'http://vmsingle-victoria-metrics-single-server.hanzo.svc:8428',
)
const json = (body: unknown, status: number) => NextResponse.json(body, { status })
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
const rawPath = trimL((await ctx.params).path.join('/')).replace(/\/+$/, '')
// Traversal + least-privilege on the RAW path (literal `.`/`..`/`%XX`/`;` rejected).
if (!pathIsClean(rawPath) || !allowTelemetrySurface(rawPath)) {
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
}
if (!process.env.VM_URL?.trim() && !VM_URL) {
return json(
{ status: 'error', errorType: 'not_configured', error: 'Telemetry store is not configured (VM_URL missing).' },
501,
)
}
// Authenticated only — the query surface is the access boundary (VM has no auth).
const user = await resolveUser(req)
if (!user) {
return json({ status: 'error', errorType: 'unauthenticated', error: 'Sign in to view platform telemetry.' }, 401)
}
// Re-validate the WHATWG-normalized destination (undici resolves %2e/double-encoded
// dot-segments a raw check can't see) — validate AND fetch the exact same URL.
let dest: URL
try {
dest = new URL(`${VM_URL}/${rawPath}${req.nextUrl.search}`)
} catch {
return json({ status: 'error', errorType: 'not_allowed', error: 'Bad telemetry path.' }, 404)
}
const normPath = trimL(dest.pathname).replace(/\/+$/, '')
if (!pathIsClean(normPath) || !allowTelemetrySurface(normPath)) {
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
}
try {
const res = await fetchWithTimeout(dest, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
signal: req.signal,
})
return new NextResponse(res.body, {
status: res.status,
headers: {
'Content-Type': res.headers.get('content-type') ?? 'application/json',
'Cache-Control': 'no-cache, no-transform',
},
})
} catch (e) {
console.error('telemetry-proxy: VictoriaMetrics unreachable:', msgOf(e))
return json({ status: 'error', errorType: 'upstream_error', error: 'Telemetry store is unavailable.' }, 502)
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
*
* The console's Training page calls its OWN origin (`/training/...`) with just the
* first-party session cookie; this server handler resolves the signed-in user from
* that cookie and forwards to the cloud backend's `/v1/...` surface, passing the
* cookie through (the proven `get-account` server-to-server pattern in
* lib/server/identity.ts) plus the active `X-Org-Id`. Training is a TENANT action —
* any signed-in org user may run it — so this is user-scoped (resolveUser), NOT the
* control-plane admin gate the `/paas` proxy uses. The cloud backend scopes by org
* (GetEffectiveOrg / the X-Org-Id the plain-REST train sub-service requires), so a
* caller can only ever touch their own org's jobs. `POST /v1/train/jobs` is
* billing-gated by the live ResourceMeter and returns 402 on an unfunded org — that
* status flows straight back so the UI can surface it honestly.
*
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
* browser — the HuggingFace token (for private repos) is resolved from KMS
* server-side inside the broker, never here.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser } from '~/lib/server/identity'
import { orgFor } from '~/lib/server/admin-policy'
import { csrfRefusal } from '~/lib/server/bearer-proxy'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
'train/jobs',
'train/experiments',
'ml/models',
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
// Read-only; 503 + body flows through so the UI can report a degraded plane.
'train/health',
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
'finetune/deploy',
'finetune/presets',
'finetune/hf/models',
'finetune/hf/datasets',
'finetune/hf/repo',
])
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
const rel = path.join('/')
if (!ALLOWED.has(rel)) {
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
const user = await resolveUser(req)
if (!user) {
return NextResponse.json(
{ status: 'error', msg: 'Sign in to manage training.' },
{ status: 401 },
)
}
const cookie = req.headers.get('cookie') ?? ''
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
const headers: Record<string, string> = {
cookie,
Accept: 'application/json',
'Content-Type': 'application/json',
// Org is SERVER-RESOLVED, not the raw browser header: a global admin's switched
// org (?/X-Org-Id) is honored, a non-global caller is PINNED to their own — so a
// brand admin can't drive another tenant's training jobs even if the backend
// trusted the forwarded header. Matches the /paas + /admin/kms orgFor pin.
'X-Org-Id': orgFor({ isGlobalAdmin: user.isGlobalAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
}
const projectId = req.headers.get('X-Project-Id')
const environment = req.headers.get('X-Environment')
if (projectId) headers['X-Project-Id'] = projectId
if (environment) headers['X-Environment'] = environment
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = await req.text()
}
try {
const res = await fetchWithTimeout(url, init)
const text = await res.text()
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
})
} catch (e) {
return NextResponse.json(
{ status: 'error', msg: `Fine-tuning backend unreachable: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path)
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Same-origin user-bearer proxy to Visor (vm.hanzo.ai) — the compute control plane
* (regions / gpus / machines / instances). The browser calls this OWN-origin route
* (`/vm/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves the
* user, mints a short-lived user-bound IAM token, and forwards to visor with that
* Bearer. Visor mints org + user from the JWT claims, so compute is org-scoped
* server-side — a caller only ever sees their own org's machines. No token reaches
* the browser.
*
* NOT the `/paas` pattern: `/paas` forwards a god-mode control-plane SERVICE token
* and is gated to brand admins. Compute is a TENANT action (any signed-in org user
* may list/manage their own machines), so this is user-scoped (resolveUser), and
* visor itself authorizes the forwarded user bearer.
*
* Least privilege on the path: only the visor `v1/*` surface is reachable
* (`allowVisorSurface`); anything else 404s.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowVisorSurface } from '~/lib/server/proxy-allow'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Visor (vm.hanzo.ai). In-cluster ClusterIP on :19000 (its Service has NO :80) — public
* egress is CF-403'd. Override with VISOR_URL (the CR sets visor.hanzo.svc:19000).
* `|| default` (not `??`): if the env is reconciled to an EMPTY string (observed drift on
* the live pod), `??` would keep the blank and every machines/GPUs call would fail —
* `|| default` treats blank/whitespace as unset so visor ALWAYS resolves. */
const VISOR_URL = trim(process.env.VISOR_URL?.trim() || 'http://visor.hanzo.svc:19000')
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: VISOR_URL,
path,
allow: allowVisorSurface,
// Org is authoritative (Bearer owner). Don't forward browser X-Project-Id/
// X-Environment (unvalidated sub-scopes) — RED MEDIUM.
unauthorizedMessage: 'Sign in to manage compute.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PUT(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+94
View File
@@ -0,0 +1,94 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
*
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
* two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
* were returned across the whole namespace).
*
* Credentials (env, never in repo). Skips unless BOTH tenants are provided:
* TENANT_A_EMAIL / TENANT_A_PASSWORD (org A)
* TENANT_B_EMAIL / TENANT_B_PASSWORD (org B, a DIFFERENT org)
* BASE_URL default https://console.hanzo.ai
*
* Run: TENANT_A_EMAIL=.. TENANT_A_PASSWORD=.. TENANT_B_EMAIL=.. TENANT_B_PASSWORD=.. pnpm e2e billing-isolation.spec.ts
*/
import { test, expect, type Page } from '@playwright/test'
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
const A = { email: process.env.TENANT_A_EMAIL ?? '', password: process.env.TENANT_A_PASSWORD ?? '' }
const B = { email: process.env.TENANT_B_EMAIL ?? '', password: process.env.TENANT_B_PASSWORD ?? '' }
async function signIn(page: Page, email: string, password: string) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
await page.fill('input[placeholder="Email"]', email)
await page.fill('input[placeholder="Password"]', password)
await page.click('button:has-text("Sign in")')
const base = new URL(BASE_URL).origin
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
const rows = Array.isArray(body)
? body
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.data ?? [])
ids = (Array.isArray(rows) ? rows : [])
.map((r: { id?: unknown }) => (typeof r?.id === 'string' ? r.id : ''))
.filter(Boolean)
} catch {
/* non-JSON (e.g. 501 not-configured) — ids stays empty */
}
return { status: res.status, ids }
}, path)
}
test.describe('billing is isolated per tenant through the proxy', () => {
test.skip(
!A.email || !A.password || !B.email || !B.password,
'TENANT_A_* / TENANT_B_* not set — skipping two-tenant billing isolation',
)
test('two distinct-org tenants never see each others subscriptions or payment methods', async ({ browser }) => {
const ctxA = await browser.newContext()
const ctxB = await browser.newContext()
const pageA = await ctxA.newPage()
const pageB = await ctxB.newPage()
await signIn(pageA, A.email, A.password)
await signIn(pageB, B.email, B.password)
for (const path of ['subscriptions', 'payment-methods']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
// A 401 would mean the session broke; a 501 means commerce isn't configured
// on this deployment (isolation is vacuously safe — nothing is returned).
expect(a.status, `tenant A /${path} not authorized`).not.toBe(401)
expect(b.status, `tenant B /${path} not authorized`).not.toBe(401)
if (a.status === 501 || b.status === 501) continue
// The core isolation assertion: the two tenants' row-id sets are disjoint.
const overlap = a.ids.filter((id) => b.ids.includes(id))
expect(overlap, `/${path} leaked ${overlap.length} shared rows across tenants`).toEqual([])
}
await ctxA.close()
await ctxB.close()
})
})
+295
View File
@@ -0,0 +1,295 @@
/**
* e2e: Hanzo Cloud Console — login → API key → AI inference
*
* z@hanzo.ai is in the `hanzo` org (isGlobalAdmin), so the OrgGate now shows
* a dismissible admin banner and renders the full console on console.hanzo.ai.
* Admin ops still live at admin.hanzo.ai.
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai
* HANZO_PASSWORD required
* HANZO_API_KEY optional; skip UI flow, go straight to inference
* HANZO_API_BASE default https://api.hanzo.ai
* BASE_URL default https://console.hanzo.ai
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const API_KEY = process.env.HANZO_API_KEY ?? ''
const API_BASE = process.env.HANZO_API_BASE ?? 'https://api.hanzo.ai'
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
// ─── helpers ────────────────────────────────────────────────────────────────
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
await page.fill('input[placeholder="Email"]', EMAIL)
await page.fill('input[placeholder="Password"]', PASSWORD)
await page.click('button:has-text("Sign in")')
}
/** Wait until we're on the dashboard (route replaced to '/'). */
async function waitForDashboard(page: Page) {
const base = new URL(BASE_URL).origin
await page.waitForURL(url => url.origin === base && url.pathname === '/', { timeout: 30_000 })
// The dashboard renders product grid cards — wait for at least one visible card/link
await page.waitForLoadState('domcontentloaded')
}
// ─── tests ──────────────────────────────────────────────────────────────────
// Public smoke — needs no credentials, so it always runs (in CI, for Dave, etc.)
// and catches a dead/blank sign-in gate. The authenticated flows below gate on
// HANZO_PASSWORD.
test.describe('Hanzo Cloud Console — public', () => {
test('sign-in page renders (email/password + OAuth + passkey)', async ({ page }) => {
await page.goto(`${BASE_URL}/signin`)
await expect(page.locator('input[placeholder="Email"]')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('input[placeholder="Password"]')).toBeVisible()
await expect(page.locator('button:has-text("Sign in")')).toBeVisible()
await expect(page.locator('button:has-text("Continue with GitHub")')).toBeVisible()
await expect(page.locator('button:has-text("Continue with Google")')).toBeVisible()
await expect(page.locator('text=/passkey/i')).toBeVisible()
})
test('root serves the app (200, dark #0a0a0a) + /base resolves', async ({ page, request }) => {
const res = await page.goto(BASE_URL)
expect(res?.status()).toBe(200)
await expect(page).toHaveTitle(/Hanzo Cloud Console/)
await expect(page.locator('meta[name="theme-color"][content="#0a0a0a"]')).toHaveCount(1)
expect((await request.get(`${BASE_URL}/base`)).status()).toBe(200)
})
// Security gates — the server proxies must reject unauthenticated calls and
// stay inside their allow-list. No credentials (plain request context) so this
// proves the production posture in CI. A regression is a real security bug.
test('server proxies reject unauthenticated calls (401)', async ({ request }) => {
for (const path of [
'/superbase/v1/collections/tenants/records',
'/keys',
]) {
const res = await request.get(`${BASE_URL}${path}`)
expect(res.status(), `${path} must gate`).toBe(401)
}
})
test('proxy allow-lists reject off-list paths (no tunnel)', async ({ request }) => {
const res = await request.get(`${BASE_URL}/superbase/v1/collections/secrets/records`)
// The point is "no tunnel to the backend": the off-list path must be blocked,
// not proxied. The proxy may reject with 404 (off allow-list) or 401 (auth
// gate hit first) — both are blocked; a 2xx would be the real bug.
expect([401, 404], `off-list path must be blocked, got ${res.status()}`).toContain(res.status())
})
test('unknown route never 5xxs', async ({ request }) => {
const res = await request.get(`${BASE_URL}/no-such-surface-xyz`)
expect(res.status()).toBeLessThan(500)
})
})
test.describe('Hanzo Cloud Console e2e', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping live authenticated tests')
test('login as z@hanzo.ai — dashboard renders', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// Dashboard renders — sign-in form must be gone
await expect(page.locator('input[placeholder="Password"]')).not.toBeVisible({ timeout: 10_000 })
// At least one product category or card is visible (Overview / AI / Compute etc.)
await expect(
page.locator('a, button, [role="link"]').filter({ hasText: /models|providers|overview|AI/i }).first()
).toBeVisible({ timeout: 15_000 })
console.log('✓ Signed in; dashboard is rendering')
})
test('admin banner visible (z is isAdmin on console.hanzo.ai)', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// OrgGate shows the admin banner for admins on the non-admin console host.
// The banner may have been dismissed in a prior run (localStorage). Skip softly.
const banner = page.locator('text=/Admin ops|admin\\.hanzo\\.ai/i').first()
const visible = await banner.isVisible({ timeout: 5_000 }).catch(() => false)
if (visible) {
console.log('✓ Admin banner visible')
await expect(page.locator('button:has-text("Open admin")')).toBeVisible()
} else {
console.log(' Admin banner was dismissed (localStorage) — OK')
}
})
test('create or confirm API key', async ({ page }) => {
await signIn(page)
await waitForDashboard(page)
// API Keys module is at /api-keys (catch-all route, id='api-keys')
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
// Wait for the module to hydrate — look for the page header or key cards
await expect(
page.locator('text=/API Keys/i, text=/Cloud API key/i, text=/Create.*API key/i').first()
).toBeVisible({ timeout: 25_000 })
const hasKey = page.locator('text=/Cloud API key/i')
const noKey = page.locator('text=/Create your Cloud API key/i')
const createBtn = page.locator('button:has-text("Create API key")')
const needsCreate = await noKey.isVisible({ timeout: 3_000 }).catch(() => false)
|| await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the hk- key
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (hk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
test('API key works — GET /v1/models', async ({ page }) => {
let apiKey = API_KEY
if (!apiKey) {
await signIn(page)
await waitForDashboard(page)
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
await expect(
page.locator('text=/API Keys/i').first()
).toBeVisible({ timeout: 25_000 })
// Rotate (or create) to show the full key on-screen
const rotateBtn = page.locator('button:has-text("Rotate")')
const createBtn = page.locator('button:has-text("Create API key")')
if (await rotateBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await rotateBtn.click()
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full hk- token (no `…`/`...`).
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
// Verify the key works against api.hanzo.ai
const resp = await page.request.get(`${API_BASE}/v1/models`, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
timeout: 30_000,
})
expect(resp.ok(), `GET /v1/models → ${resp.status()}`).toBe(true)
const json = await resp.json()
expect(json).toMatchObject({ data: expect.any(Array) })
const models: Array<{ id: string }> = json.data
expect(models.length).toBeGreaterThan(0)
const ids = models.map(m => m.id)
console.log(`✓ /v1/models: ${models.length} models`)
console.log(` GLM present: ${ids.some(id => id.includes('glm'))}`)
console.log(` Claude present: ${ids.some(id => id.includes('claude'))}`)
console.log(` DeepSeek present: ${ids.some(id => id.includes('deepseek'))}`)
console.log(` First 5: ${ids.slice(0, 5).join(', ')}`)
})
test('OpenAI inference — glm-5.2', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
model: 'glm-5.2',
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
stream: false,
},
timeout: 30_000,
})
expect(resp.ok(), `glm-5.2 → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.choices?.[0]?.message?.content ?? ''
expect(text).toBeTruthy()
console.log(`✓ glm-5.2: "${text.trim()}"`)
})
test('OpenAI inference — deepseek-v4-pro', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
stream: false,
},
timeout: 30_000,
})
expect(resp.ok(), `deepseek-v4-pro → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.choices?.[0]?.message?.content ?? ''
expect(text).toBeTruthy()
console.log(`✓ deepseek-v4-pro: "${text.trim()}"`)
})
test('Anthropic-compat /v1/messages — live catalog model', async ({ page }) => {
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
// Pick a model that is ACTUALLY available right now (the catalog changes;
// a hardcoded id like claude-sonnet-4-6 fails when it isn't provisioned).
// The /v1/messages Anthropic surface accepts any catalog model.
const listed = await page.request.get(`${API_BASE}/v1/models`, {
headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
timeout: 30_000,
})
const ids: string[] = (await listed.json()).data?.map((m: { id: string }) => m.id) ?? []
const model = ids.find((id) => id.includes('claude')) ?? ids.find((id) => id === 'glm-5.2') ?? ids[0]
expect(model, 'no model available in catalog').toBeTruthy()
const resp = await page.request.post(`${API_BASE}/v1/messages`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'anthropic-version': '2023-06-01',
},
data: {
model,
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
max_tokens: 16,
},
timeout: 30_000,
})
expect(resp.ok(), `/v1/messages (${model}) → ${resp.status()}`).toBe(true)
const json = await resp.json()
const text: string = json.content?.[0]?.text ?? ''
expect(text).toBeTruthy()
console.log(`✓ Anthropic-compat /v1/messages (${model}): "${text.trim()}"`)
})
})
+80
View File
@@ -0,0 +1,80 @@
/**
* e2e regression: the reported launch bug — product routes must render on a
* DIRECT URL load and a browser REFRESH, not only via in-app navigation.
*
* Product modules mount client-only under the catch-all route, so a throw in one
* module's first render used to bubble to Next's root fallback and white-screen
* the whole console with "Application error: a client-side exception has
* occurred" — but ONLY on a direct load / refresh (in-app nav renders fresh and
* hid it). `ProductErrorBoundary` + the dashboard `error.tsx` close that class:
* even a module throw now keeps the shell and shows a retryable card, never a
* white screen. This spec proves each target route:
* 1. direct-loads without a client-exception white-screen,
* 2. refreshes (F5) without one,
* 3. still deep-links its own real content (shell + main region present).
*
* Credentials (env, never in repo): HANZO_EMAIL / HANZO_PASSWORD, BASE_URL.
* Run: HANZO_PASSWORD=xxx BASE_URL=https://console.hanzo.ai pnpm e2e deeplink-refresh.spec.ts
*/
import { test, expect, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
// The three routes from the report, plus controls known to deep-link fine.
const TARGETS = ['/playground', '/prompts', '/gpus']
const CONTROLS = ['/models', '/providers']
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
// @hanzo/gui Input binds onChangeText — real keystrokes, not fill().
await page.locator('input[placeholder="Email"]').pressSequentially(EMAIL, { delay: 12 })
await page.locator('input[placeholder="Password"]').pressSequentially(PASSWORD, { delay: 12 })
await page.click('button:has-text("Sign in")')
const origin = new URL(BASE_URL).origin
await page.waitForURL((u) => u.origin === origin && u.pathname === '/', { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
/** Assert the page rendered the console (shell + main) and did NOT white-screen. */
async function assertRendered(page: Page, route: string, phase: string) {
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// No client-exception white-screen.
await expect(
page.locator('text=/Application error|client-side exception|Unhandled Runtime Error/i'),
`${route} (${phase}) must not white-screen`,
).toHaveCount(0)
// Shell survived: the persistent nav ("Overview"/"Apps") is present.
const body = (await page.locator('body').innerText().catch(() => '')) || ''
expect(body.length, `${route} (${phase}) has content`).toBeGreaterThan(200)
expect(body, `${route} (${phase}) kept the shell`).toMatch(/Overview|Apps|Sign out/i)
}
test.describe('deep-link + refresh must not crash', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated deep-link pass')
test.beforeEach(async ({ page }) => {
await signIn(page)
})
for (const route of [...TARGETS, ...CONTROLS]) {
test(`direct load + refresh: ${route}`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(String(e)))
// 1) DIRECT URL load (full navigation, fresh document).
const res = await page.goto(`${BASE_URL}${route}`, { waitUntil: 'domcontentloaded' })
expect(res?.status() ?? 0, `${route} HTTP`).toBeLessThan(500)
await assertRendered(page, route, 'direct')
// 2) REFRESH (F5) — the reported failing action.
await page.reload({ waitUntil: 'domcontentloaded' })
await assertRendered(page, route, 'refresh')
// An uncaught pageerror on these routes is the regression we are locking out.
expect(errors, `${route} uncaught pageerror(s): ${errors.join(' | ').slice(0, 300)}`).toEqual([])
})
}
})
+148
View File
@@ -0,0 +1,148 @@
/**
* LIVE confirmation for feat/billing-usage-admin (v8.4.15) on the deployed cluster.
*
* Topology (verified live):
* - console.hanzo.ai: the console app is directly reachable at `/`; `/v1/*` is
* routed by the ingress to cloud-api (hanzoai/gateway), which enforces its OWN
* `global admin required` gate. The console's own H1 gate is reachable directly
* at `/admin/aggregate/*`. z@hanzo.ai is a global admin on this host.
* - admin.hanzo.ai: an EDGE forward-auth (`admin-guard@file`, org=admin, cookie
* on `.hanzo.ai`) sits in front of the SAME console image. A browser must carry
* a valid `.hanzo.ai` guard/session cookie to pass; a cold hit is 401.
*
* Three required checks (task bar):
* (a) admin business board (LivingOverview: MRR/usage/orgs/top-agents/fleet)
* renders for the GLOBAL admin z@hanzo.ai.
* (b) an unprivileged caller gets 403 on /v1/admin/* — no cross-org leak
* (fail-closed gate); iam/kms are not tunneled.
* (c) billing Reports shows the product/agent cost DIMENSION (honest-empty ok).
*/
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
const ADMIN = process.env.ADMIN_URL ?? 'https://admin.hanzo.ai'
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
/** Sign in via the console app sign-in form (email/password → cloud /v1/signin). */
async function signIn(page: Page, base: string) {
await page.goto(`${base}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
await page.fill('input[placeholder="Email"]', EMAIL)
await page.fill('input[placeholder="Password"]', PASSWORD)
await page.click('button:has-text("Sign in")')
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
// ─── (b) fail-closed gate — needs no credentials, always runs ─────────────────
test.describe('LIVE v8.4.15 — (b) admin gate fail-closed', () => {
test('/v1/admin/* → 403 unauthenticated on console.hanzo.ai; iam/kms not tunneled', async ({ request }) => {
for (const head of ['overview', 'usage', 'orgs', 'audit', 'products']) {
const res = await request.get(`${CONSOLE}/v1/admin/${head}`)
expect(res.status(), `${CONSOLE}/v1/admin/${head} must be 403`).toBe(403)
}
// The console's OWN H1 route is also fail-closed.
const own = await request.get(`${CONSOLE}/admin/aggregate/overview`)
expect(own.status(), 'console app /admin/aggregate gate must be 403').toBe(403)
// Least privilege: iam/kms are NOT reachable through the aggregate rewrite.
for (const head of ['iam', 'kms']) {
const res = await request.get(`${CONSOLE}/admin/aggregate/${head}`)
expect([403, 404], `${head} must not tunnel via aggregate`).toContain(res.status())
}
// Edge-guarded admin host: cold hit is refused (401 forward-auth) — never open.
const edge = await request.get(`${ADMIN}/v1/admin/overview`)
expect(edge.status(), 'admin.hanzo.ai must be edge-gated (401/403)').toBeGreaterThanOrEqual(401)
expect(edge.status()).toBeLessThan(404)
console.log('✓ (b) console /v1/admin/* → 403 unauth; /admin/aggregate/{iam,kms} not tunneled; admin.hanzo.ai edge-gated')
})
})
// ─── (a) + (c) authenticated as the global admin z@hanzo.ai ───────────────────
test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set')
test('(a) admin business board renders for global admin z@hanzo.ai', async ({ page }) => {
await signIn(page, CONSOLE)
// The business board (catalog id `business`, admin:true) renders the ONE
// LivingOverview for `admin-business`: MRR / revenue / active orgs / customers,
// revenue+usage trend, top-agents-by-cost donut, fleet health.
await page.goto(`${CONSOLE}/business`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
// Global admin sees the board (not the admin-only "not authorized" gate).
const board = page.locator(
'text=/MRR|Revenue|Active orgs|Customers|Usage cost|Top agents|Fleet|Business/i'
).first()
await expect(board, 'admin business board did not render for the global admin').toBeVisible({ timeout: 30_000 })
// The client admin gate did NOT block z (would show a forbidden/hidden state).
await expect(page.locator('text=/not authorized|access denied|admin only|forbidden/i')).toHaveCount(0)
await page.screenshot({ path: `${SHOTS}/a-business-board.png`, fullPage: true })
console.log('✓ (a) admin business board rendered for global admin z@hanzo.ai')
})
// (a2) The god-view gate is consistent with the account's ACTUAL grant. z@hanzo.ai
// lives in org `hanzo` (a brand/org admin), NOT the global `admin` org, so the
// all-orgs god view is correctly refused (403) and the board shows the honest
// "managed by Hanzo" fallback — no fabricated cross-org KPIs, no leak. A member of
// the `admin` org would instead get 200 and the KPI board. Either way the gate is
// fail-closed and matches what the board renders.
test('(a2) god-view gate matches the account grant (hanzo org-admin → honest 403)', async ({ page }) => {
await signIn(page, CONSOLE)
const acct = await (await page.request.get(`${CONSOLE}/v1/get-account`)).json().catch(() => ({}))
const owner = acct?.data?.owner
const res = await page.request.get(`${CONSOLE}/v1/admin/overview`)
if (owner === 'admin') {
expect(res.status(), 'global admin must pass the gate').not.toBe(403)
console.log(`✓ (a2) global-admin (org=admin) /v1/admin/overview → ${res.status()} (gate passed)`)
} else {
expect(res.status(), 'non-global-admin must be refused the god view').toBe(403)
console.log(`✓ (a2) org-admin (org=${owner}) → 403 on the god view; board shows honest managed fallback (no cross-org leak)`)
}
})
// (a3) admin.hanzo.ai: after establishing the shared `.hanzo.ai` session, the
// edge guard should admit the global admin and render the same board. If the
// guard still refuses (its own OIDC bootstrap), record the honest state — the
// board is proven on console.hanzo.ai (same image) and the edge gate is proven
// fail-closed above.
test('(a3) admin.hanzo.ai admits the global admin (or is honestly edge-gated)', async ({ page }) => {
await signIn(page, CONSOLE) // sets the `.hanzo.ai`-scoped session
const res = await page.request.get(`${ADMIN}/`)
if (res.status() === 200) {
await page.goto(`${ADMIN}/business`, { waitUntil: 'domcontentloaded' })
const board = page.locator('text=/MRR|Revenue|Active orgs|Customers|Top agents|Business/i').first()
await expect(board).toBeVisible({ timeout: 30_000 })
await page.screenshot({ path: `${SHOTS}/a3-admin-host-board.png`, fullPage: true })
console.log('✓ (a3) admin.hanzo.ai admitted the global admin; board rendered')
} else {
console.log(` (a3) admin.hanzo.ai edge-guard returned ${res.status()} for the shared session — board verified on console.hanzo.ai (same image)`)
}
})
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
// The route no longer resolves to the commerce proxy JSON.
await expect(page.locator('text=/^\\{"error":"not found"\\}$|could not be found/i'),
'reports still shadowed by the /billing proxy').toHaveCount(0, { timeout: 20_000 })
// The Cost-table Reports surface: the "spend by <dimension>" control. model +
// provider are always offered; product + agent appear the moment the commerce
// ledger tags a row (honest — never a fabricated column). Assert a dimension
// affordance renders (BillingReports mounted).
const dim = page.getByText(/by model|by provider|by product|by agent|group by|dimension|spend by|Cost by/i).first()
await expect(dim, 'cost dimension control did not render').toBeVisible({ timeout: 30_000 })
await expect(page.locator('text=/something went wrong|application error/i')).toHaveCount(0)
await page.screenshot({ path: `${SHOTS}/c-billing-reports.png`, fullPage: true })
console.log('✓ (c) /billing/reports rendered the BillingReports cost-dimension control (unshadowed)')
})
})
+103
View File
@@ -0,0 +1,103 @@
/**
* e2e: the Open Edition (run-for-pay) overview renders its real, product-specific
* content — not just "a page mounted".
*
* `pages.spec.ts` proves /open-edition is reachable + doesn't crash (the generic
* sweep). THIS spec proves the run-for-pay board actually rendered the things that
* make it the Open Edition board: the "Open Edition" heading, the run-for-pay
* framing, the "cost + 25%" margin caption on the Spend KPI, and the spend/tokens
* KPI labels — all sourced from the config in
* src/components/products/overview/living/registry.ts (id `open-edition`), which
* reads the REAL commerce usage ledger scoped to the open-edition product tag.
*
* These are content assertions on the live surface, not a stub: if the config is
* unwired, the route unrouted, or the board silently swapped for another usage
* view, a specific assertion fails.
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai (global admin — sees every product)
* HANZO_PASSWORD required (skips when unset)
* BASE_URL default https://console.hanzo.ai
*
* Run: HANZO_PASSWORD=xxx pnpm e2e open-edition.spec.ts
*/
import { test, expect, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
await page.fill('input[placeholder="Email"]', EMAIL)
await page.fill('input[placeholder="Password"]', PASSWORD)
await page.click('button:has-text("Sign in")')
const base = new URL(BASE_URL).origin
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
test.describe('Open Edition — run-for-pay overview renders real content', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated Open Edition visual gate')
let ctx: import('@playwright/test').BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
ctx = await browser.newContext()
page = await ctx.newPage()
await signIn(page)
})
test.afterAll(async () => {
await ctx?.close()
})
test('the /open-edition board mounts, is reachable, and does not crash', async () => {
const errors: string[] = []
const onErr = (e: Error) => errors.push(String(e))
page.on('pageerror', onErr)
const res = await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
expect(res?.status() ?? 0, '/open-edition HTTP').toBeLessThan(500)
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
expect(bodyText.trim().length, '/open-edition rendered content').toBeGreaterThan(0)
page.off('pageerror', onErr)
if (errors.length) console.log(`⚠ /open-edition pageerror: ${errors.join(' | ').slice(0, 200)}`)
})
test('renders the Open Edition run-for-pay header + the cost+25% Spend KPI', async () => {
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// The product heading — this is the Open Edition board, not a generic usage view.
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
// The run-for-pay framing from the config subtitle (`Run open-source workloads
// for pay …`). Matches on the distinctive phrase so it can't pass on another board.
await expect(page.getByText(/run open-source workloads for pay/i).first()).toBeVisible()
// The load-bearing pricing detail: the Spend KPI carries the "cost + 25% margin"
// caption (the served revenue R = cost + resell margin). This is the visible
// proof the 25% run-for-pay model is surfaced, not just tokens.
await expect(page.getByText(/cost \+ 25% margin/i).first()).toBeVisible()
// The run-for-pay KPI labels the config declares (spend billed, tokens run).
await expect(page.getByText(/spend billed/i).first()).toBeVisible()
await expect(page.getByText(/tokens run/i).first()).toBeVisible()
})
test('captures a full-page screenshot of the Open Edition board', async () => {
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// Wait for the heading so the shot is of the rendered board, not a spinner frame.
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
await page.screenshot({ path: 'e2e/screenshots/open-edition.png', fullPage: true })
})
})
+121
View File
@@ -0,0 +1,121 @@
/**
* e2e: screenshot every console page + assert each renders (FE↔BE wired).
*
* Signs in as z@hanzo.ai (global admin, sees every product), then visits all 89
* registered product routes. For each page it:
* - navigates to /<id>,
* - waits for hydration,
* - asserts the app shell is present and the page did NOT hit a hard crash
* (Next error overlay / "Application error" / a blank body),
* - captures a full-page screenshot into e2e/screenshots/<id>.png.
*
* This is the "screenshot it all, make sure every page is wired" pass. It does
* NOT click destructive buttons — it proves each surface mounts, renders real
* state (or an honest empty/loading/403), and is reachable end to end. Deeper
* per-button flows live in console.spec.ts (API key create/rotate, inference).
*
* Credentials (env, never in repo):
* HANZO_EMAIL default z@hanzo.ai
* HANZO_PASSWORD required (skips when unset)
* BASE_URL default https://console.hanzo.ai
*
* Run: HANZO_PASSWORD=xxx pnpm e2e pages.spec.ts
*/
import { test, expect, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
/**
* Every product route id in the registry (src/lib/products/registry.tsx).
* Kept as a literal list so the spec has zero import coupling to the app bundle;
* a page added to the registry gets a screenshot by adding its id here.
*/
const PAGES: string[] = [
'overview', 'dashboards', 'status',
// AI
'models', 'model-catalog' /* alias */, 'providers', 'playground', 'chat', 'inference',
'agents', 'prompts', 'finetuning', 'embeddings', 'evals', 'datasets', 'experiments',
'annotation-queues', 'scores', 'score-configs', 'observations', 'traces', 'sessions',
// Compute / hypervisor
'machines', 'gpus', 'clusters', 'kubernetes', 'containers', 'networks', 'vpc',
'load-balancer', 'service-mesh', 'edge', 'zero-trust', 'dns', 'cdn',
// Data / storage
's3', 'sql', 'vector', 'datastore', 'kv', 'search', 'docdb', 'base', 'memory', 'indexer',
// Platform
'functions', 'pipelines', 'builds', 'releases', 'environments', 'projects', 'oracles',
// Identity / security
'iam', 'users', 'team', 'authz', 'kms', 'secrets', 'mpc', 'hsm', 'attestations',
'api-keys', 'tokens', 'applications',
// Business / analytics — the unified Billing Center + its sub-pages (Cost /
// Subscriptions / Payment-methods are now tabs of /billing, not top-level routes).
'billing', 'billing/reports', 'billing/budgets', 'billing/invoices',
'billing/subscriptions', 'billing/payment-methods', 'billing/credits',
'ai-metrics', 'open-edition', 'metrics', 'settlement', 'wallet', 'plans', 'referrals', 'marketplace',
// Ops / tooling
'logs', 'alerts', 'o11y', 'gateway', 'tasks', 'integrations', 'registry', 'audit',
'sdks', 'cli', 'ide', 'studio', 'desktop', 'bot', 'profile', 'settings',
]
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
await page.fill('input[placeholder="Email"]', EMAIL)
await page.fill('input[placeholder="Password"]', PASSWORD)
await page.click('button:has-text("Sign in")')
const base = new URL(BASE_URL).origin
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
await page.waitForLoadState('domcontentloaded')
}
// Sign in ONCE and reuse the session for all 94 pages. A per-test login (94×)
// trips IAM's "too many login attempts" rate-limit around page 35, which is a
// SECURITY FEATURE working correctly — not a page failure. One shared context
// signs in once, then every page reuses the cookie. Serial so they share it.
test.describe.configure({ mode: 'serial' })
test.describe('Hanzo Cloud Console — every page renders + screenshot', () => {
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated screenshot pass')
let ctx: import('@playwright/test').BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
ctx = await browser.newContext()
page = await ctx.newPage()
await signIn(page)
})
test.afterAll(async () => {
await ctx?.close()
})
for (const id of PAGES) {
test(`page: /${id}`, async () => {
const errors: string[] = []
const onErr = (e: Error) => errors.push(String(e))
page.on('pageerror', onErr)
const res = await page.goto(`${BASE_URL}/${id}`, { waitUntil: 'domcontentloaded' })
// Route must not 5xx.
expect(res?.status() ?? 0, `/${id} HTTP`).toBeLessThan(500)
// App shell mounted (the console renders a nav + main region on every page).
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
// Hard-crash guards: no Next error overlay, no generic crash banner.
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
// The body must have real content (not a blank white page).
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
expect(bodyText.trim().length, `/${id} rendered content`).toBeGreaterThan(0)
await page.screenshot({ path: `e2e/screenshots/${id}.png`, fullPage: true })
page.off('pageerror', onErr)
// Surface (don't fail on) any console page errors for triage.
if (errors.length) console.log(`⚠ /${id} pageerror: ${errors.join(' | ').slice(0, 200)}`)
})
}
})
+153
View File
@@ -0,0 +1,153 @@
/**
* LIVE probe of the o11y (SigNoz) backend through the console's own /cloud bearer
* proxy. Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches
* each candidate endpoint exactly as the new Observe modules will — same-origin
* `<origin>/v1/o11y/*` (rewritten to `/cloud/v1/o11y/*`, cloud rewrites to the o11y
* runtime's `/api/*`). Prints the HTTP status + a body snippet per endpoint so we
* know what returns real data vs 404 (which must be flagged) before we build.
*
* Not a pass/fail test — a discovery harness. Run:
* BASE_URL=https://console.hanzo.ai HANZO_PASSWORD='…' npx playwright test probe-o11y --reporter=line
*/
import { test, type Page } from '@playwright/test'
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
async function signIn(page: Page) {
await page.goto(`${BASE_URL}/signin`)
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
await page.fill('input[placeholder="Email"]', EMAIL)
await page.fill('input[placeholder="Password"]', PASSWORD)
await page.click('button:has-text("Sign in")')
const base = new URL(BASE_URL).origin
// Resilient: the post-login target may be '/' (dashboard) OR '/onboard' (org gate)
// OR it may just set the session cookie while staying put briefly. Wait for the
// sign-in form to DISAPPEAR (we left the /signin gate), whatever the destination.
await page
.waitForFunction(() => !document.querySelector('input[placeholder="Password"]'), { timeout: 90_000 })
.catch(() => {})
// Give the session cookie + any redirect a moment to settle, then land on '/'.
await page.goto(base, { waitUntil: 'domcontentloaded' }).catch(() => {})
await page.waitForTimeout(2500)
}
/** now() epoch — SigNoz wants ns for services/errors, ms for infra. */
const nowMs = Date.now()
const endNs = String(nowMs * 1_000_000)
const startNs = String((nowMs - 60 * 60 * 1000) * 1_000_000) // 1h window
const endMs = nowMs
const startMs = nowMs - 60 * 60 * 1000
type Probe = { name: string; path: string; method: 'GET' | 'POST'; body?: unknown }
const PROBES: Probe[] = [
// ── Dashboards (SigNoz) ──
{ name: 'dashboards.list', path: 'o11y/v1/dashboards', method: 'GET' },
{ name: 'dashboards.v2', path: 'o11y/v2/dashboards', method: 'GET' },
// ── Service map / APM ──
{ name: 'services.list', path: 'o11y/v1/services/list', method: 'GET' },
{
name: 'services',
path: 'o11y/v1/services',
method: 'POST',
body: { start: startNs, end: endNs, tags: [] },
},
{
name: 'dependency_graph',
path: 'o11y/v1/dependency_graph',
method: 'POST',
body: { start: startNs, end: endNs, tags: [] },
},
{
name: 'service.top_operations',
path: 'o11y/v1/service/top_operations',
method: 'POST',
body: { start: startNs, end: endNs, service: '' },
},
// ── Infra ──
{
name: 'hosts.list',
path: 'o11y/v1/hosts/list',
method: 'POST',
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
},
{
name: 'pods.list',
path: 'o11y/v1/pods/list',
method: 'POST',
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
},
{
name: 'nodes.list',
path: 'o11y/v1/nodes/list',
method: 'POST',
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
},
{
name: 'namespaces.list',
path: 'o11y/v1/namespaces/list',
method: 'POST',
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
},
{
name: 'clusters.list',
path: 'o11y/v1/clusters/list',
method: 'POST',
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
},
// ── Exceptions ──
{
name: 'listErrors',
path: 'o11y/v1/listErrors',
method: 'POST',
body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' },
},
{
name: 'countErrors',
path: 'o11y/v1/countErrors',
method: 'POST',
body: { start: startNs, end: endNs },
},
// ── Health (sanity: proves the runtime is reachable) ──
{ name: 'health', path: 'o11y/v1/health', method: 'GET' },
{ name: 'version', path: 'o11y/v1/version', method: 'GET' },
// ── Alerts (known-good baseline — AlertsModule already uses this) ──
{ name: 'rules', path: 'o11y/v1/rules', method: 'GET' },
]
test('probe o11y endpoints (live, authenticated)', async ({ page }) => {
test.setTimeout(180_000)
if (!PASSWORD) throw new Error('HANZO_PASSWORD required for the live probe')
await signIn(page)
const results = await page.evaluate(
async ({ probes }: { probes: Probe[] }) => {
const out: { name: string; status: number; ok: boolean; snippet: string }[] = []
for (const p of probes) {
try {
const res = await fetch(`${window.location.origin}/v1/${p.path}`, {
method: p.method,
credentials: 'include',
headers: p.body !== undefined ? { 'Content-Type': 'application/json' } : {},
body: p.body !== undefined ? JSON.stringify(p.body) : undefined,
})
const text = await res.text()
out.push({ name: p.name, status: res.status, ok: res.ok, snippet: text.slice(0, 240) })
} catch (e) {
out.push({ name: p.name, status: -1, ok: false, snippet: String(e).slice(0, 240) })
}
}
return out
},
{ probes: PROBES },
)
console.log('\n================ O11Y LIVE PROBE ================')
for (const r of results) {
console.log(`\n[${r.status}] ${r.name}`)
console.log(` ${r.snippet.replace(/\n/g, ' ')}`)
}
console.log('\n================ END PROBE ================\n')
})
+16 -1
View File
@@ -1,7 +1,22 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
export const config = createGui(defaultConfig)
// Canonical Hanzo UI face: Basel Grotesk (self-hosted via app/globals.css @font-face),
// paired with Geist Mono for code/data. Override the @hanzo/gui (Tamagui) v5 default
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
// renders Basel — one place, whole product (DRY). Size/line-height/weight scales are
// inherited from the default config; only the family swaps.
const BASEL =
"'Basel', -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
export const config = createGui({
...defaultConfig,
fonts: {
...defaultConfig.fonts,
body: { ...defaultConfig.fonts.body, family: BASEL },
heading: { ...defaultConfig.fonts.heading, family: BASEL },
},
})
export default config
Vendored
+7 -4
View File
@@ -1,11 +1,14 @@
/**
* Registers our Gui config with the type system so component style props
* (tokens, themes, shorthands) are typed. `GuiCustomConfig` is declared in
* `@hanzogui/web` and re-exported across the Gui packages; augmenting it there
* flows the types through every `@hanzo/gui` component.
* Registers our Gui config with the type system so shorthand style props
* (tokens, themes, bg/px/py/items/justify etc.) are typed correctly.
* GuiCustomConfig is declared in @hanzogui/web and flows through @hanzo/gui.
*/
import type { Conf } from './gui.config'
declare module '@hanzogui/web' {
interface GuiCustomConfig extends Conf {}
}
declare module '@hanzogui/core' {
interface GuiCustomConfig extends Conf {}
}
+129 -2
View File
@@ -1,4 +1,4 @@
import { readdirSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
@@ -20,6 +20,12 @@ import { dirname, join } from 'node:path'
*/
const __dirname = dirname(fileURLToPath(import.meta.url))
// Single source of the app version: package.json. Exposed to the browser as
// NEXT_PUBLIC_APP_VERSION so the shell can render the shared "Hanzo Cloud <MAJOR.MINOR>"
// product-release label (console app-semver; cloud ships its own Go v1.x under the
// same umbrella). No second place holds the version.
const pkgVersion = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version
/** Every installed `@hanzogui/*` package, discovered (not hardcoded). */
function guiPackages() {
const dir = join(__dirname, 'node_modules', '@hanzogui')
@@ -29,13 +35,134 @@ function guiPackages() {
} catch {
scoped = []
}
return ['@hanzo/gui', '@hanzo/iam-js-sdk', 'react-native-web', ...scoped]
// @hanzo/dash and @hanzo/data ship their screens/components as ESM/TSX
// source (no compiled dist), so the shared Base UI is transpiled here the same
// way Gui is.
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', 'react-native-web', ...scoped]
}
/**
* Same-origin `/v1/*` for the AI product surface — ZERO client-visible prefix.
*
* The CTO contract is "no prefix before /v1/ in any API call": the browser calls
* its OWN origin at a clean `/v1/<head>/...`, never `/cloud/...` or `/ai/...`.
* These rewrites map exactly the AI-surface heads to the console's already-hardened
* server-side bearer proxies (`app/cloud`, `app/ai`) — so the URL the client builds
* is `/v1/prompts` while the request still terminates at OUR Next origin, which
* mints a short-lived user bearer and forwards it (the raw session cookie NEVER
* reaches cloud-api, so cloud-api carries no cookie-CSRF surface). This gives the
* one-endpoint-form goal WITHOUT weakening the bearer trust boundary.
*
* Scope is deliberately the CLOSED head list the AI clients use (prompts/agents/
* evals via /cloud, models/chat/embeddings/rerank via /ai) — a blanket `/v1/:path*`
* would shadow paths meant for other backends. Each destination handler still
* enforces its own least-privilege allow-list (`proxy-allow.ts`), so a rewrite can
* never widen what the proxy admits. `beforeFiles` so these win over any route.
*
* The admin AGGREGATE reads (`/v1/admin/{overview,usage,orgs,audit,products}` — the
* cross-tenant business/platform board) map to the GLOBAL-ADMIN-GATED proxy
* (`app/admin/aggregate`), which runs `getAdminGate` (fail-closed 403) BEFORE
* forwarding. This is the console-side server gate for the all-orgs god view (RED
* H1) — NOT the ungated `/cloud` proxy. `admin/iam` + `admin/kms` are deliberately
* NOT rewritten here: they keep their own gated proxies with their own tenant
* scoping, and are reached by the client's explicit `/admin/*` origin path.
*/
const CLOUD_V1_HEADS = ['prompts', 'agents', 'evals', 'analytics', 'templates', 'projects', 'platform', 'crm', 'ml', 'vpcs', 'load-balancers', 'networks', 'mesh', 'edge', 'indexers', 'oracles', 'authz', 'o11y', 'websearch', 'enablement']
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos']
// The admin aggregate heads rewritten to the GLOBAL-ADMIN-GATED proxy. `providers`
// is the AI-provider control board — its GET (the list) AND its POST mutations
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
// which is method-agnostic (Next matches on the URL), so POST is covered without a
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'providers', 'customers', 'revenue', 'analytics', 'enablement']
/**
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
* the built image), so production is unchanged — there the console host's edge routes
* `/v1` to cloud-api. The request cookie is forwarded by the rewrite, so the local
* dev session resolves against the real cloud.
*/
const DEV_CLOUD_ORIGIN = process.env.DEV_CLOUD_ORIGIN?.replace(/\/+$/, '')
const devCloudRewrites = () =>
DEV_CLOUD_ORIGIN
? [
{ source: '/v1/iam/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/iam/:path*` },
{ source: '/v1/o11y/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/o11y/:path*` },
]
: []
// Native cloud INFRA + managed-data heads the data-product clients call at a clean
// `/v1/<head>` (nothing before /v1/); each is rewritten to the same-origin user-
// bearer `/cloud` proxy (app/cloud) — which mints a per-user token and forwards to
// cloud-api — and is allow-listed in proxy-allow.ts CLOUD_HEADS (defense in depth).
const CLOUD_INFRA_V1_HEADS = ['machines', 'gpus', 'clusters', 'org', 'sql', 'vector', 'datastore', 'kv', 'search', 's3', 'docdb']
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor `/vm` proxy
// (app/vm). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes` so it
// never collides with the cloud-api GPU INVENTORY at `/v1/gpus`.
const VM_V1_HEADS = ['regions', 'sizes']
const aiSurfaceRewrites = () => ({
beforeFiles: [
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/v1/${h}` })),
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/v1/${h}/:path*` })),
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
// Data-product clients (compute / visor / platform / provisioning / storage) —
// clean `/v1/<head>` → the user-bearer `/cloud` proxy (org from the Bearer owner).
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
// Public compute catalog → the visor `/vm` proxy.
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/vm/v1/${h}` })),
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/vm/v1/${h}/:path*` })),
{ source: `/v1/gpu-sizes`, destination: `/vm/v1/gpus` },
// Per-tenant billing DATA → the service-token commerce proxy (app/billing/v1).
{ source: `/v1/billing/:path*`, destination: `/billing/v1/:path*` },
...devCloudRewrites(),
],
})
/**
* EMBED MODE (the "True 1-binary FE" target — task #41).
*
* `npm run build:embed` sets CONSOLE_EMBED=1 and produces a STATIC EXPORT (`out/`)
* that the hanzoai/cloud Go binary go:embeds and serves at its own web root. In this
* mode the console SPA is same-origin with the cloud `/v1` API (config.cloudUrl
* defaults to window.location.origin), so:
*
* - `output: 'export'` — emit a pure static bundle, no Node server.
* - NO `rewrites` — a static export cannot run rewrites, and it does not need
* them: the clean `/v1/<head>` calls the SPA already builds now terminate
* DIRECTLY at the embedded cloud's mounted subsystems (prompts/agents/evals/…,
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites used
* to forward to via the Next BFF. The BFF proxy routes (app/cloud, app/ai,
* app/commerce, …) are the server, and in one-binary the cloud binary IS the
* server — so they are simply absent from the export (see below).
* - `images.unoptimized` — the export has no Image Optimization server.
*
* PRECONDITION for a clean `output:'export'`: the app/ tree must contain NO dynamic
* server route handlers (a static export has no server runtime to run them).
* Those handlers are the BFF proxies + the two standalone routes; the latter
* (keys/onboard) are ported to cloud `/v1/console/*`, and the proxies collapse to
* the cloud `/v1/*` the SPA calls directly. The embed build therefore runs against
* a tree with every app route handler removed (the build:embed script prunes the
* "route" files into a scratch stash so the server build on `main` is untouched).
*
* The normal `npm run build` is UNCHANGED (server build with rewrites) so nothing
* regresses for the standalone console deployment during the transition.
*/
const EMBED = process.env.CONSOLE_EMBED === '1'
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
env: { NEXT_PUBLIC_APP_VERSION: pkgVersion },
transpilePackages: guiPackages(),
...(EMBED
? { output: 'export', images: { unoptimized: true } }
: { rewrites: aiSurfaceRewrites }),
experimental: {
esmExternals: true,
},
+1681 -84
View File
File diff suppressed because it is too large Load Diff
+22 -9
View File
@@ -1,36 +1,49 @@
{
"name": "@hanzo/console2",
"version": "0.1.3",
"name": "@hanzo/console",
"version": "8.4.66",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
"build:embed": "node scripts/build-embed.mjs",
"start": "next start -p 4000",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"@hanzo/dash": "0.3.0",
"@hanzo/data": "^1.2.0",
"@hanzo/gui": "7.3.0",
"@hanzo/iam-js-sdk": "0.19.1",
"@zap-proto/web": "1.0.0",
"@zap-proto/zap": "1.6.0",
"superjson": "2.2.2",
"@hanzo/logo": "^1.0.7",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@luxfi/logo": "^1.0.1",
"@zap-proto/web": "1.0.0",
"@zap-proto/zap": "1.6.0",
"@zooai/logo": "^1.0.2",
"axe-core": "4.12.1",
"ethers": "6.17.0",
"next": "15.5.19",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-web": "0.21.2"
"react-native-web": "0.21.2",
"superjson": "2.2.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "22.20.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"react-native": "0.83.9",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "3.2.4"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright e2e config targeting console.hanzo.ai (live) and localhost:4000 (dev).
* Run against production: BASE_URL=https://console.hanzo.ai pnpm e2e
* Run against dev: pnpm dev # then pnpm e2e (default)
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: process.env.BASE_URL ?? 'https://console.hanzo.ai',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Needed for Tamagui/RNW rendering (blocks by default on non-Chromium)
headless: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
/**
* build-embed.mjs — produce the STATIC console bundle the hanzoai/cloud Go binary
* go:embeds (task #41, "True 1-binary FE"). Invoked by `npm run build:embed`.
*
* A Next `output: 'export'` build imposes two constraints this app does not meet on
* `main` (it is a server build there). This script makes the app export-clean for
* the duration of ONE build and ALWAYS restores it (a finally block; in CI the
* checkout is disposable, locally it keeps the tree pristine), so nothing on `main`
* and the normal `npm run build` are unaffected:
*
* 1) NO server route handlers. A static export has no runtime to run an
* app/route.ts. This repo's route handlers are (a) BFF reverse-proxies that in
* one-binary collapse to the cloud `/v1/*` the SPA calls directly, and (b) the
* two standalone routes (keys/onboard) now ported to cloud `/v1/console/*`.
* Either way they must be absent from the export → we STASH them.
*
* 2) Every dynamic page segment needs `generateStaticParams()`. The console's two
* dynamic pages ([...slug] and discover/[id]) are `'use client'` catch-alls that
* resolve their view CLIENT-side from the product registry — so the correct
* static-export shape is "pre-render NONE; serve the SPA shell for every deep
* link" (cloud/webui.go's serveIndex is exactly that fallback). A `'use client'`
* module may not itself export the server-only generateStaticParams, so we
* OVERLAY each dynamic page with a tiny SERVER wrapper (generateStaticParams →
* [] + dynamicParams=false) that renders the original client body, which we move
* beside it as `_body.tsx` (a leading-underscore dir/file is not a route).
*
* 3) NO request-time dynamic API in the ROOT LAYOUT. `output: 'export'` prerenders
* EVERY page THROUGH app/layout.tsx, so a request-time read there — the root
* `generateMetadata` calling `headers()` for a per-host <title> — throws in the
* Server Components render for ALL pages (it surfaces on /_not-found and aborts
* the export). The embed is same-origin and resolves brand CLIENT-side from
* window.location, so the SSR title can safely fall back to the build-time
* default; the browser re-renders the correct brand. So we NEUTRALIZE the
* layout's `next/headers` read for the export (drop the import, host → undefined)
* and restore the pristine layout after. Without this, the export FAILS and the
* cloud image degrades to the committed fallback shell (the 1-binary console
* silently ships as a stub).
*
* Steps: stash route handlers → overlay dynamic pages → neutralize root-layout
* dynamic APIs → `CONSOLE_EMBED=1 next build` (emits out/) → restore everything.
*/
import { execFileSync } from 'node:child_process'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { basename, dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const appDir = join(root, 'app')
const stashDir = join(root, '.embed-stash')
/** Recursively collect files under dir whose basename matches re. */
function findFiles(dir, re) {
const out = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name)
if (entry.isDirectory()) out.push(...findFiles(p, re))
else if (re.test(entry.name)) out.push(p)
}
return out
}
/** Move a file, creating the destination's parent dirs. */
function move(from, to) {
mkdirSync(dirname(to), { recursive: true })
renameSync(from, to)
}
/** The dynamic segment of a page dir: `[id]` → "id", `[...slug]` → "slug", or "". */
function dynamicSegment(file) {
const m = basename(dirname(file)).match(/^\[(?:\.\.\.)?([^\]]+)\]$/)
return m ? m[1] : ''
}
/** Whether the dir name is a catch-all (`[...seg]`) vs a single segment (`[seg]`). */
function isCatchAll(file) {
return /^\[\.\.\./.test(basename(dirname(file)))
}
/** A dynamic page needing generateStaticParams under output:export. */
function isDynamicPage(file) {
return basename(file) === 'page.tsx' && dynamicSegment(file) !== ''
}
/**
* The server wrapper written in place of a dynamic client page for the export.
*
* - `dynamic = 'force-static'`: the export prerender step treats the body's
* dynamic reads (use(params)/searchParams) as static — it renders a placeholder
* instead of throwing. The route's REAL client component still ships in the JS
* bundle, so client-side navigation hydrates the true page; direct deep links
* hit the embedding host's SPA fallback (cloud/webui.go serveIndex → index.html)
* which re-resolves the route client-side.
* - `generateStaticParams` returns ONE placeholder param (output:export requires a
* non-empty set) keyed to the segment; a catch-all takes an array value.
* - `dynamicParams = false`: no on-demand params (there is no server); everything
* else is the SPA fallback. The real body lives beside this as `_body.tsx`.
*/
function wrapperSource(seg, catchAll) {
const value = catchAll ? `['index']` : `'index'`
return `// AUTO-GENERATED for \`output: 'export'\` by scripts/build-embed.mjs — do not commit.
// The real (client) page body is ./_body.tsx (still bundled → client nav hydrates it);
// this server wrapper only satisfies the static-export contract. See the script.
import Body from './_body'
export const dynamic = 'force-static'
export const dynamicParams = false
export function generateStaticParams() {
return [{ ${seg}: ${value} }]
}
export default function Page(props: any) {
return <Body {...props} />
}
`
}
/**
* Rewrite the root layout to be export-clean: `output: 'export'` prerenders EVERY
* page through app/layout.tsx, so a request-time dynamic API there (next/headers'
* `headers()`) throws in the Server Components render for ALL pages and aborts the
* export (it surfaces on /_not-found). The root `generateMetadata` reads the Host
* header only to brand the SSR <title>; in the same-origin embed the brand is
* resolved CLIENT-side from window.location, so we drop the `next/headers` import
* and resolve the host to `undefined` (→ the build-time default brand; the browser
* re-renders the correct brand). Returns the pristine source (to restore in the
* finally), or null when the layout has no request-time read to neutralize.
*/
function neutralizeLayout(file) {
if (!existsSync(file)) return null
const src = readFileSync(file, 'utf8')
if (!/from ['"]next\/headers['"]/.test(src)) return null
const patched = src
.replace(/^\s*import\s*\{[^}]*\}\s*from\s*['"]next\/headers['"];?[^\n]*\n/m, '')
.replace(/\(await\s+headers\(\)\)\.get\([^)]*\)\s*\?\?\s*undefined/g, 'undefined')
.replace(/\(await\s+headers\(\)\)\.get\([^)]*\)/g, 'undefined')
writeFileSync(file, patched, 'utf8')
return src
}
const routes = existsSync(appDir) ? findFiles(appDir, /^route\.(ts|tsx|js)$/) : []
const dynamicPages = existsSync(appDir) ? findFiles(appDir, /^page\.tsx$/).filter(isDynamicPage) : []
const stashedRoutes = []
const wrappedPages = [] // { page, body }
const layoutFile = join(appDir, 'layout.tsx')
let layoutPristine = null // pristine app/layout.tsx source while neutralized for the export
if (existsSync(stashDir)) rmSync(stashDir, { recursive: true, force: true })
try {
// 1) Stash server route handlers out of the export.
for (const r of routes) {
const rel = relative(appDir, r)
move(r, join(stashDir, 'routes', rel))
stashedRoutes.push(rel)
}
// 2) Overlay each dynamic page: keep a copy of the original, move the original to
// _body.tsx, write the server wrapper as page.tsx.
for (const page of dynamicPages) {
const rel = relative(appDir, page)
const pristine = join(stashDir, 'pages', rel)
const body = join(dirname(page), '_body.tsx')
mkdirSync(dirname(pristine), { recursive: true })
copyFileSync(page, pristine) // pristine original for restore
renameSync(page, body) // client body → non-route sibling
writeFileSync(page, wrapperSource(dynamicSegment(page), isCatchAll(page)), 'utf8')
wrappedPages.push({ page, body })
}
// 3) Neutralize the root layout's request-time dynamic APIs (next/headers) so the
// export prerender doesn't throw for every page through the layout.
layoutPristine = neutralizeLayout(layoutFile)
console.log(
`[build:embed] stashed ${stashedRoutes.length} route handler(s), wrapped ${wrappedPages.length} dynamic page(s)${
layoutPristine ? ', neutralized root-layout headers()' : ''
}; building static export…`,
)
// The project pins Next 15 (webpack is its default builder; the custom `webpack()`
// config in next.config.mjs applies). No Turbopack flag is passed.
execFileSync('npx', ['next', 'build'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, CONSOLE_EMBED: '1' },
})
const out = join(root, 'out')
if (!existsSync(out) || !statSync(out).isDirectory()) {
throw new Error('next build did not emit out/ (static export failed)')
}
console.log('[build:embed] static export ready at out/')
} finally {
// ALWAYS restore: put the pristine root layout back; remove wrappers + _body, put
// the original page back; un-stash routes.
if (layoutPristine != null) writeFileSync(layoutFile, layoutPristine, 'utf8')
for (const { page, body } of wrappedPages) {
if (existsSync(body)) rmSync(body, { force: true })
const rel = relative(appDir, page)
const pristine = join(stashDir, 'pages', rel)
if (existsSync(pristine)) {
rmSync(page, { force: true })
move(pristine, page)
}
}
for (const rel of stashedRoutes) move(join(stashDir, 'routes', rel), join(appDir, rel))
if (existsSync(stashDir)) rmSync(stashDir, { recursive: true, force: true })
if (stashedRoutes.length || wrappedPages.length) {
console.log(
`[build:embed] restored ${stashedRoutes.length} route handler(s) + ${wrappedPages.length} dynamic page(s)`,
)
}
}
+199
View File
@@ -0,0 +1,199 @@
'use client'
/**
* App launcher — a fullscreen, Launchpad-style grid of every product, with a
* live filter. Opened from the header affordance and from the command palette.
*
* Renders entirely from the catalog registry (DRY): with no query it groups by
* the canonical categories; while filtering it shows a flat ranked grid (the same
* `searchCatalog` scorer the palette uses). A tile opens the product the one way
* (`openProduct` — in-console route or external tab) and closes the launcher.
*/
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, Input, ScrollView, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
import { Lock, Search } from '@hanzogui/lucide-icons-2'
import { visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
import { searchCatalog } from '~/lib/products/search'
import { useProductColors } from '~/lib/products/pins'
import { asColor } from '~/components/ui/color'
import { openProduct } from '~/lib/products/open'
import { useIsGlobalAdmin } from '~/lib/auth/admin'
type LauncherApi = { isOpen: boolean; open: () => void; close: () => void }
const Ctx = createContext<LauncherApi | null>(null)
export function useAppLauncher(): LauncherApi {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAppLauncher must be used within <AppLauncherProvider>')
return ctx
}
function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; onPress: () => void }) {
const Icon = entry.icon
return (
<YStack
onPress={onPress}
cursor="pointer"
width={132}
height={124}
p="$3"
gap="$2.5"
items="center"
justify="center"
rounded="$6"
hoverStyle={{ bg: '$color3' }}
>
<XStack
width={56}
height={56}
items="center"
justify="center"
rounded="$7"
position="relative"
style={{ backgroundColor: `${color}22` }}
>
<Icon size={26} color={asColor(color)} />
{entry.admin ? (
<XStack position="absolute" t={-4} r={-4} bg="$color2" rounded="$10" p="$1">
<Lock size={11} opacity={0.7} />
</XStack>
) : null}
</XStack>
<Text fontSize="$2" fontWeight="600" color="$color12" numberOfLines={1}>
{entry.label}
</Text>
{entry.status === 'soon' ? (
<YStack px="$1.5" py={1} rounded="$10" bg="$color4" position="absolute" b="$2">
<Text fontSize={8} fontWeight="800" letterSpacing={0.5} color="$color11">
SOON
</Text>
</YStack>
) : null}
</YStack>
)
}
function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const router = useRouter()
const showAdmin = useIsGlobalAdmin()
const { colorOf } = useProductColors()
const [query, setQuery] = useState('')
const groups = useMemo(() => visibleCatalogByCategory(showAdmin), [showAdmin])
const filtered = useMemo(
() => (query.trim() ? searchCatalog(query).filter((e) => showAdmin || !e.admin) : null),
[query, showAdmin],
)
const activate = useCallback(
(entry: CatalogEntry) => {
onOpenChange(false)
openProduct(entry, (p) => router.push(p))
},
[onOpenChange, router],
)
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay key="launcher-overlay" bg="rgba(0,0,0,0.6)" />
<Dialog.Content
key="launcher-content"
bordered
elevate
width="92vw"
height="88vh"
maxW={1180}
p="$0"
gap="$0"
overflow="hidden"
>
<VisuallyHidden>
<Dialog.Title>All products</Dialog.Title>
</VisuallyHidden>
{/* Search row */}
<XStack
items="center"
gap="$2.5"
px="$4"
py="$3.5"
borderBottomWidth={1}
borderColor="$borderColor"
>
<Search size={18} opacity={0.7} />
<Input
flex={1}
unstyled
autoFocus
value={query}
onChangeText={setQuery}
placeholder="Filter products…"
fontSize="$5"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
<Text fontSize="$1" color="$color10">
esc
</Text>
</XStack>
{/* Grid */}
<ScrollView flex={1}>
<YStack p="$4" gap="$5">
{filtered ? (
filtered.length === 0 ? (
<YStack p="$8" items="center">
<Text color="$color10">No products match {query.trim()}.</Text>
</YStack>
) : (
<XStack flexWrap="wrap" gap="$2">
{filtered.map((entry) => (
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
))}
</XStack>
)
) : (
groups.map((group) => (
<YStack key={group.category} gap="$2">
<Text fontSize="$2" color="$color10" fontWeight="800" textTransform="uppercase" px="$2">
{group.category}
</Text>
<XStack flexWrap="wrap" gap="$2">
{group.entries.map((entry) => (
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
))}
</XStack>
</YStack>
))
)}
</YStack>
</ScrollView>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
export function AppLauncherProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
return (
<Ctx.Provider value={{ isOpen, open, close }}>
{children}
<LauncherDialog open={isOpen} onOpenChange={setIsOpen} />
</Ctx.Provider>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
/**
* ChunkGuard — recover gracefully from a stale-deploy chunk error.
*
* After a console deploy, an open tab still references the previous build's
* hashed chunks. Those chunk URLs no longer exist, so the request falls through
* to the app shell (HTML), and the browser throws `ChunkLoadError` /
* "Unexpected token '<'" trying to parse HTML as JS — an unrecoverable blank
* screen. This catches that exact failure and does ONE full reload, which pulls
* the fresh HTML + current chunks. A sessionStorage flag prevents reload loops
* if the failure is genuine (not a stale deploy); it clears on the next load.
*/
import { useEffect } from 'react'
const FLAG = 'hz_chunk_reloaded'
const PATTERN = /ChunkLoadError|Loading chunk [\d]+ failed|Loading CSS chunk|Importing a module script failed|Unexpected token '<'/i
export function ChunkGuard() {
useEffect(() => {
// A clean load means any prior stale-chunk reload worked — reset the guard.
try {
sessionStorage.removeItem(FLAG)
} catch {
/* sessionStorage may be unavailable (private mode) — best-effort only */
}
const recover = (message: string) => {
if (!PATTERN.test(message)) return
try {
if (sessionStorage.getItem(FLAG)) return // already tried once — let the error surface
sessionStorage.setItem(FLAG, '1')
} catch {
/* ignore */
}
window.location.reload()
}
const onError = (e: ErrorEvent) => recover(e?.message ?? String(e?.error ?? ''))
const onRejection = (e: PromiseRejectionEvent) => {
const r = e?.reason
recover(typeof r === 'string' ? r : (r?.message ?? ''))
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onRejection)
return () => {
window.removeEventListener('error', onError)
window.removeEventListener('unhandledrejection', onRejection)
}
}, [])
return null
}
+707
View File
@@ -0,0 +1,707 @@
'use client'
/**
* Command palette — ONE command surface for the whole console (⌘K / Ctrl+K).
*
* It is one widget with modes, not four. The query string selects the mode:
* - default fuzzy-filters the product catalog; ↵ jumps to the product
* (in-console route or external tab) — instant across every product.
* - `>` prefix asks the AI (one cloud `/v1` backend) to find a product or
* answer; a clear product match becomes a "Go to …" jump.
* - `?` prefix asks the docs knowledge store (RAG, store=`docs`) and shows the
* grounded answer with any links it cites.
*
* Beyond navigation it also runs ACTIONS — toggle theme, browse all apps, open
* settings, switch organization, ask AI / search docs, sign out — ranked by the
* same query, so ⌘K is ONE surface for "go somewhere" and "do something".
*
* Everything composes existing pieces: the catalog registry (`searchCatalog` +
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/launcher/
* session/org-scope), and the honest backend-state mapper. Nothing is fabricated —
* AI/RAG failures degrade to a truthful state card.
*
* Keyboard is handled on `window`: ⌘K toggles from anywhere; while open, ↑/↓ move
* the selection (over actions then products), ↵ activates, Esc closes. The header
* search box opens it; type `>` for AI, `?` for docs.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from 'react'
import { useRouter } from 'next/navigation'
import { useThemeSetting } from '@hanzogui/next-theme'
import {
Anchor,
Dialog,
Input,
ScrollView,
Spinner,
Text,
VisuallyHidden,
XStack,
YStack,
} from '@hanzo/gui'
import {
ArrowRight,
Building2,
Command,
CornerDownLeft,
House,
LayoutGrid,
Lock,
LogOut,
Moon,
Search,
SlidersHorizontal,
Sparkles,
Sun,
Zap,
} from '@hanzogui/lucide-icons-2'
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { searchDestinations, type Destination } from '~/lib/products/search'
import { useProductColors } from '~/lib/products/pins'
import { asColor } from '~/components/ui/color'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
import { useSession } from '~/lib/auth/session'
import { useIsGlobalAdmin } from '~/lib/auth/admin'
import { useAppLauncher } from '~/components/AppLauncher'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
/**
* A non-navigation command: a verb the palette can run (toggle theme, browse all
* apps, switch org, sign out, …). Orthogonal to catalog entries — both are ranked
* by the same query so ⌘K is ONE surface for "go somewhere" AND "do something".
*/
type PaletteAction = {
id: string
label: string
hint: string
/** Extra text the query matches against (synonyms). */
keywords: string
icon: ComponentType<{ size?: number }>
/** Runs on activate. Closing the palette (if wanted) is the action's own job. */
run: () => void
}
/** Substring match over an action's label + synonyms (lowercased query). */
function actionMatches(a: PaletteAction, q: string): boolean {
if (!q) return false
return `${a.label} ${a.keywords}`.toLowerCase().includes(q)
}
type Mode = 'catalog' | 'ai' | 'help'
type PaletteApi = {
isOpen: boolean
open: () => void
close: () => void
}
const Ctx = createContext<PaletteApi | null>(null)
export function useCommandPalette(): PaletteApi {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useCommandPalette must be used within <CommandPaletteProvider>')
return ctx
}
type RunState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'nav'; entry: CatalogEntry }
| { status: 'text'; text: string }
| { status: 'error'; state: BackendState }
/** Render an answer as lines, then surface any URLs it cites as real links. */
function Answer({ text }: { text: string }) {
const urls = Array.from(new Set(text.match(/https?:\/\/[^\s)]+/g) ?? []))
return (
<YStack gap="$2.5">
<YStack>
{text.split('\n').map((line, i) => (
<Text key={i} fontSize="$3" color="$color12">
{line === '' ? ' ' : line}
</Text>
))}
</YStack>
{urls.length > 0 ? (
<YStack gap="$1" borderTopWidth={1} borderColor="$borderColor" pt="$2">
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Links
</Text>
{urls.map((u) => (
<Anchor key={u} href={u} target="_blank" fontSize="$2" color="$color12" textDecorationLine="underline">
{u}
</Anchor>
))}
</YStack>
) : null}
</YStack>
)
}
function CatalogRow({
entry,
active,
color,
onPress,
}: {
entry: CatalogEntry
active: boolean
color?: string
onPress: () => void
}) {
const Icon = entry.icon
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$3"
px="$3"
py="$2.5"
rounded="$3"
id={active ? 'cmdk-active' : undefined}
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
>
<ProductIcon icon={Icon} color={color} size={24} />
<YStack flex={1}>
<Text fontSize="$3" fontWeight="600" color="$color12">
{entry.label}
</Text>
<Text fontSize="$1" color="$color10">
{entry.category}
{entry.gcp ? ` · ${entry.gcp}` : ''}
</Text>
</YStack>
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
</XStack>
)
}
/** A ⌘K result — a product (via CatalogRow) or a deep sub-page jump. */
function DestinationRow({
dest,
active,
colorOf,
onPress,
}: {
dest: Destination
active: boolean
colorOf: (id: string) => string
onPress: () => void
}) {
if (dest.kind === 'product')
return <CatalogRow entry={dest.entry} active={active} color={colorOf(dest.entry.id)} onPress={onPress} />
const { entry, subpage } = dest
const Icon = subpage.icon ?? entry.icon
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$3"
px="$3"
py="$2.5"
rounded="$3"
id={active ? 'cmdk-active' : undefined}
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
>
<Icon size={17} color={asColor(colorOf(entry.id))} />
<YStack flex={1}>
<Text fontSize="$3" fontWeight="600" color="$color12">
{entry.label} {subpage.label}
</Text>
<Text fontSize="$1" color="$color10">
{entry.category} · {entry.label}
</Text>
</YStack>
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
</XStack>
)
}
/** Stable key for a destination (product id, or `id/slug` for a sub-page). */
const destKey = (d: Destination): string => (d.kind === 'product' ? d.entry.id : `${d.entry.id}/${d.subpage.slug}`)
function ActionRow({
action,
active,
onPress,
}: {
action: PaletteAction
active: boolean
onPress: () => void
}) {
const Icon = action.icon
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$3"
px="$3"
py="$2.5"
rounded="$3"
id={active ? 'cmdk-active' : undefined}
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
>
<Icon size={17} />
<YStack flex={1}>
<Text fontSize="$3" fontWeight="600" color="$color12">
{action.label}
</Text>
<Text fontSize="$1" color="$color10">
{action.hint}
</Text>
</YStack>
<CornerDownLeft size={13} opacity={active ? 0.8 : 0.3} />
</XStack>
)
}
/** A small uppercase section label inside the palette result list. */
function SectionLabel({ children }: { children: ReactNode }) {
return (
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
{children}
</Text>
)
}
function PaletteDialog({
open,
seed,
onOpenChange,
}: {
open: boolean
seed: string
onOpenChange: (open: boolean) => void
}) {
const router = useRouter()
const launcher = useAppLauncher()
const { signOut } = useSession()
const showAdmin = useIsGlobalAdmin()
const { colorOf } = useProductColors()
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
const [query, setQuery] = useState(seed)
const [sel, setSel] = useState(0)
const [run, setRun] = useState<RunState>({ status: 'idle' })
const [orgs, setOrgs] = useState<Organization[]>([])
const mode: Mode = query.startsWith('>') ? 'ai' : query.startsWith('?') ? 'help' : 'catalog'
const sub = (mode === 'catalog' ? query : query.slice(1)).trim()
// The org list powers the "Switch to <org>" actions. Only an admin who can see
// more than one org gets switch actions; everyone else just gets the verbs. The
// list comes from the cross-tenant `/admin/iam/get-organizations?owner=admin`
// aggregate, which is server-gated to global admins — so don't fire it for a tenant
// user (it only 403s); they just get the verbs, no switch actions.
useEffect(() => {
if (!open || !showAdmin) return
let live = true
IamAdminApi.organizations()
.then((p) => {
if (live) setOrgs(p.rows ?? [])
})
.catch(() => {
if (live) setOrgs([])
})
return () => {
live = false
}
}, [open, showAdmin])
// Every command the palette can RUN (verbs + per-org switches). Composed from the
// same pieces the chrome uses (router, launcher, theme, session, org scope) — no
// dead entries: each `run` is wired.
const actions = useMemo<PaletteAction[]>(() => {
const cur = currentOrg()
const verbs: PaletteAction[] = [
{ id: 'home', label: 'Go to Overview', hint: 'Dashboard home', keywords: 'home start dashboard root overview', icon: House, run: () => { onOpenChange(false); router.push('/') } },
{ id: 'apps', label: 'Browse all apps', hint: 'Open the app launcher', keywords: 'launcher grid all products everything apps', icon: LayoutGrid, run: () => { onOpenChange(false); launcher.open() } },
{ id: 'settings', label: 'Open Settings', hint: 'Account, organization, branding', keywords: 'preferences account profile settings', icon: SlidersHorizontal, run: () => { onOpenChange(false); router.push('/settings') } },
{ id: 'theme', label: isDark ? 'Switch to light theme' : 'Switch to dark theme', hint: 'Toggle appearance', keywords: 'dark light appearance theme mode color', icon: isDark ? Sun : Moon, run: () => setTheme(isDark ? 'light' : 'dark') },
{ id: 'ai', label: 'Ask AI', hint: 'Find or do something with AI', keywords: 'assistant zen gpt ask question ai', icon: Sparkles, run: () => setQuery('> ') },
{ id: 'docs', label: 'Search the docs', hint: 'Ask the documentation', keywords: 'help docs documentation manual guide', icon: Zap, run: () => setQuery('? ') },
{ id: 'signout', label: 'Sign out', hint: 'End your session', keywords: 'logout sign out exit leave', icon: LogOut, run: () => { onOpenChange(false); void signOut() } },
]
const orgVerbs: PaletteAction[] = orgs
.filter((o) => o.name !== cur)
.map((o) => ({
id: `org:${o.name}`,
label: `Switch to ${o.displayName || titleCase(o.name)}`,
hint: 'Switch organization',
keywords: `org organization tenant switch ${o.name}`,
icon: Building2,
run: () => switchOrg(o.name),
}))
return [...verbs, ...orgVerbs]
}, [isDark, orgs, router, launcher, signOut, setTheme, onOpenChange])
// Every jump target — products AND deep sub-pages ("queues" → Tasks Queues) —
// gated so a customer never sees an admin-only surface.
const destResults = useMemo(
() => (mode === 'catalog' ? searchDestinations(query, showAdmin).slice(0, 50) : []),
[mode, query, showAdmin],
)
const matchedActions = useMemo(
() => (mode === 'catalog' && sub ? actions.filter((a) => actionMatches(a, sub.toLowerCase())) : []),
[mode, sub, actions],
)
// One ordered list (actions first, then destinations) so ↑/↓/↵ traverse both.
type Item = { kind: 'action'; action: PaletteAction } | { kind: 'dest'; dest: Destination }
const items = useMemo<Item[]>(
() => [
...matchedActions.map((action) => ({ kind: 'action' as const, action })),
...destResults.map((dest) => ({ kind: 'dest' as const, dest })),
],
[matchedActions, destResults],
)
// Seed the query each time the palette opens.
useEffect(() => {
if (open) setQuery(seed)
}, [open, seed])
// A new query resets selection + any prior AI run.
useEffect(() => {
setSel(0)
setRun({ status: 'idle' })
}, [query])
const activate = useCallback(
(entry: CatalogEntry) => {
onOpenChange(false)
openProduct(entry, (p) => router.push(p))
},
[onOpenChange, router],
)
/** Activate a destination — a product (open) or a sub-page (navigate deep). */
const activateDest = useCallback(
(dest: Destination) => {
onOpenChange(false)
if (dest.kind === 'subpage') router.push(dest.path)
else openProduct(dest.entry, (p) => router.push(p))
},
[onOpenChange, router],
)
const submit = useCallback(async () => {
if (!sub) return
setRun({ status: 'loading' })
try {
if (mode === 'ai') {
// Same grounded expert prompt as the chat, plus the nav contract: a clear
// "open X" jumps to the product; anything else gets a real, accurate answer.
const ans = (await AiApi.chat({ question: sub, system: commandBarSystemPrompt({ showAdmin }) })).trim()
const m = ans.match(/^NAV\s+([a-z0-9-]+)/i)
const entry = m ? findEntry(m[1]) : undefined
if (entry) setRun({ status: 'nav', entry })
else setRun({ status: 'text', text: ans })
} else {
// Docs mode: retrieval grounded in the same expert context.
const ans = await AiApi.ragChat({
question: sub,
store: 'docs',
system: hanzoAssistantSystemPrompt({ showAdmin }),
})
setRun({ status: 'text', text: ans })
}
} catch (e) {
setRun({ status: 'error', state: classifyBackend(e) })
}
}, [mode, sub, showAdmin])
// Keyboard while open: ↑/↓ select, ↵ activate/ask, Esc close.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onOpenChange(false)
return
}
if (mode === 'catalog') {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(s + 1, Math.max(items.length - 1, 0)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const it = items[sel]
if (it) {
if (it.kind === 'action') it.action.run()
else activateDest(it.dest)
}
}
} else if (e.key === 'Enter') {
e.preventDefault()
if (run.status === 'loading') return
if (run.status === 'nav') activate(run.entry)
else void submit()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, mode, items, sel, run, submit, activate, activateDest, onOpenChange])
// Keep the ↑/↓-selected row visible: as selection moves past the fold, scroll
// the active row into view (the list can hold 50 results — well beyond 420px).
useEffect(() => {
if (!open || mode !== 'catalog' || typeof document === 'undefined') return
document.getElementById('cmdk-active')?.scrollIntoView({ block: 'nearest' })
}, [sel, open, mode])
const placeholder =
mode === 'ai'
? 'Ask AI to find or do something…'
: mode === 'help'
? 'Ask the docs…'
: 'Search products, or type > for AI, ? for docs'
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay key="palette-overlay" bg="rgba(0,0,0,0.5)" />
{/* Full-screen on mobile (fills the viewport, reachable from the mobile
menu); a floating 640 box at lg+. */}
<Dialog.Content
key="palette-content"
bordered
elevate
width="100vw"
height="100dvh"
maxW="100vw"
rounded="$0"
$lg={{ width: 640, height: 'auto', maxW: '90%', rounded: '$6' }}
p="$0"
gap="$0"
overflow="hidden"
>
<VisuallyHidden>
<Dialog.Title>Command palette</Dialog.Title>
</VisuallyHidden>
{/* Query row */}
<XStack items="center" gap="$2.5" px="$3.5" py="$3" borderBottomWidth={1} borderColor="$borderColor">
{mode === 'ai' ? (
<Sparkles size={18} opacity={0.7} />
) : (
<Search size={18} opacity={0.7} />
)}
<Input
flex={1}
unstyled
autoFocus
value={query}
onChangeText={setQuery}
placeholder={placeholder}
fontSize="$4"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
<XStack items="center" gap="$1" opacity={0.5}>
<Text fontSize="$1" color="$color10">
esc
</Text>
</XStack>
</XStack>
{/* Body — fills the viewport on mobile, capped at lg+. */}
<YStack flex={1} minH={0} overflow="hidden" $lg={{ flex: 0, minH: 120, maxH: 420 }}>
{mode === 'catalog' ? (
items.length === 0 ? (
<YStack p="$5" items="center">
<Text color="$color10">No commands or products match {sub}.</Text>
</YStack>
) : (
<ScrollView flex={1} p="$2" showsVerticalScrollIndicator keyboardShouldPersistTaps="handled">
<YStack gap="$0.5">
{matchedActions.length > 0 ? <SectionLabel>Actions</SectionLabel> : null}
{matchedActions.map((action, i) => (
<ActionRow key={`action-${action.id}`} action={action} active={i === sel} onPress={action.run} />
))}
{destResults.length > 0 && matchedActions.length > 0 ? <SectionLabel>Go to</SectionLabel> : null}
{destResults.map((dest, j) => {
const i = matchedActions.length + j
return (
<DestinationRow
key={destKey(dest)}
dest={dest}
active={i === sel}
colorOf={colorOf}
onPress={() => activateDest(dest)}
/>
)
})}
</YStack>
</ScrollView>
)
) : (
<YStack p="$4" gap="$3">
{run.status === 'idle' ? (
<XStack gap="$2" items="center">
<CornerDownLeft size={15} opacity={0.6} />
<Text color="$color10" fontSize="$3">
{mode === 'ai'
? 'Press ↵ to ask AI to find a product or answer.'
: 'Press ↵ to search the docs.'}
</Text>
</XStack>
) : run.status === 'loading' ? (
<XStack gap="$2.5" items="center">
<Spinner color="$color11" />
<Text color="$color11" fontSize="$3">
{mode === 'ai' ? 'Thinking…' : 'Searching the docs…'}
</Text>
</XStack>
) : run.status === 'nav' ? (
<CatalogRow entry={run.entry} active color={colorOf(run.entry.id)} onPress={() => activate(run.entry)} />
) : run.status === 'text' ? (
<Answer text={run.text} />
) : (
<BackendStateCard state={run.state} onRetry={() => void submit()} />
)}
</YStack>
)}
</YStack>
{/* Legend */}
<XStack
px="$3.5"
py="$2"
gap="$3"
borderTopWidth={1}
borderColor="$borderColor"
bg="$color1"
flexWrap="wrap"
>
<Legend keys="↑↓" label="navigate" />
<Legend keys="↵" label="open" />
<Legend keys=">" label="AI" />
<Legend keys="?" label="docs" />
<XStack flex={1} />
<XStack
onPress={() => {
onOpenChange(false)
launcher.open()
}}
cursor="pointer"
items="center"
gap="$1.5"
opacity={0.8}
hoverStyle={{ opacity: 1 }}
>
<LayoutGrid size={13} />
<Text fontSize="$1" color="$color11" fontWeight="600">
Browse all apps
</Text>
</XStack>
</XStack>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
function Legend({ keys, label }: { keys: string; label: string }) {
return (
<XStack items="center" gap="$1.5" opacity={0.6}>
<Text fontSize="$1" color="$color12" fontWeight="700">
{keys}
</Text>
<Text fontSize="$1" color="$color10">
{label}
</Text>
</XStack>
)
}
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [seed, setSeed] = useState('')
const open = useCallback(() => {
setSeed('')
setIsOpen(true)
}, [])
const close = useCallback(() => setIsOpen(false), [])
// ⌘K / Ctrl+K toggles the palette from anywhere.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
setSeed('')
setIsOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
return (
<Ctx.Provider value={{ isOpen, open, close }}>
{children}
<PaletteDialog open={isOpen} seed={seed} onOpenChange={setIsOpen} />
</Ctx.Provider>
)
}
/** Header trigger — a search box that opens the palette. */
export function CommandSearchBox() {
const { open } = useCommandPalette()
return (
<XStack
onPress={open}
cursor="pointer"
items="center"
gap="$2"
px="$3"
height={36}
flex={1}
maxW={420}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
hoverStyle={{ borderColor: '$color8' }}
>
<Search size={15} opacity={0.6} />
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
Search or jump to
</Text>
<XStack items="center" gap="$1" opacity={0.6}>
<Command size={12} />
<Text fontSize="$2" color="$color10">
K
</Text>
</XStack>
</XStack>
)
}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
'use client'
/**
* DetailPane — the ONE reusable right-side pane for viewing / editing the details
* of an item, anywhere in the console. A product does NOT build its own pane: it
* writes a DESCRIPTOR (`{ title, subtitle?, icon?, content, footer? }`) and calls
* `useDetailPane().open(descriptor)`. The pane slides out smoothly from the right
* (full-screen on mobile) via the shared `SlideOver`, so every "open item detail"
* looks and behaves identically (DRY).
*
* Rendered ONCE at the shell root (via `DetailPaneProvider`), so any module — a
* machine row, a provider, a pinned product's customize form — opens the same
* pane. Interactive content closes itself with `useDetailPane().close()`.
*
* The last descriptor stays mounted through the close transition, so the exit
* animates with its content (no flash of empty pane). Opening a new descriptor
* replaces it.
*/
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react'
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { X } from '@hanzogui/lucide-icons-2'
import { SlideOver } from '~/components/ui/SlideOver'
import { asColor, type IconLike } from '~/components/ui/color'
export type DetailDescriptor = {
/** Pane title (the item's name). */
title: ReactNode
/** Optional secondary line under the title (type, id, status…). */
subtitle?: ReactNode
/** Optional title icon, tinted with `iconColor`. */
icon?: IconLike
iconColor?: string
/** Desktop pane width (px). Mobile is always full-screen. Default 460. */
size?: number
/** The body — the detail view or edit form for the item. */
content: ReactNode
/** Optional sticky footer (primary actions), pinned below the scroll body. */
footer?: ReactNode
}
export type DetailPaneApi = {
open: (descriptor: DetailDescriptor) => void
close: () => void
isOpen: boolean
}
const Ctx = createContext<DetailPaneApi | null>(null)
export function useDetailPane(): DetailPaneApi {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useDetailPane must be used within <DetailPaneProvider>')
return ctx
}
export function DetailPaneProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [desc, setDesc] = useState<DetailDescriptor | null>(null)
const open = useCallback((descriptor: DetailDescriptor) => {
setDesc(descriptor)
setIsOpen(true)
}, [])
const close = useCallback(() => setIsOpen(false), [])
const api = useMemo<DetailPaneApi>(() => ({ open, close, isOpen }), [open, close, isOpen])
const Icon = desc?.icon
return (
<Ctx.Provider value={api}>
{children}
<SlideOver
open={isOpen}
onClose={close}
side="right"
size={desc?.size ?? 460}
ariaLabel={typeof desc?.title === 'string' ? desc?.title : 'Details'}
zIndex={1100}
>
{/* Bare-mode layout: fixed header · scroll body · optional sticky footer. */}
<XStack items="center" gap="$2.5" px="$4" height={56} borderBottomWidth={1} borderColor="$borderColor">
{Icon ? <Icon size={18} color={desc?.iconColor ? asColor(desc.iconColor) : undefined} /> : null}
<YStack flex={1} minW={0}>
<Text fontSize="$5" fontWeight="700" color="$color12" numberOfLines={1}>
{desc?.title}
</Text>
{desc?.subtitle !== undefined ? (
<Text fontSize="$2" color="$color10" numberOfLines={1}>
{desc?.subtitle}
</Text>
) : null}
</YStack>
<Button size="$2" chromeless icon={<X size={18} />} onPress={close} aria-label="Close" />
</XStack>
<ScrollView flex={1} minH={0}>
<YStack p="$4" gap="$3">
{desc?.content}
</YStack>
</ScrollView>
{desc?.footer !== undefined ? (
<XStack px="$4" py="$3" gap="$2" borderTopWidth={1} borderColor="$borderColor" bg="$color1">
{desc?.footer}
</XStack>
) : null}
</SlideOver>
</Ctx.Provider>
)
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
/**
* Floating AI chat — a bubble fixed bottom-right on every dashboard page, so the
* assistant is one tap away from any view (the "where's chat on mobile" ask).
*
* It REUSES the one working chat surface (`ChatConversation` → `AiApi.chat` → the
* keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt here;
* this is purely a container that mounts that conversation in an overlay:
* - phone/tablet (<1024px): a full-screen sheet.
* - laptop/desktop: a ~380×560 popover anchored bottom-right.
*
* "History" inside the conversation deep-links to the full `/chat` page (the
* bubble is a quick-ask surface, not the session browser) and closes the bubble.
*
* Mounted once in the dashboard layout (like the app launcher), so a single
* instance floats over all children — lightweight, no per-page wiring.
*/
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
import { MessageCircle, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { ChatConversation } from '~/components/products/chat/ChatConversation'
type FloatingChatApi = { isOpen: boolean; open: () => void; close: () => void; toggle: () => void }
const Ctx = createContext<FloatingChatApi | null>(null)
/** Open/close the floating assistant from anywhere (e.g. an empty-state CTA). */
export function useFloatingChat(): FloatingChatApi {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useFloatingChat must be used within <FloatingChatProvider>')
return ctx
}
function ChatSheet({
open,
onOpenChange,
onHistory,
}: {
open: boolean
onOpenChange: (o: boolean) => void
onHistory: () => void
}) {
// Size is CSS-driven (media props), not a JS branch: base = mobile full-bleed
// sheet; `$lg` = a compact popover pinned bottom-right. The scrim dims the page
// on mobile and goes transparent on desktop (popover, no full-screen dim).
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay key="chat-overlay" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
<Dialog.Content
key="chat-content"
bordered
elevate
position="absolute"
bg="$color1"
overflow="hidden"
p="$0"
// Mobile/tablet: full-bleed.
t={0}
l={0}
r={0}
b={0}
width="100vw"
height="100dvh"
rounded="$0"
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
$lg={{
t: 'auto',
l: 'auto',
b: 88,
r: 24,
width: 380,
height: 560,
rounded: '$6',
}}
>
<VisuallyHidden>
<Dialog.Title>Assistant</Dialog.Title>
</VisuallyHidden>
<YStack flex={1} minH={0}>
<XStack
items="center"
justify="space-between"
px="$3"
py="$2.5"
borderBottomWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<XStack items="center" gap="$2">
<Sparkles size={16} opacity={0.8} />
<Text fontSize="$4" fontWeight="700" color="$color12">
Assistant
</Text>
</XStack>
<Button
size="$2"
chromeless
icon={<X size={18} />}
onPress={() => onOpenChange(false)}
aria-label="Close assistant"
/>
</XStack>
{/* The ONE working conversation, given a flex container to fill. */}
<YStack flex={1} minH={0} p="$3">
<ChatConversation compact onShowHistory={onHistory} />
</YStack>
</YStack>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
export function FloatingChatProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((v) => !v), [])
const onHistory = useCallback(() => {
setIsOpen(false)
router.push('/chat')
}, [router])
return (
<Ctx.Provider value={{ isOpen, open, close, toggle }}>
{children}
{/* The bubble — fixed bottom-right over every page (rendered last in this
provider so DOM order keeps it above normal-flow content; the chat sheet
portals above it). Hidden while open so the sheet's own close control is
the single dismiss affordance. */}
{!isOpen ? (
<YStack position="fixed" b={24} r={24}>
<Button
circular
size="$6"
bg="$color5"
hoverStyle={{ bg: '$color6' }}
pressStyle={{ bg: '$color7' }}
icon={<MessageCircle size={24} />}
onPress={open}
shadowColor="rgba(0,0,0,0.35)"
shadowRadius={16}
shadowOffset={{ width: 0, height: 4 }}
aria-label="Open AI assistant"
/>
</YStack>
) : null}
<ChatSheet open={isOpen} onOpenChange={setIsOpen} onHistory={onHistory} />
</Ctx.Provider>
)
}
+120
View File
@@ -0,0 +1,120 @@
'use client'
/**
* Home summary strip — the at-a-glance account header on the console home, the
* way DigitalOcean/Vercel open with balance + spend before the resource grid.
*
* Real per-tenant data over the same `/billing/*` proxy the Cost page and sidebar
* wallet use (server-injected token, scoped to the caller's org). It is purely
* additive and fail-quiet: if billing isn't configured/routed on a deployment the
* strip simply doesn't render (the catalog below is the home's substance) — it
* never shows a fabricated balance and never blocks the page.
*/
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowRight, CreditCard, TrendingUp } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { BillingApi } from '~/lib/api/billing'
const usd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
type Stats = { availableCents: number | null; spendCents: number | null }
function Stat({
icon,
label,
value,
hint,
}: {
icon: React.ReactNode
label: string
value: string
hint?: string
}) {
return (
<Card p="$3.5" gap="$1.5" borderWidth={1} borderColor="$borderColor" flex={1} minW={200}>
<XStack items="center" gap="$1.5">
{icon}
<Text fontSize="$2" color="$color10">
{label}
</Text>
</XStack>
<Text fontSize="$8" fontWeight="900" color="$color12">
{value}
</Text>
{hint ? (
<Text fontSize="$2" color="$color10">
{hint}
</Text>
) : null}
</Card>
)
}
export function HomeSummary() {
const router = useRouter()
const { account } = useSession()
const signedIn = Boolean(account?.owner)
const [stats, setStats] = useState<Stats | null>(null)
useEffect(() => {
if (!signedIn) return
let alive = true
void (async () => {
const [balance, usage] = await Promise.allSettled([BillingApi.balance(), BillingApi.usage()])
if (!alive) return
const availableCents = balance.status === 'fulfilled' ? balance.value.available : null
const spendCents = usage.status === 'fulfilled' ? usage.value.totalCents : null
// Only render the strip if at least one real number came back — otherwise it
// stays hidden (no fabricated stats, no empty scaffold).
if (availableCents === null && spendCents === null) return
setStats({ availableCents, spendCents })
})()
return () => {
alive = false
}
}, [signedIn])
if (!signedIn || !stats) return null
return (
<XStack flexWrap="wrap" gap="$3" items="stretch">
<Stat
icon={<CreditCard size={14} opacity={0.7} />}
label="Cloud credit"
value={stats.availableCents != null ? usd(stats.availableCents) : '—'}
hint="available to spend"
/>
<Stat
icon={<TrendingUp size={14} opacity={0.7} />}
label="Spend this period"
value={stats.spendCents != null ? usd(stats.spendCents) : '—'}
hint="across every product"
/>
<Card
p="$3.5"
gap="$2"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
flex={1}
minW={220}
justify="center"
>
<Text fontSize="$3" color="$color11">
Manage balance, usage, and invoices.
</Text>
<XStack gap="$2">
<Button size="$2" iconAfter={<ArrowRight size={14} />} onPress={() => router.push('/billing')}>
View cost
</Button>
<Button size="$2" chromeless onPress={() => router.push('/billing/credits')}>
Add credits
</Button>
</XStack>
</Card>
</XStack>
)
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
/**
* Org accent applier — reads the signed-in org's persisted brand color and applies it
* to the live console accent on EVERY load (so a custom theme survives reload), and
* clears it on sign-out. This is the load-time half of the "apply the org's theme
* color" fix; Settings → Branding applies it immediately on save via the same
* `applyOrgAccent`, so the two share one mechanism.
*
* It fetches the active org's `themeData` (`TeamApi.organization`, the org-scoped IAM
* read an org admin can make) once the session resolves, then applies. Best-effort: a
* failed fetch simply leaves the default monochrome accent (never throws, never blocks
* render). Renders nothing.
*/
import { useEffect } from 'react'
import { TeamApi } from '~/lib/api'
import { currentOrg } from '~/lib/org-scope'
import { useSession } from '~/lib/auth/session'
import { setOrgAccent } from '~/lib/theme/accent'
export function OrgAccentProvider() {
const { account } = useSession()
// Re-resolve when the signed-in identity changes (sign-in / sign-out / org switch,
// which reloads with a new `currentOrg()`).
const owner = account?.owner ?? null
useEffect(() => {
if (!owner) {
setOrgAccent(null) // signed out → default monochrome accent
return
}
let cancelled = false
TeamApi.organization(currentOrg())
.then((org) => {
if (!cancelled) setOrgAccent(org.themeData)
})
.catch(() => {
// Org theme unreadable on this host → keep the default accent (no fabrication).
if (!cancelled) setOrgAccent(null)
})
return () => {
cancelled = true
}
}, [owner])
return null
}
+160
View File
@@ -0,0 +1,160 @@
'use client'
/**
* Org gate — the console operates inside the user's organization.
*
* Behaviours:
* 1. isAdmin on a non-admin host → show a dismissible amber banner linking to
* admin.hanzo.ai (IAM/KMS ops), but render the full console. Admins use the
* console for all normal cloud work (models, API keys, AI, etc.).
* 2. Any non-admin user in any org → render console normally.
* 3. No org yet → first-run org onboarding.
*
* Switching orgs at runtime is the OrgSwitcher's job; this gate only covers
* the "no org" degenerate case and the admin hint.
*
* Last org: restored from localStorage on sign-in so the scope remembers where
* the user left off.
*/
import { useEffect, useState, type ReactNode } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { useSession } from '~/lib/auth/session'
import { isGlobalAdminAccount } from '~/lib/auth/admin'
import { currentOrg, setCurrentOrg } from '~/lib/org-scope'
import { OrgOnboarding } from '~/components/OrgOnboarding'
const LS_LAST_ORG = 'hz_last_org'
const LS_BANNER_DISMISSED = 'hz_admin_banner_dismissed'
function onAdminHost(): boolean {
return typeof window !== 'undefined' && window.location.hostname.startsWith('admin.')
}
function AdminBanner({ onDismiss }: { onDismiss: () => void }) {
const brand = getBrand()
const adminUrl = `https://admin.${brand.adminDomain}`
return (
<XStack
bg="$yellow2"
borderBottomWidth={1}
borderColor="$yellow7"
px="$4"
py="$2"
items="center"
justify="space-between"
gap="$3"
flexWrap="wrap"
>
<Text fontSize="$2" color="$yellow11" flex={1}>
{'Admin ops (IAM · KMS · orgs) → '}
<Text fontSize="$2" color="$yellow12" fontWeight="700">
{`admin.${brand.adminDomain}`}
</Text>
</Text>
<XStack gap="$2" items="center">
<Button
size="$2"
bg="$yellow4"
borderColor="$yellow7"
onPress={() => window.location.assign(adminUrl)}
>
{`Open admin`}
</Button>
<Button size="$2" chromeless theme="yellow" onPress={onDismiss}>
</Button>
</XStack>
</XStack>
)
}
export function OrgGate({ children }: { children: ReactNode }) {
const { account } = useSession()
const owner = account?.owner ?? ''
// GLOBAL (cross-tenant) admin — the only one who may use admin.hanzo.ai. The
// decision (membership in the reserved `admin` org, or an explicit isGlobalAdmin
// claim) lives in ONE place — `isGlobalAdminAccount` — shared with the nav gate.
// A tenant org owner (e.g. Dave/maxpower) has owner!=='admin' → never global.
const isGlobalAdmin = isGlobalAdminAccount(account)
const [bannerDismissed, setBannerDismissed] = useState(true) // start hidden to avoid flash
// Restore banner dismissed state and last org on mount
useEffect(() => {
if (typeof window === 'undefined') return
const dismissed = localStorage.getItem(LS_BANNER_DISMISSED) === '1'
setBannerDismissed(dismissed)
}, [])
// Seed org scope on sign-in. A non-global admin can ONLY act in their OWN org —
// the server pins them there and every cross-tenant call 403s — so they are
// ALWAYS scoped to `owner`, ignoring a stale/switched org left in localStorage
// (e.g. a leftover `adnexus` from a prior global-admin switch, which would make
// the whole console 403 against a tenant they can't read). Only a global admin
// restores a previously-switched org.
useEffect(() => {
if (!owner || typeof window === 'undefined') return
if (!isGlobalAdmin) {
// Hard-pin to own org. If a stale cross-tenant scope was active, reset it and
// reload so every module refetches under the correct X-Org-Id. The reload is
// guarded on `currentOrg() !== owner`, so once the scope is right it never
// fires again — no loop.
localStorage.removeItem(LS_LAST_ORG)
if (currentOrg() !== owner) {
setCurrentOrg(owner)
window.location.reload()
}
return
}
const lastOrg = localStorage.getItem(LS_LAST_ORG)
if (currentOrg() === config.iamOrgName) {
const target = (lastOrg && lastOrg !== config.iamOrgName) ? lastOrg : owner
if (target !== config.iamOrgName) setCurrentOrg(target)
}
// Persist current org whenever it updates
const cur = currentOrg()
if (cur && cur !== config.iamOrgName) localStorage.setItem(LS_LAST_ORG, cur)
}, [owner, isGlobalAdmin])
const dismissBanner = () => {
setBannerDismissed(true)
if (typeof window !== 'undefined') localStorage.setItem(LS_BANNER_DISMISSED, '1')
}
// admin.hanzo.ai is GLOBAL-admin-only (cross-tenant IAM/KMS/orgs ops). A
// non-global-admin who lands here — e.g. an org owner like Dave/maxpower — is
// bounced to the regular console host; they never reach the admin surfaces (the
// server /admin/* proxies also fail-closed, this is the matching UI gate).
useEffect(() => {
if (typeof window === 'undefined') return
if (onAdminHost() && owner && !isGlobalAdmin) {
const consoleHost = window.location.hostname.replace(/^admin\./, 'console.')
window.location.replace(`https://${consoleHost}${window.location.pathname}${window.location.search}`)
}
}, [owner, isGlobalAdmin])
// No org yet → first-run onboarding
if (!owner) {
return <OrgOnboarding />
}
// On the admin host but not a global admin → render nothing while the redirect
// above fires, so the admin console never flashes for an unauthorized user.
if (onAdminHost() && !isGlobalAdmin) {
return null
}
// GLOBAL admin on a non-admin host: show dismissible banner, render console
// normally. Org-level admins (org owners) never see it — admin.hanzo.ai is
// cross-tenant ops they cannot use.
const showBanner = isGlobalAdmin && !onAdminHost() && !bannerDismissed
return (
<YStack flex={1}>
{showBanner && <AdminBanner onDismiss={dismissBanner} />}
{children}
</YStack>
)
}
+188
View File
@@ -0,0 +1,188 @@
'use client'
/**
* Org onboarding — the first-run screen for a user who isn't in an organization.
*
* Shown by {@link OrgGate} when the session has no org. Two predictable paths:
* - "Create your organization": type a name; we show the exact slug it becomes,
* then create the org and make you its admin.
* - "Skip — use a personal organization": one click creates a `<username>` org
* so EVERYONE always has an org and lands straight in the console.
*
* The mutation is the server route `/onboard` (it acts as the confidential
* console client; the browser only sends its cookie). Because an IAM user's
* org IS their identity, creating the org moves the user into it — so on success
* we re-authenticate (sign out → sign in) to mint a session for the new org, and
* the user arrives in their console. Honest inline errors; never a fake success.
*/
import { useState, type ReactNode } from 'react'
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { v1Url } from '~/lib/api/client'
import { FadeIn } from '~/components/ui/FadeIn'
type Phase = 'form' | 'done'
export function OrgOnboarding() {
const { signIn, signOut } = useSession()
const [name, setName] = useState('')
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
const [error, setError] = useState<string | null>(null)
const [phase, setPhase] = useState<Phase>('form')
const slug = slugifyOrg(name)
const named = validateOrgName(name)
const canCreate = named.ok && !busy
async function onboard(payload: { name: string } | { personal: true }, which: 'create' | 'personal') {
setError(null)
setBusy(which)
let res: Response
try {
res = await fetch(v1Url('console/onboard'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} catch {
setError('Network error — please try again.')
setBusy(false)
return
}
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
if (!res.ok || !json?.org) {
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
setBusy(false)
return
}
// Created + joined. Re-authenticate so the new session carries the new org.
setPhase('done')
try {
await signOut()
} catch {
// ignore — we re-auth regardless
}
signIn()
}
if (phase === 'done') {
return (
<Center>
<FadeIn style={CENTER_STYLE}>
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
<Spinner size="large" color="$color11" />
<YStack gap="$1" items="center">
<Text fontSize="$6" fontWeight="800">
Organization ready
</Text>
<Text fontSize="$3" color="$color11" text="center">
Signing you in to your new organization
</Text>
</YStack>
<Button size="$3" chromeless onPress={() => signIn()}>
Continue
</Button>
</Card>
</FadeIn>
</Center>
)
}
return (
<Center>
<FadeIn style={CENTER_STYLE}>
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1">
<YStack gap="$2">
<XStack gap="$2" items="center">
<Building2 size={20} />
<Text fontSize="$7" fontWeight="800">
Create your organization
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
Your account isnt in an organization yet. Create one to get started you can
rename it and invite teammates later.
</Text>
</YStack>
<YStack gap="$2">
<Text fontSize="$2" color="$color11" fontWeight="600">
Organization name
</Text>
<Input
value={name}
onChangeText={(v) => {
setName(v)
if (error) setError(null)
}}
placeholder="Acme Inc"
autoCapitalize="words"
autoFocus
onSubmitEditing={() => canCreate && void onboard({ name }, 'create')}
/>
{slug ? (
<Text fontSize="$2" color="$color10">
Identifier: <Text color="$color12">{slug}</Text>
</Text>
) : (
<Text fontSize="$2" color="$color10">
Letters and numbers spaces become hyphens.
</Text>
)}
</YStack>
{error ? (
<Text fontSize="$2" color="$red10">
{error}
</Text>
) : null}
<Button
size="$4"
theme="light"
disabled={!canCreate}
iconAfter={busy === 'create' ? <Spinner color="$color1" /> : <ArrowRight size={16} />}
onPress={() => void onboard({ name }, 'create')}
>
{busy === 'create' ? 'Creating…' : 'Create organization'}
</Button>
<XStack items="center" gap="$3">
<YStack flex={1} height={1} bg="$borderColor" />
<Text fontSize="$1" color="$color10">
OR
</Text>
<YStack flex={1} height={1} bg="$borderColor" />
</XStack>
<Button
size="$3"
disabled={!!busy}
icon={busy === 'personal' ? <Spinner color="$color11" /> : <Sparkles size={16} />}
onPress={() => void onboard({ personal: true }, 'personal')}
>
{busy === 'personal' ? 'Setting up…' : 'Skip — use a personal organization'}
</Button>
<Button size="$2" chromeless disabled={!!busy} onPress={() => void signOut()}>
Sign out
</Button>
</Card>
</FadeIn>
</Center>
)
}
const CENTER_STYLE = { display: 'flex', justifyContent: 'center', width: '100%' } as const
function Center({ children }: { children: ReactNode }) {
return (
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
{children}
</YStack>
)
}
+223
View File
@@ -0,0 +1,223 @@
'use client'
/**
* Org switcher — shows the organization the console is scoped to, lets the user
* switch between the orgs they can see, and CREATE a new one.
*
* The org LIST comes from IAM (`get-organizations`, via the gated `/admin/iam`
* proxy): a global admin sees every org; a tenant whose account can't list gets
* an empty result and simply sees their current org. Either way the trigger is
* ALWAYS interactive (so "Create organization" is reachable even with one org).
* Selecting one re-scopes the console IN PLACE — `switchOrg` persists the choice
* and reloads so every module refetches under the new `X-Org-Id`.
*
* Create posts to the same-origin `/onboard` route: a zero-org user is created +
* joined as admin; a user who already has an org gets an ADDITIONAL org (created
* without moving them) and is scope-switched into it. We never fabricate orgs.
*/
import { useEffect, useMemo, useState } from 'react'
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, Check, ChevronsUpDown, Plus, Search } from '@hanzogui/lucide-icons-2'
import { currentOrg, switchOrg, filterOrgs } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
import { v1Url } from '~/lib/api/client'
import { useIsGlobalAdmin } from '~/lib/auth/admin'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
export function OrgSwitcher() {
const currentId = currentOrg()
const isGlobalAdmin = useIsGlobalAdmin()
const [orgs, setOrgs] = useState<Organization[]>([])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
// The cross-tenant org list (`/admin/iam/get-organizations?owner=admin`) is
// server-gated to global admins; a tenant user only ever 403s it. Don't fire it
// for them — they simply see their current org (synthesized below) and can still
// "Create organization". Global admins get the full switchable list.
if (!isGlobalAdmin) {
setOrgs([])
return
}
let live = true
IamAdminApi.organizations()
.then((p) => {
if (live) setOrgs(p.rows ?? [])
})
.catch(() => {
// IAM org listing not available to this account (tenant) — current-org only.
if (live) setOrgs([])
})
return () => {
live = false
}
}, [isGlobalAdmin])
// Always include the current org so the switcher is meaningful even when the
// (admin-gated) list is empty for a tenant.
const allOrgs = useMemo(() => {
if (orgs.some((o) => o.name === currentId)) return orgs
return [{ owner: 'admin', name: currentId, displayName: titleCase(currentId) } as Organization, ...orgs]
}, [orgs, currentId])
const currentName = allOrgs.find((o) => o.name === currentId)?.displayName || titleCase(currentId)
const filtered = useMemo(() => filterOrgs(allOrgs, query), [allOrgs, query])
const select = (org: Organization) => {
setOpen(false)
switchOrg(org.name)
}
const create = async () => {
const name = newName.trim()
if (!name) return
setBusy(true)
setErr(null)
try {
const res = await fetch(v1Url('console/onboard'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = (await res.json().catch(() => ({}))) as { org?: string; error?: string }
if (!res.ok || !data.org) throw new Error(data.error || `Could not create organization (${res.status}).`)
// Scope into the new org (persists + reloads so every module refetches).
switchOrg(data.org)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not create the organization.')
setBusy(false)
}
}
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<Building2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{currentName}
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
{creating ? (
<YStack gap="$2">
<Text fontSize="$2" color="$color12" fontWeight="700">
Create organization
</Text>
<Input
size="$3"
placeholder="Organization name"
value={newName}
onChangeText={setNewName}
autoCapitalize="words"
onSubmitEditing={() => void create()}
/>
{err ? (
<Text fontSize="$1" color="$red10">
{err}
</Text>
) : null}
<XStack gap="$2" justify="flex-end">
<Button
size="$2"
chromeless
onPress={() => {
setCreating(false)
setErr(null)
}}
disabled={busy}
>
Cancel
</Button>
<Button
size="$2"
onPress={() => void create()}
disabled={busy || !newName.trim()}
icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
>
Create
</Button>
</XStack>
</YStack>
) : (
<YStack gap="$1">
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
<Search size={13} opacity={0.6} />
<Input
flex={1}
size="$2"
borderWidth={0}
bg="transparent"
placeholder="Filter organizations…"
value={query}
onChangeText={setQuery}
autoCapitalize="none"
/>
</XStack>
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Organizations · {allOrgs.length}
</Text>
<YStack gap="$0.5" maxH={300} overflow="scroll">
{filtered.length === 0 ? (
<Text px="$2" py="$2" fontSize="$2" color="$color10">
No organizations match {query}.
</Text>
) : (
filtered.map((org) => {
const isCurrent = org.name === currentId
return (
<XStack
key={`${org.owner}/${org.name}`}
onPress={() => select(org)}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
bg={isCurrent ? '$color4' : 'transparent'}
hoverStyle={{ bg: '$color5' }}
>
<Building2 size={14} opacity={0.7} />
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
{org.displayName || titleCase(org.name)}
</Text>
{isCurrent ? <Check size={14} /> : null}
</XStack>
)
})
)}
</YStack>
<XStack
onPress={() => {
setCreating(true)
setErr(null)
}}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
mt="$1"
rounded="$3"
borderTopWidth={1}
borderColor="$borderColor"
hoverStyle={{ bg: '$color5' }}
>
<Plus size={14} />
<Text fontSize="$2" color="$color12">
Create organization
</Text>
</XStack>
</YStack>
)}
</Popover.Content>
</Popover>
)
}
+24 -1
View File
@@ -7,9 +7,21 @@
import { useMemo, type ReactNode } from 'react'
import { GuiProvider } from '@hanzo/gui'
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
import { registerDefaultFields } from '@hanzo/data'
import config from '../../gui.config'
import { SessionProvider } from '~/lib/auth/session'
import { OrgAccentProvider } from './OrgAccentProvider'
// @hanzo/data populates its field-INPUT registry via an import SIDE EFFECT, but the
// package ships `"sideEffects": false`, so production tree-shaking (we consume it via
// `transpilePackages`) PRUNES that registration — leaving the registry empty. Then
// `FieldInput` returns null for every field, so a record CREATE/EDIT form (Base +
// Records) renders its labels with ZERO inputs and "Create" persists a BLANK row. An
// explicit call is a USED binding webpack cannot drop; it lights up every field input.
// Idempotent (guarded internally), no window/DOM — safe at module scope (SSR + client).
// ONE place, DRY — fixes every editable @hanzo/data surface, current and future.
registerDefaultFields()
function Themed({ children }: { children: ReactNode }) {
// `dark` fallback so the server render and the client's initial state agree
@@ -26,6 +38,17 @@ function Themed({ children }: { children: ReactNode }) {
}
export function Provider({ children }: { children: ReactNode }) {
const tree = useMemo(() => <SessionProvider>{children}</SessionProvider>, [children])
// OrgAccentProvider lives INSIDE SessionProvider (it reads the session to resolve the
// org's accent) and applies it at the document root — so every accent surface picks
// up the org's brand color on load, DRY.
const tree = useMemo(
() => (
<SessionProvider>
<OrgAccentProvider />
{children}
</SessionProvider>
),
[children],
)
return <Themed>{tree}</Themed>
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
/**
* Scope switcher — the project + environment pickers that scope every module.
*
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
* org-level scope) and the active ENVIRONMENT (mainnet/testnet/devnet + custom).
* Both write through `useScope`, which updates the module-level scope the API
* client reads — so changing either re-scopes every product at once. A "New
* project" affordance routes to the Projects module; we never fabricate a project.
*/
import type { ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
/** A small colored dot keyed to the environment's network tier. */
type DotColor = '$green10' | '$yellow10' | '$blue10' | '$purple10'
const ENV_DOT: Record<string, DotColor> = {
mainnet: '$green10',
testnet: '$yellow10',
devnet: '$blue10',
}
const envDot = (env: string): DotColor => ENV_DOT[env] ?? '$purple10'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
function ProjectPicker() {
const router = useRouter()
const { scope, projects, loadingProjects, selectProject } = useScope()
const label = scope.project ? scope.project : 'All projects'
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{label}
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={260} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Project
</Text>
{/* Org-level scope — no X-Project-Id sent. */}
<Row
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={() => selectProject(undefined)}
/>
{projects.map((p) => (
<Row
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={() => selectProject(p.name)}
/>
))}
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<XStack height={1} bg="$borderColor" my="$1" />
<Row
label="New project"
icon={<Plus size={14} />}
onPress={() => router.push('/projects')}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
function EnvironmentPicker() {
const { scope, environments, selectEnvironment } = useScope()
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<Layers size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
<XStack items="center" gap="$2">
<YStack width={8} height={8} rounded={999} bg={envDot(scope.environment)} />
<Text fontSize="$2" color="$color12">
{scope.environment}
</Text>
</XStack>
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={220} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Environment
</Text>
{environments.map((env) => {
const stock = (STOCK_ENVIRONMENTS as readonly string[]).includes(env)
return (
<Row
key={env}
label={titleCase(env)}
sub={stock ? undefined : 'Custom'}
dot={envDot(env)}
active={scope.environment === env}
onPress={() => selectEnvironment(env)}
/>
)
})}
</YStack>
</Popover.Content>
</Popover>
)
}
/** One selectable row in a picker popover. */
function Row({
label,
sub,
dot,
icon,
active,
onPress,
}: {
label: string
sub?: string
dot?: DotColor
icon?: ReactNode
active?: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color4' }}
>
{dot ? <YStack width={8} height={8} rounded={999} bg={dot} /> : icon}
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10">
{sub}
</Text>
) : null}
</YStack>
{active ? <Check size={14} /> : null}
</XStack>
)
}
/** Project + environment pickers as a unit (topbar). */
export function ScopeSwitcher() {
return (
<XStack items="center" gap="$1">
<ProjectPicker />
<EnvironmentPicker />
</XStack>
)
}
+366
View File
@@ -0,0 +1,366 @@
'use client'
/**
* Sidebar customization panes — the CONTENT the DetailPane renders when a user
* customizes their sidebar. Two surfaces, both backed by the ONE account-persisted
* store (`usePins` + `useProductColors`), so every choice follows the user:
*
* - `ProductCustomize` — set a single product's icon COLOR, PIN it, and file it
* into a GROUP. Opened from a product row's color dot.
* - `ManagePins` — the full manager: DRAG to reorder within a group, create /
* rename / remove groups, move a pin between groups, unpin. Opened from the
* "Manage" affordance on the Pinned header.
*
* These are pane bodies (rendered inside `DetailPane`), not their own overlays —
* the pane chrome (slide, backdrop, close) is the shared `SlideOver`.
*/
import { useState } from 'react'
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Check, GripVertical, Plus, Star, X } from '@hanzogui/lucide-icons-2'
import { COLOR_SWATCHES } from '~/lib/products/colors'
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { findEntry } from '~/lib/products/registry'
import { Reorder } from '~/components/ui/Reorder'
import { asColor } from '~/components/ui/color'
/** A round color swatch button; ringed + checked when selected. */
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
return (
<XStack
onPress={onPress}
cursor="pointer"
width={32}
height={32}
rounded="$10"
items="center"
justify="center"
borderWidth={2}
borderColor={selected ? '$color12' : 'transparent'}
hoverStyle={{ borderColor: selected ? '$color12' : '$color8' }}
style={{ backgroundColor: hex }}
aria-label={selected ? 'Selected color' : 'Set color'}
>
{selected ? <Check size={16} color={asColor('#ffffff')} /> : null}
</XStack>
)
}
/** A pressable group chip (default bucket, a named group, or the active state). */
function GroupChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
return (
<XStack
onPress={onPress}
cursor="pointer"
px="$2.5"
py="$1.5"
rounded="$10"
borderWidth={1}
borderColor={active ? '$color8' : '$borderColor'}
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
>
<Text fontSize="$2" fontWeight="600" color="$color12">
{label}
</Text>
</XStack>
)
}
/** New-group input + add — shared by both panes. */
function NewGroup({ onAdd }: { onAdd: (name: string) => void }) {
const [name, setName] = useState('')
const add = () => {
const n = name.trim()
if (!n) return
onAdd(n)
setName('')
}
return (
<XStack items="center" gap="$2">
<XStack
flex={1}
items="center"
gap="$2"
px="$2.5"
height={36}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Input
flex={1}
unstyled
value={name}
onChangeText={setName}
placeholder="New group…"
fontSize="$3"
color="$color12"
autoCapitalize="none"
onSubmitEditing={add}
/>
</XStack>
<Button size="$3" icon={<Plus size={16} />} onPress={add} disabled={!name.trim()}>
Add
</Button>
</XStack>
)
}
// ── Single-product customize ─────────────────────────────────────────────────
export function ProductCustomize({ id }: { id: string }) {
const entry = findEntry(id)
const { keyOf, setColor, resetColor } = useProductColors()
const { isPinned, toggle, assign, view, manageView, addGroup } = usePins()
const activeKey = keyOf(id)
const pinned = isPinned(id)
const groupOf =
view.flatMap((g) => g.entries.map((e) => ({ id: e.id, group: g.name }))).find((e) => e.id === id)?.group ??
DEFAULT_GROUP
const groups = manageView.map((g) => g.name)
if (!entry) return <Text color="$color10">Unknown product.</Text>
return (
<YStack gap="$5">
{/* Color */}
<YStack gap="$2.5">
<XStack items="center" justify="space-between">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
Icon color
</Text>
<Button size="$1" chromeless onPress={() => resetColor(id)}>
<Text fontSize="$1" color="$color10">
Reset
</Text>
</Button>
</XStack>
<XStack flexWrap="wrap" gap="$2.5">
{COLOR_SWATCHES.map((s) => (
<SwatchButton key={s.key} hex={s.hex} selected={s.key === activeKey} onPress={() => setColor(id, s.key)} />
))}
</XStack>
</YStack>
{/* Pin */}
<YStack gap="$2.5">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
Pin
</Text>
<Button
justify="flex-start"
icon={<Star size={16} />}
onPress={() => toggle(id)}
bg={pinned ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
>
{pinned ? 'Pinned to sidebar' : 'Pin to sidebar'}
</Button>
</YStack>
{/* Group (only meaningful once pinned) */}
{pinned ? (
<YStack gap="$2.5">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
Group
</Text>
<XStack flexWrap="wrap" gap="$2">
<GroupChip label={DEFAULT_GROUP_LABEL} active={groupOf === DEFAULT_GROUP} onPress={() => assign(id, DEFAULT_GROUP)} />
{groups
.filter((g) => g !== DEFAULT_GROUP)
.map((g) => (
<GroupChip key={g} label={g} active={groupOf === g} onPress={() => assign(id, g)} />
))}
</XStack>
<NewGroup
onAdd={(name) => {
addGroup(name)
assign(id, name)
}}
/>
</YStack>
) : null}
</YStack>
)
}
// ── Full pins manager ────────────────────────────────────────────────────────
/** One draggable pinned row inside a group (icon + label + move-to-group + unpin). */
function PinRow({
id,
groups,
currentGroup,
colorOf,
onAssign,
onUnpin,
handle,
}: {
id: string
groups: string[]
currentGroup: string
colorOf: (id: string) => string
onAssign: (id: string, group: string) => void
onUnpin: (id: string) => void
handle: { onPointerDown: (e: never) => void }
}) {
const entry = findEntry(id)
const Icon = entry?.icon
return (
<XStack items="center" gap="$2" px="$2" height={44} rounded="$3" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack
{...handle}
cursor="grab"
px="$1"
py="$2"
opacity={0.6}
hoverStyle={{ opacity: 1 }}
aria-label="Drag to reorder"
>
<GripVertical size={16} />
</XStack>
{Icon ? <Icon size={16} color={asColor(colorOf(id))} /> : null}
<Text flex={1} fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{entry?.label ?? id}
</Text>
{groups.length > 1 ? (
<XStack items="center" gap="$1">
{groups.map((g) => (
<XStack
key={g || 'default'}
onPress={() => onAssign(id, g)}
cursor="pointer"
width={18}
height={18}
rounded="$10"
items="center"
justify="center"
borderWidth={1}
borderColor={g === currentGroup ? '$color12' : '$borderColor'}
bg={g === currentGroup ? '$color12' : 'transparent'}
aria-label={`Move to ${g || DEFAULT_GROUP_LABEL}`}
/>
))}
</XStack>
) : null}
<Button size="$1" chromeless icon={<X size={15} />} onPress={() => onUnpin(id)} aria-label="Unpin" />
</XStack>
)
}
function ManageGroup({
group,
groups,
colorOf,
onReorder,
onAssign,
onUnpin,
onRename,
onRemove,
}: {
group: PinGroupView
groups: string[]
colorOf: (id: string) => string
onReorder: (group: string, from: number, to: number) => void
onAssign: (id: string, group: string) => void
onUnpin: (id: string) => void
onRename: (from: string, to: string) => void
onRemove: (name: string) => void
}) {
const [rename, setRename] = useState(group.label)
const named = group.name !== DEFAULT_GROUP
return (
<YStack gap="$2">
<XStack items="center" gap="$2">
{named ? (
<XStack flex={1} items="center" gap="$2" px="$2.5" height={32} rounded="$3" borderWidth={1} borderColor="$borderColor" bg="$color2">
<Input
flex={1}
unstyled
value={rename}
onChangeText={setRename}
onSubmitEditing={() => onRename(group.name, rename)}
onBlur={() => onRename(group.name, rename)}
fontSize="$2"
fontWeight="700"
color="$color12"
/>
</XStack>
) : (
<Text flex={1} fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
{group.label}
</Text>
)}
{named ? (
<Button size="$1" chromeless icon={<X size={14} />} onPress={() => onRemove(group.name)} aria-label={`Remove ${group.label}`} />
) : null}
</XStack>
{group.entries.length === 0 ? (
<Text px="$2" py="$2" fontSize="$2" color="$color10">
Empty move a pin here.
</Text>
) : (
<Reorder
items={group.entries}
keyOf={(e) => e.id}
rowHeight={48}
onReorder={(from, to) => onReorder(group.name, from, to)}
renderItem={(e, h) => (
<PinRow
id={e.id}
groups={groups}
currentGroup={group.name}
colorOf={colorOf}
onAssign={onAssign}
onUnpin={onUnpin}
handle={h as { onPointerDown: (e: never) => void }}
/>
)}
/>
)}
</YStack>
)
}
export function ManagePins() {
const { manageView, move, assign, unpin, addGroup, removeGroup, renameGroup } = usePins()
const { colorOf } = useProductColors()
const groupNames = manageView.map((g) => g.name)
return (
<YStack gap="$5">
<Text fontSize="$2" color="$color10">
Drag the handle to reorder. Use the dots to move a pin between groups. Create groups to organize your sidebar.
</Text>
{manageView.map((group) => (
<ManageGroup
key={group.name || 'default'}
group={group}
groups={groupNames}
colorOf={colorOf}
onReorder={(g, from, to) => {
const entry = manageView.find((v) => v.name === g)?.entries[from]
if (entry) move(entry.id, g, to)
}}
onAssign={assign}
onUnpin={unpin}
onRename={renameGroup}
onRemove={removeGroup}
/>
))}
<YStack gap="$2">
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
New group
</Text>
<NewGroup onAdd={addGroup} />
</YStack>
</YStack>
)
}
+119
View File
@@ -0,0 +1,119 @@
'use client'
/**
* Always-visible identity + wallet — pinned to the bottom of the sidebar so a
* customer's account, balance, top-up, and sign-out are one glance / one click
* away on every page.
*
* Identity (avatar + display name) comes from the signed-in IAM account. Three
* destinations, one way each:
* - The user row → the **Profile** page (`/profile`): account, security, keys.
* - The balance row → the in-console **Billing** module (`/billing`): balance,
* usage, invoices — the org's own data, scoped to the active org.
* - **Top up** → the brand billing portal (billing.hanzo.ai) — payment is never
* rebuilt here. **Sign out** sits directly beneath it.
*
* The balance comes from the per-tenant `/billing/*` server proxy, scoped to the
* caller's OWN org — the exact credit the gateway debits.
*/
import { useRouter } from 'next/navigation'
import { Avatar, Button, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronRight, LogOut, Wallet } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
const fmtUsd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
/** Up-to-two-letter initials from a display name / handle (avatar fallback). */
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
/** The account avatar — IAM photo when present, initials circle otherwise. */
function IdentityAvatar({ name, avatar, size }: { name: string; avatar?: string; size: number }) {
return (
<Avatar circular size={size}>
{avatar ? <Avatar.Image accessibilityLabel={name} src={avatar} /> : null}
<Avatar.Fallback bg="$color5" items="center" justify="center">
<Text fontSize={size <= 28 ? '$1' : '$2'} fontWeight="800" color="$color12">
{initials(name)}
</Text>
</Avatar.Fallback>
</Avatar>
)
}
export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
const { account, signOut } = useSession()
const router = useRouter()
const owner = account?.owner ?? ''
// ONE shared live balance (same value the Wallet/Cost pages show): refetches on
// mount, on window focus/visibility, on a 30s poll, and after any completion or
// top-up — so spend/credit changes reflect here without a reload.
const { balance } = useCloudBalance()
const cents = spendableCents(balance)
if (!owner) return null
const name = account?.displayName || account?.name || 'Account'
const avatar = typeof account?.avatar === 'string' ? account.avatar : undefined
const balanceText = cents === null ? '—' : fmtUsd(cents)
const openProfile = () => router.push('/profile')
const openCost = () => router.push('/billing')
// In-console card top-up (Square) — the /billing/credits page. Replaces the old
// window.open to the external billing.hanzo.ai portal.
const openTopUp = () => router.push('/billing/credits')
if (collapsed) {
// Three stacked affordances: identity (→ Profile), top-up, sign out.
return (
<YStack items="center" gap="$2">
<YStack onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
<IdentityAvatar name={name} avatar={avatar} size={32} />
</YStack>
<Button size="$2" chromeless onPress={openTopUp} icon={<Wallet size={18} />} aria-label={`Wallet ${balanceText} — top up`} />
<Button size="$2" chromeless onPress={() => void signOut()} icon={<LogOut size={18} />} aria-label="Sign out" />
</YStack>
)
}
return (
<YStack gap="$2" px="$2.5" py="$2.5" rounded="$3" bg="$color2" borderWidth={1} borderColor="$borderColor">
{/* Identity → Profile */}
<XStack items="center" gap="$2.5" onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
<IdentityAvatar name={name} avatar={avatar} size={36} />
<YStack flex={1} minW={0}>
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>{name}</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>{account?.email || 'View profile'}</Text>
</YStack>
<ChevronRight size={16} opacity={0.5} />
</XStack>
{/* Balance → Cost */}
<XStack
items="center"
justify="space-between"
onPress={openCost}
cursor="pointer"
hoverStyle={{ opacity: 0.85 }}
px="$1"
aria-label={`Balance ${balanceText} — open Cost`}
>
<XStack items="center" gap="$1.5">
<Wallet size={13} opacity={0.7} />
<Text fontSize="$2" color="$color11">{balanceText}</Text>
</XStack>
<ChevronRight size={14} opacity={0.4} />
</XStack>
<Button size="$2" onPress={openTopUp}>Top up</Button>
<Button size="$2" chromeless icon={<LogOut size={15} />} onPress={() => void signOut()} justify="center">
Sign out
</Button>
</YStack>
)
}
+225 -34
View File
@@ -1,24 +1,43 @@
'use client'
/**
* Sign-in card — adapted from the @hanzo/gui `sign-in-form` recipe.
* Sign-in / create-account card — multi-tenant credential entry (HIP-0111).
*
* Authentication is delegated to Hanzo IAM (OIDC, hanzo.id). The console never
* collects credentials or reconstructs provider OAuth URLs: every button starts
* the IAM authorize redirect — the social buttons hint a provider, the primary
* button opens IAM's email / passkey flow — and IAM owns the rest (provider
* client ids, callbacks), returning to `/auth/callback`.
* The console resolves a user's ORG from their email (not the brand's own org),
* so a customer in any org signs in here with email + password: we POST the
* canonical IAM login with `organization: ""` (see `lib/auth/iam-login`), get an
* OAuth code, and complete the SAME `/v1/iam/signin` exchange the redirect flow uses.
*
* CREATE ACCOUNT (self-serve email signup): a net-new stranger toggles to signup,
* which POSTs `/auth/signup` — the BFF mints an account + its own org (as admin) —
* then this form signs them in with the same credentials via the identical login
* path, so they land in the console as the admin of their new workspace. Social
* signup for a brand-new user needs the IAM app's signup enabled (separate); email
* signup is the guaranteed path.
*
* Social buttons start IAM's hosted provider flow (IAM owns each provider's
* OAuth — client id, scope, callback). Accounts that require two-factor finish
* on IAM's same-site hosted page (the IAM session cookie is `SameSite=Lax`, so a
* cross-site fetch can't carry the MFA challenge) — we hand off with a redirect
* rather than fake an inline step.
*/
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Anchor, Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Github } from '@hanzogui/lucide-icons-2'
import { branding } from '~/config'
import { HanzoMark } from '~/components/ui/Loader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { useSession } from '~/lib/auth/session'
import { getSigninUrl } from '~/lib/auth/iam'
import { loginState, loginWithPassword } from '~/lib/auth/iam-login'
import { signUp } from '~/lib/auth/signup'
/** Monochrome Google "G" — the canonical mark, filled with the current text
* color so it stays black/white with the rest of the console chrome. */
type Mode = 'signin' | 'signup'
/** Monochrome Google "G" — filled with the current text color so it tracks the
* console's black/white chrome. */
function GoogleMark({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="Google">
@@ -30,8 +49,7 @@ function GoogleMark({ size = 18 }: { size?: number }) {
)
}
export function SignInForm() {
const { signIn, signInWith } = useSession()
function CardShell({ subtitle, children }: { subtitle: string; children: React.ReactNode }) {
return (
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
<Card p="$5" gap="$4" width={380} borderWidth={1} borderColor="$borderColor" bg="$color1">
@@ -41,33 +59,206 @@ export function SignInForm() {
<Text fontSize="$7" fontWeight="800">
{branding.name}
</Text>
<Text fontSize="$3" color="$color11">
Sign in to manage your cloud.
<Text fontSize="$3" color="$color11" text="center">
{subtitle}
</Text>
</YStack>
</YStack>
<YStack gap="$2.5">
<Button size="$4" icon={<Github size={18} />} onPress={() => signInWith('provider-github')}>
Continue with GitHub
</Button>
<Button size="$4" icon={<GoogleMark />} onPress={() => signInWith('provider-google')}>
Continue with Google
</Button>
<XStack items="center" gap="$3" my="$1">
<YStack flex={1} height={1} bg="$borderColor" />
<Text fontSize="$2" color="$color10">
or
</Text>
<YStack flex={1} height={1} bg="$borderColor" />
</XStack>
<PrimaryButton size="$4" onPress={signIn}>
Continue with Hanzo ID
</PrimaryButton>
</YStack>
{children}
</Card>
<Text fontSize="$1" color="$color9" text="center" mt="$3">
{branding.productLine}
</Text>
</YStack>
)
}
export function SignInForm() {
const { completeSignIn, signInWith, establishConsoleSession } = useSession()
const router = useRouter()
const [mode, setMode] = useState<Mode>('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mfa, setMfa] = useState(false)
// The ONE credential login path — shared by sign-in and by post-signup auto-login.
async function logInWithCredentials(): Promise<void> {
const res = await loginWithPassword(email.trim(), password)
if (res.kind === 'code') {
await completeSignIn(res.code, loginState())
// Upgrade to the console's OWN durable, silently-refreshed session (server-side
// password grant, gated by the casibase session we just established). Best-effort:
// it never throws, and a failure leaves the user signed in on the casibase session
// — so login is never blocked by this enhancement. MFA accounts don't reach here
// (they hand off to the hosted flow), so this never bypasses MFA.
await establishConsoleSession(email.trim(), password)
router.replace('/')
} else if (res.kind === 'mfa') {
setMfa(true)
} else {
setError(res.message)
setBusy(false)
}
}
async function submitSignIn() {
if (busy || !email || !password) return
setBusy(true)
setError(null)
try {
await logInWithCredentials()
} catch (e) {
setError(e instanceof Error ? e.message : 'Sign-in failed.')
setBusy(false)
}
}
async function submitSignUp() {
if (busy || !email || !password) return
setBusy(true)
setError(null)
try {
const r = await signUp(email.trim(), password)
if (r.kind === 'exists') {
setError('An account with this email already exists — sign in below.')
setMode('signin')
setBusy(false)
return
}
if (r.kind === 'error') {
setError(r.message)
setBusy(false)
return
}
// Account + org created — sign in with the same credentials so the new admin
// lands straight in their workspace (fresh accounts have no MFA to satisfy).
await logInWithCredentials()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create your account.')
setBusy(false)
}
}
const submit = () => (mode === 'signup' ? submitSignUp() : submitSignIn())
function switchMode(next: Mode) {
setMode(next)
setError(null)
}
// Two-factor accounts finish on IAM's same-site hosted page (cross-site cookie
// limitation). Honest hand-off — not a faked inline step.
if (mfa) {
return (
<CardShell subtitle="Sign in to manage your cloud.">
<YStack gap="$3" items="center">
<Text fontSize="$5" fontWeight="700">
Two-factor required
</Text>
<Text fontSize="$3" color="$color11" text="center">
Your account uses two-factor authentication. Continue on the secure Hanzo ID page to
enter your code.
</Text>
<PrimaryButton size="$4" width="100%" onPress={() => window.location.assign(getSigninUrl())}>
Continue on Hanzo ID
</PrimaryButton>
<Button size="$3" chromeless onPress={() => { setMfa(false); setBusy(false) }}>
Back
</Button>
</YStack>
</CardShell>
)
}
const signup = mode === 'signup'
return (
<CardShell subtitle={signup ? `Create your ${branding.name} account.` : 'Sign in to manage your cloud.'}>
<YStack gap="$2.5">
<Button size="$4" icon={<Github size={18} />} onPress={() => signInWith('provider-github')}>
Continue with GitHub
</Button>
<Button size="$4" icon={<GoogleMark />} onPress={() => signInWith('provider-google')}>
Continue with Google
</Button>
<XStack items="center" gap="$3" my="$1">
<YStack flex={1} height={1} bg="$borderColor" />
<Text fontSize="$2" color="$color10">
or
</Text>
<YStack flex={1} height={1} bg="$borderColor" />
</XStack>
<Input
size="$4"
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
value={email}
onChangeText={setEmail}
disabled={busy}
/>
<Input
size="$4"
placeholder="Password"
// secureTextEntry alone did not mask in this @hanzo/gui build; set the
// web input type explicitly so the password is masked (RNW passthrough).
secureTextEntry
{...{ type: 'password' }}
autoComplete={signup ? 'new-password' : 'current-password'}
value={password}
onChangeText={setPassword}
disabled={busy}
onSubmitEditing={submit}
/>
{error ? (
<Text fontSize="$2" color="$red10" role="alert">
{error}
</Text>
) : null}
<PrimaryButton
size="$4"
disabled={busy || !email || !password}
icon={busy ? <Spinner size="small" /> : undefined}
onPress={submit}
>
{busy ? (signup ? 'Creating account…' : 'Signing in…') : signup ? 'Create account' : 'Sign in'}
</PrimaryButton>
{signup ? (
<Text fontSize="$2" color="$color10" text="center">
Already have an account?{' '}
<Anchor fontSize="$2" color="$color11" onPress={() => switchMode('signin')}>
Sign in
</Anchor>
</Text>
) : (
<>
<Text fontSize="$2" color="$color10" text="center">
New to {branding.name}?{' '}
<Anchor fontSize="$2" color="$color11" onPress={() => switchMode('signup')}>
Create an account
</Anchor>
</Text>
<Text fontSize="$2" color="$color10" text="center">
Trouble signing in?{' '}
<Anchor
fontSize="$2"
color="$color11"
onPress={() => window.location.assign(getSigninUrl())}
>
Use a passkey or recovery
</Anchor>
</Text>
</>
)}
</YStack>
</CardShell>
)
}
@@ -0,0 +1,355 @@
'use client'
/**
* AgentBuilder — the CANONICAL, shareable Hanzo agent builder.
*
* ONE builder, one and only one way to define an agent (name · model · prompt ·
* tools · description), used by every surface. It is self-contained and injected:
* the host passes `AgentBuilderLoaders` (the live model catalog, the saved-prompt
* library, a prompt-body fetch, and the create effect) — the builder owns the form,
* the LIVE dropdowns, validation, and the honest states, but knows NOTHING about
* any host's API client. That is what lets console2, chat, app, bot, and team all
* import this exact component over the SAME backend (`POST /cloud/v1/agents`, org
* resolved server-side from the caller's bearer) instead of each rebuilding a form.
*
* Dynamic by construction:
* - Model → a ComboBox: type any id OR pick from the LIVE `/v1/models` catalog.
* - Prompt → a selector of the org's saved prompts (fills the system prompt), with
* a "Custom" option for free text — "the system prompt is selectable
* from saved prompts OR typed".
* - Tools → a ComboBox with the live tool catalog, added as chips (typeable too).
* - Advanced → the hanzo.chat power-user generation config (temperature · top-p ·
* top-k · reasoning effort · use-tools · web-search · thinking · stream),
* folded into the ONE builder. Hidden by default; any knob left at its
* default is pruned, so opening it never changes a simple agent's body.
* Every option set is REAL (from a loader) or the field degrades to typeable — never
* a fabricated model/prompt/tool. This is the SUPERSET builder: console2's decoupled
* injected-loader seam + hanzo.chat's advanced config — one component, both surfaces.
*
* Deps: only `@hanzo/gui`, the shared `ComboBox`/`Field` UI primitives, and this
* module's own `./types`/`./logic`. No `~/lib/api` — so it lifts cleanly into
* `@hanzo/agent-builder`.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
import {
canSubmit,
classifyBuilderError,
defaultConfig,
defaultModel,
emptySpec,
promptBodyFromRow,
promptOptions,
toCreateBody,
} from './logic'
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
/** Async option-list state for the live pickers (model/tool). */
type OptState =
| { phase: 'idle' }
| { phase: 'loading' }
| { phase: 'error'; message: string }
| { phase: 'ready'; options: BuilderOption[] }
const CUSTOM = '__custom__'
export function AgentBuilder({
loaders,
onCreated,
onCancel,
submitLabel = 'Create agent',
}: {
loaders: AgentBuilderLoaders
/** Called after a successful create (the host reloads its list + closes the form). */
onCreated: () => void
/** Called when the user cancels (optional — omit for an always-open form). */
onCancel?: () => void
submitLabel?: string
}) {
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [models, setModels] = useState<OptState>({ phase: 'idle' })
const [tools, setTools] = useState<OptState>({ phase: 'idle' })
const [prompts, setPrompts] = useState<BuilderPrompt[] | null>(null)
const [promptPick, setPromptPick] = useState<string | null>(null)
const set = <K extends keyof AgentSpec>(k: K, v: AgentSpec[K]) => setSpec((s) => ({ ...s, [k]: v }))
// Advanced generation config — bound lazily to `spec.config ?? defaultConfig()`;
// a knob left at its default is pruned by `toCreateBody`, so a simple agent still
// posts no `config`. Functional update = no stale-closure read.
const [advanced, setAdvanced] = useState(false)
const cfg = spec.config ?? defaultConfig()
const setCfg = <K extends keyof AgentConfig>(k: K, v: AgentConfig[K]) =>
setSpec((s) => ({ ...s, config: { ...(s.config ?? defaultConfig()), [k]: v } }))
// Preselect a Zen default ONLY if the user hasn't typed a model yet — refs so the
// loader doesn't re-run on every keystroke.
const specRef = useRef(spec)
specRef.current = spec
const loadModels = useCallback(() => {
if (!loaders.loadModels) return
setModels({ phase: 'loading' })
loaders
.loadModels()
.then((options) => {
setModels({ phase: 'ready', options })
if (!specRef.current.model) {
const d = defaultModel(options)
if (d) set('model', d)
}
})
.catch((e) => setModels({ phase: 'error', message: msg(e) }))
}, [loaders])
const loadTools = useCallback(() => {
if (!loaders.loadTools) return
setTools({ phase: 'loading' })
loaders
.loadTools()
.then((options) => setTools({ phase: 'ready', options }))
.catch((e) => setTools({ phase: 'error', message: msg(e) }))
}, [loaders])
useEffect(() => {
loadModels()
loadTools()
if (loaders.loadPrompts) {
loaders
.loadPrompts()
.then((rows) => setPrompts(rows))
.catch(() => setPrompts([])) // prompts optional — hide the selector on failure
} else {
setPrompts([])
}
}, [loadModels, loadTools, loaders])
// ── Prompt selector: pick a saved prompt → fill systemPrompt (Custom = free) ──
const onPickPrompt = async (name: string | null) => {
setPromptPick(name)
if (!name || name === CUSTOM) return // Custom → leave the textarea as the user's own
const inline = promptBodyFromRow(prompts ?? [], name)
if (inline != null) {
set('systemPrompt', inline)
return
}
if (loaders.loadPromptBody) {
try {
const body = await loaders.loadPromptBody(name)
set('systemPrompt', body)
} catch {
// couldn't fetch the body — keep whatever's typed; the pick still records intent
}
}
}
// ── Tools as chips ────────────────────────────────────────────────────────
const [toolDraft, setToolDraft] = useState('')
const addTool = (v: string) => {
const t = v.trim()
if (t && !spec.tools.includes(t)) set('tools', [...spec.tools, t])
setToolDraft('')
}
const removeTool = (t: string) => set('tools', spec.tools.filter((x) => x !== t))
const submit = async () => {
if (!canSubmit(spec) || busy) return
setBusy(true)
setError(null)
setUnavailable(false)
try {
await loaders.createAgent(toCreateBody(spec))
onCreated()
} catch (e) {
const c = classifyBuilderError(e)
if (c.kind === 'unavailable') setUnavailable(true)
else setError(c.message)
} finally {
setBusy(false)
}
}
const modelOptions: ComboOption[] = models.phase === 'ready' ? models.options : []
const toolOptions: ComboOption[] = tools.phase === 'ready' ? tools.options : []
const promptSelectOptions: SelectOption<string>[] = [
{ key: CUSTOM, label: 'Custom (type your own)' },
...promptOptions(prompts ?? []).map((o) => ({ key: o.value, label: o.label ?? o.value })),
]
return (
<YStack gap="$3">
<Text fontSize="$2" color="$color11">
An agent is a model, a system prompt, and a set of tools that runs on Hanzo compute and calls your APIs on
its own.
</Text>
<FieldRow label="Name">
<FieldText value={spec.name} onChange={(v) => set('name', v)} placeholder="support-triage" />
</FieldRow>
<FieldRow label="Model">
<ComboBox
value={spec.model}
onChange={(v) => set('model', v)}
options={modelOptions}
loading={models.phase === 'loading'}
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
onRetry={loadModels}
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
/>
</FieldRow>
{/* Prompt selector — only when a prompt library is wired + has entries. */}
{prompts && prompts.length > 0 ? (
<FieldRow label="Load prompt">
<SelectMenu
options={promptSelectOptions}
value={promptPick}
onChange={(v) => void onPickPrompt(v)}
allLabel="Custom (type your own)"
minWidth={240}
/>
</FieldRow>
) : null}
<FieldRow label="System prompt">
<FieldTextArea
value={spec.systemPrompt}
onChange={(v) => {
set('systemPrompt', v)
if (promptPick && promptPick !== CUSTOM) setPromptPick(CUSTOM) // editing => now custom
}}
rows={6}
/>
</FieldRow>
<FieldRow label="Tools">
<YStack gap="$2">
<ComboBox
value={toolDraft}
onChange={setToolDraft}
options={toolOptions}
loading={tools.phase === 'loading'}
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
onRetry={loadTools}
placeholder="add a tool — e.g. web.search, code.exec"
emptyText="Press Add to include what you typed."
/>
<XStack gap="$2">
<Button size="$2" chromeless icon={<Plus size={14} />} onPress={() => addTool(toolDraft)} disabled={!toolDraft.trim()}>
Add tool
</Button>
</XStack>
{spec.tools.length > 0 ? (
<XStack gap="$1.5" flexWrap="wrap">
{spec.tools.map((t) => (
<XStack key={t} items="center" gap="$1" px="$2" py="$1" rounded="$3" bg="$color3" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$1" color="$color12">
{t}
</Text>
<Button size="$1" chromeless icon={<X size={11} />} onPress={() => removeTool(t)} aria-label={`Remove ${t}`} />
</XStack>
))}
</XStack>
) : null}
</YStack>
</FieldRow>
<FieldRow label="Description">
<FieldText value={spec.description} onChange={(v) => set('description', v)} placeholder="What this agent does" />
</FieldRow>
{/* Advanced generation config — the hanzo.chat power-user knobs, folded into
the ONE builder. Hidden by default; every value that stays at its default
is pruned, so opening this never changes what a simple agent posts. */}
<YStack gap="$2">
<Button size="$2" chromeless self="flex-start" onPress={() => setAdvanced((a) => !a)}>
{advanced ? 'Hide advanced settings' : 'Advanced settings'}
</Button>
{advanced ? (
<YStack gap="$3" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Temperature">
<FieldSlider value={cfg.temperature} min={0} max={2} step={0.1} onChange={(v) => setCfg('temperature', v)} />
</FieldRow>
<FieldRow label="Top-p">
<FieldSlider value={cfg.topP} min={0} max={1} step={0.05} onChange={(v) => setCfg('topP', v)} />
</FieldRow>
<FieldRow label="Top-k">
<FieldSlider value={cfg.topK} min={0} max={100} step={1} onChange={(v) => setCfg('topK', v)} />
</FieldRow>
<FieldRow label="Reasoning effort">
<FieldSelect
value={cfg.reasoningEffort ?? 'default'}
options={['default', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']}
onChange={(v) => setCfg('reasoningEffort', v === 'default' ? undefined : (v as ReasoningEffort))}
/>
</FieldRow>
<FieldRow label="Use tools">
<FieldSwitch checked={cfg.useTools} onChange={(v) => setCfg('useTools', v)} />
</FieldRow>
<FieldRow label="Web search">
<FieldSwitch checked={cfg.webSearch} onChange={(v) => setCfg('webSearch', v)} />
</FieldRow>
<FieldRow label="Thinking">
<FieldSwitch checked={cfg.thinking} onChange={(v) => setCfg('thinking', v)} />
</FieldRow>
<FieldRow label="Stream">
<FieldSwitch checked={cfg.stream} onChange={(v) => setCfg('stream', v)} />
</FieldRow>
</YStack>
) : null}
</YStack>
{unavailable ? (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Terminal size={14} />
<Text fontSize="$3" fontWeight="700">
Agents API isnt connected on this deployment yet
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
Your definition wasnt saved (the `/v1/agents` route isnt bound here yet). Create with the CLI {' '}
<Text fontSize="$1" bg="$color3" px="$1" py="$0.5" rounded="$2">hanzo agents create</Text> and it appears here once the backend is live.
</Text>
</Card>
) : null}
{error ? (
<Text fontSize="$2" color="$red10">
{error}
</Text>
) : null}
<XStack gap="$2" pt="$1">
{onCancel ? (
<Button flex={1} chromeless onPress={onCancel} disabled={busy}>
Cancel
</Button>
) : null}
<Button
flex={1}
theme="light"
icon={busy ? undefined : <Bot size={15} />}
onPress={() => void submit()}
disabled={!canSubmit(spec) || busy}
>
{busy ? <Spinner size="small" /> : submitLabel}
</Button>
</XStack>
</YStack>
)
}
const msg = (e: unknown): string =>
e && typeof e === 'object' && typeof (e as { message?: unknown }).message === 'string'
? (e as { message: string }).message
: 'unavailable'
+37
View File
@@ -0,0 +1,37 @@
/**
* Canonical Hanzo agent builder — the ONE agent builder, shareable across every
* surface (console, chat, app, bot, team).
*
* A host imports `AgentBuilder` and supplies `AgentBuilderLoaders` (its own live
* `/v1` sources — model catalog, saved prompts, create) over the SAME agent backend
* (`POST /v1/agents`, org resolved server-side). The component, its schema
* (`AgentSpec`), and all pure logic live here with NO host coupling, so this module
* lifts cleanly into a published `@hanzo/agent-builder` package.
*/
export { AgentBuilder } from './AgentBuilder'
export type {
AgentSpec,
AgentConfig,
AgentCreateBody,
ReasoningEffort,
AgentBuilderLoaders,
BuilderOption,
BuilderPrompt,
BuilderError,
BuilderErrorKind,
} from './types'
export {
emptySpec,
defaultConfig,
defaultModel,
canSubmit,
normalizeList,
normalizeTools,
normalizeKnowledge,
clampConfig,
pruneConfig,
toCreateBody,
promptBodyFromRow,
promptOptions,
classifyBuilderError,
} from './logic'
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect } from 'vitest'
import {
emptySpec,
defaultConfig,
defaultModel,
canSubmit,
normalizeList,
normalizeTools,
normalizeKnowledge,
clampConfig,
pruneConfig,
toCreateBody,
promptBodyFromRow,
promptOptions,
classifyBuilderError,
} from './logic'
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
const spec = (over: Partial<AgentSpec> = {}): AgentSpec => ({ ...emptySpec(), ...over })
describe('emptySpec', () => {
it('is a clean, empty spec', () => {
expect(emptySpec()).toEqual({ name: '', model: '', description: '', systemPrompt: '', tools: [] })
})
})
describe('defaultModel', () => {
const opt = (value: string, hint?: string): BuilderOption => ({ value, hint })
it('returns "" for an empty catalog (nothing to default to)', () => {
expect(defaultModel([])).toBe('')
})
it('prefers the exact zen-omni default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
})
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
})
it('falls back to the first catalog id when no Zen model exists', () => {
expect(defaultModel([opt('gpt-4o'), opt('claude')])).toBe('gpt-4o')
})
it('never invents an id outside the catalog', () => {
const d = defaultModel([opt('only-model')])
expect(['only-model']).toContain(d)
})
})
describe('canSubmit', () => {
it('requires a non-empty trimmed name', () => {
expect(canSubmit(spec({ name: '' }))).toBe(false)
expect(canSubmit(spec({ name: ' ' }))).toBe(false)
expect(canSubmit(spec({ name: 'triage' }))).toBe(true)
})
})
describe('normalizeList / normalizeTools / normalizeKnowledge', () => {
it('trims, drops blanks, and de-duplicates preserving first-seen order', () => {
expect(normalizeList([' a ', 'b', 'a', '', ' '])).toEqual(['a', 'b'])
})
it('normalizeTools + normalizeKnowledge share the one behavior (DRY)', () => {
const raw = [' web.search ', 'code.exec', 'web.search', '', ' ']
expect(normalizeTools(raw)).toEqual(['web.search', 'code.exec'])
expect(normalizeKnowledge([' kb:1 ', 'kb:2', 'kb:1'])).toEqual(['kb:1', 'kb:2'])
})
})
describe('defaultConfig', () => {
it('is the documented generation default', () => {
expect(defaultConfig()).toEqual({
temperature: 0.7,
topP: 1,
topK: 0,
stream: true,
thinking: false,
useTools: true,
webSearch: false,
})
})
it('is a fresh object each call (no shared mutable state)', () => {
const a = defaultConfig()
a.temperature = 2
expect(defaultConfig().temperature).toBe(0.7)
})
})
describe('clampConfig', () => {
const base = defaultConfig()
it('clamps temperature into [0,2]', () => {
expect(clampConfig({ ...base, temperature: 9 }).temperature).toBe(2)
expect(clampConfig({ ...base, temperature: -1 }).temperature).toBe(0)
})
it('clamps topP into [0,1] and topK to a non-negative integer', () => {
expect(clampConfig({ ...base, topP: 5 }).topP).toBe(1)
expect(clampConfig({ ...base, topP: -0.5 }).topP).toBe(0)
expect(clampConfig({ ...base, topK: -3 }).topK).toBe(0)
expect(clampConfig({ ...base, topK: 7.9 }).topK).toBe(7)
})
it('maps NaN → min but clamps ±∞ to the nearest bound', () => {
expect(clampConfig({ ...base, temperature: NaN }).temperature).toBe(0) // garbage → min
expect(clampConfig({ ...base, topP: Infinity }).topP).toBe(1) // slider-to-top → max
expect(clampConfig({ ...base, temperature: -Infinity }).temperature).toBe(0) // → min bound
})
})
describe('pruneConfig', () => {
it('returns undefined when every knob is at its default (simple agent posts no config)', () => {
expect(pruneConfig(defaultConfig())).toBeUndefined()
})
it('returns ONLY the knobs that differ from default', () => {
expect(pruneConfig({ ...defaultConfig(), temperature: 0.2, thinking: true })).toEqual({
temperature: 0.2,
thinking: true,
})
})
it('clamps before diffing (an out-of-range value that clamps to default prunes away)', () => {
// topP default is 1; a value of 5 clamps to 1 → same as default → pruned out.
expect(pruneConfig({ ...defaultConfig(), topP: 5 })).toBeUndefined()
})
it('carries reasoningEffort across the full ladder when set (it has no default)', () => {
expect(pruneConfig({ ...defaultConfig(), reasoningEffort: 'high' })).toEqual({ reasoningEffort: 'high' })
// The top tier — "xhigh + workflows" — survives pruning like any other level.
expect(pruneConfig({ ...defaultConfig(), reasoningEffort: 'ultracode' })).toEqual({ reasoningEffort: 'ultracode' })
})
})
describe('toCreateBody', () => {
it('trims name and omits empty optional fields', () => {
expect(toCreateBody(spec({ name: ' triage ' }))).toEqual({ name: 'triage' })
})
it('includes only the non-empty fields, with normalized tools', () => {
const body = toCreateBody(
spec({ name: 'triage', model: ' zen-omni ', description: '', systemPrompt: ' be terse ', tools: ['a', 'a', ' '] }),
)
expect(body).toEqual({ name: 'triage', model: 'zen-omni', systemPrompt: 'be terse', tools: ['a'] })
expect(body).not.toHaveProperty('description')
})
it('omits an empty tools array (no key the backend didnt need)', () => {
expect(toCreateBody(spec({ name: 'x', tools: [] }))).not.toHaveProperty('tools')
})
})
describe('promptBodyFromRow', () => {
const rows: BuilderPrompt[] = [
{ name: 'with-body', body: 'you are helpful' },
{ name: 'no-body' },
]
it('returns the inline body when present', () => {
expect(promptBodyFromRow(rows, 'with-body')).toBe('you are helpful')
})
it('returns null when the row has no body (caller must fetch it)', () => {
expect(promptBodyFromRow(rows, 'no-body')).toBeNull()
})
it('returns null for an unknown name', () => {
expect(promptBodyFromRow(rows, 'missing')).toBeNull()
})
})
describe('promptOptions', () => {
it('maps prompts to picker rows (value=name, label defaults to name)', () => {
expect(promptOptions([{ name: 'triage' }, { name: 'router', label: 'Router', hint: 'chat' }])).toEqual([
{ value: 'triage', label: 'triage', hint: undefined },
{ value: 'router', label: 'Router', hint: 'chat' },
])
})
})
describe('classifyBuilderError', () => {
it('classifies a 404 / 501 as "unavailable" (route not bound)', () => {
expect(classifyBuilderError({ status: 404 }).kind).toBe('unavailable')
expect(classifyBuilderError({ status: 501 }).kind).toBe('unavailable')
})
it('classifies an explicit "unavailable" BackendState kind as unavailable', () => {
expect(classifyBuilderError({ kind: 'unavailable', message: 'x' }).kind).toBe('unavailable')
})
it('classifies other errors as real errors and surfaces the message', () => {
const c = classifyBuilderError({ status: 400, message: 'name is required' })
expect(c.kind).toBe('error')
expect(c.message).toBe('name is required')
})
it('has a safe default message for an opaque error', () => {
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
})
})
+192
View File
@@ -0,0 +1,192 @@
/**
* Agent-builder pure logic — the decisions the form makes, with no React and no
* network, so they are unit-tested in isolation (the UI just renders these).
*
* The create body is trimmed + pruned here: empty optional fields are dropped so
* the backend stores clean values, and only a non-empty `name` makes a spec valid.
* A saved-prompt pick fills the system prompt; the tools list is de-duplicated and
* blank-stripped. Everything here is a value transform — no side effects.
*/
import type { AgentConfig, AgentCreateBody, AgentSpec, BuilderError, BuilderOption, BuilderPrompt } from './types'
/** A fresh, empty spec (the New-Agent form's initial state). PURE. */
export function emptySpec(): AgentSpec {
return { name: '', model: '', description: '', systemPrompt: '', tools: [] }
}
/**
* The default generation config — the value the advanced controls bind to until
* the user changes something. Kept in ONE place so `pruneConfig` can drop knobs
* still at their default (a simple agent posts no `config`). PURE.
*/
export function defaultConfig(): AgentConfig {
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
}
/** The default Zen model to preselect when the catalog offers one. */
const ZEN_DEFAULT = 'zen-omni'
/**
* Pick a sensible default model from a live catalog: the Zen default if present,
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
* PURE. Never invents an id — only returns one the catalog actually lists.
*/
export function defaultModel(options: BuilderOption[]): string {
if (options.length === 0) return ''
const exact = options.find((o) => o.value === ZEN_DEFAULT)
if (exact) return exact.value
const zen = options.find(
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
)
return (zen ?? options[0]).value
}
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
export function canSubmit(spec: AgentSpec): boolean {
return spec.name.trim().length > 0
}
/** De-duplicate + blank-strip a string list, preserving first-seen order. PURE. */
export function normalizeList(items: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of items) {
const t = raw.trim()
if (t && !seen.has(t)) {
seen.add(t)
out.push(t)
}
}
return out
}
/** De-duplicate + blank-strip a tool list, preserving first-seen order. PURE. */
export function normalizeTools(tools: string[]): string[] {
return normalizeList(tools)
}
/** De-duplicate + blank-strip a knowledge-source list. PURE. */
export function normalizeKnowledge(sources: string[]): string[] {
return normalizeList(sources)
}
/**
* Clamp a number into [min,max]. NaN (garbage) → min; ±∞ clamp to the nearest
* bound naturally (dragging a slider to the top lands on max, not min). PURE.
*/
function clampNum(v: number, min: number, max: number): number {
if (Number.isNaN(v)) return min
return Math.min(max, Math.max(min, v))
}
/**
* Clamp a config into valid ranges (temperature 02, topP 01, topK a
* non-negative integer) so a bad slider/typed value can never post an
* out-of-range generation param. PURE.
*/
export function clampConfig(c: AgentConfig): AgentConfig {
return {
...c,
temperature: clampNum(c.temperature, 0, 2),
topP: clampNum(c.topP, 0, 1),
topK: Math.max(0, Math.floor(Number.isFinite(c.topK) ? c.topK : 0)),
}
}
/**
* Reduce a config to only the knobs that DIFFER from the default — so a simple
* agent (all defaults) yields `undefined` (no `config` key posted) while a
* power-user agent posts exactly what they changed. Clamps first. PURE.
*/
export function pruneConfig(c: AgentConfig): Partial<AgentConfig> | undefined {
const def = defaultConfig()
const cur = clampConfig(c)
const out: Partial<AgentConfig> = {}
;(Object.keys(def) as (keyof AgentConfig)[]).forEach((k) => {
if (cur[k] !== def[k]) (out as Record<string, unknown>)[k] = cur[k]
})
if (cur.reasoningEffort) out.reasoningEffort = cur.reasoningEffort
return Object.keys(out).length ? out : undefined
}
/**
* The clean create body for `POST /cloud/v1/agents`: name is trimmed (required);
* every other field is trimmed and OMITTED when empty, and tools are normalized —
* so the backend never stores a blank model/description/prompt or a `[]` tools
* key it didn't need. PURE.
*/
export function toCreateBody(spec: AgentSpec): AgentCreateBody {
const name = spec.name.trim()
const model = spec.model.trim()
const description = spec.description.trim()
const systemPrompt = spec.systemPrompt.trim()
const tools = normalizeTools(spec.tools)
const knowledge = normalizeKnowledge(spec.knowledge ?? [])
const config = spec.config ? pruneConfig(spec.config) : undefined
return {
name,
...(model ? { model } : {}),
...(description ? { description } : {}),
...(systemPrompt ? { systemPrompt } : {}),
...(tools.length ? { tools } : {}),
...(knowledge.length ? { knowledge } : {}),
...(config ? { config } : {}),
}
}
/**
* Resolve the body a saved-prompt pick should put into `systemPrompt`: the row's
* own `body` when present, else null (the caller must `loadPromptBody(name)`).
* PURE — no fetch here.
*/
export function promptBodyFromRow(prompts: BuilderPrompt[], name: string): string | null {
const row = prompts.find((p) => p.name === name)
return row?.body != null ? row.body : null
}
/** Map saved prompts to picker rows (label = name, hint = any provided hint). PURE. */
export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
}
/**
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
* connected, use the CLI" state, NOT a scary error. Anything else is a real error
* whose message is surfaced. PURE (reads status/kind/message off the error object).
*/
export function classifyBuilderError(e: unknown): BuilderError {
const status = statusOf(e)
const kind = kindOf(e)
if (status === 404 || status === 501 || kind === 'unavailable') {
return { kind: 'unavailable', message: 'The agents API is not connected on this deployment yet.' }
}
return { kind: 'error', message: messageOf(e) }
}
// ── error-shape readers (defensive; the builder is client-agnostic) ──────────
function statusOf(e: unknown): number | undefined {
if (e && typeof e === 'object' && 'status' in e) {
const s = (e as { status?: unknown }).status
if (typeof s === 'number') return s
}
return undefined
}
function kindOf(e: unknown): string | undefined {
if (e && typeof e === 'object' && 'kind' in e) {
const k = (e as { kind?: unknown }).kind
if (typeof k === 'string') return k
}
return undefined
}
function messageOf(e: unknown): string {
if (e && typeof e === 'object') {
const m = (e as { message?: unknown }).message
if (typeof m === 'string' && m) return m
}
return 'Could not create the agent.'
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Canonical agent-builder contract — the ONE way to describe and build a Hanzo
* agent, shared across every surface (console, chat, app, bot, team).
*
* This file has NO console2 coupling on purpose: it imports nothing from `~/lib`
* or any host app. The builder takes its data + effects as INJECTED async
* loaders (`AgentBuilderLoaders`) — the model catalog, the saved-prompt library,
* a prompt body, and the create effect — so a surface wires it to whatever client
* it already has, as long as that client speaks the ONE agent backend
* (`POST /cloud/v1/agents`, org resolved server-side from the caller's bearer).
* That is what makes the builder truly shareable (extractable to
* `@hanzo/agent-builder`) rather than a console2 one-off.
*/
/**
* Reasoning budget for models that expose one (maps to `reasoning_effort`). The
* canonical Hanzo/Claude effort ladder, faster→smarter:
*
* low · medium · high · xhigh · max · ultracode
*
* `ultracode` is the top tier — "xhigh + workflows" — and may use excessive tokens
* (long responses / overthinking); use it sparingly for the hardest tasks. The
* gateway maps each level onto the target model's native effort/thinking budget.
* (Supersedes the old 3-level low/medium/high.)
*/
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultracode'
/**
* The agent's generation config — the advanced knobs the hanzo.chat builder
* exposes, folded into the canonical contract so ONE builder covers both the
* simple (name·model·prompt·tools) and the power-user surface. Every field has a
* sane default (`defaultConfig`); `toCreateBody` prunes anything left at default,
* so a simple agent still posts a clean `{name,…}` with no `config` key.
*
* Field names mirror the cloud `/v1/agents` body (camelCase, like `systemPrompt`);
* they correspond 1:1 to hanzo.chat's `temperature/top_p/top_k/stream/thinking/
* use_tools/web_search_enabled/reasoning_effort`.
*/
export type AgentConfig = {
/** Sampling temperature, 02 (clamped). Higher = more random. */
temperature: number
/** Nucleus sampling, 01 (clamped). */
topP: number
/** Top-k sampling; 0 = disabled. Non-negative integer (clamped). */
topK: number
/** Stream tokens as they generate. */
stream: boolean
/** Expose the model's thinking/reasoning trace. */
thinking: boolean
/** Let the agent call its tools during a turn. */
useTools: boolean
/** Enable the built-in web-search tool. */
webSearch: boolean
/** Reasoning budget, when the model supports it (omitted = model default). */
reasoningEffort?: ReasoningEffort
}
/** The editable shape of an agent under construction (the form's state). */
export type AgentSpec = {
/** Org-unique handle (also the URL segment). Required. */
name: string
/** Backing model id (a live catalog id or a custom one). */
model: string
/** One-line description of what the agent does. */
description: string
/** The system prompt — typed free-form OR loaded from a saved prompt. */
systemPrompt: string
/** Tool ids the agent may call (live catalog and/or custom). */
tools: string[]
/**
* Knowledge sources (vector-store / file ids) the agent retrieves from.
* Optional — absent until the user adds one; normalized + omitted when empty.
*/
knowledge?: string[]
/**
* Advanced generation config. Optional — the builder binds it lazily
* (`spec.config ?? defaultConfig()`), and `toCreateBody` includes only the
* non-default knobs, so a simple agent never carries a `config` key.
*/
config?: AgentConfig
}
/** One option in a live picker (model / prompt / tool). Mirrors the ComboBox option. */
export type BuilderOption = {
/** The value committed to the spec when chosen. */
value: string
/** Display label (defaults to `value`). */
label?: string
/** Optional secondary text (provider, description) — shown + searched. */
hint?: string
}
/**
* A saved prompt as the builder needs it: a name to pick by, and — when the list
* carries it — the body to fill the system prompt. When `body` is absent the
* builder fetches it lazily via `loadPromptBody(name)`.
*/
export type BuilderPrompt = {
name: string
/** The prompt body, if the list row already carries it (else fetched on select). */
body?: string
/** Optional label (defaults to name) + hint (labels/type). */
label?: string
hint?: string
}
/**
* The pruned wire body the builder emits for a create — `toCreateBody(spec)`. Every
* field but `name` is optional and OMITTED when empty/default, so a simple agent
* posts a clean `{name,…}` and a power-user agent posts exactly the knobs they set.
* This is what the injected `createAgent` effect receives (NOT the raw form spec) —
* so a host serializes nothing itself; the shared pure logic already did.
*/
export type AgentCreateBody = {
name: string
model?: string
description?: string
systemPrompt?: string
tools?: string[]
knowledge?: string[]
config?: Partial<AgentConfig>
}
/**
* The effects + data the builder needs, injected by the host surface. Every loader
* is async and may reject; the builder renders honest loading/error states and
* NEVER fabricates an option. All are optional except `createAgent`:
* - no `loadModels` → the model field is a plain typeable input (still works).
* - no `loadPrompts` → the prompt selector is hidden (system prompt stays free-text).
* - no `loadTools` → the tools field is typeable-only (no live options).
*/
export type AgentBuilderLoaders = {
/** The live model catalog (ids the gateway accepts). Rejects → typeable fallback. */
loadModels?: () => Promise<BuilderOption[]>
/** The org's saved prompts (names + optional bodies). Rejects → selector hidden. */
loadPrompts?: () => Promise<BuilderPrompt[]>
/** Fetch ONE saved prompt's body by name (used when the list row lacks it). */
loadPromptBody?: (name: string) => Promise<string>
/** The live tool catalog. Rejects → typeable-only tools. */
loadTools?: () => Promise<BuilderOption[]>
/**
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
* resolves the org from the caller's bearer server-side. Rejects with the backend
* error (the builder classifies 404 as "not connected" honestly).
*/
createAgent: (body: AgentCreateBody) => Promise<unknown>
}
/** The reason a create failed, distinguished so the UI reacts correctly. */
export type BuilderErrorKind =
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
| 'unavailable'
/** A real error (validation, auth, upstream) — show the message. */
| 'error'
/** A classified builder error: a kind plus a human message. */
export type BuilderError = { kind: BuilderErrorKind; message: string }
+136
View File
@@ -0,0 +1,136 @@
'use client'
/**
* CollectionView — the Twenty-grade records surface for ONE Hanzo Base collection.
*
* It loads the collection schema (→ @hanzo/data `FieldDefinition[]` via
* `baseCollectionToFields`) and the records, then renders them through @hanzo/data's
* `RecordsView` — the shared table ⇆ board view with filter / sort / group, inline
* cell editing, row selection, and a detail hand-off. Every field type shows the
* right Display/Input with zero per-type code here, so a new Base field just works.
*
* Persistence is REAL and honest: an inline cell edit or a board card move calls
* `BaseDataApi.updateRecord` and reflects the server's response (no optimistic
* fakery beyond the board's follow-the-pointer, which reverts on failure); a failed
* mutation surfaces an inline banner. Load failures use the shared backend-state
* card; empty + loading are the view's own honest states. No demo rows, ever.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
type LoadState =
| { phase: 'loading' }
| { phase: 'missing' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; fields: FieldDefinition[]; records: BaseRecord[] }
export interface CollectionViewProps {
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
api: BaseDataApi
/** Collection name (or id) to render. */
collection: string
/** Open a record's detail. */
onOpen: (record: Record<string, unknown>) => void
/** Start creating a record (the host routes to the create form). */
onCreate: () => void
/** Optional title above the view. */
title?: ReactNode
}
export function CollectionView({ api, collection, onOpen, onCreate, title }: CollectionViewProps) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [mutationError, setMutationError] = useState<string | null>(null)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const schema = await api.getCollection(collection)
if (!schema) {
if (!signal.cancelled) setState({ phase: 'missing' })
return
}
const fields = baseCollectionToFields(schema)
const { items } = await api.listRecords(collection, { perPage: 200 })
if (!signal.cancelled) setState({ phase: 'ready', fields, records: items })
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[api, collection],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => { signal.cancelled = true }
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
/** Persist one inline field edit (table cell or board move) and reflect the server row. */
const onEditCommit = useCallback(
async (record: Record<string, unknown>, field: FieldDefinition, value: unknown) => {
const id = record.id != null ? String(record.id) : ''
if (!id) return
setMutationError(null)
try {
const updated = await api.updateRecord(collection, id, { [field.name]: value })
setState((s) => (s.phase === 'ready' ? { ...s, records: s.records.map((r) => (String(r.id) === id ? updated : r)) } : s))
} catch (e) {
setMutationError(classifyBackend(e).message)
throw e // let the board revert its optimistic move
}
},
[api, collection],
)
if (state.phase === 'error') {
return <BackendStateCard state={state.error} onRetry={reload} hint={`base · GET /v1/collections/${collection}/records`} />
}
if (state.phase === 'missing') {
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxW={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">Collection not found</Text>
</XStack>
<Text fontSize="$3" color="$color11">
No collection named {collection} is visible on this Base. Check the name, or sign in with an account that can read it.
</Text>
</Card>
)
}
const ready = state.phase === 'ready' ? state : undefined
return (
<YStack gap="$3">
{mutationError ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxW={720}>
<Text fontSize="$3" color="$red11">{mutationError}</Text>
</Card>
) : null}
<RecordsView
title={title}
fields={ready?.fields ?? []}
records={ready?.records ?? []}
loading={state.phase === 'loading'}
onOpen={onOpen}
onCreate={onCreate}
createLabel="New record"
onEditCommit={onEditCommit}
toolbarExtra={<Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>Refresh</Button>}
empty={`No records in ${collection} yet.`}
/>
</YStack>
)
}
export default CollectionView
@@ -0,0 +1,254 @@
'use client'
/**
* RecordDetailView — view / edit / create / delete ONE Base record, metadata-driven.
*
* It loads the collection schema (→ @hanzo/data `FieldDefinition[]`) and the record,
* then renders through @hanzo/data's `RecordDetail` (read) and `RecordForm` (edit) —
* the same field routers the list uses — so every field type shows the right
* Display/Input with zero per-type code here. Create (`recordId === 'new'`) opens
* straight into the form over a blank draft. Persistence is the Base record CRUD
* (`createRecord` / `updateRecord` / `deleteRecord`) through whatever transport the
* `BaseDataApi` was given (the same `/superbase` proxy the list uses).
*
* States are honest, never fabricated: the shared backend-state card on a load
* failure, a "collection not found" note, an inline save error, and a two-step
* delete confirmation. No optimistic fakery — the view reflects what the server
* actually returned.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { recordLabel, savePayload } from './records'
export interface RecordDetailViewProps {
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
api: BaseDataApi
/** Collection name the record belongs to. */
collection: string
/** Record id, or `'new'` to open the create form. */
recordId: string
/** Back to the collection's record list. */
onBack: () => void
/** Open a record's detail (after a create, to land on the new record). */
onView: (id: string) => void
}
type LoadState =
| { phase: 'loading' }
| { phase: 'missing' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; fields: FieldDefinition[]; record: BaseRecord | null }
export function RecordDetailView({ api, collection, recordId, onBack, onView }: RecordDetailViewProps) {
const isNew = recordId === 'new'
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [editing, setEditing] = useState(isNew)
const [draft, setDraft] = useState<Record<string, unknown>>({})
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const schema = await api.getCollection(collection)
if (!schema) {
if (!signal.cancelled) setState({ phase: 'missing' })
return
}
const fields = baseCollectionToFields(schema)
const record = isNew ? null : await api.getRecord(collection, recordId)
if (signal.cancelled) return
setState({ phase: 'ready', fields, record })
setDraft(record ? { ...record } : {})
setEditing(isNew)
setSaveError(null)
setConfirmingDelete(false)
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[api, collection, recordId, isNew],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => {
signal.cancelled = true
}
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
const ready = state.phase === 'ready' ? state : undefined
const fields = ready?.fields ?? []
const record = ready?.record ?? null
const title = isNew ? `New ${collection} record` : recordLabel(record ?? {}, fields)
const onChange = useCallback((name: string, value: unknown) => {
setDraft((d) => ({ ...d, [name]: value }))
}, [])
const save = useCallback(async () => {
setSaving(true)
setSaveError(null)
try {
const payload = savePayload(draft, fields)
if (isNew) {
const created = await api.createRecord(collection, payload)
if (created.id) onView(created.id)
else onBack()
return
}
if (!record?.id) throw new Error('This record has no id to update.')
const updated = await api.updateRecord(collection, record.id, payload)
setState({ phase: 'ready', fields, record: updated })
setDraft({ ...updated })
setEditing(false)
} catch (e) {
setSaveError(classifyBackend(e).message)
} finally {
setSaving(false)
}
}, [api, collection, draft, fields, isNew, onBack, onView, record])
const remove = useCallback(async () => {
if (!record?.id) return
setSaving(true)
setSaveError(null)
try {
await api.deleteRecord(collection, record.id)
onBack()
} catch (e) {
setSaveError(classifyBackend(e).message)
setSaving(false)
setConfirmingDelete(false)
}
}, [api, collection, onBack, record])
const backButton = useMemo(
() => (
<Button size="$2" icon={<ArrowLeft size={15} />} onPress={onBack}>
Back
</Button>
),
[onBack],
)
if (state.phase === 'error') {
return (
<YStack gap="$3">
{backButton}
<BackendStateCard
state={state.error}
onRetry={reload}
hint={`base · ${collection}/${recordId}`}
/>
</YStack>
)
}
if (state.phase === 'missing') {
return (
<YStack gap="$3">
{backButton}
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
Collection not found
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
No collection named {collection} is visible on this Base. Check the name, or sign in with an
account that can read it.
</Text>
</Card>
</YStack>
)
}
return (
<YStack gap="$3">
<XStack items="center" justify="space-between" gap="$4">
<XStack items="center" gap="$3" flex={1}>
{backButton}
<YStack flex={1}>
<Text fontSize="$6" fontWeight="800" numberOfLines={1}>
{title}
</Text>
<Text fontSize="$2" color="$color10">
{collection}
</Text>
</YStack>
</XStack>
<XStack items="center" gap="$2">
{editing ? (
<>
{!isNew ? (
<Button size="$2" icon={<X size={15} />} onPress={() => { setDraft(record ? { ...record } : {}); setEditing(false); setSaveError(null) }} disabled={saving}>
Cancel
</Button>
) : null}
<PrimaryButton size="$2" icon={<Save size={15} />} onPress={save} disabled={saving}>
{saving ? 'Saving…' : isNew ? 'Create' : 'Save'}
</PrimaryButton>
</>
) : (
<>
<Button size="$2" theme="red" icon={<Trash2 size={15} />} onPress={() => setConfirmingDelete(true)} disabled={saving}>
Delete
</Button>
<PrimaryButton size="$2" icon={<Pencil size={15} />} onPress={() => setEditing(true)}>
Edit
</PrimaryButton>
</>
)}
</XStack>
</XStack>
{confirmingDelete ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" gap="$2" maxWidth={620}>
<Text fontSize="$3" fontWeight="700">
Delete {title}? This cannot be undone.
</Text>
<XStack gap="$2">
<Button size="$2" theme="red" onPress={remove} disabled={saving}>
{saving ? 'Deleting…' : 'Confirm delete'}
</Button>
<Button size="$2" onPress={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
</XStack>
</Card>
) : null}
{saveError ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxWidth={620}>
<Text fontSize="$3" color="$red11">
{saveError}
</Text>
</Card>
) : null}
<Card borderWidth={1} borderColor="$borderColor" p="$4" maxWidth={720}>
{editing ? (
<RecordForm fields={fields} values={draft} onChange={onChange} />
) : record ? (
<RecordDetail title={title} fields={fields} record={record} />
) : null}
</Card>
</YStack>
)
}
export default RecordDetailView
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import type { FieldDefinition } from '@hanzo/data'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { editableFields, isEditable, recordLabel, savePayload } from '~/components/base-data/records'
/** A realistic mapped field model: id (read-only) + user fields + an autodate. */
const fields: FieldDefinition[] = baseCollectionToFields({
name: 'contacts',
fields: [
{ name: 'id', type: 'text', system: true },
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'active', type: 'bool' },
{ name: 'tags', type: 'select', maxSelect: 3, values: ['a', 'b'] },
{ name: 'updated', type: 'autodate' },
],
})
describe('isEditable / editableFields', () => {
it('excludes the read-only id + autodate, keeps the user fields in order', () => {
expect(editableFields(fields).map((f) => f.name)).toEqual(['name', 'email', 'active', 'tags'])
})
it('isEditable is the single !readOnly predicate', () => {
const id = fields.find((f) => f.name === 'id')!
const name = fields.find((f) => f.name === 'name')!
expect(isEditable(id)).toBe(false)
expect(isEditable(name)).toBe(true)
})
})
describe('savePayload', () => {
it('sends only editable field values — never the server-owned id/timestamps', () => {
const values = { id: 'rec_1', name: 'Ada', email: 'ada@x.io', active: true, tags: ['a'], updated: 'yesterday' }
expect(savePayload(values, fields)).toEqual({ name: 'Ada', email: 'ada@x.io', active: true, tags: ['a'] })
})
it('drops undefined (untouched) fields but keeps meaningful empties (false / [] / "" / 0)', () => {
const values = { name: '', active: false, tags: [] as string[] } // email untouched (undefined)
expect(savePayload(values, fields)).toEqual({ name: '', active: false, tags: [] })
})
it('is total for an empty draft — sends nothing', () => {
expect(savePayload({}, fields)).toEqual({})
})
})
describe('recordLabel', () => {
it('uses the first non-empty text/email/url value (skipping id)', () => {
expect(recordLabel({ id: 'rec_1', name: 'Ada Lovelace', email: 'ada@x.io' }, fields)).toBe('Ada Lovelace')
})
it('falls back to the id when no text-ish field has a value', () => {
expect(recordLabel({ id: 'rec_9', name: '' }, fields)).toBe('rec_9')
})
it('falls back to a stable placeholder when there is neither', () => {
expect(recordLabel({}, fields)).toBe('(untitled)')
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* Pure record-form logic for the Base records surface — the decisions the
* create/edit views take, isolated from React + I/O so they are trivially
* testable (data in, data out).
*
* The field model is already the mapped @hanzo/data `FieldDefinition[]`
* (`baseCollectionToFields`), so "editable" is one predicate — `!readOnly` — and
* read-only-ness lives in ONE place (the mapper marks `id` + autodate timestamps).
* The `@hanzo/data` import is `import type` (erased), so this module pulls in none
* of its runtime and the unit test runs in plain Node.
*/
import type { FieldDefinition } from '@hanzo/data'
/** A field the user can edit — everything except server-owned read-only fields
* (`id`, the create/update timestamps). Read-only fields still show in the detail
* view; they just never render an input. */
export const isEditable = (f: FieldDefinition): boolean => !f.readOnly
/** The editable subset of a collection's fields — the inputs a create/edit form
* renders (order preserved). */
export function editableFields(fields: FieldDefinition[]): FieldDefinition[] {
return fields.filter(isEditable)
}
/**
* The payload to persist for a create/update: the editable field values only, so a
* server-owned field (`id`, `created`, `updated`) is NEVER sent back. Fields left
* `undefined` are dropped, so a partial edit (or a blank create) doesn't push a
* value the user never entered — Base keeps its own default/previous value.
*/
export function savePayload(
values: Record<string, unknown>,
fields: FieldDefinition[],
): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const f of editableFields(fields)) {
const v = values[f.name]
if (v !== undefined) out[f.name] = v
}
return out
}
/**
* A human label for a record — the first non-empty text/email/url field value
* (skipping the id), else the record id, else a stable placeholder. Used for the
* detail header and the delete confirmation, so a row is never referred to as
* "[object Object]".
*/
export function recordLabel(record: Record<string, unknown>, fields: FieldDefinition[]): string {
const textish = fields.find(
(f) =>
f.name !== 'id' &&
(f.type === 'text' || f.type === 'email' || f.type === 'url') &&
typeof record[f.name] === 'string' &&
record[f.name] !== '',
)
if (textish) return String(record[textish.name])
if (typeof record.id === 'string' && record.id) return record.id
return '(untitled)'
}
@@ -0,0 +1,220 @@
'use client'
/**
* CollectionsBrowser — the app-lane home: the DocTypes of a `module` (a CMS
* "collection" IS a framework DocType tagged with the lane's module). It lists
* them as cards, offers a first-run "Set up" that installs the lane's fixtures
* (POST /v1/framework/modules/:module/install), and a "New collection" that
* defines a fresh content DocType. Everything is per-org and honest — an org with
* the lane not yet installed sees the setup CTA, never a fabricated collection.
*
* This is generic over `module`, so CMS (`cms`), ERP (`erp`), and Helpdesk
* (`help`) all reuse it — the ONE collections home for every lane.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import { Loader } from '~/components/ui/Loader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType } from '~/lib/framework/types'
import { moduleDoctypes, isValidDoctypeName } from '~/lib/framework/fields'
export interface CollectionsBrowserProps {
client: FrameworkClient
/** The app lane: 'cms' | 'erp' | 'help' | … */
module: string
/** Human label for the lane (e.g. "Content"). */
label: string
subtitle: string
/** Open a collection's records. */
onOpen: (doctype: string) => void
/**
* Lane-appropriate copy for the first-run (pre-install) empty state. Optional and
* defaulted to the CMS wording, so this component stays generic over the lane: a
* CMS caller renders identically, while ERP/Help pass their own description +
* bullets. Additive only — no behavior/permission/proxy change.
*/
setupDescription?: string
setupBullets?: string[]
}
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; collections: DocType[]; registered: boolean }
export function CollectionsBrowser({ client, module, label, subtitle, onOpen, setupDescription, setupBullets }: CollectionsBrowserProps) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [busy, setBusy] = useState(false)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [actionError, setActionError] = useState<string | null>(null)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const [dts, mod] = await Promise.all([
client.doctypes.list(),
client.modules.get(module).catch(() => null), // module may not be registered on older cloud
])
if (signal.cancelled) return
setState({ phase: 'ready', collections: moduleDoctypes(dts, module), registered: Boolean(mod && mod.doctypes.length) })
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[client, module],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => { signal.cancelled = true }
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
const install = useCallback(async () => {
setBusy(true)
setActionError(null)
try {
await client.modules.install(module)
reload()
} catch (e) {
setActionError(classifyBackend(e).message)
} finally {
setBusy(false)
}
}, [client, module, reload])
const create = useCallback(async () => {
const name = newName.trim()
if (!isValidDoctypeName(name)) {
setActionError('Use a name with letters, digits, dashes or underscores — no spaces.')
return
}
setBusy(true)
setActionError(null)
try {
await client.doctypes.create(contentCollection(name, module))
setCreating(false)
setNewName('')
onOpen(name)
} catch (e) {
setActionError(classifyBackend(e).message)
} finally {
setBusy(false)
}
}, [client, module, newName, onOpen])
if (state.phase === 'loading') return <Loader label={`Loading ${label}`} />
if (state.phase === 'error') {
return (
<>
<PageHeader title={label} subtitle={subtitle} />
<BackendStateCard state={state.error} onRetry={reload} hint="framework · GET /v1/framework/doctypes" />
</>
)
}
const { collections } = state
return (
<>
<PageHeader
title={label}
subtitle={subtitle}
actions={
collections.length ? (
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={() => { setCreating((c) => !c); setActionError(null) }}>New collection</PrimaryButton>
) : undefined
}
/>
{actionError ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" mb="$3" maxWidth={620}>
<XStack gap="$2" items="center"><TriangleAlert size={15} /><Text fontSize="$3" color="$red11">{actionError}</Text></XStack>
</Card>
) : null}
{creating ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" mb="$3" maxWidth={620}>
<Text fontSize="$4" fontWeight="700">New collection</Text>
<Text fontSize="$2" color="$color10">A content type on the framework (title, slug, body, status). Add fields later in Settings.</Text>
<XStack gap="$2" flexWrap="wrap">
<Input flex={1} minW={220} placeholder="e.g. Recipe or LandingPage" value={newName} onChangeText={setNewName} disabled={busy} />
<PrimaryButton size="$2" disabled={busy} onPress={create}>{busy ? 'Creating…' : 'Create'}</PrimaryButton>
<Button size="$2" disabled={busy} onPress={() => { setCreating(false); setNewName('') }}>Cancel</Button>
</XStack>
</Card>
) : null}
{collections.length === 0 ? (
<EmptyState
icon={Boxes}
title={`Set up ${label}`}
description={setupDescription ?? `${label} is a set of content collections — Pages, Posts, Articles, Media, and Navigation — as DocTypes on the Hanzo Framework, per organization.`}
bullets={setupBullets ?? [
'Installs the default collections into your organization',
'Content is documents on the framework — versioned, permissioned, per-org',
'Add your own collections and fields any time',
]}
primary={state.registered ? { label: busy ? 'Setting up…' : `Set up ${label}`, onPress: install } : undefined}
/>
) : (
<XStack gap="$3" flexWrap="wrap">
{collections.map((dt) => (
<YStack
key={dt.name}
onPress={() => onOpen(dt.name)}
hoverStyle={{ borderColor: '$color8' }}
cursor="pointer"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
p="$4"
gap="$2"
width={240}
>
<XStack gap="$2" items="center">
<Boxes size={16} />
<Text fontSize="$4" fontWeight="700" numberOfLines={1}>{dt.name}</Text>
</XStack>
<Text fontSize="$2" color="$color10">
{(dt.fields?.length ?? 0)} field{(dt.fields?.length ?? 0) === 1 ? '' : 's'}
{dt.isSubmittable ? ' · submittable' : ''}
</Text>
</YStack>
))}
</XStack>
)}
</>
)
}
/**
* The DocType a "New collection" creates: a minimal, immediately-usable content
* type (title, URL slug, rich body, publish status) tagged with the lane module.
* Secure by default — the engine seeds a System-Manager grant; the owner widens.
*/
function contentCollection(name: string, module: string): DocType {
return {
name,
module,
autoname: 'field:slug',
titleField: 'title',
fields: [
{ fieldname: 'title', fieldtype: 'Data', label: 'Title', reqd: true, inListView: true },
{ fieldname: 'slug', fieldtype: 'Data', label: 'Slug', reqd: true, inListView: true },
{ fieldname: 'body', fieldtype: 'Text', label: 'Body' },
{ fieldname: 'status', fieldtype: 'Select', label: 'Status', options: 'Draft\nPublished', default: 'Draft', inListView: true },
],
}
}
export default CollectionsBrowser
+253
View File
@@ -0,0 +1,253 @@
'use client'
/**
* DocTypeDetail — view / edit / create / delete ONE framework document, plus the
* lifecycle actions the engine exposes: PUBLISH (a status field flips Draft ⇆
* Published) and, for a submittable DocType, SUBMIT / CANCEL (docstatus 0→1→2).
* All metadata-driven: it loads the schema (→ @hanzo/data `FieldDefinition[]`) and
* renders through `RecordDetail` (read) + `RecordForm` (edit) — the SAME field
* routers the list uses — so every field type just works with zero per-doctype
* code. Create (`name === 'new'`) opens the form over a defaulted blank draft.
*
* The engine validates the WHOLE document on update, so save/publish always send
* the full record (via `savePayload`, which slugifies the URL key + strips the
* redacted Password). States are honest: backend-error, inline save error, and a
* two-step delete confirmation — never optimistic fakery.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
export interface DocTypeDetailProps {
client: FrameworkClient
doctype: string
/** Document name, or `'new'` to open the create form. */
name: string
onBack: () => void
/** Land on a document (after create). */
onView: (name: string) => void
}
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; dt: DocType; record: Record<string, unknown> | null; linkOptions: Record<string, SelectOption[]> }
export function DocTypeDetail({ client, doctype, name, onBack, onView }: DocTypeDetailProps) {
const isNew = name === 'new'
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [editing, setEditing] = useState(isNew)
const [draft, setDraft] = useState<Record<string, unknown>>({})
const [busy, setBusy] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const dt = await client.doctypes.get(doctype)
const linkOptions = await loadLinkOptions(client, dt)
const doc = isNew ? null : await client.records.get(doctype, name)
if (signal.cancelled) return
const record = doc ? enrichLinks(toRecord(doc, dt), dt, linkOptions) : null
setState({ phase: 'ready', dt, record, linkOptions })
setDraft(record ? { ...record } : newDraft(dt))
setEditing(isNew)
setSaveError(null)
setConfirmingDelete(false)
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[client, doctype, name, isNew],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => { signal.cancelled = true }
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
const ready = state.phase === 'ready' ? state : undefined
const dt = ready?.dt
const record = ready?.record ?? null
const fields: FieldDefinition[] = useMemo(
() => (dt ? docTypeToFields(dt, { editing: !isNew }) : []),
[dt, isNew],
)
const fieldOptions = useMemo(() => (ready ? makeFieldOptions(ready.linkOptions) : undefined), [ready])
const title = dt ? (isNew ? `New ${doctype}` : titleOf(record ?? {}, dt)) : doctype
const onChange = useCallback((field: string, value: unknown) => {
setDraft((d) => ({ ...d, [field]: value }))
}, [])
const reflect = useCallback(
(saved: FrameworkDoc) => {
if (!dt || !ready) return
const next = enrichLinks(toRecord(saved, dt), dt, ready.linkOptions)
setState({ phase: 'ready', dt, record: next, linkOptions: ready.linkOptions })
setDraft({ ...next })
},
[dt, ready],
)
const save = useCallback(async () => {
if (!dt) return
setBusy(true)
setSaveError(null)
try {
const body = savePayload(draft, dt)
if (isNew) {
const created = await client.records.create(doctype, body)
if (created.name) onView(String(created.name))
else onBack()
return
}
if (!record?.name) throw new Error('This record has no name to update.')
reflect(await client.records.update(doctype, String(record.name), body))
setEditing(false)
} catch (e) {
setSaveError(classifyBackend(e).message)
} finally {
setBusy(false)
}
}, [client, doctype, draft, dt, isNew, onBack, onView, record, reflect])
/** Lifecycle op on the current record (publish/unpublish/submit/cancel/delete). */
const run = useCallback(
async (op: () => Promise<FrameworkDoc | void>) => {
setBusy(true)
setSaveError(null)
try {
const saved = await op()
if (saved) reflect(saved)
} catch (e) {
setSaveError(classifyBackend(e).message)
} finally {
setBusy(false)
}
},
[reflect],
)
const remove = useCallback(async () => {
if (!record?.name) return
setBusy(true)
setSaveError(null)
try {
await client.records.remove(doctype, String(record.name))
onBack()
} catch (e) {
setSaveError(classifyBackend(e).message)
setBusy(false)
setConfirmingDelete(false)
}
}, [client, doctype, onBack, record])
const backButton = (
<Button size="$2" icon={<ArrowLeft size={15} />} onPress={onBack}>Back</Button>
)
if (state.phase === 'error') {
return (
<YStack gap="$3">
{backButton}
<BackendStateCard state={state.error} onRetry={reload} hint={`framework · ${doctype}/${name}`} />
</YStack>
)
}
if (!dt) return <YStack gap="$3">{backButton}</YStack>
// Publish (status field) + submit/cancel (submittable) are only offered in read
// mode on a persisted, draft-status document.
const statusF = statusField(dt)
const status = record ? String(record[statusF] ?? '') : ''
const docstatus = record ? Number(record.docstatus ?? 0) : 0
const canPublish = !editing && !isNew && statusF && docstatus === 0
const canSubmit = !editing && !isNew && dt.isSubmittable && docstatus === 0
const canCancel = !editing && !isNew && dt.isSubmittable && docstatus === 1
return (
<YStack gap="$3">
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
<XStack items="center" gap="$3" flex={1}>
{backButton}
<YStack flex={1}>
<Text fontSize="$6" fontWeight="800" numberOfLines={1}>{title}</Text>
<Text fontSize="$2" color="$color10">
{doctype}{status ? ` · ${status}` : ''}{docstatus === 1 ? ' · submitted' : docstatus === 2 ? ' · cancelled' : ''}
</Text>
</YStack>
</XStack>
<XStack items="center" gap="$2" flexWrap="wrap">
{editing ? (
<>
{!isNew ? (
<Button size="$2" icon={<X size={15} />} disabled={busy} onPress={() => { setDraft(record ? { ...record } : {}); setEditing(false); setSaveError(null) }}>Cancel</Button>
) : null}
<PrimaryButton size="$2" icon={<Save size={15} />} disabled={busy} onPress={save}>
{busy ? 'Saving…' : isNew ? 'Create' : 'Save'}
</PrimaryButton>
</>
) : (
<>
<Button size="$2" theme="red" icon={<Trash2 size={15} />} disabled={busy} onPress={() => setConfirmingDelete(true)}>Delete</Button>
{canCancel ? (
<Button size="$2" icon={<Ban size={15} />} disabled={busy} onPress={() => run(() => client.records.cancel(doctype, String(record!.name)))}>Cancel doc</Button>
) : null}
{canSubmit ? (
<Button size="$2" icon={<Send size={15} />} disabled={busy} onPress={() => run(() => client.records.submit(doctype, String(record!.name)))}>Submit</Button>
) : null}
{canPublish ? (
status === 'Published' ? (
<Button size="$2" icon={<PenOff size={15} />} disabled={busy} onPress={() => run(async () => client.records.update(doctype, String(record!.name), savePayload({ ...record, [statusF]: 'Draft' }, dt)))}>Unpublish</Button>
) : (
<PrimaryButton size="$2" icon={<Globe size={15} />} disabled={busy} onPress={() => run(async () => client.records.update(doctype, String(record!.name), savePayload({ ...record, [statusF]: 'Published' }, dt)))}>Publish</PrimaryButton>
)
) : null}
<PrimaryButton size="$2" icon={<Pencil size={15} />} onPress={() => setEditing(true)}>Edit</PrimaryButton>
</>
)}
</XStack>
</XStack>
{confirmingDelete ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" gap="$2" maxWidth={620}>
<Text fontSize="$3" fontWeight="700">Delete {title}? This cannot be undone.</Text>
<XStack gap="$2">
<Button size="$2" theme="red" disabled={busy} onPress={remove}>{busy ? 'Deleting…' : 'Confirm delete'}</Button>
<Button size="$2" disabled={busy} onPress={() => setConfirmingDelete(false)}>Cancel</Button>
</XStack>
</Card>
) : null}
{saveError ? (
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxWidth={620}>
<XStack gap="$2" items="center"><TriangleAlert size={15} /><Text fontSize="$3" color="$red11">{saveError}</Text></XStack>
</Card>
) : null}
<Card borderWidth={1} borderColor="$borderColor" p="$4" maxWidth={760}>
{editing ? (
<RecordForm fields={fields} values={draft} onChange={onChange} fieldOptions={fieldOptions} />
) : record ? (
<RecordDetail title={title} fields={fields} record={record} fieldOptions={fieldOptions} />
) : null}
</Card>
</YStack>
)
}
export default DocTypeDetail
+129
View File
@@ -0,0 +1,129 @@
'use client'
/**
* DocTypeRecords — the Twenty-grade records surface for ONE framework DocType,
* driven ENTIRELY by DocType metadata. It loads the schema (→ @hanzo/data
* `FieldDefinition[]` via `docTypeToFields`), the documents, and each relation's
* candidate records, then renders through @hanzo/data's `RecordsView` — the shared
* table ⇆ board view with filter/sort/group, inline cell editing, and a detail
* hand-off. Every field type shows the right Display/Input with ZERO per-doctype
* code, so a CMS Page, an ERP Invoice, or a Helpdesk Ticket all render here.
*
* Persistence is REAL: an inline cell edit sends the FULL record (the engine
* validates the whole document on update) via `savePayload`, and reflects the
* server's row. States are honest — loading / empty / backend-error, never a
* fabricated row.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Button } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import type { FrameworkClient } from '~/lib/framework/client'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype } from '~/lib/framework/fields'
import { loadLinkOptions, makeFieldOptions } from './data'
import { MediaGrid } from './MediaGrid'
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; dt: DocType; fields: FieldDefinition[]; docs: FrameworkDoc[]; records: Record<string, unknown>[]; linkOptions: Record<string, SelectOption[]> }
export interface DocTypeRecordsProps {
client: FrameworkClient
/** DocType name to render. */
doctype: string
/** Open a document's detail (by document name). */
onOpen: (name: string) => void
/** Start creating a document. */
onCreate: () => void
title?: ReactNode
}
export function DocTypeRecords({ client, doctype, onOpen, onCreate, title }: DocTypeRecordsProps) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [mutationError, setMutationError] = useState<string | null>(null)
const load = useCallback(
async (signal: { cancelled: boolean }) => {
setState({ phase: 'loading' })
try {
const dt = await client.doctypes.get(doctype)
const linkOptions = await loadLinkOptions(client, dt)
const docs = await client.records.list(doctype, { limit: 200 })
if (signal.cancelled) return
const records = docs.map((d) => enrichLinks(toRecord(d, dt), dt, linkOptions))
setState({ phase: 'ready', dt, fields: docTypeToFields(dt), docs, records, linkOptions })
} catch (e) {
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
}
},
[client, doctype],
)
useEffect(() => {
const signal = { cancelled: false }
void load(signal)
return () => { signal.cancelled = true }
}, [load])
const reload = useCallback(() => void load({ cancelled: false }), [load])
const fieldOptions = useMemo(
() => (state.phase === 'ready' ? makeFieldOptions(state.linkOptions) : undefined),
[state],
)
/** Persist ONE inline edit. The engine validates the whole document on update,
* so send the full record with the cell change merged in (never a partial body). */
const onEditCommit = useCallback(
async (record: Record<string, unknown>, field: FieldDefinition, value: unknown) => {
if (state.phase !== 'ready') return
const name = typeof record.name === 'string' ? record.name : ''
if (!name) return
setMutationError(null)
try {
const body = savePayload({ ...record, [field.name]: value }, state.dt)
const saved = await client.records.update(doctype, name, body)
const next = enrichLinks(toRecord(saved, state.dt), state.dt, state.linkOptions)
setState((s) => (s.phase === 'ready' ? { ...s, records: s.records.map((r) => (r.name === name ? next : r)) } : s))
} catch (e) {
setMutationError(classifyBackend(e).message)
throw e // let the board revert its optimistic move
}
},
[client, doctype, state],
)
if (state.phase === 'error') {
return <BackendStateCard state={state.error} onRetry={reload} hint={`framework · GET /v1/framework/${doctype}`} />
}
const refresh = <Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>Refresh</Button>
// A media/asset library (a required Attach) gets the gallery instead of the table.
if (state.phase === 'ready' && isMediaDoctype(state.dt)) {
return <MediaGrid dt={state.dt} docs={state.docs} onOpen={onOpen} onCreate={onCreate} toolbarExtra={refresh} />
}
const ready = state.phase === 'ready' ? state : undefined
return (
<RecordsView
title={title}
fields={ready?.fields ?? []}
records={ready?.records ?? []}
loading={state.phase === 'loading'}
onOpen={(r) => onOpen(String((r as { name?: unknown }).name ?? (r as { id?: unknown }).id ?? ''))}
onCreate={onCreate}
createLabel="New record"
onEditCommit={onEditCommit}
fieldOptions={fieldOptions}
toolbarExtra={refresh}
empty={mutationError ?? `No records in ${doctype} yet.`}
/>
)
}
export default DocTypeRecords
+90
View File
@@ -0,0 +1,90 @@
'use client'
/**
* MediaGrid — the presentational DAM gallery for a media DocType (an Attach-backed
* collection). Pure view: it takes the already-loaded documents + schema and
* renders asset cards (thumbnail from the primary Attach field). `DocTypeRecords`
* renders this instead of the table when `isMediaDoctype(dt)` — so media is just a
* DocType with a nicer default view, no separate data path and no bespoke subsystem.
*
* Assets are per-org: the Attach holds an object URL under the org's own S3 /
* SeaweedFS prefix. Honest empty state; never a fabricated tile.
*/
import type { ReactNode } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Image as ImageIcon, Plus } from '@hanzogui/lucide-icons-2'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { EmptyState } from '~/components/ui/EmptyState'
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
import { mediaFileField, titleOf } from '~/lib/framework/fields'
export interface MediaGridProps {
dt: DocType
docs: FrameworkDoc[]
onOpen: (name: string) => void
onCreate: () => void
toolbarExtra?: ReactNode
}
const looksLikeImage = (url: string): boolean =>
/\.(png|jpe?g|gif|webp|svg|avif)(\?|#|$)/i.test(url) || url.startsWith('data:image/')
export function MediaGrid({ dt, docs, onOpen, onCreate, toolbarExtra }: MediaGridProps) {
const fileField = mediaFileField(dt)
if (docs.length === 0) {
return (
<YStack gap="$3">
<XStack justify="flex-end" gap="$2">{toolbarExtra}</XStack>
<EmptyState
icon={ImageIcon}
title="No media yet"
description="Add an image or file by URL (from your object storage). It becomes a Media document you can reference from any page or post."
primary={{ label: 'Add media', onPress: onCreate }}
/>
</YStack>
)
}
return (
<YStack gap="$3">
<XStack justify="flex-end" gap="$2" items="center">
{toolbarExtra}
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={onCreate}>Add media</PrimaryButton>
</XStack>
<XStack gap="$3" flexWrap="wrap">
{docs.map((d) => {
const url = String(d[fileField] ?? '')
const title = titleOf(d, dt)
return (
<YStack
key={d.name}
onPress={() => onOpen(String(d.name))}
cursor="pointer"
hoverStyle={{ borderColor: '$color8' }}
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
width={200}
overflow="hidden"
>
<YStack height={130} bg="$color3" items="center" justify="center" overflow="hidden">
{url && looksLikeImage(url) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ImageIcon size={28} />
)}
</YStack>
<YStack p="$3" gap="$1">
<Text fontSize="$3" fontWeight="700" numberOfLines={1}>{title}</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>{String(d.mime ?? (url || 'file'))}</Text>
</YStack>
</YStack>
)
})}
</XStack>
</YStack>
)
}
export default MediaGrid
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared, non-React data helpers for the generic DocType renderer. A relation
* (Link) field is a picker over the TARGET doctype's records, so before a view
* can render or edit one it must load those records (+ the target's schema for
* the human label). This is the ONE place that does it, used by both the records
* list and the record detail — no per-view duplication.
*/
import type { SelectOption, FieldDefinition } from '@hanzo/data'
import type { DocType } from '~/lib/framework/types'
import type { FrameworkClient } from '~/lib/framework/client'
import { linkFields, titleOf } from '~/lib/framework/fields'
/** The picker options for every Link field of `dt`: value = target id, label = its title. */
export async function loadLinkOptions(
client: FrameworkClient,
dt: DocType,
): Promise<Record<string, SelectOption[]>> {
const links = linkFields(dt)
const out: Record<string, SelectOption[]> = {}
await Promise.all(
links.map(async (f) => {
const target = f.options
if (!target) return
// A target the caller can't read (403) → no options → the field falls back
// to a raw-id input (honest, never a fabricated candidate list).
const [tdt, docs] = await Promise.all([
client.doctypes.get(target).catch(() => null),
client.records.list(target, { limit: 200 }).catch(() => []),
])
out[f.fieldname] = docs.map((d) => ({
value: String(d.name),
label: tdt ? titleOf(d, tdt) : String(d.name),
}))
}),
)
return out
}
/**
* The `fieldOptions` callback @hanzo/data's views take: relation fields get their
* loaded target records; select fields get the choices declared in metadata (so
* the table cell and the form input agree). Everything else → undefined.
*/
export function makeFieldOptions(
linkOptions: Record<string, SelectOption[]>,
): (field: FieldDefinition) => SelectOption[] | undefined {
return (field) => {
if (field.type === 'relation') return linkOptions[field.name] ?? []
if (field.type === 'select' || field.type === 'multiSelect') {
return (field.metadata as { options?: SelectOption[] } | undefined)?.options
}
return undefined
}
}
@@ -0,0 +1,110 @@
'use client'
/**
* ProductErrorBoundary — the ONE boundary that keeps a single product module's
* client-render throw from white-screening the whole console.
*
* Product modules mount client-only under the catch-all route, so before this
* boundary a throw in any module (a data edge case, a `ChunkLoadError` from a
* rolling deploy, a future regression) bubbled to Next's root fallback and
* replaced everything with "Application error: a client-side exception has
* occurred" — the shell, the nav, and the URL gone. Wrapped, the shell and nav
* survive and only the content region shows an honest, retryable card. One
* boundary in the catch-all closes the class for every product route (DRY).
*
* Decisions live in the pure, unit-tested `boundary-logic`; this class only wires
* them to React lifecycle + the browser (reload/sessionStorage).
*/
import { Component, type ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { isChunkLoadError, isNextControlFlowError, shouldReloadForChunk } from './boundary-logic'
/** epoch-ms of the last chunk-recovery reload — bounds it to once per window. */
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
function reloadPage(): void {
if (typeof window !== 'undefined') window.location.reload()
}
type Props = {
children: ReactNode
/** Changing this (the route slug) clears a prior crash on navigation. */
resetKey?: string
}
type State = { error: Error | null }
export class ProductErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error): void {
// Control flow (notFound/redirect) is re-thrown in render — never a "crash".
if (isNextControlFlowError(error)) return
// Surface for triage; never swallowed.
console.error('[console] product route crashed:', error)
if (isChunkLoadError(error)) this.recoverFromChunkSkew()
}
componentDidUpdate(prev: Props): void {
// A fresh route mounts fresh — clear a prior route's error on navigation.
if (this.state.error && prev.resetKey !== this.props.resetKey) this.reset()
}
private reset = (): void => this.setState({ error: null })
/** Reload once per window to pick up the shipped chunks; never loop. */
private recoverFromChunkSkew(): void {
if (typeof window === 'undefined') return
try {
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
const last = raw ? Number(raw) : null
if (shouldReloadForChunk(Date.now(), last)) {
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
window.location.reload()
}
} catch {
// sessionStorage blocked (private mode) — fall through to the manual card.
}
}
render(): ReactNode {
const { error } = this.state
if (!error) return this.props.children
// notFound()/redirect() are Next control flow — re-throw so Next handles them.
if (isNextControlFlowError(error)) throw error
const chunk = isChunkLoadError(error)
return (
<YStack p="$4">
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{chunk
? 'A newer version of the console just shipped. Reload to load the latest — the rest of the console keeps working.'
: 'The rest of the console still works. Try again, or reload the page. If it keeps happening, this surface will be looked at.'}
</Text>
<XStack gap="$2">
{!chunk ? (
<Button size="$2" icon={<RefreshCw size={14} />} onPress={this.reset}>
Try again
</Button>
) : null}
<Button size="$2" chromeless={!chunk} icon={<RefreshCw size={14} />} onPress={reloadPage}>
Reload
</Button>
</XStack>
</Card>
</YStack>
)
}
}
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import { isChunkLoadError, isNextControlFlowError, shouldReloadForChunk } from './boundary-logic'
/** A thrown value shaped like a webpack ChunkLoadError. */
function chunkError(message: string): Error {
const e = new Error(message)
e.name = 'ChunkLoadError'
return e
}
/** A thrown value shaped like a Next control-flow error (notFound/redirect). */
function nextError(digest: string): Error {
const e = new Error('control-flow') as Error & { digest: string }
e.digest = digest
return e
}
describe('isChunkLoadError', () => {
it('matches by error name', () => {
expect(isChunkLoadError(chunkError('boom'))).toBe(true)
})
it('matches the webpack "Loading chunk N failed" message', () => {
expect(isChunkLoadError(new Error('Loading chunk 4821 failed.\n(error: https://x/_next/static/chunks/4821.js)'))).toBe(true)
expect(isChunkLoadError(new Error('Loading CSS chunk app-layout failed'))).toBe(true)
})
it('matches the Next/Turbopack dynamic-import failure messages', () => {
expect(isChunkLoadError(new Error('Failed to fetch dynamically imported module: https://x/_next/static/chunks/playground.js'))).toBe(true)
expect(isChunkLoadError(new Error('error loading dynamically imported module'))).toBe(true)
expect(isChunkLoadError(new Error('Importing a module script failed.'))).toBe(true)
})
it('matches the stale-chunk HTML-as-JS signature (a 404 chunk served the app shell)', () => {
// The most common "crash on refresh during a deploy" — the chunk URL 404s and
// falls through to the SPA HTML, which the browser then parses as JS.
expect(isChunkLoadError(new SyntaxError("Unexpected token '<'"))).toBe(true)
expect(isChunkLoadError(new SyntaxError('expected expression, got \'<\''))).toBe(true)
expect(isChunkLoadError(new Error('Unexpected token < in JSON at position 0'))).toBe(true)
expect(isChunkLoadError(new SyntaxError('Unexpected token \'<\', "<!DOCTYPE "... is not valid JSON'))).toBe(true)
})
it('is false for ordinary render errors', () => {
expect(isChunkLoadError(new Error("Cannot read properties of undefined (reading 'map')"))).toBe(false)
expect(isChunkLoadError(new TypeError('x is not a function'))).toBe(false)
expect(isChunkLoadError(null)).toBe(false)
expect(isChunkLoadError(undefined)).toBe(false)
expect(isChunkLoadError('some string')).toBe(false)
})
})
describe('isNextControlFlowError', () => {
it('matches notFound() and redirect() digests (must be re-thrown)', () => {
expect(isNextControlFlowError(nextError('NEXT_NOT_FOUND'))).toBe(true)
expect(isNextControlFlowError(nextError('NEXT_REDIRECT;replace;/signin;307;'))).toBe(true)
expect(isNextControlFlowError(nextError('NEXT_HTTP_ERROR_FALLBACK;404'))).toBe(true)
})
it('matches the useSearchParams CSR bailout', () => {
expect(isNextControlFlowError(nextError('BAILOUT_TO_CLIENT_SIDE_RENDERING'))).toBe(true)
})
it('is false for real crashes (they should render the fallback, not re-throw)', () => {
expect(isNextControlFlowError(new Error('Cannot read properties of undefined'))).toBe(false)
expect(isNextControlFlowError(chunkError('Loading chunk 1 failed'))).toBe(false)
expect(isNextControlFlowError({ digest: 12345 })).toBe(false)
expect(isNextControlFlowError(null)).toBe(false)
})
})
describe('shouldReloadForChunk', () => {
it('reloads on the first chunk error (no prior reload)', () => {
expect(shouldReloadForChunk(1_000_000, null)).toBe(true)
})
it('does NOT reload again within the window (no reload loop)', () => {
expect(shouldReloadForChunk(1_000_000, 995_000)).toBe(false) // 5s ago, window 15s
})
it('reloads again once the window has passed', () => {
expect(shouldReloadForChunk(1_000_000, 980_000)).toBe(true) // 20s ago
expect(shouldReloadForChunk(1_000_000, 985_000, 15_000)).toBe(true) // exactly 15s
})
it('treats a non-finite/absent last-reload as safe to reload', () => {
expect(shouldReloadForChunk(1_000_000, Number.NaN)).toBe(true)
})
})
+71
View File
@@ -0,0 +1,71 @@
/**
* Pure decisions for the product-route error boundary.
*
* A `'use client'` product module mounts CLIENT-ONLY under the catch-all route
* (the authed dashboard renders a loader during SSR, so the module never renders
* server-side — verified: the /playground server HTML carries no module markup).
* That means a throw during a module's first client render has no error boundary
* to catch it, so it bubbles to Next's built-in root fallback and white-screens
* the whole console with "Application error: a client-side exception has
* occurred" — losing the shell, nav, and the URL. `ProductErrorBoundary` is the
* one boundary that closes that class for every product route; this module is its
* decision logic, kept pure so it is unit-tested without a browser.
*
* Three orthogonal decisions:
* - `isChunkLoadError` — a dynamic-import/chunk fetch failed. On a rolling
* deploy the just-served HTML references new content-hashed chunks; a refresh
* that lands on the other replica (or a stale CDN edge) can 404 a chunk. This
* is the most likely real cause of a "direct-load / refresh only" crash.
* - `isNextControlFlowError` — `notFound()` / `redirect()` (and the CSR bailout)
* throw a tagged error as CONTROL FLOW. A custom boundary MUST re-throw these
* so Next renders the 404/redirect instead of swallowing them into a fallback.
* - `shouldReloadForChunk` — recover from chunk skew by reloading ONCE per short
* window, never looping when a chunk is genuinely gone.
*/
/**
* True for a webpack/Next dynamic chunk (JS or CSS) load failure — including the
* stale-deploy case where a 404'd chunk URL falls through to the SPA and the
* browser parses HTML as JS ("Unexpected token '<'" / module-script failed). In a
* PRODUCTION build user code never throws "Unexpected token '<'" at runtime (that
* is a build-time syntax error), so at runtime it is a chunk skew — recover, don't
* crash. Kept in sync with `ChunkGuard`'s window-level pattern (one definition of
* "this is a chunk skew" for both the boundary and the global listeners).
*/
export function isChunkLoadError(e: unknown): boolean {
if (!e) return false
const name = typeof e === 'object' && e !== null && 'name' in e ? String((e as { name?: unknown }).name) : ''
const msg = e instanceof Error ? e.message : String(e)
return (
name === 'ChunkLoadError' ||
/ChunkLoadError/i.test(msg) ||
/Loading (?:CSS )?chunk [\w./-]+ failed/i.test(msg) ||
/(?:Failed to fetch|error loading|Importing a module script failed).*dynamically imported module/i.test(msg) ||
/Failed to fetch dynamically imported module/i.test(msg) ||
/Importing a module script failed/i.test(msg) ||
// Stale chunk URL served the HTML shell → parsed as JS.
/Unexpected token '<'|expected expression, got '<'|Unexpected token <|<!DOCTYPE/i.test(msg)
)
}
/**
* True for Next.js control-flow throws (`notFound()`, `redirect()`, the
* `useSearchParams` CSR bailout). These are tagged with a `digest` and MUST be
* re-thrown by a custom error boundary so Next handles them, never rendered as a
* crash.
*/
export function isNextControlFlowError(e: unknown): boolean {
const digest = typeof e === 'object' && e !== null && 'digest' in e ? (e as { digest?: unknown }).digest : undefined
if (typeof digest !== 'string') return false
return digest.startsWith('NEXT_') || digest === 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
}
/**
* Reload at most once per `windowMs` to recover from a chunk skew. `lastReloadAt`
* is the epoch-ms of the last recovery reload (null when we have not reloaded);
* returns true when a fresh reload is safe (no reload in the window → no loop).
*/
export function shouldReloadForChunk(now: number, lastReloadAt: number | null, windowMs = 15_000): boolean {
if (lastReloadAt == null || !Number.isFinite(lastReloadAt)) return true
return now - lastReloadAt >= windowMs
}
@@ -0,0 +1,155 @@
'use client'
/**
* Accessibility — a Wix-style WCAG checker for the site you are building. It runs
* Deque's axe-core against the CURRENT page, entirely in the browser: nothing is
* sent to a server, no page content leaves the tab. The engine is loaded on demand
* (`import('axe-core')` → its own chunk), so it never weighs down the main bundle;
* a scan is user-triggered (never on every render) and asks axe for violations only
* (`resultTypes: ['violations']`) to keep it fast on large DOMs.
*
* All processing is the pure, unit-tested `~/lib/a11y/scan` (sort/summarize/WCAG
* labels) — this file is only the panel: a Scan button, per-severity count cards,
* and an honest table (idle → scanning → results/empty, or a plain error card).
*/
import { useCallback, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
type State =
| { phase: 'idle' }
| { phase: 'scanning' }
| { phase: 'done'; issues: A11yIssue[]; summary: A11ySummary }
| { phase: 'error'; message: string }
const IMPACT_COLOR = {
critical: '$red10',
serious: '$orange10',
moderate: '$yellow10',
minor: '$color11',
} as const satisfies Record<Impact, string>
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
/** The one axe-core entrypoint we use — declared locally so the dynamic import interop stays typed. */
type AxeModule = { run: (ctx: Document, opts?: { resultTypes?: string[] }) => Promise<{ violations: unknown[] }> }
export function AccessibilityModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<State>({ phase: 'idle' })
const scan = useCallback(async () => {
if (typeof document === 'undefined') return
setState({ phase: 'scanning' })
try {
const mod = (await import('axe-core')) as unknown as { default?: AxeModule } & Partial<AxeModule>
const axe = mod.default ?? (mod as AxeModule)
if (typeof axe.run !== 'function') throw new Error('axe-core failed to load')
const { violations } = await axe.run(document, { resultTypes: ['violations'] })
const issues = toIssues(violations)
setState({ phase: 'done', issues, summary: summarize(issues) })
} catch (e) {
setState({ phase: 'error', message: e instanceof Error ? e.message : String(e) })
}
}, [])
const columns: Column<A11yIssue>[] = [
{
key: 'impact',
header: 'Impact',
width: 100,
render: (r) => (
<Text fontSize="$3" fontWeight="700" color={IMPACT_COLOR[r.impact]}>
{cap(r.impact)}
</Text>
),
},
{
key: 'help',
header: 'Issue',
render: (r) => (
<YStack>
<Text fontSize="$3" fontWeight="600">{r.help}</Text>
<Text fontSize="$1" color="$color10">{r.id}</Text>
</YStack>
),
},
{ key: 'wcag', header: 'WCAG', width: 160, render: (r) => <Text fontSize="$2" color="$color11">{r.wcag.join(' · ') || '—'}</Text> },
{ key: 'nodes', header: 'Elements', width: 90, render: (r) => <Text fontSize="$3" color="$color11">{r.nodes}</Text> },
{ key: 'target', header: 'First match', render: (r) => <Text fontSize="$2" color="$color10">{r.target || '—'}</Text> },
{
key: 'learn',
header: '',
width: 56,
render: (r) =>
r.helpUrl ? (
<Button
size="$2"
chromeless
aria-label={`How to fix: ${r.help}`}
icon={<ExternalLink size={14} />}
onPress={() => {
if (typeof window !== 'undefined') window.open(r.helpUrl, '_blank', 'noopener,noreferrer')
}}
/>
) : null,
},
]
return (
<YStack gap="$4" p="$4">
<PageHeader
title="Accessibility"
subtitle="Scan the current page for WCAG issues — axe-core runs in your browser, nothing leaves the page."
actions={
<Button
size="$3"
theme="light"
icon={<Accessibility size={15} />}
disabled={state.phase === 'scanning'}
onPress={() => void scan()}
>
{state.phase === 'scanning' ? 'Scanning…' : 'Scan this page'}
</Button>
}
/>
{state.phase === 'idle' ? (
<Card p="$4" borderWidth={1} borderColor="$borderColor">
<Text color="$color11">
Run a scan to check this page against the WCAG 2 A/AA rule set. The engine loads on demand and runs entirely
in your browser no page content is sent anywhere.
</Text>
</Card>
) : null}
{state.phase === 'error' ? (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text color="$red10" fontWeight="600">Scan failed</Text>
<Text fontSize="$2" color="$color11">{state.message}</Text>
</Card>
) : null}
{state.phase === 'done' ? (
<>
<XStack gap="$3" flexWrap="wrap">
{IMPACTS.map((imp) => (
<Card key={imp} p="$3" minWidth={150} borderWidth={1} borderColor="$borderColor">
<Text fontSize="$2" color="$color10">{cap(imp)}</Text>
<Text fontSize="$7" fontWeight="800" color={IMPACT_COLOR[imp]}>{state.summary.byImpact[imp]}</Text>
</Card>
))}
</XStack>
<DataTable
columns={columns}
rows={state.issues}
rowKey={(r) => r.id}
empty="No accessibility violations found on this page. Nice work."
/>
</>
) : null}
</YStack>
)
}
@@ -0,0 +1,45 @@
'use client'
/**
* Admin-managed notice — the graceful, honest state a CUSTOMER sees if they reach
* an admin-only surface directly (a deep link or a stale bookmark). Instead of the
* module's hostile 403 "Not authorized" red error, it explains the surface is
* managed by the platform administrator, not per organization, and points the way
* back. Access is always enforced server-side; this is only the friendly UI gate.
*/
import { useRouter } from 'next/navigation'
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
export function AdminManagedNotice({
entry,
subpage,
}: {
entry: CatalogEntry
subpage?: ProductSubpage
}) {
const router = useRouter()
const what = subpage ? `${entry.label} · ${subpage.label}` : entry.label
return (
<>
<PageHeader title={what} subtitle={entry.description} />
<FadeIn style={{ width: '100%' }}>
<EmptyState
icon={entry.icon}
title="Managed by Hanzo"
description={`${what} is a platform-managed surface — it's configured by your ${config.brandName} administrator, not per organization. You have full access to everything your organization owns.`}
bullets={[
'Nothing is broken — this is intentionally managed at the platform level.',
'Need a change here? Ask your platform administrator.',
]}
primary={{ label: 'Back to Overview', onPress: () => router.push('/') }}
secondary={{ label: 'Your API keys', onPress: () => router.push('/api-keys') }}
/>
</FadeIn>
</>
)
}
+510
View File
@@ -0,0 +1,510 @@
'use client'
/**
* Identity & access admin — Organizations, Users, Roles (RBAC), and the Audit
* trail, served by Hanzo IAM under `/v1/iam/*` (HIP-0111). ONE generic list view
* (`AdminListView`) drives every surface over the shared cookie `/v1` envelope
* client; the tabbed `IamModule` and the standalone `AuditModule` just supply a
* fetcher + columns. No data is ever fabricated: loading, not-available (404),
* access-required (401/403/unauthorized), error, and empty are all honest states.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, ExternalLink, Plus, Trash2, ShieldCheck, ShieldOff } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import {
IamAdminApi,
type Paged,
type Organization,
type IamUser,
type Role,
type AuditRecord,
} from '~/lib/api/admin'
import { config } from '~/config'
import { currentOrg } from '~/lib/org-scope'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { ErrorState, asApiError, isForbidden, OperatorAccessRequired, type HonestCopy } from '~/components/ui/States'
/** IAM-specific guidance for the honest 404 / unauthorized states. */
const IAM_COPY: HonestCopy = {
notFound:
'The IAM admin API (/v1/iam) is not routed on this host yet. It appears automatically once the deployment proxies /v1/iam to Hanzo IAM.',
unauthorized:
'This view requires an admin session. Access is enforced server-side by IAM — sign in with an account that has the right role.',
}
const fmtDate = (v?: string): string => {
if (!v) return '—'
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
const dim = (v?: string | number | null) =>
v === undefined || v === null || v === '' ? (
<Text fontSize="$3" color="$color10">
</Text>
) : (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{String(v)}
</Text>
)
type LoadState<T> = { phase: 'loading' } | { phase: 'error'; err: ApiError } | { phase: 'ready'; rows: T[] }
/** Generic admin list: runs a fetcher, renders an honest state, then a table. */
function AdminListView<T>({
fetcher,
columns,
rowKey,
empty,
}: {
fetcher: () => Promise<Paged<T>>
columns: Column<T>[]
rowKey: (r: T) => string
empty: string
}) {
const [state, setState] = useState<LoadState<T>>({ phase: 'loading' })
const run = useCallback(() => {
setState({ phase: 'loading' })
fetcher()
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [fetcher])
useEffect(() => {
run()
}, [run])
return (
<>
<XStack justify="flex-end">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>
Refresh
</Button>
</XStack>
{state.phase === 'error' ? (
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
) : (
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={rowKey}
empty={empty}
/>
)}
</>
)
}
type Tone = { tone: 'ok' | 'err'; text: string }
/**
* Users with full CRUD — create, promote/demote admin, and delete — over the
* ready IamAdminApi mutations (add/update/delete-user) through the server-gated
* /admin/iam proxy, scoped to `owner`. This is the casdoor user surface, in
* console: no link-out for the common lifecycle. Honest states throughout.
*/
function UsersAdminView({ owner }: { owner: string }) {
const [state, setState] = useState<LoadState<IamUser>>({ phase: 'loading' })
const [busy, setBusy] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<Tone | null>(null)
const run = useCallback(() => {
setState({ phase: 'loading' })
IamAdminApi.users(owner)
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [owner])
useEffect(() => {
run()
}, [run])
const create = useCallback(async () => {
if (!name.trim() || !password) {
setMsg({ tone: 'err', text: 'Name and password are required.' })
return
}
setSaving(true)
setMsg(null)
try {
await IamAdminApi.addUser({ owner, name: name.trim(), email: email.trim(), password } as IamUser)
setMsg({ tone: 'ok', text: `Created ${owner}/${name.trim()}.` })
setName('')
setEmail('')
setPassword('')
run()
} catch (e) {
setMsg({ tone: 'err', text: asApiError(e).message })
} finally {
setSaving(false)
}
}, [owner, name, email, password, run])
const toggleAdmin = useCallback(
async (u: IamUser) => {
const k = `${u.owner}/${u.name}`
setBusy(k)
try {
await IamAdminApi.updateUser(k, { ...u, isAdmin: !u.isAdmin })
run()
} catch (e) {
setState({ phase: 'error', err: asApiError(e) })
} finally {
setBusy(null)
}
},
[run],
)
const remove = useCallback(
async (u: IamUser) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete user ${u.owner}/${u.name}? This cannot be undone.`)) return
const k = `${u.owner}/${u.name}`
setBusy(k)
try {
await IamAdminApi.deleteUser(u)
run()
} catch (e) {
setState({ phase: 'error', err: asApiError(e) })
} finally {
setBusy(null)
}
},
[run],
)
const crudColumns: Column<IamUser>[] = [
...userColumns,
{
key: 'actions',
header: '',
width: 120,
render: (u) => {
const k = `${u.owner}/${u.name}`
return (
<XStack gap="$1.5">
<Button
size="$1"
chromeless
icon={u.isAdmin ? <ShieldOff size={14} /> : <ShieldCheck size={14} />}
disabled={busy === k}
onPress={() => void toggleAdmin(u)}
/>
<Button size="$1" chromeless icon={<Trash2 size={14} />} disabled={busy === k} onPress={() => void remove(u)} />
</XStack>
)
},
},
]
return (
<>
<XStack justify="flex-end" gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>Refresh</Button>
<Button size="$2" icon={<Plus size={15} />} onPress={() => setShowForm((v) => !v)}>New user</Button>
</XStack>
{showForm && (
<Card p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" maxWidth={820}>
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
<XStack gap="$2" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
{saving ? 'Creating…' : 'Create user'}
</Button>
{msg && <Text fontSize="$2" color={msg.tone === 'ok' ? '$green10' : '$red10'}>{msg.text}</Text>}
</XStack>
<Text fontSize="$2" color="$color10">Password is hashed by IAM (argon2id). The shield toggles global-admin.</Text>
</Card>
)}
{state.phase === 'error' ? (
isForbidden(state.err) ? (
<OperatorAccessRequired />
) : (
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
)
) : (
<DataTable
columns={crudColumns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(u) => `${u.owner}/${u.name}`}
empty="No users in this organization."
/>
)}
</>
)
}
/**
* Roles with full CRUD — create and delete RBAC roles over IamAdminApi
* (add/update/delete-role → /v1/iam/*), scoped to `owner`. Mirrors
* UsersAdminView; membership editing stays in the full IAM app.
*/
function RolesAdminView({ owner }: { owner: string }) {
const [state, setState] = useState<LoadState<Role>>({ phase: 'loading' })
const [busy, setBusy] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<Tone | null>(null)
const run = useCallback(() => {
setState({ phase: 'loading' })
IamAdminApi.roles(owner)
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [owner])
useEffect(() => {
run()
}, [run])
const create = useCallback(async () => {
if (!name.trim()) {
setMsg({ tone: 'err', text: 'Name is required.' })
return
}
setSaving(true)
setMsg(null)
try {
await IamAdminApi.addRole({ owner, name: name.trim(), displayName: displayName.trim() || name.trim(), isEnabled: true } as Role)
setMsg({ tone: 'ok', text: `Created role ${owner}/${name.trim()}.` })
setName('')
setDisplayName('')
run()
} catch (e) {
setMsg({ tone: 'err', text: asApiError(e).message })
} finally {
setSaving(false)
}
}, [owner, name, displayName, run])
const remove = useCallback(
async (r: Role) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete role ${r.owner}/${r.name}? This cannot be undone.`)) return
const k = `${r.owner}/${r.name}`
setBusy(k)
try {
await IamAdminApi.deleteRole(r)
run()
} catch (e) {
setState({ phase: 'error', err: asApiError(e) })
} finally {
setBusy(null)
}
},
[run],
)
const crudColumns: Column<Role>[] = [
...roleColumns,
{
key: 'actions',
header: '',
width: 60,
render: (r) => {
const k = `${r.owner}/${r.name}`
return <Button size="$1" chromeless icon={<Trash2 size={14} />} disabled={busy === k} onPress={() => void remove(r)} />
},
},
]
return (
<>
<XStack justify="flex-end" gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>Refresh</Button>
<Button size="$2" icon={<Plus size={15} />} onPress={() => setShowForm((v) => !v)}>New role</Button>
</XStack>
{showForm && (
<Card p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" maxWidth={820}>
<Text fontSize="$4" fontWeight="700">New role in {owner}</Text>
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="deployers" disabled={saving} /></FieldRow>
<FieldRow label="Display name"><FieldText value={displayName} onChange={setDisplayName} placeholder="Deployers" disabled={saving} /></FieldRow>
<XStack gap="$2" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
{saving ? 'Creating…' : 'Create role'}
</Button>
{msg && <Text fontSize="$2" color={msg.tone === 'ok' ? '$green10' : '$red10'}>{msg.text}</Text>}
</XStack>
<Text fontSize="$2" color="$color10">Assign members in the full IAM app; this manages the role itself.</Text>
</Card>
)}
{state.phase === 'error' ? (
isForbidden(state.err) ? (
<OperatorAccessRequired />
) : (
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
)
) : (
<DataTable
columns={crudColumns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(r) => `${r.owner}/${r.name}`}
empty="No roles defined yet."
/>
)}
</>
)
}
/** A "manage in the full IAM app" deep-link, shown in the admin headers. */
function ManageInIam() {
return (
<Button
icon={<ExternalLink size={15} />}
onPress={() => {
if (typeof window !== 'undefined') window.open(config.iamUrl, '_blank', 'noopener')
}}
>
IAM console
</Button>
)
}
// ── Column sets ──────────────────────────────────────────────────────────────
const orgColumns: Column<Organization>[] = [
{ key: 'name', header: 'Name', render: (o) => <Text fontSize="$3" fontWeight="600">{o.name}</Text> },
{ key: 'displayName', header: 'Display name', render: (o) => dim(o.displayName) },
{ key: 'websiteUrl', header: 'Website', width: 220, render: (o) => dim(o.websiteUrl) },
{ key: 'createdTime', header: 'Created', width: 200, render: (o) => dim(fmtDate(o.createdTime)) },
]
const userColumns: Column<IamUser>[] = [
{ key: 'name', header: 'Name', render: (u) => <Text fontSize="$3" fontWeight="600">{u.name}</Text> },
{ key: 'displayName', header: 'Display name', render: (u) => dim(u.displayName) },
{ key: 'email', header: 'Email', render: (u) => dim(u.email) },
{
key: 'role',
header: 'Role',
width: 110,
render: (u) => (
<Text fontSize="$2" px="$2" py="$1" rounded="$2" bg={u.isAdmin ? '$color5' : '$color3'} color={u.isAdmin ? '$color12' : '$color11'}>
{u.isAdmin ? 'admin' : u.type || 'member'}
</Text>
),
},
{ key: 'createdTime', header: 'Created', width: 200, render: (u) => dim(fmtDate(u.createdTime)) },
]
const roleColumns: Column<Role>[] = [
{ key: 'name', header: 'Name', render: (r) => <Text fontSize="$3" fontWeight="600">{r.name}</Text> },
{ key: 'displayName', header: 'Display name', render: (r) => dim(r.displayName) },
{ key: 'members', header: 'Members', width: 100, render: (r) => dim(r.users?.length ?? 0) },
{
key: 'isEnabled',
header: 'Enabled',
width: 100,
render: (r) => dim(r.isEnabled === false ? 'no' : 'yes'),
},
{ key: 'createdTime', header: 'Created', width: 200, render: (r) => dim(fmtDate(r.createdTime)) },
]
const recordColumns: Column<AuditRecord>[] = [
{ key: 'createdTime', header: 'Time', width: 200, render: (r) => dim(fmtDate(r.createdTime)) },
{ key: 'user', header: 'User', render: (r) => dim(r.user) },
{ key: 'action', header: 'Action', width: 150, render: (r) => dim(r.action) },
{
key: 'request',
header: 'Request',
render: (r) => dim([r.method, r.requestUri].filter(Boolean).join(' ')),
},
{ key: 'clientIp', header: 'IP', width: 140, render: (r) => dim(r.clientIp) },
]
// ── Modules ──────────────────────────────────────────────────────────────────
const IAM_TABS = [
{ id: '', label: 'Organizations' },
{ id: 'users', label: 'Users' },
{ id: 'roles', label: 'Roles' },
] as const
/**
* Identity & access — Organizations, Users, Roles in one tabbed module. The tab
* is the route param (`/iam`, `/iam/users`, `/iam/roles`), mirroring the
* ProvidersModule param branch.
*/
export function IamModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const tab = params.tab ?? ''
// The ACTIVE org scope (the brand org, or the org a global admin switched to);
// users/roles below are read for it. The org LIST itself is unscoped — a global
// admin sees every org (what powers the switcher).
const org = currentOrg()
const orgFetcher = useCallback(() => IamAdminApi.organizations(), [])
const view = useMemo(() => {
if (tab === 'users') return <UsersAdminView key="users" owner={org} />
if (tab === 'roles') return <RolesAdminView key="roles" owner={org} />
return <AdminListView key="orgs" fetcher={orgFetcher} columns={orgColumns} rowKey={(o) => `${o.owner}/${o.name}`} empty="No organizations visible to this account." />
}, [tab, org, orgFetcher])
return (
<>
<PageHeader
title="Identity & Access"
subtitle="Organizations, users, and roles (RBAC), served by Hanzo IAM."
actions={<ManageInIam />}
/>
<XStack gap="$1" flexWrap="wrap">
{IAM_TABS.map((t) => (
<Button
key={t.id || 'orgs'}
size="$2"
bg={t.id === tab ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => router.push(t.id ? `/iam/${t.id}` : '/iam')}
>
{t.label}
</Button>
))}
</XStack>
{view}
</>
)
}
/** Audit — the identity & access event log (`/v1/iam/get-records`). */
export function AuditModule(_props: { params: Record<string, string> }) {
const org = currentOrg()
const fetcher = useCallback(() => IamAdminApi.records(org), [org])
return (
<>
<PageHeader
title="Audit"
subtitle="Identity and access events recorded by Hanzo IAM."
actions={<ManageInIam />}
/>
<AdminListView
fetcher={fetcher}
columns={recordColumns}
rowKey={(r) => String(r.id ?? `${r.createdTime ?? ''}-${r.user ?? ''}-${r.requestUri ?? ''}`)}
empty="No audit events recorded yet."
/>
</>
)
}
+524
View File
@@ -0,0 +1,524 @@
'use client'
/**
* Agents — the autonomous-agent dashboard (AI / Automation). A rich, at-a-glance
* board over the REAL agent registry (`AgentsApi.list` → the user-bearer
* `/cloud/v1/agents` proxy, org-scoped server-side): five headline stat cards, an
* invocations-over-time area chart, an agent-health donut, the agents table
* (status tabs + pagination), a recent-activity feed, a top-agents bar list, and a
* 30-day resource-usage panel.
*
* Honest by construction — EVERY number is real or derived from real rows
* (`deriveAgentStats`, `healthBreakdown`, `topByInvocations`, `deriveActivity`); a
* metric no row carries reads "—". The chart + resource panel read the time-series
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
* "create your first agent" empty state with the real New-Agent flow — never the
* mockup's sample data.
*
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import {
Activity,
Bot,
ChevronLeft,
ChevronRight,
Gauge,
MoreHorizontal,
Plus,
RefreshCw,
Search,
Timer,
Trash2,
Zap,
} from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import {
AgentsApi,
deriveActivity,
deriveAgentStats,
filterAgents,
fmtCompact,
fmtDuration,
fmtInt,
fmtPct,
fmtRelative,
healthBreakdown,
paginate,
summedSeries,
topByInvocations,
trendPct,
AGENT_STATUSES,
AGENT_STATUS_LABEL,
METRICS_RANGES,
type Agent,
type AgentActivity,
type AgentsMetrics,
type AgentStatus,
type MetricsRange,
} from '~/lib/api/agents'
import { productColorHex } from '~/lib/products/colors'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { EmptyState } from '~/components/ui/EmptyState'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
import { useDetailPane } from '~/components/DetailPane'
import { MetricCard, Delta } from './functions/parts'
import {
AgentAvatar,
ActivityFeed,
HealthDonut,
Panel,
ResourceUsagePanel,
StatusPill,
TopAgents,
VersionBadge,
} from './agents/parts'
import { AgentDetailView, NewAgentForm } from './agents/forms'
const PAGE_SIZE = 8
const DASH = '—'
const DOCS = `${config.docsUrl}/agents`
type StatusTab = AgentStatus | 'all'
/** Compact bucket label from a loose timestamp (month/day/hour). */
function bucketLabel(t: string): string {
if (!t) return ''
const d = new Date(t)
if (Number.isNaN(d.getTime())) return t
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric' })
}
/** Load the registry once; `live` = it loaded over a real 200 (gates mutations). */
function useAgents() {
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<BackendState | null>(null)
const [live, setLive] = useState(false)
const [activity, setActivity] = useState<AgentActivity[]>([])
const reload = useCallback(async () => {
setLoading(true)
try {
const list = await AgentsApi.list()
setAgents(list)
setLive(true)
setError(null)
// Prefer a real activity feed; fall back to the agents' own timestamps.
try {
const feed = await AgentsApi.activity()
setActivity(feed.length ? feed : deriveActivity(list))
} catch {
setActivity(deriveActivity(list))
}
} catch (e) {
setAgents([])
setLive(false)
setActivity([])
setError(classifyBackend(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
return { agents, loading, error, live, activity, reload, setAgents }
}
export function AgentsModule(_props: { params: Record<string, string> }) {
const detail = useDetailPane()
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
const [range, setRange] = useState<MetricsRange>('30D')
const [metrics, setMetrics] = useState<AgentsMetrics | null>(null)
const [metricsConnected, setMetricsConnected] = useState(false)
const [q, setQ] = useState('')
const [tab, setTab] = useState<StatusTab>('all')
const [page, setPage] = useState(1)
const loadMetrics = useCallback(async (r: MetricsRange) => {
try {
const m = await AgentsApi.metrics(r)
setMetrics(m)
setMetricsConnected(true)
} catch {
setMetrics(null)
setMetricsConnected(false)
}
}, [])
useEffect(() => {
void loadMetrics(range)
}, [loadMetrics, range])
const stats = useMemo(() => deriveAgentStats(agents), [agents])
const health = useMemo(() => healthBreakdown(agents), [agents])
const top = useMemo(() => topByInvocations(agents, 5), [agents])
const invTotals = useMemo(() => summedSeries(metrics?.series ?? []), [metrics])
const invDelta = trendPct(invTotals)
const chartData: ChartPoint[] = useMemo(() => {
const series = metrics?.series ?? []
if (!series.length) return []
const longest = series.reduce((a, b) => (b.points.length > a.points.length ? b : a), series[0])
return invTotals.map((v, i) => ({ label: bucketLabel(longest.points[i]?.t ?? ''), value: v }))
}, [metrics, invTotals])
const filtered = useMemo(() => filterAgents(agents, { search: q, status: tab }), [agents, q, tab])
const pageState = paginate(filtered, page, PAGE_SIZE)
const agentColor = productColorHex('agents')
const openDetail = useCallback(
(a: Agent) =>
detail.open({
title: a.name,
subtitle: `${a.model || 'agent'} · ${AGENT_STATUS_LABEL[a.status]}`,
icon: Bot,
iconColor: agentColor,
content: <AgentDetailView agent={a} />,
footer: live ? (
<>
<Button flex={1} chromeless onPress={detail.close}>
Close
</Button>
<Button
flex={1}
theme="red"
icon={<Trash2 size={15} />}
onPress={() => {
if (typeof window !== 'undefined' && !window.confirm(`Delete agent “${a.name}”? This cannot be undone.`)) return
// Keyed by NAME — the backend's `DELETE /v1/agents/:name` handle. Passing
// the display `id` (`agent_…`) 404s, which is what made this button a
// silent no-op.
void AgentsApi.remove(a.name)
.then(() => {
setAgents((rows) => rows.filter((r) => r.id !== a.id))
detail.close()
})
.catch((e) => {
// Never a silent failure: surface why the delete didn't take (the row stays).
if (typeof window !== 'undefined') {
window.alert(`Couldnt delete “${a.name}”: ${e instanceof Error ? e.message : 'request failed'}`)
}
})
}}
>
Delete
</Button>
</>
) : (
<Button flex={1} chromeless onPress={detail.close}>
Close
</Button>
),
}),
[detail, agentColor, live, setAgents],
)
const openNew = useCallback(
() =>
detail.open({
title: 'New agent',
subtitle: 'Define a model, prompt, and tools',
icon: Bot,
iconColor: agentColor,
content: (
<NewAgentForm
onCancel={detail.close}
onCreated={() => {
detail.close()
void reload()
}}
/>
),
}),
[detail, agentColor, reload],
)
const header = (
<PageHeader
title="Agents"
subtitle="Autonomous agents — a model, a prompt, and tools that run on Hanzo compute."
actions={
<XStack gap="$2" items="center" flexWrap="wrap">
{agents.length > 0 ? (
<XStack items="center" gap="$2" px="$3" borderWidth={1} borderColor="$borderColor" rounded="$3" minW={200}>
<Search size={15} />
<Input
flex={1}
unstyled
value={q}
onChangeText={(v: string) => {
setQ(v)
setPage(1)
}}
placeholder="Search agents…"
autoCapitalize="none"
py="$2"
/>
</XStack>
) : null}
<Button size="$3" chromeless icon={<RefreshCw size={15} />} onPress={() => void reload()} aria-label="Refresh" />
<Button size="$3" theme="light" icon={<Plus size={15} />} onPress={openNew}>
New Agent
</Button>
</XStack>
}
/>
)
// ── Initial loading ─────────────────────────────────────────────────────────
if (loading && agents.length === 0 && !error) {
return (
<>
{header}
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
</>
)
}
// ── Hard backend failure (access / not-initialized / error) ───────────────────
if (error && error.kind !== 'unavailable') {
return (
<>
{header}
<BackendStateCard state={error} onRetry={() => void reload()} hint="endpoint · GET /v1/agents" />
</>
)
}
// ── Honest empty (zero agents, or the /v1/agents route isn't bound yet) ───────
if (agents.length === 0) {
return (
<>
{header}
{live ? (
<XStack items="center" gap="$2" px="$3" py="$2" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor" self="center">
<YStack width={8} height={8} rounded="$10" bg="$green10" />
<Text fontSize="$2" color="$color11">Connected to Agents · no agents yet</Text>
</XStack>
) : null}
<EmptyState
icon={Bot}
title="Create your first agent"
description="Agents are autonomous workers — a model, a system prompt, and a set of tools that run on Hanzo compute and call your APIs on their own."
bullets={[
'Define an agent with a model, prompt, and tools',
'Give it tools — HTTP, MCP servers, or your own functions',
'Deploy to Hanzo compute — invocations, health, and cost show up here',
]}
primary={{ label: 'New Agent', onPress: openNew }}
secondary={{ label: 'Agents docs', href: DOCS }}
/>
</>
)
}
const spark = invTotals.length >= 2 ? <Sparkline values={invTotals} /> : undefined
const now = Date.now()
const columns: Column<Agent>[] = [
{
key: 'name',
header: 'Name',
render: (a) => (
<XStack items="center" gap="$2.5" minW={0}>
<AgentAvatar />
<YStack minW={0} gap="$0.5">
<XStack items="center" gap="$2" minW={0}>
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>
{a.name}
</Text>
<VersionBadge version={a.version} />
</XStack>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{a.model || DASH}
</Text>
</YStack>
</XStack>
),
},
{
key: 'description',
header: 'Description',
render: (a) => (
<Text fontSize="$2" color="$color11" numberOfLines={2}>
{a.description || DASH}
</Text>
),
},
{ key: 'status', header: 'Status', width: 104, render: (a) => <StatusPill status={a.status} /> },
{
key: 'last',
header: 'Last invocation',
width: 130,
render: (a) => (
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{fmtRelative(a.lastInvocationAt, now)}
</Text>
),
},
{
key: 'invocations',
header: 'Invocations 30d',
width: 122,
render: (a) => (
<Text fontSize="$3" color="$color12" fontWeight="600">
{fmtCompact(a.invocations30d)}
</Text>
),
},
{
key: 'actions',
header: '',
width: 40,
render: () => (
<XStack justify="flex-end" opacity={0.6}>
<MoreHorizontal size={16} />
</XStack>
),
},
]
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
return (
<>
{header}
{/* Row 1 — five headline stat cards (real / derived; spark+delta from series) */}
<XStack flexWrap="wrap" gap="$3" items="stretch">
<MetricCard icon={Bot} label="Total agents" value={fmtInt(stats.total)} sub="registered" />
<MetricCard icon={Activity} label="Active" value={fmtInt(stats.active)} sub={`${stats.idle} idle · ${stats.error} error`} />
<MetricCard icon={Gauge} label="Success rate 30d" value={fmtPct(stats.successRate)} sub="weighted" />
<MetricCard
icon={Zap}
label="Invocations 30d"
value={fmtCompact(stats.invocations30d)}
sub="last 30 days"
spark={spark}
delta={<Delta pct={invDelta} />}
/>
<MetricCard icon={Timer} label="Avg latency" value={fmtDuration(stats.avgLatencyMs)} sub="per invocation" />
</XStack>
{/* Row 2 — invocations over time + agent health donut */}
<XStack flexWrap="wrap" gap="$3" items="stretch">
<Panel
title="Invocations over time"
flex={2}
minW={360}
action={
<XStack gap="$1">
{METRICS_RANGES.map((r) => (
<Button
key={r}
size="$1"
bg={r === range ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => setRange(r)}
>
{r}
</Button>
))}
</XStack>
}
>
{chartData.length >= 2 ? (
<LineChart data={chartData} formatValue={(v) => fmtCompact(v)} />
) : (
<YStack height={210} items="center" justify="center" gap="$1">
<Text fontSize="$3" color="$color11" fontWeight="600">
No invocation time-series yet
</Text>
<Text fontSize="$2" color="$color10" text="center" maxW={420}>
{metricsConnected
? 'No agent invocations in this range yet — the headline counts above read from the live registry.'
: 'The Agents metrics route isnt connected on this deployment, so no trend is drawn. Counts above are read from the live registry.'}
</Text>
</YStack>
)}
</Panel>
<Panel title="Agent health" flex={1} minW={240}>
<HealthDonut breakdown={health} />
</Panel>
</XStack>
{/* Row 3 — agents table (status tabs + pagination) */}
<Panel title="Agents" minW={320}>
<XStack gap="$1" flexWrap="wrap">
{tabs.map((t) => (
<Button
key={t}
size="$2"
bg={t === tab ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => {
setTab(t)
setPage(1)
}}
>
{t === 'all' ? 'All' : AGENT_STATUS_LABEL[t]} · {tabCount(t)}
</Button>
))}
</XStack>
<DataTable
columns={columns}
rows={pageState.rows}
rowKey={(a) => a.id}
onRowPress={openDetail}
empty="No agents match these filters."
/>
{pageState.totalPages > 1 ? (
<XStack justify="space-between" items="center">
<Text fontSize="$2" color="$color10">
Page {pageState.page} of {pageState.totalPages} · {filtered.length} agents
</Text>
<XStack gap="$2">
<Button size="$2" chromeless icon={<ChevronLeft size={15} />} disabled={pageState.page <= 1} onPress={() => setPage(pageState.page - 1)}>
Prev
</Button>
<Button size="$2" chromeless iconAfter={<ChevronRight size={15} />} disabled={pageState.page >= pageState.totalPages} onPress={() => setPage(pageState.page + 1)}>
Next
</Button>
</XStack>
</XStack>
) : null}
</Panel>
{/* Row 4 — recent activity · top agents · resource usage */}
<XStack flexWrap="wrap" gap="$3" items="stretch">
<Panel title="Recent activity" flex={1} minW={280}>
<ActivityFeed events={activity} now={now} />
</Panel>
<Panel title="Top agents by invocations" flex={1} minW={280}>
<TopAgents agents={top} />
</Panel>
<Panel title="Resource usage · 30d" flex={1} minW={260}>
<ResourceUsagePanel
usage={metrics?.resource ?? { cpuVcpuHours: null, memGbHours: null, storageIoBytes: null, costCents: null }}
connected={metricsConnected}
/>
</Panel>
</XStack>
</>
)
}
+165
View File
@@ -0,0 +1,165 @@
'use client'
/**
* Alerts — alert rules (conditions, severities, current state) evaluated by the
* platform observability engine.
*
* Reads the REAL alert rules from Hanzo o11y through the same-origin user-bearer
* `/cloud` proxy: `GET /v1/o11y/v1/rules` (cloud rewrites `/v1/o11y/*` → the o11y
* runtime's `/api/v1/rules`, `listRules` → `ListRuleStates`). The o11y envelope is
* `{status,data:{rules:[…]}}` where each rule is a flattened `GettableRule`
* (`alert`, `state`, `labels.severity`, `description`, …) — normalized here to the
* console's flat Alert row. When o11y isn't reachable / authorized the load fails and
* the honest not-configured / unavailable card renders instead of an empty grid —
* matching every other infra module. Nothing is fabricated.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
type Alert = {
id: string
name?: string
severity?: string
status?: string
condition?: string
lastFired?: string
}
/** One rule as o11y's `GettableRule` serializes it (PostableRule fields flattened). */
type O11yRule = {
id?: string
/** Evaluated state: inactive | pending | recovering | firing | nodata | disabled. */
state?: string
/** Alert name (`PostableRule.alert`). */
alert?: string
description?: string
ruleType?: string
disabled?: boolean
labels?: Record<string, string>
}
/** o11y wraps every read as `{status:"success", data:…}`. */
type O11yRulesResponse = { status?: string; data?: { rules?: O11yRule[] } }
/**
* Flatten one o11y rule to the console Alert row. Honest mapping only:
* - `status` is the real evaluated `state` (or `disabled` when the rule is off);
* - `condition` is the rule's own `description` (o11y's `condition` field is a
* composite query object, not a display string — we never stringify it into a
* fake human condition);
* - `lastFired` is left empty: the rule-list endpoint carries no last-fired
* timestamp (that lives in the per-rule history timeline), so we show "—" rather
* than mislabel `updateAt` (rule edit time) as a firing time.
*/
const toAlert = (r: O11yRule): Alert => ({
id: r.id || r.alert || '',
name: r.alert,
severity: r.labels?.severity,
status: r.disabled ? 'disabled' : r.state,
condition: r.description,
})
export function AlertsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Alert[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<O11yRulesResponse>(cloudProxyV1Url('o11y/v1/rules'))
setRows((r?.data?.rules ?? []).map(toAlert))
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Alert>[] = [
{
key: 'name',
header: 'Name',
render: (a) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{a.name || a.id}
</Text>
),
},
{
key: 'severity',
header: 'Severity',
width: 110,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.severity || '—'}
</Text>
),
},
{
key: 'condition',
header: 'Condition',
width: 220,
render: (a) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{a.condition || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (a) => <StatusTag status={a.status ?? 'unknown'} />,
},
{
key: 'lastFired',
header: 'Last fired',
width: 190,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.lastFired ? new Date(a.lastFired).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Alerts"
subtitle="Alert rules — conditions, severities, recent firings."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
}
/>
{loadError ? (
<PlatformStateCard error={loadError} onRetry={() => void load()} />
) : (
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(a) => a.id}
empty="No alert rules yet."
/>
)}
</>
)
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Native Analytics — the per-org analytics module over the unified ClickHouse
* warehouse (datastore), read through cloud-api `/v1/analytics/*` via the same-origin
* `/cloud` bearer proxy, so every metric is scoped to the caller's own IAM org
* (server-authoritative, from the Bearer owner) — the browser holds no datastore
* credential.
*
* Bound to exactly the FOUR routes the backend mounts (overview/timeseries/top/health):
* - Overview — the LLM lens (requests/tokens/spend/models/providers/errors) is REAL
* live per-org data (hanzo.cloud_usage), charted over time; the Web + Commerce
* lenses (hanzo.events) render HONEST-empty ("no events yet") until a collector
* emits — never fabricated numbers.
* - LLM — the real per-model table + spend donut (top models).
*
* There is deliberately NO "Real-Time" tab: the backend exposes no realtime feed, so
* inventing one would be a fabrication. Every number here is a real query result;
* empty is honest-empty ("—" / "no data yet"); a 403 (cookie-only) or 503 (warehouse
* unwired) surfaces the shared BackendStateCard.
*/
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, BarChart3, DollarSign, Sparkles, Server, TriangleAlert, Zap } from '@hanzogui/lucide-icons-2'
import {
AnalyticsApi,
RANGES,
type ModelRow,
type Overview,
type Range,
type SeriesPoint,
type Top,
} from '~/lib/api/analytics'
import { fmtUsd, fmtInt } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { LineChart, Donut, CHART_PALETTE, type ChartPoint, type Slice } from '~/components/ui/Charts'
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
import { MetricCard } from './functions/parts'
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
const TABS = [
{ id: '', label: 'Overview', icon: BarChart3 },
{ id: 'llm', label: 'LLM', icon: Sparkles },
] as const
export function AnalyticsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const tab = useMemo(() => {
const t = params.tab ?? ''
return TABS.some((x) => x.id === t) ? t : ''
}, [params.tab])
return (
<YStack gap="$5">
<PageHeader
title="Analytics"
subtitle="Per-org LLM, web, and commerce analytics over the unified warehouse."
actions={
<XStack gap="$1" flexWrap="wrap">
{TABS.map((t) => (
<TabButton key={t.id || 'overview'} active={t.id === tab} label={t.label} Icon={t.icon}
onPress={() => router.push(t.id ? `/analytics/${t.id}` : '/analytics')} />
))}
</XStack>
}
/>
{tab === 'llm' ? <LlmTab /> : <OverviewTab />}
</YStack>
)
}
function TabButton({ active, label, Icon, onPress }: { active: boolean; label: string; Icon: typeof BarChart3; onPress: () => void }) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$1.5"
px="$3"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: '$color3' }}
>
<Icon size={15} />
<Text fontSize="$3" fontWeight="600" color="$color12">{label}</Text>
</XStack>
)
}
function RangeBar({ range, onChange }: { range: Range; onChange: (r: Range) => void }) {
return (
<XStack gap="$1">
{RANGES.map((r) => (
<XStack
key={r}
onPress={() => onChange(r)}
cursor="pointer"
px="$2.5"
height={30}
items="center"
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg={r === range ? '$color6' : 'transparent'}
hoverStyle={{ bg: '$color3' }}
>
<Text fontSize="$2" fontWeight="600" color="$color12">{r}</Text>
</XStack>
))}
</XStack>
)
}
// ── Overview ──────────────────────────────────────────────────────────────────
type OverviewState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; overview: Overview; series: SeriesPoint[] }
function OverviewTab() {
const [range, setRange] = useState<Range>('7d')
const [state, setState] = useState<OverviewState>({ phase: 'loading' })
const load = useCallback(async (r: Range) => {
setState({ phase: 'loading' })
try {
const [overview, series] = await Promise.all([AnalyticsApi.overview(r), AnalyticsApi.timeseries(r)])
setState({ phase: 'ready', overview, series })
} catch (e) {
setState({ phase: 'error', error: classifyBackend(e) })
}
}, [])
useEffect(() => { void load(range) }, [load, range])
if (state.phase === 'error') {
return (
<YStack gap="$4">
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
<BackendStateCard state={state.error} onRetry={() => void load(range)} hint="endpoint · GET /v1/analytics/overview" />
</YStack>
)
}
const o = state.phase === 'ready' ? state.overview : null
const series = state.phase === 'ready' ? state.series : []
const chartData: ChartPoint[] = series.map((p) => ({ label: p.t, value: p.spendCents }))
return (
<YStack gap="$4">
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
{/* LLM lens — REAL per-org data. */}
<XStack flexWrap="wrap" gap="$3" items="stretch">
<MetricCard icon={Sparkles} label="LLM requests" value={fmtInt(o?.llm.requests)} sub={range} />
<MetricCard icon={Activity} label="Tokens" value={fmtInt(o?.llm.tokens)} sub="total" />
<MetricCard icon={DollarSign} label="LLM spend" value={fmtUsd(o?.llm.spendCents)} sub={range} />
<MetricCard icon={BarChart3} label="Models" value={fmtInt(o?.llm.models)} sub="distinct" />
<MetricCard icon={Server} label="Providers" value={fmtInt(o?.llm.providers)} sub="distinct" />
<MetricCard icon={TriangleAlert} label="Error rate" value={o ? fmtPct(o.llm.errorRate) : '—'} sub="errors/requests" />
</XStack>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="800" color="$color12">LLM spend over time</Text>
{chartData.some((p) => p.value > 0) ? (
<LineChart data={chartData} formatValue={(v) => fmtUsd(v)} />
) : (
<Text fontSize="$2" color="$color10">No LLM usage in this range yet.</Text>
)}
</Card>
{/* Web + Commerce lenses — honest-empty until the events collector emits. */}
<XStack flexWrap="wrap" gap="$3" items="stretch">
<LensCard
title="Web"
available={Boolean(o?.web.available)}
reason={o?.web.reason || 'No web analytics events yet'}
metrics={[
{ label: 'Pageviews', value: fmtInt(o?.web.pageviews) },
{ label: 'Visitors', value: fmtInt(o?.web.visitors) },
{ label: 'Sessions', value: fmtInt(o?.web.sessions) },
]}
/>
<LensCard
title="Commerce"
available={Boolean(o?.commerce.available)}
reason={o?.commerce.reason || 'No commerce events yet'}
metrics={[
{ label: 'Orders', value: fmtInt(o?.commerce.orders) },
{ label: 'Revenue', value: o ? `$${o.commerce.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—' },
{ label: 'AOV', value: o ? `$${o.commerce.aov.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—' },
]}
/>
</XStack>
</YStack>
)
}
function LensCard({ title, available, reason, metrics }: { title: string; available: boolean; reason: string; metrics: { label: string; value: string }[] }) {
return (
<Card flex={1} minW={320} p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Text fontSize="$4" fontWeight="800" color="$color12">{title}</Text>
{!available ? <Text fontSize="$1" color="$color10">· honest-empty</Text> : null}
</XStack>
{available ? (
<XStack flexWrap="wrap" gap="$4">
{metrics.map((m) => (
<YStack key={m.label} minW={90}>
<Text fontSize="$2" color="$color10">{m.label}</Text>
<Text fontSize="$6" fontWeight="800" color="$color12">{m.value}</Text>
</YStack>
))}
</XStack>
) : (
<Text fontSize="$2" color="$color10">
{reason}. Install the tracking snippet or connect commerce to start collecting {title.toLowerCase()} events for
this organization this shows real data only, nothing is fabricated.
</Text>
)}
</Card>
)
}
// ── LLM (top models) ───────────────────────────────────────────────────────────
type LlmState = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; top: Top }
function LlmTab() {
const [range, setRange] = useState<Range>('7d')
const [state, setState] = useState<LlmState>({ phase: 'loading' })
const load = useCallback(async (r: Range) => {
setState({ phase: 'loading' })
try {
setState({ phase: 'ready', top: await AnalyticsApi.top(r, 20) })
} catch (e) {
setState({ phase: 'error', error: classifyBackend(e) })
}
}, [])
useEffect(() => { void load(range) }, [load, range])
if (state.phase === 'error') {
return (
<YStack gap="$4">
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
<BackendStateCard state={state.error} onRetry={() => void load(range)} hint="endpoint · GET /v1/analytics/top" />
</YStack>
)
}
const models = state.phase === 'ready' ? state.top.models.items : []
const slices: Slice[] = models.slice(0, 6).map((m, i) => ({
label: m.model,
value: m.spendCents,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const cols: Column<ModelRow>[] = [
{ key: 'model', header: 'Model', render: (r) => <Text fontSize="$3" fontWeight="600" color="$color12">{r.model || '—'}</Text> },
{ key: 'provider', header: 'Provider', width: 130, render: (r) => <Text fontSize="$3" color="$color11">{r.provider || '—'}</Text> },
{ key: 'requests', header: 'Requests', width: 110, render: (r) => <Text fontSize="$3" color="$color11">{fmtInt(r.requests)}</Text> },
{ key: 'tokens', header: 'Tokens', width: 120, render: (r) => <Text fontSize="$3" color="$color11">{fmtInt(r.tokens)}</Text> },
{ key: 'spend', header: 'Spend', width: 110, render: (r) => <Text fontSize="$3" color="$color11">{fmtUsd(r.spendCents)}</Text> },
{ key: 'pct', header: 'Share', width: 90, render: (r) => <Text fontSize="$3" color="$color11">{Number.isFinite(r.pct) ? `${r.pct.toFixed(1)}%` : '—'}</Text> },
]
return (
<YStack gap="$4">
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
<XStack flexWrap="wrap" gap="$3" items="stretch">
<Card flex={1} minW={280} p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" items="center">
<XStack items="center" gap="$2" self="flex-start">
<Zap size={16} />
<Text fontSize="$3" fontWeight="800" color="$color12">Spend by model</Text>
</XStack>
{slices.some((s) => s.value > 0) ? (
<Donut slices={slices} legend />
) : (
<Text fontSize="$2" color="$color10">No LLM usage in this range.</Text>
)}
</Card>
<Card flex={2} minW={360} p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" fontWeight="800" color="$color12">Usage by model</Text>
<DataTable
columns={cols}
rows={models}
loading={state.phase === 'loading'}
rowKey={(r) => r.model || '—'}
empty="No LLM usage yet. Model usage for this organization appears here as calls are made."
/>
</Card>
</XStack>
</YStack>
)
}
@@ -0,0 +1,231 @@
'use client'
/**
* Annotation Queues — list of review queues (HIP-0106), native on @hanzo/gui.
*
* An annotation queue is a named work queue of traces/observations to review and
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
* honest RuntimeNotice — never fabricated queues. Read-only list here; items are
* worked in the annotation flow.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack } from '@hanzo/gui'
import { ArrowLeft, ChevronRight, RefreshCw } from '@hanzogui/lucide-icons-2'
import {
O11yApi,
type AnnotationQueue,
type AnnotationQueueDetail,
type AnnotationQueueItem,
type O11yPageMeta,
} from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { RuntimeNotice } from './observability/RuntimeNotice'
import { Pager } from './observability/Pager'
import { fmtDate } from './observability/format'
const PAGE_LIMIT = 50
function AnnotationQueueListView({ onOpen }: { onOpen: (q: AnnotationQueue) => void }) {
const [rows, setRows] = useState<AnnotationQueue[]>([])
const [meta, setMeta] = useState<O11yPageMeta | null>(null)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const load = useCallback(async (p: number) => {
setLoading(true)
try {
const res = await O11yApi.annotationQueues({ page: p, limit: PAGE_LIMIT })
setRows(res.data ?? [])
setMeta(res.meta ?? null)
setError(null)
} catch (e) {
setError(e)
setRows([])
setMeta(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load(page)
}, [load, page])
const columns: Column<AnnotationQueue>[] = [
{
key: 'name',
header: 'Name',
render: (q) => (
<Button chromeless px="$0" onPress={() => onOpen(q)}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{q.name}
</Text>
</Button>
),
},
{ key: 'description', header: 'Description', render: (q) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{q.description || '—'}</Text> },
{ key: 'scoreConfigIds', header: 'Score configs', width: 130, render: (q) => <Text fontSize="$3" color="$color11">{q.scoreConfigIds?.length ?? 0}</Text> },
{ key: 'createdAt', header: 'Created', width: 190, render: (q) => <Text fontSize="$3" color="$color11">{fmtDate(q.createdAt)}</Text> },
{
key: 'action',
header: '',
width: 120,
render: (q) => (
<XStack justify="flex-end" flex={1}>
<Button size="$2" iconAfter={<ChevronRight size={14} />} onPress={() => onOpen(q)}>
Open
</Button>
</XStack>
),
},
]
return (
<>
<PageHeader
title="Annotation Queues"
subtitle="Review queues — traces and observations to score against score configs."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="annotation-queues" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(q) => q.id}
onRowPress={onOpen}
empty="No annotation queues yet. Create a queue to organize human review and scoring."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
function AnnotationQueueDetailView({ id, onBack, onOpenTrace }: { id: string; onBack: () => void; onOpenTrace: (traceId: string) => void }) {
const [queue, setQueue] = useState<AnnotationQueueDetail | null>(null)
const [items, setItems] = useState<AnnotationQueueItem[]>([])
const [meta, setMeta] = useState<O11yPageMeta | null>(null)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const load = useCallback(async (p: number) => {
setLoading(true)
try {
const [detail, list] = await Promise.all([
O11yApi.annotationQueue(id),
O11yApi.annotationQueueItems(id, { page: p, limit: PAGE_LIMIT }),
])
setQueue(detail)
setItems(list.data ?? detail.items ?? [])
setMeta(list.meta ?? null)
setError(null)
} catch (e) {
setError(e)
setQueue(null)
setItems([])
setMeta(null)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load(page)
}, [load, page])
const itemColumns: Column<AnnotationQueueItem>[] = [
{ key: 'id', header: 'Item', render: (i) => <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{i.id}</Text> },
{
key: 'traceId',
header: 'Trace',
render: (i) =>
i.traceId ? (
<Button chromeless px="$0" onPress={() => onOpenTrace(i.traceId as string)}>
<Text fontSize="$3" color="$color12" numberOfLines={1}>{i.traceId}</Text>
</Button>
) : (
<Text fontSize="$3" color="$color10"></Text>
),
},
{ key: 'observationId', header: 'Observation', render: (i) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{i.observationId || '—'}</Text> },
{ key: 'status', header: 'Status', width: 120, render: (i) => <Text fontSize="$3" color="$color11">{i.status || '—'}</Text> },
{ key: 'assignee', header: 'Assignee', width: 180, render: (i) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{i.assignee || '—'}</Text> },
{ key: 'createdAt', header: 'Created', width: 190, render: (i) => <Text fontSize="$3" color="$color11">{fmtDate(i.createdAt)}</Text> },
]
return (
<>
<PageHeader
title={queue?.name ?? id}
subtitle="Annotation queue detail and work items."
actions={
<XStack gap="$2">
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
Back
</Button>
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
</XStack>
}
/>
{error ? (
<RuntimeNotice surface="annotation-queue" error={error} />
) : loading && !queue ? (
<Text color="$color11">Loading</Text>
) : (
<>
{queue ? (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700">Overview</Text>
<Text fontSize="$3" color="$color11">{queue.description || 'No description.'}</Text>
<Text fontSize="$2" color="$color10">
Score configs: {queue.scoreConfigIds?.length ? queue.scoreConfigIds.join(', ') : '—'}
</Text>
</Card>
) : null}
<DataTable
columns={itemColumns}
rows={items}
loading={loading}
rowKey={(i) => i.id}
empty="No items returned for this queue."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
export function AnnotationQueuesModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const id = params.id
if (id) {
return (
<AnnotationQueueDetailView
id={decodeURIComponent(id)}
onBack={() => router.push('/annotation-queues')}
onOpenTrace={(traceId) => router.push(`/o11y/${encodeURIComponent(traceId)}`)}
/>
)
}
return <AnnotationQueueListView onOpen={(q) => router.push(`/annotation-queues/${encodeURIComponent(q.id)}`)} />
}
+268
View File
@@ -0,0 +1,268 @@
'use client'
/**
* API Keys — create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
*
* The key is minted server-side (`/keys` route → IAM, app-on-behalf as the
* confidential console client); the browser only sends its session cookie and
* never holds a long-lived secret beyond the one-time reveal at creation. This
* is the real credential the user presents as `Authorization: Bearer hk-…` to
* the SDKs, CLI, and the api.hanzo.ai gateway.
*
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
* `ApiKeysModule` wraps it with the page header for the standalone Dev route.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Copy, Check, KeyRound, RefreshCw, Trash2, TriangleAlert, Plus } from '@hanzogui/lucide-icons-2'
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { ErrorState } from '~/components/ui/States'
const DOCS_API = 'https://docs.hanzo.ai/api'
/** Honest date label for the key's last mint/rotate; empty string when unknown. */
function fmtKeyDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/** Copy-to-clipboard button with a transient confirmed state. */
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard?.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard blocked (insecure context) — the value is already visible */
}
}
return (
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
{copied ? 'Copied' : label}
</Button>
)
}
/** The full secret, shown ONCE right after creation. */
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green8" bg="$green2" maxWidth={720}>
<XStack gap="$2" items="center">
<KeyRound size={16} />
<Text fontSize="$4" fontWeight="700">
Your new API key
</Text>
</XStack>
<XStack gap="$2" items="center" flexWrap="wrap">
<Text
fontSize="$3"
color="$color12"
flex={1}
minW={260}
selectable
style={{ fontFamily: 'monospace' }}
>
{accessKey}
</Text>
<CopyButton value={accessKey} />
</XStack>
<XStack gap="$2" items="center">
<TriangleAlert size={14} color="$yellow10" />
<Text fontSize="$2" color="$color11">
Copy it now for your security the full key is shown only once and cannot be retrieved again.
</Text>
</XStack>
<Button size="$2" self="flex-start" onPress={onDone}>
Done
</Button>
</Card>
)
}
/** The credential surface — embeddable (no page header). */
export function ApiKeysView() {
const { account, loading: sessionLoading } = useSession()
const [status, setStatus] = useState<KeyStatus | null>(null)
const [loading, setLoading] = useState(true)
const [working, setWorking] = useState<null | 'create' | 'rotate' | 'revoke'>(null)
const [newKey, setNewKey] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setStatus(await KeysApi.status())
setError(null)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load API key status')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!sessionLoading && account) void load()
else if (!sessionLoading) setLoading(false)
}, [sessionLoading, account, load])
const mint = useCallback(
async (mode: 'create' | 'rotate') => {
setWorking(mode)
setError(null)
try {
const { accessKey } = await KeysApi.create()
setNewKey(accessKey)
setStatus({ hasKey: true, keyPrefix: accessKey.slice(0, 11), createdAt: new Date().toISOString() })
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to create the API key')
} finally {
setWorking(null)
}
},
[],
)
const revoke = useCallback(async () => {
if (typeof window !== 'undefined' && !window.confirm('Revoke your API key? Any app using it will stop working immediately.')) return
setWorking('revoke')
setError(null)
try {
await KeysApi.revoke()
setNewKey('')
setStatus({ hasKey: false, keyPrefix: '' })
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to revoke the API key')
} finally {
setWorking(null)
}
}, [])
if (sessionLoading || loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (!account) {
return <ErrorState err={new ApiError('Not authorized', 401)} />
}
const hasKey = status?.hasKey ?? false
return (
<YStack gap="$3" maxW={720}>
{error ? (
<Card p="$3" gap="$2" borderWidth={1} borderColor="$red8" bg="$red2">
<XStack gap="$2" items="center">
<TriangleAlert size={15} color="$red10" />
<Text fontSize="$3" color="$color12">
{error}
</Text>
</XStack>
</Card>
) : null}
{newKey ? <NewKeyCard accessKey={newKey} onDone={() => setNewKey('')} /> : null}
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
{hasKey ? (
<>
<XStack gap="$2" items="center">
<KeyRound size={16} />
<YStack flex={1}>
<Text fontSize="$4" fontWeight="700">
Cloud API key
</Text>
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
{status?.keyPrefix ? `${status.keyPrefix}` : 'hk-…'}
</Text>
{fmtKeyDate(status?.createdAt) ? (
<Text fontSize="$1" color="$color10">
Last created/rotated {fmtKeyDate(status?.createdAt)}
</Text>
) : null}
</YStack>
</XStack>
<Text fontSize="$3" color="$color11">
Your account has an active key. The full secret is shown only at creation; rotate to
replace it (the old key stops working), or revoke it entirely.
</Text>
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
icon={<RefreshCw size={14} />}
disabled={working !== null}
onPress={() => void mint('rotate')}
>
{working === 'rotate' ? 'Rotating…' : 'Rotate'}
</Button>
<Button
size="$2"
icon={<Trash2 size={14} />}
theme="red"
disabled={working !== null}
onPress={() => void revoke()}
>
{working === 'revoke' ? 'Revoking…' : 'Revoke'}
</Button>
</XStack>
</>
) : (
<>
<XStack gap="$2" items="center">
<KeyRound size={16} />
<Text fontSize="$4" fontWeight="700">
Create your Cloud API key
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
One key for your account, scoped to your organization. Use it as{' '}
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-</Text> with the SDKs,
CLI, and the api.hanzo.ai gateway.
</Text>
<Button
size="$3"
self="flex-start"
bg="$color5"
icon={<Plus size={16} />}
disabled={working !== null}
onPress={() => void mint('create')}
>
{working === 'create' ? 'Creating…' : 'Create API key'}
</Button>
</>
)}
</Card>
<Button
size="$2"
chromeless
self="flex-start"
onPress={() => {
if (typeof window !== 'undefined') window.open(DOCS_API, '_blank', 'noopener')
}}
>
API documentation
</Button>
</YStack>
)
}
export function ApiKeysModule(_props: { params: Record<string, string> }) {
return (
<>
<PageHeader
title="API Keys"
subtitle="The cloud API credential for your account. Use it with the SDKs, CLI, and gateway."
/>
<ApiKeysView />
</>
)
}
+13 -26
View File
@@ -1,33 +1,20 @@
'use client'
/**
* Applications admin — list + edit with deploy/undeploy, native on @hanzo/gui.
* Applications — the org's REAL deployed apps on the live Hanzo PaaS. Thin route
* adapter over `PaasApplications`, which drives the per-org `/v1/platform/*`
* surface (projects → apps → deployments) through the `/cloud` bearer proxy.
*
* Logic ported from ApplicationListPage.js + ApplicationEditPage.js +
* backend/ApplicationBackend.js: the new-application template, the field set
* (template/namespace/parameters/status), and the deploy/undeploy lifecycle. UI
* rebuilt clean on GUI primitives (no antd).
*
* Routing: `/applications` lists; `/applications/<name>` edits one.
* This is the Compute "Applications" surface: real deployed apps with their
* project, status, source, and live URL — NOT the IAM/OAuth "application"
* registry (an identity concern), and no longer the admin-only apps-inventory
* board (that stays under Status/Kubernetes). Per-org by construction: cloud
* resolves the org from the Bearer owner, so a caller only sees their own apps.
*/
import { useRouter } from 'next/navigation'
import { PaasApplications } from './paas/PaasApplications'
import { ApplicationListView } from './applications/ApplicationListView'
import { ApplicationEditView } from './applications/ApplicationEditView'
export function ApplicationsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name) {
return (
<ApplicationEditView
name={decodeURIComponent(name)}
onDone={() => router.push('/applications')}
/>
)
}
return (
<ApplicationListView onOpen={(a) => router.push(`/applications/${encodeURIComponent(a.name)}`)} />
)
export function ApplicationsModule(props: { params: Record<string, string> }) {
return <PaasApplications {...props} />
}
export default ApplicationsModule
+303
View File
@@ -0,0 +1,303 @@
'use client'
/**
* Apps (Sites) — the org's BUILDABLE SITES, the projects hanzo.app publishes when a
* user ships a site from the conversational builder. This closes the console→app
* round-trip: every site the user builds in hanzo.app shows up here, and each row
* deep-links straight back into hanzo.app for more conversational editing.
*
* Read-only over the REAL cloud `/v1/projects` store (cloud `clients/projectsvc`):
* every read is same-origin and keyless (`AppsApi` → `originV1Url('projects')` →
* `<origin>/v1/projects`); `next.config.mjs` rewrites the `projects` head to the
* console's OWN user-bearer `/cloud` proxy, which mints a short-lived user token and
* forwards it, so the backend resolves the org from the token owner claim — every row
* is org-scoped SERVER-SIDE (switching org in the OrgSwitcher re-lists that org's
* sites) and no credential reaches the browser. The EXACT per-tenant path Agents/CRM
* use. Distinct from IAM "Projects" (org resource scope) and PaaS "Applications"
* (container apps) — this is the hanzo.app buildable-sites store only.
*
* One list + an optional per-site detail rail (real deployment history). Per row TWO
* actions: "Open site" (the live URL) and "Edit in hanzo.app" (`/dev?project=<slug>`,
* the deep-link the builder honors). Every state is honest: loading, a true empty
* state ("build one in hanzo.app"), and the shared BackendStateCard on 401/403/404/
* 503 — never a fabricated row.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { AppWindow, ExternalLink, Pencil, RefreshCw, X } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { EmptyState } from '~/components/ui/EmptyState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
type Async<T> =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; data: T }
const fmtTime = (s?: number) => (s ? new Date(s * 1000).toLocaleString() : '-')
const dash = (s: string) => (s.trim() ? s : '-')
const fmtBytes = (n: number): string => {
if (!n) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(i && v < 10 ? 1 : 0)} ${units[i]}`
}
/** Open an external URL in a new tab (no-opener), guarded for SSR. */
const openHref = (href: string) => {
if (href && typeof window !== 'undefined') window.open(href, '_blank', 'noopener,noreferrer')
}
/** The hanzo.app builder deep-link for a site, using the configured app base. */
const editUrl = (slug: string) => builderEditUrl(slug, config.appUrl)
// ── Per-row actions: Open site + Edit in hanzo.app ────────────────────────────
function RowActions({ app }: { app: App }) {
return (
<XStack gap="$2" items="center">
{app.liveUrl ? (
<Button
size="$2"
chromeless
aria-label={`Open ${dash(app.name || app.slug)}`}
icon={<ExternalLink size={14} />}
onPress={() => openHref(app.liveUrl)}
>
<Text fontSize="$2" color="$color11">Open site</Text>
</Button>
) : (
<Text fontSize="$2" color="$color10"></Text>
)}
<Button
size="$2"
aria-label={`Edit ${dash(app.name || app.slug)} in hanzo.app`}
icon={<Pencil size={14} />}
onPress={() => openHref(editUrl(app.slug))}
>
<Text fontSize="$2">Edit in hanzo.app</Text>
</Button>
</XStack>
)
}
// ── Detail rail: real deployment history + the two actions ────────────────────
type DetailState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; app: App; deployments: AppDeployment[] }
function Fact({ label, value }: { label: string; value: string }) {
return (
<XStack justify="space-between" gap="$3" items="flex-start">
<Text fontSize="$2" color="$color10">{label}</Text>
<Text fontSize="$2" color="$color12" numberOfLines={1} text="right" maxW={220}>{value}</Text>
</XStack>
)
}
/**
* Self-contained by slug: loads the site (`GET /v1/projects/:slug`) and its
* deploy history (`.../deployments`) so the rail renders identically whether opened
* from a row press or a `/apps/:slug` deep link. A deployments failure degrades that
* section (the site facts still render); a site-load failure shows the honest error.
*/
function AppDetail({ slug, onClose }: { slug: string; onClose: () => void }) {
const [state, setState] = useState<DetailState>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
Promise.all([
AppsApi.get(slug),
AppsApi.deployments(slug).catch(() => [] as AppDeployment[]),
])
.then(([app, deployments]) => setState({ phase: 'ready', app, deployments }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [slug])
useEffect(() => { load() }, [load])
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack items="center" justify="space-between">
<Text fontSize="$5" fontWeight="800" numberOfLines={1}>
{state.phase === 'ready' ? dash(state.app.name || state.app.slug) : slug}
</Text>
<Button size="$2" chromeless aria-label="Close details" icon={<X size={16} />} onPress={onClose} />
</XStack>
{state.phase === 'loading' ? (
<XStack p="$4" justify="center"><Spinner size="small" color="$color11" /></XStack>
) : state.phase === 'error' ? (
<BackendStateCard state={state.error} onRetry={load} hint={`endpoint · GET /v1/projects/${slug}`} />
) : (
<>
<XStack gap="$2" items="center">
<StatusTag status={state.app.status || 'unknown'} />
{state.app.framework ? <Text fontSize="$2" color="$color10">{state.app.framework}</Text> : null}
</XStack>
<XStack gap="$2" flexWrap="wrap">
{state.app.liveUrl ? (
<Button size="$2" chromeless icon={<ExternalLink size={14} />} onPress={() => openHref(state.app.liveUrl)}>
<Text fontSize="$2" color="$color11">Open site</Text>
</Button>
) : null}
<Button size="$2" icon={<Pencil size={14} />} onPress={() => openHref(editUrl(state.app.slug))}>
<Text fontSize="$2">Edit in hanzo.app</Text>
</Button>
</XStack>
<YStack gap="$1.5" pt="$1" borderTopWidth={1} borderColor="$borderColor">
<Fact label="Slug" value={state.app.slug} />
<Fact label="Live URL" value={state.app.liveUrl || '—'} />
<Fact label="Repo" value={state.app.repo.url || '—'} />
<Fact label="Created" value={fmtTime(state.app.createdAt)} />
<Fact label="Updated" value={fmtTime(state.app.updatedAt)} />
</YStack>
<YStack gap="$2" pt="$1" borderTopWidth={1} borderColor="$borderColor">
<Text fontSize="$2" fontWeight="700" color="$color11">Deployments</Text>
{state.deployments.length === 0 ? (
<Text fontSize="$2" color="$color10">No deployments yet.</Text>
) : (
state.deployments.slice(0, 12).map((d) => (
<XStack key={d.id} items="center" justify="space-between" gap="$2" py="$1">
<YStack minW={0} flex={1}>
<Text fontSize="$2" fontWeight="600" numberOfLines={1}>
v{d.version}{d.commit ? ` · ${d.commit.slice(0, 7)}` : ''}{d.source ? ` · ${d.source}` : ''}
</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{fmtTime(d.createdAt)} · {d.files || 0} files · {fmtBytes(d.bytes)}
</Text>
</YStack>
<StatusTag status={d.status || 'unknown'} />
</XStack>
))
)}
</YStack>
</>
)}
</Card>
)
}
// ── Module ────────────────────────────────────────────────────────────────────
export function AppsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const active = params.slug || null
const [state, setState] = useState<Async<App[]>>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
AppsApi.list()
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => { load() }, [load])
const columns: Column<App>[] = useMemo(
() => [
{
key: 'name',
header: 'Site',
render: (a) => (
<XStack items="center" gap="$2" minW={0}>
<AppWindow size={15} color="$color10" />
<YStack minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{dash(a.name || a.slug)}</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>{a.slug}</Text>
</YStack>
</XStack>
),
},
{ key: 'framework', header: 'Framework', width: 130, render: (a) => <Text fontSize="$3" color="$color11">{dash(a.framework)}</Text> },
{ key: 'status', header: 'Status', width: 120, render: (a) => <StatusTag status={a.status || 'unknown'} /> },
{ key: 'updatedAt', header: 'Updated', width: 180, render: (a) => <Text fontSize="$3" color="$color10">{fmtTime(a.updatedAt || a.createdAt)}</Text> },
{ key: 'actions', header: '', width: 260, render: (a) => <RowActions app={a} /> },
],
[],
)
const header = (
<PageHeader
title="Apps"
subtitle="The sites you build in hanzo.app — per organization. Open the live site, or jump back into hanzo.app to keep editing."
actions={
<XStack gap="$2">
<Button size="$3" icon={<RefreshCw size={15} />} aria-label="Refresh" onPress={load} />
<PrimaryButton size="$3" icon={<AppWindow size={16} />} onPress={() => openHref(`${config.appUrl}/dev`)}>
Build in hanzo.app
</PrimaryButton>
</XStack>
}
/>
)
if (state.phase === 'error') {
return (
<>
{header}
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/projects" />
</>
)
}
const apps = state.phase === 'ready' ? state.data : []
if (state.phase === 'ready' && apps.length === 0) {
return (
<>
{header}
<EmptyState
icon={AppWindow}
title="No apps yet"
description="Build and publish your first site in hanzo.app — describe what you want and ship it. Every site you publish shows up here, per organization."
bullets={[
'Build conversationally in hanzo.app — no boilerplate.',
'Each published site gets a live URL and a versioned deploy history.',
'Come back here to open the live site or jump straight into editing.',
]}
primary={{ label: 'Build in hanzo.app', href: `${config.appUrl}/dev` }}
/>
</>
)
}
return (
<>
{header}
<XStack gap="$4" flexWrap="wrap" items="flex-start">
<YStack flex={2} minW={320} gap="$2">
<DataTable
columns={columns}
rows={apps}
loading={state.phase === 'loading'}
rowKey={(a) => a.slug || a.id}
onRowPress={(a) => router.push(`/apps/${a.slug}`)}
empty="No apps yet — build one in hanzo.app."
/>
</YStack>
{active ? (
<YStack flex={1} minW={300}>
<AppDetail slug={active} onClose={() => router.push('/apps')} />
</YStack>
) : null}
</XStack>
</>
)
}
@@ -0,0 +1,130 @@
'use client'
/**
* Attestations — verifiable proofs (TEE/TDX remote attestation, signed build
* provenance, on-chain attestations) issued and verified by the platform.
*
* Reads the attestation ledger from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/attestations`), which injects the service token server-side. When the
* attestation service isn't provisioned for the org the list load fails and the
* honest not-configured / unavailable card renders instead of an empty grid —
* matching every other infra module.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { restGet } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
type Attestation = {
id: string
type?: string
subject?: string
status?: string
issuer?: string
createdAt?: string
}
export function AttestationsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Attestation[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ attestations?: Attestation[] }>(paas('attestations'))
setRows(r.attestations ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Attestation>[] = [
{
key: 'subject',
header: 'Subject',
render: (a) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{a.subject || a.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 160,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.type || '—'}
</Text>
),
},
{
key: 'issuer',
header: 'Issuer',
width: 200,
render: (a) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{a.issuer || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (a) => <StatusTag status={a.status ?? 'unknown'} />,
},
{
key: 'createdAt',
header: 'Issued',
width: 190,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.createdAt ? new Date(a.createdAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Attestations"
subtitle="Verifiable proofs — TEE remote attestation, build provenance, on-chain attestations."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
}
/>
{loadError ? (
<PlatformStateCard error={loadError} onRetry={() => void load()} />
) : (
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(a) => a.id}
empty="No attestations yet. Proofs issued by the platform appear here."
/>
)}
</>
)
}
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More