hanzo.chat embeds hanzo.app/edit.js on every page, but script-src listed
only analytics + cloudflareinsights, so the widget was CSP-blocked. Add
hanzo.app to script-src (load) and connect-src (its /v1 calls).
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.
Replace the script.js/data-website-id snippet in client/index.html <head> with the ONE canonical <script async src=https://analytics.hanzo.ai/hz.js data-site="hanzo.chat">. Drops the %VITE_ANALYTICS_SITE_ID% build placeholder (site key is now the static domain).
The controller required '@librechat/data-schemas', but this fork renames all
internal packages to @hanzochat/* (214 other files use @hanzochat/data-schemas;
that upstream name resolves to nothing). Runtime-only failure (require-time), so
the green build hid it and 0.9.21 CrashLooped at CloudUsage.js:1 MODULE_NOT_FOUND
— AFTER clearing the 0.9.21 zod/v4 fix. One-line name fix; all other requires in
the usage backend already resolve.
Any fresh chat image built after 74954c3f8 (@hanzo/iam 0.13 lockfile regen,
which pulled openai@6.46.0) crashes on boot:
ERR_PACKAGE_PATH_NOT_EXPORTED: subpath './v4' is not defined by
"exports" in openai/node_modules/zod/package.json
openai@6.46.0 declares zod '^3.25 || ^4.0' and imports zod/v4, but pnpm
mis-resolved its peer to the older zod@3.24.4 (which has no ./v4 subpath).
The deployed 0.9.19 image predates openai@6 entering the tree, so it never
hit this — 0.9.20 was simply the first rebuild to trip the latent landmine.
Fix: a single pnpm override zod@3.24.4 → 3.25.76 (already in-tree, a
backward-compatible superset that ships the zod/v4 compat subpath),
collapsing to ONE zod. Verified: no other package floated; openai@6.46.0
now pairs zod@3.25.76. Unblocks the whole chat build pipeline, not just
the 0.9.20 usage panel.
Adds the shared Hanzo usage read to LibreChat's Usage tab, beside (not
replacing) the Mongo token-credit view. Backend CloudUsage.js proxies
cloud's GET /v1/get-cloud-usages on-behalf-of (hanzo.id bearer resolved
server-side, never to browser); client renders the CloudUsageOverview
with @hanzo/usage's headless normalizeCloudUsage over native Tailwind.
Honest when unavailable: 200 {enabled:false} hides the section.
Web: 'Smart routing' toggle in the Usage settings tab, persisted via the
smartRouting localStorage recoil atom. When on, new conversations on the
Hanzo endpoint default to model "auto" (gateway routes each prompt to the
best/cheapest capable model; billed as whatever serves it). Scoped to the
Hanzo house endpoint and only applied to defaults — an explicit model pick
or a non-Hanzo provider family is never rewritten. Message header surfaces
the served model (msg.model) when a routed convo echoes it. Docs link +
en localization strings.
Mobile: same toggle on the Usage screen, persisted to localStorage via a
small settings helper; flips chat between VITE_CHAT_MODEL and "auto" in
lib/api.ts.
Backend: GET /api/usage (requireJwtAuth) aggregates this user's prompt+
completion Transaction spend over today/7d/30d plus a per-model breakdown,
enriched with the org tier from CommerceClient. Response mirrors the
@hanzo/usage UsageSnapshot shape (providerId 'hanzo', totals, providerCost)
for one wire format across Hanzo products.
Frontend: new Usage settings tab beside Balance, reusing the UsageBar idiom;
useGetUserUsage hook follows useGetUserBalance; en localization strings added.
The Hanzo Cloud gateway (api.hanzo.ai) answers some failures -- most importantly a request for a premium model (e.g. zen5-mini) when the caller's balance is only the $5 starter credit -- with HTTP 200 and a JSON error envelope ({status:"error", msg}) that carries no `choices`. The OpenAI client treated the 200 as success and parsed the choices-less body to `undefined`, so the agent run threw the opaque "Cannot read properties of undefined (reading 'role')" (and the title call died on reading 'message') -- no AI reply rendered, the generation job expired, and the resume stream 404'd.
Wrap the custom-endpoint OpenAI client fetch (scoped to the Hanzo gateway) so that envelope is rewritten into a clean 402 carrying the gateway's actionable message. Successful SSE streams and normal completions pass through untouched; the request is never inspected, so per-user hk- billing is unaffected.
- packages/api: hanzoGatewayFetch response-only wrapper + wire into initializeCustom + unit tests
A fresh /c/new defaulted to the built-in `agents` endpoint with no agent
selected. All chat routes through POST /api/agents/chat/:endpoint; with
endpoint=agents and no agent_id the access middleware (canAccessAgentFromBody)
returns 400 "agent_id is required in request body" and the SSE stream never
starts — every first message failed with no AI response.
Root cause: orderEndpointsConfig gives built-in `agents` order 1 but defaults
every custom endpoint to order 9999, so the client's getDefaultEndpoint picked
`agents` first. The intended override — a custom endpoint's `customOrder` — was
silently dropped by loadCustomEndpointsConfig (it never emitted `order`), even
though orderEndpointsConfig already types the custom spread as `& { order?: number }`.
Fix: loadCustomEndpointsConfig now honors `customOrder` -> `order`, and the Hanzo
endpoint sets `customOrder: 0` so it outranks `agents`. A new conversation now
defaults to Hanzo, which routes as an ephemeral agent (provider=Hanzo) through
the proven per-user hk- key path (resolveHanzoCloudKey) — real AI response,
metered, no agent_id 400. Agents endpoint stays available for explicit selection.
- packages/api: honor customOrder, widen TCustomEndpointsConfig, + unit test
- librechat.yaml: customOrder: 0 on Hanzo (mirrored in universe chat-config)
New-account chat now works end to end, fail-closed, no shared-org bleed:
- resolveHanzoCloudKey computes the canonical per-user billing subject
(object.BillingSubject parity: 'owner/name' for the shared hanzo catch-all),
stamps it on req.user, and ensures the one-time $5 Commerce starter credit on
THAT subject (POST /v1/billing/grant-starter) BEFORE forwarding the user's
hk- key — so the gateway's first balance check sees $5 instead of 402-ing.
Idempotent (in-process + commerce tag-dedupe), best-effort (gateway enforces).
- CommerceClient: checkBalance/tier/breakdown key on the subject and derive the
X-Hanzo-Org namespace from it; replace the wrong-ledger createTrialGrant
(credit-grants, invisible to /billing/balance) with grantStarter (Deposit).
- balanceMethods gate keys on the per-user subject (req.user.billingSubject).
- Drop chat-side Commerce usage write (single debit = the gateway).
- Remove the dead createTrialGrant call in user.ts (never fired for SSO users,
wrong ledger).
The prior HEAD (4233c8760) was reverted in the preceding commit: it rewrote the
gate to forward the user's IAM JWT to commerce AND added a chat-side
spendTokens -> /v1/billing/usage decrement. With per-user hk- forwarding ON,
that double-debits (cloud gateway debits the org via the user's hk- key, AND
chat debits the same org via spendTokens). One money authority only: the cloud
gateway debits via the per-user hk- key; chat's balanceMethods is a READ-ONLY,
fail-closed pre-flight gate (service token + X-Hanzo-Org, the proven pattern).
This commit adds the one missing piece for the anon free tier: guests are
exempt from the balance gate. Guests hit checkBalance via BaseClient
(supportsBalanceCheck[custom]=true) with no billing org; under startBalance:0
the legacy local gate would block the free tier entirely. Their spend is
bounded instead by (1) the per-IP guest message limiter and (2) the separate,
small-capped, NON-exempt guest key (HANZO_API_KEY) the gateway 402s when empty.
Tests: fail-closed decision matrix (funded/insufficient/unavailable/
model_not_allowed/no-org) + guest exemption. 8/8 green.
Commerce is already IAM-native + multi-tenant; chat was sending a static
service token (not a JWT) so commerce 401'd and the balance check fell open =
unmetered AI. Rewire to forward the logged-in user's hanzo.id IAM JWT per
request and fail CLOSED.
- CommerceClient: drop the static COMMERCE_TOKEN singleton; every call carries
the user's IAM JWT (Authorization: Bearer). getMyBalance (fail-closed read),
grantWelcome ($5 idempotent), recordUsage (decrement). getIamToken/
getBillingOrg/computeUsageCents/recordCommerceSpend helpers.
- balanceMethods.checkBalance: IAM-native money gate, FAIL CLOSED — no token /
commerce-unreachable / below-min => refuse. Funded user passes. Self-heals a
new account by granting the idempotent $5 once and re-reading (a spent wallet
stays blocked — grant is tag-deduped).
- spendTokens / spendStructuredTokens: decrement the user's per-org commerce
balance, mirroring the local ledger cost; threaded from the agent spend sites.
- openidStrategy: grant the $5 welcome credit on new-account signup under the
user's IAM identity (synchronous, best-effort).
- Balance controller: surface the authoritative commerce balance (display).
- Tests: fail-closed/fail-open decision matrix + cost mirror (12 cases).
Requires OPENID_REUSE_TOKENS=true (chat CR) so the IAM JWT is on the request,
and COMMERCE_EDGE_AUTH=true + hanzo-chat audience (commerce CR).
hanzo.chat money path. Previously every authenticated chat ran on z's shared
hk- key (hanzo/z) against the shared 'hanzo' commerce org, plus a $5 local
startBalance per user and a fail-OPEN balance gate -> unbounded free spend.
- Per-user billing: resolve (mint on first chat) each authenticated user's OWN
hk- key from IAM by org+email and forward it to the gateway, which debits
THEIR org. New packages/api/src/endpoints/custom/hanzoCloudKey.ts; injected at
the single apiKey chokepoint in custom/initialize.ts. FAIL CLOSED: an authed
user whose key cannot be resolved is blocked, never falls back to the shared
key. The resolver also stamps the authoritative billing org back onto req.user
(OIDC 'owner' can be the Casdoor super-org 'admin', not the real billing org).
- Balance gate commerce-first + FAIL CLOSED + decisive (balanceMethods.js):
when commerce is configured and the user's org is known, the org's commerce
balance is authoritative (>= HANZO_MIN_BALANCE); insufficient/unreachable ->
block. No fall-through to local tokenCredits, so startBalance:0 cannot
false-block a funded user. CommerceClient balance read is now fail-closed
(throws on cold error) and per-org scoped (X-Hanzo-Org).
- startBalance: 0 (librechat.yaml): no free local credits; new users claim $5
at billing.hanzo.ai. Client shows a claim-credit link on empty balance.
Gated by HANZO_PER_USER_KEY=true (reuses OPENID_* client creds for IAM).
The dark theme tinted all panels/sidebar blue via --background/--card/--border/
--input/--muted/--accent at hue 222 (and ring #2563eb). Neutralized to 0%
saturation (grayscale) to match the Hanzo monochrome brand.
The monochrome rebrand changed the LandingPage `colors` object + login CTAs
but left 8 inline rgba(253,68,68,…) (#fd4444) accents — hero gradient,
"AI Chat Platform" badge border, Zen-models card border/bg + hover, and the
"Free credit" pricing tier border/bg/label. Convert all to rgba(255,255,255,…)
(preserving alpha), matching the existing white monochrome accents. The
terminal traffic-light dots (bg-red/yellow/green-500) already render grey via
the tailwind color-scale remap. Landing is now fully black/white/grey.
Replace the upstream LibreChat feather/quill brand fallback with the
official Hanzo ▼/H mark (currentColor, monochrome) across every agent and
endpoint icon path (Landing welcome via ConvoIcon→AgentAvatar, agent
avatars, message + minimal icons).
Collapse all Tailwind color scales (green/red/blue/sky/…/rose) to a single
neutral grey ramp so UI chrome is black/white/grey only; destructive
semantics keep one muted red via --text-destructive. Convert the green
Create/Save CTAs (Add-MCP-Server dialog, Agent + Assistant builders,
preset and API-key dialogs) and .btn-primary to the adaptive
white-on-dark primary button.
Set favicon/PWA assets to the canonical hanzo.app ▼/H set (favicon.ico
byte-identical to hanzo.app) and the manifest theme_color to monochrome.
Tests: 34 passed — Hanzo-mark fallback assertions updated.
client/package.json: ^0.4.0 -> ^0.13.1 (npm latest). 0.13.x moves the PKCE tx (state+verifier) from sessionStorage to localStorage so it survives the OAuth redirect, fixing login.
iam.ts: BrowserIamSdk class renamed to IAM. Import bare '@hanzo/iam' (not the '/browser' subpath): the client tsconfig uses moduleResolution=node, which cannot resolve the exports subpath for types; the bare entry (dist/index.d.ts) re-exports IAM and Vite still bundles dist/browser.js via the browser export condition. Constructor config (IAMConfig) is unchanged.
OAuthCallback.tsx: IAMToken fields camelCased in 0.13 (access_token -> accessToken); without this the callback never set the auth header.
Lockfile: pnpm-lock.yaml regenerated (authoritative per packageManager=pnpm@10.27.0 + Dockerfile 'pnpm install --frozen-lockfile'). package-lock.json/bun.lock left untouched (not consumed by any build or CI).