Re-adds the deliberately-deleted (98d0dad34b) guest stack so an anonymous
visitor lands on the chat composer and can chat ONE free model without login,
then hits the SSO wall after a small per-IP quota. Adapted to today's code
(@hanzochat/* package names, @hanzo/iam bridge, tenant-bearer billing) — NOT a
blind revert: hanzoCloudKey.ts stays deleted.
Server (fail-closed, server-enforced):
- services/guestConfig.js: env-gated config + ephemeral guest principal/user +
guest-scoped endpoints/models builders (default model zen5-flash).
- controllers/auth/GuestController.js + POST /v1/chat/auth/guest: short-lived
guest JWT ({guest:true}, per-token random id) signed with JWT_SECRET; the
route is per-IP mint-limited (guestTokenLimiter).
- middleware/requireGuestOrJwtAuth.js: accepts guest tokens ONLY where mounted;
the jwt strategy still rejects guests everywhere else (clean 401, no CastError).
- middleware/enforceGuestScope.js: pins endpoint+model, strips
agents/tools/files/spec/preset; 403 on any other endpoint/model.
- limiters/{guestLimiters,guestMessageLimiter}.js + utils/guestClientIp.js:
per-REAL-IP quota (CF-Connecting-IP, not the token) via in-memory limiterCache
(correct at replicas:1/Recreate; Redis NOT required). Exhaust -> 402 GUEST_LIMIT.
- endpoints/custom/initialize.ts: guest principal -> shared capped GUEST_API_KEY
(KMS chat-guest-key), skipping per-user hk-/bearer billing; fail-closed if
unset. Authenticated bearer path unchanged.
- agents/index.js: guest-capable completion chain; guest-safe /chat/active poll;
AND the guest SSE read-back — GET /chat/stream/:streamId registered with
requireGuestOrJwtAuth ABOVE the strict guard (else a guest 401s reading its own
stream -> empty reply). Per-job ownership check (job.metadata.userId===req.user.id)
is unchanged: foreign=403, missing=404, no cross-principal stream leak.
- Wire models/endpoints + guest-safe bootstrap (user/convos/favorites) to
requireGuestOrJwtAuth; emit allowGuestChat/guestMessageMax in /v1/config;
balance-gate bypass for guests (no org, bounded by limiter + capped key).
Tests: restored 6 guest specs (40/40 green) + 3 guest-billing cases in
initialize.spec. Client already guest-ready (zero client changes).
Docs: LLM.md guest section corrected to in-memory-at-replicas:1 + GUEST_API_KEY.
Do NOT deploy: red security review next, then cto native build + CR image pin.
18 KiB
Hanzo Chat
AI chat interface with multi-model support, MCP integration, agents, and RAG. Live at hanzo.chat.
Repo: github.com/hanzoai/chat
Upstream: Chat (MIT) — internal package names kept (@hanzochat/*)
Package: @hanzochat/chat
Runtime: Node.js 20 (Alpine)
Branding
- Hanzo red
#fd4444replaces OpenAI green#10a37f(Tailwind, CSS vars, emails) - APP_TITLE:
Hanzo Chat - CUSTOM_FOOTER:
Powered by Hanzo AI - Hanzo geometric H logo throughout
- 34 language files updated
Commands
# Install (NOT npm ci -- workspace peer deps break it)
npm install # .npmrc has legacy-peer-deps=true
# Build
npm run build:packages # Build data-provider, data-schemas, api, client-package
npm run frontend # Build all packages + client
# Dev
npm run backend:dev # API server (nodemon, port 3080)
npm run frontend:dev # Client dev server
# Test
npm run test:all # All workspace tests
npm run test:client # Client tests
npm run test:api # API tests
npm run e2e # Playwright e2e tests
# Lint/Format
npm run lint # ESLint
npm run format # Prettier
Workspace Structure
api/ # Express backend (port 3080)
server/ # Entry point, routes, controllers, middleware
models/ # Mongoose models (MongoDB)
client/ # React frontend (Vite)
src/components/ # UI components
src/routes/ # Client-side routing
src/store/ # State management
packages/
data-provider/ # Shared data layer (@hanzochat/data-provider)
data-schemas/ # Validation schemas
api/ # API client package (@hanzochat/api)
client/ # Shared client components
agents/ # Agent definitions
mcp/ # MCP server integration
Configuration
chat.yaml(or ConfigMapchat-config->/app/chat.yaml)hanzo-chat.example.yaml- Hanzo-specific example config.envfor secrets
Key env vars:
OPENAI_BASE_URL=http://llm.hanzo.svc.cluster.local:4000/v1 # Internal LLM gateway
MONGO_URI= # MongoDB connection
JWT_SECRET= # Auth token signing
CREDS_KEY= CREDS_IV= # Credential encryption
K8s Deployment
- 2 replicas, port 3080
- Ingress:
hanzo.chat(primary) +chat.hanzo.ai(301 → hanzo.chat) - Secret:
chat-secrets(MONGO_URI, JWT_SECRET, CREDS_KEY/IV) - CI:
docker-publish.yml->hanzoai/chat:lateston Docker Hub - Image:
hanzoai/chat:latest(amd64 only)
Docker Build Notes
- Uses
npm installnotnpm ci(workspace peer dep issues) --max-old-space-size=4096for client build- jemalloc preloaded for memory efficiency
uvbundled for MCP server supportdompurifymust be inclient/package.json(externalized by bundler)
Guest Chat (anonymous preview)
Off by default (ALLOW_GUEST_CHAT=false). When enabled, the landing IS the chat
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
Zen model (GUEST_MODEL, default zen5-flash) via the Hanzo custom endpoint
(api.hanzo.ai). Prod uses GUEST_MESSAGE_MAX=2. Exhausting the quota returns
402 {type:'GUEST_LIMIT'} and the client opens the existing OpenID/hanzo.id login.
Client render path (guest === chat, not a marketing gate):
AuthContextauto-acquires a guest token whenstartupConfig.allowGuestChatis true (silentRefreshfallback + a dedicated effect closing the config race); setsisGuest=true,isAuthenticated=false.useAuthRedirectkeeps guests on the chat surface (only truly anonymous, non-guest, non-guest-enabled users go to/login).Rootshows the chat shell forisAuthenticated || isGuest.ChatRouterendersChatViewforcanChat = isAuthenticated || isGuest; the/v1/chat/models+/v1/chat/endpointsqueries run for guests (both routes userequireGuestOrJwtAuthand return the guest-scoped single-model config), and the roles gate treats a guest as loaded (guests have no agent access). Files:client/src/routes/{ChatRoute,useAuthRedirect}.tsx,hooks/useGuestAuth.ts,hooks/AuthContext.tsx,components/Auth/GuestLimitDialog.tsx.
Security model (fail-closed, server-enforced):
POST /v1/chat/auth/guestissues a short-lived guest JWT ({guest:true}, per-token random id) signed withJWT_SECRET. Rate-limited per IP (guestTokenLimiter,GUEST_TOKEN_MAX/GUEST_TOKEN_WINDOW) so tokens can't be spam-minted.requireGuestOrJwtAuth(chat-completion + guest-safe bootstrap routes: models, endpoints, user, convos, favorites, agents/chat/active) accepts guest tokens; the standardjwtstrategy rejects them everywhere else (no DB user), so every other route stays closed.enforceGuestScopepins endpoint+model and strips agents/tools/files/spec/preset. Guests use the shared, capped guest gateway keyGUEST_API_KEY(the KMSchat-guest-key;HANZO_API_KEYis the dev fallback), resolved inpackages/api/src/endpoints/custom/initialize.ts— the guest key's OWN org is metered+capped at the gateway, and per-userhk-billing is skipped forguestprincipals (they carry no forwardable bearer and noX-Org-Id).guestMessageLimiterenforces the quota against the REAL client IP (utils/guestClientIp→ CloudflareCF-Connecting-IP, falls back toreq.ip), NOT the token — clearing cookies / incognito / minting a fresh token does NOT reset it. The store islimiterCache, which returnsundefinedwhenUSE_REDISis off →express-rate-limituses its in-process MemoryStore. That store is authoritative at the live deploy'sreplicas: 1+Recreate(never two live pods to round-robin); Redis is NOT required (it was killed platform-wide). The only reset is a pod restart — operational, not attacker-triggerable. If guest chat ever scales past one replica, setUSE_REDIS=trueso the count holds across pods.- Key files:
api/server/services/guestConfig.js,api/server/controllers/auth/GuestController.js,api/server/middleware/{requireGuestOrJwtAuth,enforceGuestScope}.js,api/server/middleware/limiters/{guestLimiters,guestMessageLimiter}.js,api/server/utils/guestClientIp.js, router wiring inapi/server/routes/agents/index.js. - Env:
ALLOW_GUEST_CHAT,GUEST_MODEL(prodzen5-flash),GUEST_ENDPOINT(Hanzo),GUEST_MESSAGE_MAX(prod2),GUEST_TOKEN_EXPIRY,GUEST_TOKEN_MAX,GUEST_TOKEN_WINDOW. Requires the shared, capped guest keyGUEST_API_KEY(KMSchat-guest-key; falls back toHANZO_API_KEY). No Redis dependency atreplicas: 1— the in-process MemoryStore is the quota's single source of truth.
Cloud Agents (canonical /v1/agents)
Chat can RUN a user's canonical Hanzo Cloud agents (cloud /v1/agents, the ONE
production agent registry) from the thread — alongside the Chat-legacy local
agent builder, which is untouched.
- Two surfaces, ONE run path: the
/agent <name> [prompt]slash command and the @mention picker (cloud agents appear as acloudAgenttype). Both funnel throughuseRunCloudAgent→POST /v1/chat/agents/cloud/:name/run. The @mention //agentpicker arms/agent <name>in the composer; submit is intercepted inChatForm(parseAgentCommand) and dispatched to the run path. - Server proxy + auth (token never reaches the browser): the chat backend reads
the user's hanzo.id token from the server-side session
(
req.session.openidTokens.idToken, thenaccessToken, then the httpOnly cookies) and forwards it asAuthorization: Bearerto cloud. Cloud'sSanitizeIdentity(HIP-0026) validates it and pinsX-Org-Idfrom theownerclaim, so a user only ever reaches their OWN org's agents — chat never asserts an org.requireJwtAuthgates the proxy (guests rejected); missing token → honest 401, never a service-token fallback (fail-secure). Agent name is validated against cloud's handle grammar (traversal/SSRF guard); it is NOT an open proxy (three fixed endpoints). Principal guard: the on-behalf-of decision keys off the VALIDATED principal (req.user.provider==='openid'), not the mutabletoken_providerrefresh-strategy cookie, so a local user (who never carries a hanzo.id token) can't run under a stale OpenID session. EVERY forwarded token — id_token preferred, access_token fallback, from session or the httpOnly cookie — must passisForwardableToken: a decodable JWT whosesubEQUALSreq.user.openidId(binding MANDATORY — absentopenidId/subor a mismatch ⇒ no forward, no fail-open) and is unexpired. Decode-only is sound because cloud does the authoritative JWKS validation over the SAME claims, so it runs as exactly thatsub; the gate only ever removes a token. An unbindable/expired/foreign token yields an honest 401, never a fabricated or wrong-principal run. - id_token persistence is DECOUPLED from the refresh strategy: OIDC login ALWAYS
persists the on-behalf-of BEARER (id_token + access_token) to
req.session.openidTokens(server-side only), regardless ofOPENID_REUSE_TOKENS. It does NOT persist the OIDC refresh credential in the decoupled default — the session refreshes via the local JWT cookie, sosession.openidTokens.refreshTokenis written ONLY in REUSE mode (whererefreshController/logoutControllerread it). That keeps login, refresh AND logout on the local-JWT path byte-identical to a non-OpenID login; that flag SOLELY gates whether/v1/chat/auth/refreshperforms the OIDC refresh-grant. The ~1h id_token is used while valid; durable refresh (hanzo.id/Casdoor OIDC refresh or an RFC-8693 token-exchange from the chat session) is a tracked FOLLOW-UP — the login-breaking refresh-grant is NOT enabled here. - Abuse limits (a run is a real billable completion): a per-user rate limiter
(
cloudAgentLimiter,CLOUD_AGENT_USER_MAX/CLOUD_AGENT_WINDOW) guards the whole/cloudrouter; the client caps input by UTF-8 bytes (128 KiB), caps the buffered response (4 MiB → 502), and sheds load past a process-wide in-flight ceiling (CLOUD_AGENT_MAX_CONCURRENT, 503). - Key files: backend
api/server/services/CloudAgentsClient.js,api/server/routes/agents/cloud.js(mounted/cloudinapi/server/routes/agents/index.js); data layerpackages/data-provider/src/{types/cloudAgents.ts,api-endpoints.ts,data-service.ts}; clientclient/src/hooks/Agents/useRunCloudAgent.ts,client/src/utils/agentCommand.ts,client/src/components/Chat/Input/AgentsCommand.tsx, and the @mention wiring inclient/src/hooks/Input/useMentions.ts+Mention.tsx. - Env:
HANZO_CLOUD_URL(optional; falls back to theOPENAI_BASE_URLhost). - Convergence path (later): chat's Chat-legacy
/v1/chat/agentsCRUD should converge onto cloud/v1/agents; this step only ADDS cloud-agent RUN.
Unified cloud architecture (2026-07) — investigate-before-ripping map
hanzo.chat is the chat view of the Hanzo AI cloud (sibling to hanzo.app =
builder, console = admin). This section is the honest map of what is ALREADY
unified onto the Go backend (api.hanzo.ai/v1) vs the one real seam that is not.
Verified by full call-graph + route-table trace; do NOT rip blind.
What already routes through the Go backend api.hanzo.ai/v1 (no shadow LLM)
- Chat completions: client
useSSE→POST /v1/chat/agents/chat/Hanzo(all chat, incl. plain-model, goes through the agents framework) → custom-endpoint resolver (packages/api/src/endpoints/custom/initialize.ts) readsHANZO_API_KEY+ literalbaseURL https://api.hanzo.ai/v1from the loaded config → LangChain OpenAI client →POST https://api.hanzo.ai/v1/chat/completions(SSE stream, resumable viaGenerationJobManager). Per-userhk-key + per-org Commerce debit; fail-closed 402. THIS is the one inference path. - Code interpreter →
CHAT_CODE_BASEURL= cloud/v1/exec. - Web search →
webSearchblock (searxng+firecrawl contracts) = cloud/v1/websearch. - Cloud agents →
POST /v1/chat/agents/cloud/:name/runserver-proxies to cloud/v1/agentswith the user's hanzo.id bearer (see "Cloud Agents" section). - Model list: curated zen-only (
fetch:false) in the loaded config — NO raw upstream names (brand policy). Authoritative prod list lives in thechat-configConfigMap (universe infra/k8s/chat/configmap.yaml); repochat.yamlmirrors it (one way).
DEAD residue — do NOT treat as a live backend
config.yaml(LiteLLMmodel_list/litellm_params),docker/Dockerfile.{simple,dev,custom_ui}(CMD litellm),deploy/migrations/*(LiteLLM_*Prisma tables),CONTRIBUTING.md(upstream LiteLLM's),scripts/cleanup-{litellm,hanzo-chat}.sh: all unreferenced by any compose/k8s/helm/Dockerfile.multi. Prod runsnode server/index.jsand hitsapi.hanzo.ai/v1directly — NO local litellm sidecar. This is upstream merge residue; safe to delete in a dedicated sweep.
The ONE real parallel store (FLAG — needs a Go-backend home)
Chat's Express backend owns, in MongoDB (HanzoChat DB), all of:
convos, messages, presets, prompts/promptGroups, users, balances/
transactions, files, sessions (refresh-token hashes), plus agents/assistants/
memory/RBAC. Schemas: packages/data-schemas/src/schema/*. This is the shadow
store that is NOT on the Go backend.
- The Go backend (
hanzoai/ai, mounted at bare/v1/*in cloud) DOES have a persistence home, but under casibase names (/v1/get-chats,/v1/get-chat,/v1/add-chat,/v1/get-messages,/v1/add-message,/v1/get-usages) — a different schema/shape than Chat's Mongo. - The canonical OpenAPI repo has
chat/openapi.yamldescribing the INTENDED Chat-shaped REST surface (/v1/chat/convos,/v1/chat/messages,/v1/chat/presets,/v1/chat/balance,/v1/chat/auth/*) — but the Go binary does not implement it yet, andai/openapi.yamlunder-documents the real casibase routes. - To truly kill the parallel store WITHOUT breaking live chat: the Go backend
(or Base) must implement
chat/openapi.yaml(conversations/messages/presets), then repoint chat's data layer at it behind a flag and dual-write during cutover. Until then Mongo stays (ripping it = data loss + dead chat). Coordinate with the openapi agent (canonical spec + SDK regen).
IAM-native auth (HIP-0111) — federated to hanzo.id, LIVE
- Prod (backend-proxied): Chat passport
openid-clientstrategy, OIDC discovery from${OPENID_ISSUER}=https://hanzo.id(/.well-known/openid-configuration; discovery fetched via in-clusteriam.hanzo.svcto dodge the CF hairpin), client_idhanzo-chat, callback/oauth/openid/callback. Local email/password is OFF in prod (ALLOW_EMAIL_LOGIN=false,ALLOW_REGISTRATION=false); social OIDC only. Files:api/strategies/openidStrategy.js,api/server/socialLogins.js,api/server/routes/oauth.js. This IS IAM-native (federated), just not the console@hanzo/iam-js-sdkshape. - Static/IAM SPA mode (
Dockerfile.static, not the live prod deploy): browser@hanzo/iamBrowserIamSdkPKCE straight to hanzo.id (client/src/utils/iam.ts,OAuthCallback.tsx). ⚠️ INCONSISTENCY: uses client_idapp-chatwhile prod useshanzo-chat— align tohanzo-chat.@hanzo/iamis pinned^0.4.0(HIP-0111 wants ≥0.11.0); this path is dormant.
One brand system, but pick the RIGHT one for a Tailwind app
@hanzo/ui and @hanzo/gui are two different, non-overlapping design systems:
@hanzo/ui= shadcn/ui + Tailwind + Radix (multi-framework). chat is Vite + React 18 + Tailwind, so THIS is the correct shared lib. Already used:client/src/components/Nav/HanzoHeader.tsxmounts@hanzo/ui/navigationHanzoHeaderfor cross-app chrome. Monochrome rebrand already done (grey ramp, H mark, favicon = hanzo.app set).@hanzo/gui= a Tamagui fork (Next.js 15 / React 19, RN-web) — console's stack. Forcing it into the Vite/React18 Chat client = a ground-up rewrite of a live product; NOT done. Unify by widening@hanzo/uiadoption + matching console's monochrome tokens, NOT by swapping component frameworks.
Config filename caveat
loadCustomConfig.js defaults to chat.yaml (CONFIG_PATH || <root>/chat.yaml).
Prod sets CONFIG_PATH=/app/chat.yaml (ConfigMap mount). Repo ships
chat.yaml (reference); a deploy that doesn't set CONFIG_PATH to it (or
provide chat.yaml) falls back to the built-in openAI endpoint. OPENAI_BASE_URL
in compose.prod.yml is inert here (built-in openAI reads OPENAI_REVERSE_PROXY).
Internal Package Names
These are kept as-is from upstream (npm deps, not worth renaming):
@hanzochat/api,@hanzochat/client,@hanzochat/data-schemas,@hanzochat/data-provider,@hanzochat/agents- Functions:
extractChatParams,importChatConvo - Type names:
ChatKeys,ChatParams - Config filename:
chat.yaml(upstream convention) - Env var:
CHAT_LOG_DIR
Branding Cleanup Log
All user-visible Chat / chat.ai references replaced with Hanzo equivalents:
- All
chat.aiURLs ->hanzo.ai/docs/chat/... code.chat.ai->hanzo.ai/docs/chat/code-interpreter/...- package.json repo URLs ->
github.com/hanzoai/chat - package.json homepages ->
hanzo.ai/chat - package.json descriptions -> "Hanzo Chat"
- Help/FAQ default URL ->
hanzo.ai/chat - Docker Compose MongoDB DB name ->
HanzoChat - GitHub workflow repo refs ->
hanzoai/chat - MCP User-Agent ->
HanzoChat-MCP-Client - JSDoc comments: Chat -> Hanzo Chat
- Log messages: Chat -> Hanzo Chat
- Helm chart URLs -> hanzo.ai/docs/chat/...