Compare commits

..
Author SHA1 Message Date
Hanzo AI c737ed137d feat(mobile): scaffold Hanzo AI mobile app (Tauri v2 + React 19 + @hanzo/gui)
Self-contained mobile/ app (not in any workspace):
- Tauri v2 shell (productName "Hanzo AI", id ai.hanzo.chat) with fs + http
  plugins, scoped capabilities (fs: ~/.codex ~/.claude ~/.hanzo; http:
  chatgpt.com, api.anthropic.com, api.hanzo.ai, claude.ai, hanzo.chat).
- React 19 + Vite + @hanzo/gui (createGui + defaultConfig from
  @hanzogui/config/v5). Three state-nav screens: Chat, Sessions, Usage.
- Chat -> POST /api/agents/v1/chat/completions (OpenAI-compatible);
  Sessions -> GET /api/convos; both Bearer-auth. Usage via @hanzo/usage
  (createTauriHost + useUsage), connect empty-state outside Tauri.
- Bearer token via VITE_CHAT_TOKEN or paste screen (localStorage); IAM TODO.
- Rust engine/node feature flags OFF by default; cfg-gated engine_status +
  device_register stubs pointing at hanzoai/engine + hanzoai/node.

vite build green; cargo check green (default + engine,node).
2026-07-07 15:26:29 -07:00
c4f48f6af5 feat(chat): wire Run Code + Web Search to Hanzo unified backend (no external SaaS) (#52)
Both Agent Builder capabilities were 🔑-gated because nothing was wired.

- librechat.yaml: add webSearch{} pinned to Hanzo's own /v1/websearch surface
  — searchProvider=searxng (Hanzo metasearch), scraperProvider=firecrawl
  (Hanzo Crawl), no reranker. NO external Serper/Tavily/Jina/Cohere. Add
  endpoints.agents.capabilities so execute_code + web_search show active.
- .env.hanzo-cloud: the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were DEAD
  (LibreChat never reads them, and the path was /execute not /exec). Replace
  with the real vars: LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1 +
  LIBRECHAT_CODE_API_KEY (KMS). Add SEARXNG_INSTANCE_URL / FIRECRAWL_API_URL /
  WEBSEARCH_API_KEY -> api.hanzo.ai/v1/websearch.
- .env.example: document the Hanzo-backend vars as the one way; note external
  providers are intentionally disabled.

Validated against the LibreChat Zod configSchema: CONFIG VALID (searxng +
firecrawl + execute_code/web_search capabilities).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 18:39:33 -07:00
f466b6c2ca fix(chat): treat missing agent_id as ephemeral so 'hi' just replies (#51)
In the agents-centric chat UI ('My Agents'), sending a message with no agent
created/selected POSTed to the agents completion endpoint without an agent_id,
which canAccessAgentFromBody rejected with 400 'agent_id is required in request
body'. A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
not an error — isEphemeralAgentId(undefined) is already true.

- canAccessAgentFromBody: drop the 400 branch; a missing id falls through the
  existing ephemeral path (no per-agent ACL) to plain chat.
- agents/build.buildOptions: resolve a missing agent_id to EPHEMERAL_AGENT_ID so
  loadAgent builds an ephemeral plain-model agent instead of returning null.
- Hermetic regression specs for both fix points (no Mongo/winston); update the
  stale middleware test that codified the 400.

Logged-in user types 'hi' -> ephemeral agent on the configured default model ->
reply, with no agent build required first.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-29 22:07:27 -07:00
z 26591fb2ba docs(brand): add hero banner 2026-06-28 20:06:46 -07:00
z f54d01289b chore(brand): dynamic hero banner 2026-06-28 20:06:45 -07:00
Hanzo AI 1a8456bbb9 fix(chat): surface Hanzo gateway 200 error-envelope instead of crashing the agent run
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
2026-06-27 23:48:12 -07:00
Hanzo AI 0dfa7df1da fix(chat): default new conversations to the Hanzo endpoint, not bare agents
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)
2026-06-27 22:22:06 -07:00
Hanzo AI 44d41f6235 fix(billing): auto-$5 starter credit on first chat + per-user billing subject
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).
2026-06-27 17:57:43 -07:00
Hanzo AI 0becb68c80 fix(billing): converge money path on per-user hk- (single debit) + exempt guests
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.
2026-06-27 12:26:37 -07:00
Hanzo AI 04210dea16 Revert "fix(billing): IAM-native, fail-closed AI metering (stop unmetered leak)"
This reverts commit 4233c8760f.
2026-06-27 12:21:31 -07:00
Hanzo AI 4233c8760f fix(billing): IAM-native, fail-closed AI metering (stop unmetered leak)
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).
2026-06-27 11:21:52 -07:00
Hanzo AI d20ba76651 fix(billing): per-user hk- key forwarding + commerce-first fail-closed balance gate (stop the bleed)
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).
2026-06-27 10:59:29 -07:00
Hanzo AI 3e36ede65d fix(theme): monochrome UI — neutralize blue-hued tokens (222/215/220 → 0 0%) + #2563eb ring/border → gray
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.
2026-06-26 23:42:25 -07:00
Hanzo AI 18f3d9ce80 fix(brand): convert residual landing-page red accents to monochrome white
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.
2026-06-26 19:45:46 -07:00
Hanzo AI 5578ef924d feat(brand): monochrome Hanzo rebrand — feather→H-mark, white CTAs, canonical favicon
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.
2026-06-26 19:19:23 -07:00
Hanzo AI 5e0b6038af chore(iam): unify @hanzo/iam to ^0.13.1; BrowserIamSdk->IAM (localStorage PKCE login fix)
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).
2026-06-26 13:11:15 -07:00
a5a2498997 fix(oauth): scope loginLimiter to initiation routes, not OIDC callbacks (fixes 429 on code exchange) (#42)
Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-25 15:22:56 -07:00
Antje Worring dbed3bf2fb fix(csp): allow analytics + Cloudflare beacon scripts, fonts, media
The CSP script-src was 'self' only, blocking the app's own
analytics.hanzo.ai/script.js and static.cloudflareinsights.com beacon, plus a
data: font and assets/silence.mp3 (console errors + broken assets on every
page load). Allow-list the two external script hosts in script-src/connect-src
and add font-src/media-src for data:/blob:.
2026-06-23 18:05:34 -07:00
Antje Worring 462dbfdc84 fix(branding): canonical @hanzo blocky-H assets/logo.svg (replace placeholder 24x24 H)
The OIDC/model iconURL and OPENID_IMAGE_URL point at hanzo.chat/assets/logo.svg;
that asset was still a made-up 24x24 single-path H. Replace with the canonical
64x64 blocky-H (5 paths) byte-matching @hanzo/brand — matches what the live
0.7.12 image serves.
2026-06-23 14:29:53 -07:00
hanzoandAntje Worring c2aff88ca6 fix(branding): real @hanzo blocky-H logo+favicon, dev-only devtools, working OIDC button icon
- App.jsx: gate ReactQueryDevtools behind import.meta.env.DEV via lazy dynamic
  import so the devtools floating button (red flower) never ships in prod.
- librechat.yaml + compose.prod.yml: OIDC/model iconURL & OPENID_IMAGE_URL
  hanzo.ai/logo/icon.svg (404) -> hanzo.chat/assets/logo.svg (served, canonical).
- index.html: add scalable SVG favicon (canonical blocky-H) as primary icon.
- post-build.cjs: copy favicon.ico into dist root so /favicon.ico serves.
- add real multi-size favicon.ico generated from @hanzo/brand favicon (blocky-H).

logo.svg/favicons already match @hanzo/brand canonical blocky-H byte-for-byte;
prod was serving a stale dist (title 'Chat', no assets) -> fresh build fixes.
2026-06-23 14:29:53 -07:00
Hanzo DevandGitHub 05396959ae Merge pull request #50 from hanzoai/fix/guest-bootstrap-endpoints
fix(guest): UI-bootstrap endpoints serve guest-scoped data (fix HTTP 500 → working composer)
2026-06-21 14:12:49 -07:00
zeekay da475d299b feat(guest): serve guest-scoped data on UI-bootstrap endpoints
The composer hard-requires a handful of read-only bootstrap calls. Switch
exactly those to requireGuestOrJwtAuth and add a guest branch that returns
safe, scoped data — never a DB lookup, never another user's data:

- GET /api/endpoints  → ONLY the guest endpoint (Hanzo)
- GET /api/models     → ONLY the single guest model
- GET /api/user       → ephemeral guest principal (no email)
- GET /api/convos     → empty conversation page
- GET /api/user/settings/favorites → []
- GET /api/agents/chat/active      → { activeJobIds: [] }

Every mutation and read-by-id route stays JWT-only and rejects guests with
a clean 401 (post jwt-strategy fix). Banner (optionalJwtAuth) now resolves
with no user for guests. Adds controller + full-chain integration tests.
2026-06-21 14:10:21 -07:00
zeekay 2e794d3228 feat(guest): central guest principal + scoped-config builders
Single source of truth in guestConfig.js for the ephemeral guest shape:
- buildGuestPrincipal(id): plain GUEST req.user (no DB id/email)
- buildGuestUser: safe /api/user payload (name 'Guest', no email)
- buildGuestEndpointsConfig: ONLY the configured guest endpoint
- buildGuestModelsConfig: ONLY the single configured guest model

requireGuestOrJwtAuth now builds its principal from buildGuestPrincipal
(adds name 'Guest'), keeping the guest shape DRY across auth and bootstrap.
2026-06-21 14:10:12 -07:00
zeekay 9cd97355fe fix(guest): jwt strategy rejects guest tokens cleanly (no CastError → 500)
The guest JWT carries a synthetic `guest_<uuid>` id that is not a Mongo
ObjectId. On any requireJwtAuth route the 'jwt' passport strategy called
getUserById(guest_id) → Mongoose CastError → done(err) → HTTP 500.

Detect the `guest: true` claim at the top of the verify callback and
return done(null, false) before any DB lookup. Guest tokens now fail the
strict strategy with a clean 401, and reach their scoped routes only via
requireGuestOrJwtAuth. Fail closed by construction.
2026-06-21 14:10:05 -07:00
Hanzo DevandGitHub b70c5400a2 Merge pull request #49 from hanzoai/fix/guest-route-guard
fix(guest): route guard honors guest mode (composer for logged-out)
2026-06-21 13:45:38 -07:00
zeekay 8a89cfed07 fix(guest): useAuthRedirect must honor guest mode (stop bouncing guests to /login)
The chat route guard redirected any !isAuthenticated user to /login on a
300ms timer, ignoring isGuest/allowGuestChat and beating startup-config
load — so logged-out guests never reached the composer. Wait for config,
and skip the redirect when guest chat is enabled or the user is a guest.
2026-06-21 13:45:32 -07:00
Hanzo DevandGitHub c3114a1258 Merge pull request #48 from hanzoai/fix/guest-acquire-race
fix(guest): acquire guest session when allowGuestChat resolves (fixes landing-page race)
2026-06-21 13:26:05 -07:00
zeekay 821b3b59a2 fix(guest): acquire guest session when allowGuestChat config resolves
silentRefresh's guest fallback could run before startupConfig loaded
(allowGuestChat undefined) and was never retried, leaving logged-out
visitors on the landing page instead of the guest composer. Add a
dedicated effect that acquires the guest token once the flag is true.
2026-06-21 13:25:59 -07:00
Hanzo DevandGitHub 8a6c63a869 Merge pull request #47 from hanzoai/feat/guest-chat
feat(guest): anonymous guest preview chat (opt-in, fail-closed)
2026-06-21 12:52:28 -07:00
zeekay 86ecab135e docs(guest): document guest-chat env vars and security model 2026-06-21 12:46:56 -07:00
zeekay 196e3c3d61 feat(guest): client guest-chat mode + login gate
When unauthenticated and startupConfig.allowGuestChat is set, acquire a
guest token on load and render the chat composer instead of the landing
gate. On a 402 GUEST_LIMIT response, surface GuestLimitDialog which
triggers the existing OpenID/hanzo.id login flow.

- useGuestAuth: acquires an ephemeral guest session via dataService.
- AuthContext: isGuest state; guest fallback in silentRefresh; userQuery
  and silentRefresh disabled for guests (capability-scoped).
- Root: render chat for guests (auth-gated hooks stay disabled); mount
  GuestLimitDialog.
- useResumableSSE: dispatch guestLimitReached on 402 GUEST_LIMIT.
2026-06-21 12:46:56 -07:00
zeekay 46b636c8c0 feat(guest): server-side anonymous guest chat (fail-closed, scoped)
Add an opt-in anonymous preview mode gated behind ALLOW_GUEST_CHAT
(default off). Guests get a per-IP free quota (GUEST_MESSAGE_MAX,
default 3) on the free Zen model via the Hanzo gateway endpoint.

- POST /api/auth/guest issues a short-lived guest JWT ({guest:true},
  per-token random id) signed with JWT_SECRET; rate-limited per IP.
- requireGuestOrJwtAuth accepts guest tokens ONLY on the chat-completion
  router; the standard jwt strategy rejects them everywhere else.
- enforceGuestScope pins endpoint+model to the free Zen endpoint and
  strips agents/tools/files/spec/preset server-side.
- guestMessageLimiter enforces the per-IP quota via the shared Redis
  limiterCache; returns 402 {type:'GUEST_LIMIT'} on exhaustion.
- Reserved chat subpaths (stream/active/status/abort) stay JWT-only.
- Expose allowGuestChat/guestMessageMax in /api/config; add GUEST role.

Tests: 27 unit/integration tests covering config, guest auth wrapper,
scope enforcement, quota, and the router chain.
2026-06-21 12:46:27 -07:00
0cc792d02d fix(agents): stop post-reply error banner from nulled config.configurable (#46)
run.processStream (@librechat/agents >= 3.1.52) treats the passed config as
owned and, in its post-stream cleanup, sets config.configurable = undefined to
break the AsyncLocalStorage -> LangGraph reference chain that keeps heavy graph
state (base64 images/PDFs) alive (agents dist/esm/run.mjs:239). Reading
config.configurable.hide_sequential_outputs AFTER runAgents() therefore threw
"Cannot read properties of undefined (reading 'hide_sequential_outputs')",
caught by chatCompletion and pushed as an ERROR content part -- the red
post-reply banner. The same throw aborted the post-stream finalize, which is
why auto-titling also stopped.

Hoist the read to before runAgents() (the value is a static agent property only
needed afterward by the deprecated Agent Chain output filter), so it no longer
depends on post-run config state. Matches upstream LibreChat's fix
(PR #11942, a0f9782e6).

Adds a regression test that reproduces the mutation (processStream nulls
config.configurable) and asserts no ERROR content part is produced, plus
coverage for the hide_sequential_outputs true/false filter behavior.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-21 10:28:01 -07:00
zeekay ca632b467f chore: restore upstream attribution + NOTICE (OSS compliance) 2026-06-21 02:10:07 -07:00
9df97c7393 fix(deps): dedupe @ariakit/react to a single instance (fixes chat Nav crash) (#44)
After the session-handoff fix let authenticated users reach /c/new, the
chat Nav crashed the whole route with react-router-looking 'Invariant
failed' — actually an Ariakit invariant: useMenuButton(store||context,
false). Root cause: two @ariakit/react copies in the bundle — the app
(client, ^0.4.15 -> 0.4.23) and @librechat/client (packages/client,
^0.4.16 -> nested 0.4.21). DropdownPopup (from @librechat/client) creates
<MenuProvider> in one Ariakit instance; BookmarkNav's <MenuButton> reads
the MenuProvider context of the *other* instance -> empty -> invariant ->
ErrorBoundary 'Oops! Something Unexpected Occurred'.

Pin @ariakit/react to a single 0.4.29 (latest 0.4.x; satisfies both
^0.4.15 and ^0.4.16) via overrides + pnpm.overrides so app and
@librechat/client share one Ariakit module/context. Verified:
@librechat/client + client (Vite) rebuild green; lockfile now resolves a
single @ariakit/react@0.4.29 with no nested copy.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-21 00:42:56 -07:00
879bba70dd fix(spa): restore native /api/* data layer so OIDC session establishes (#43)
The data-provider had been rewired to a non-existent api.hanzo.ai 'cloud
gateway' surface (/v1/get-account, /v1/get-chats, /v1/signin, ZAP WebSocket).
The deployed backend is stock LibreChat serving /api/* — so the SPA's silent
refresh hit GET /v1/get-account, got the index.html shell back (no token/user),
left isAuthenticated=false, and Root rendered the marketing LandingPage even
for fully OIDC-authenticated users. Every chat/model/convo call was equally
broken against the live backend.

- api-endpoints.ts: restore the LibreChat-native /api/* endpoint surface
  (refresh -> POST /api/auth/refresh, user -> /api/user, convos -> /api/convos,
  models/presets/prompts/config/endpoints, etc). Drop dead Vite import.meta
  base-url branches (unused in deploy; also unblocks Jest/CJS) while keeping
  same-origin <base>/relative resolution + setApiBaseUrl override.
- request.ts: refresh via POST /api/auth/refresh (native), keep withCredentials
  so the httpOnly refreshToken cookie is sent; retain pk- key helpers.
- config.ts: EndpointURLs stream submit -> /api/{agents,assistants}/chat.

Verified: tsc clean, 666/666 data-provider tests pass (2 pre-existing
import.meta-broken suites now green), data-provider + api + client-package +
client (Vite) all build.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-21 00:17:59 -07:00
ba982d26e1 fix(api): restore deleted Message/Conversation/Preset/Prompt models (runtime MODULE_NOT_FOUND) (#41)
The upstream LibreChat consolidation (#11830) deleted api/models/{Message,
Conversation,Preset,Prompt}.js and moved their logic into @librechat/data-schemas'
createMethods. A later conflict-resolution merge (73d4b423a) reverted the
data-schemas createMethods composition back to the pre-consolidation form (so it
no longer provides message/conversation/preset/prompt methods) but did NOT restore
the deleted api/models/*.js files. The result is a half-applied consolidation:

  - api/models/index.js still requires ./Message, ./Conversation, ./Preset
  - api/server/middleware/error.js, .../abortRun.js, .../fork.js, prompt access
    guards, etc. still require('~/models/Message' | '~/models/Conversation' |
    '~/models/Prompt')
  - but those files no longer exist -> backend crashes at startup with
    MODULE_NOT_FOUND (api/models/index.js:13 -> ./Message), crashlooping the pod.

Frontend build + jest passed because they never exercised this require chain.

Fix: restore the four model files from their last good pre-#11830 revision
(214252d2e, on this branch's own ancestry, so namespaces match HEAD:
@librechat/data-schemas, librechat-data-provider, ~/db/models), updating only the
@librechat/api import to @hanzochat/api to match the forked package (#36).

Verified locally with module-alias registered as the server does:
  require('./models') loads (138 exports, all message/convo/preset present);
  the full crash chain models -> banViolation -> logViolation -> cache ->
  db/indexSync -> server/middleware/error resolves with no MODULE_NOT_FOUND;
  @hanzochat/api still exports handleError/sendEvent/createTempChatExpirationDate/
  escapeRegExp/sanitizeMessageForTransmit.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-20 01:10:16 -07:00
154 changed files with 20173 additions and 752 deletions
+29 -19
View File
@@ -463,6 +463,21 @@ ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
ALLOW_PASSWORD_RESET=false
#=====================================================#
# Guest Chat (anonymous preview) #
#=====================================================#
# Off by default. When enabled, unauthenticated visitors get a small free
# quota on the free Zen model routed through api.hanzo.ai. After the quota is
# spent the UI surfaces the existing OpenID/hanzo.id login. Guests are scoped
# server-side to the free model only and rejected from every other route.
ALLOW_GUEST_CHAT=false
# GUEST_MESSAGE_MAX=3 # free messages per IP before login is required
# GUEST_ENDPOINT=Hanzo # custom endpoint name from librechat.yaml (api.hanzo.ai)
# GUEST_MODEL=zen3-nano # free Zen model id guests are pinned to
# GUEST_TOKEN_EXPIRY=3600000 # guest JWT lifetime in ms (default 1h)
# GUEST_TOKEN_MAX=20 # max guest sessions issued per IP per window
# GUEST_TOKEN_WINDOW=60 # guest-session issuance window in minutes
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
@@ -810,31 +825,26 @@ OPENWEATHER_API_KEY=
# Hanzo Chat Code Interpreter API #
#====================================#
# https://hanzo.ai/docs/chat/code-interpreter
# LIBRECHAT_CODE_API_KEY=your-key
# "Run Code" routes through the Hanzo unified backend (api.hanzo.ai/v1/exec),
# which executes in a Hanzo sandbox. execute_code POSTs {BASEURL}/exec with
# X-API-Key. https://hanzo.ai/docs/chat/code-interpreter
# LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
# LIBRECHAT_CODE_API_KEY=your-key # prod: KMS chat-secrets/LIBRECHAT_CODE_API_KEY
#======================#
# Web Search #
#======================#
# Note: All of the following variable names can be customized.
# Omit values to allow user to provide them.
# For more information on configuration values, see:
# Web Search routes through the Hanzo unified backend ONLY — no external SaaS.
# The searxng provider + firecrawl scraper both point at api.hanzo.ai/v1/websearch
# (Hanzo metasearch + Hanzo Crawl). See librechat.yaml `webSearch`.
# https://hanzo.ai/docs/chat/features/web_search
# Search Provider (Required)
# SERPER_API_KEY=your_serper_api_key
# Scraper (Required)
# FIRECRAWL_API_KEY=your_firecrawl_api_key
# Optional: Custom Firecrawl API URL
# FIRECRAWL_API_URL=your_firecrawl_api_url
# Reranker (Required)
# JINA_API_KEY=your_jina_api_key
# or
# COHERE_API_KEY=your_cohere_api_key
# SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
# FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
# WEBSEARCH_API_KEY=your-key # prod: KMS chat-secrets/WEBSEARCH_API_KEY
#
# Do NOT set SERPER_API_KEY / TAVILY_API_KEY / JINA_API_KEY / COHERE_API_KEY:
# external providers are intentionally disabled (one way, unified backend).
#======================#
# MCP Configuration #
+13 -3
View File
@@ -5,9 +5,19 @@
OPENAI_API_KEY=sk-hanzo-your-api-key-here
OPENAI_BASE_URL=https://api.hanzo.ai/v1
# Runtime Execution (Code Interpreter)
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
RUNTIME_API_KEY=${OPENAI_API_KEY}
# Code Interpreter ("Run Code") — Hanzo unified backend.
# LibreChat's execute_code tool POSTs {LIBRECHAT_CODE_BASEURL}/exec with header
# X-API-Key. cloud-api serves /v1/exec (-> sandboxed executor). The key is
# KMS-sourced (chat-secrets/LIBRECHAT_CODE_API_KEY). These are the ONLY vars
# LibreChat reads — the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were dead.
LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
LIBRECHAT_CODE_API_KEY=set-in-KMS-chat-secrets
# Web Search — Hanzo unified backend (searxng + firecrawl compat over Hanzo
# search + crawl). See librechat.yaml `webSearch`. One shared service key.
SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
WEBSEARCH_API_KEY=set-in-KMS-chat-secrets
# ====== BRANDING ======
APP_TITLE=Hanzo Chat
+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="chat">
<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">chat</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">AI chat with MCP integration and multi-provider support</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.3 KiB

+1
View File
@@ -5,6 +5,7 @@ Portions of this software are licensed as follows:
---
MIT License
Copyright (c) 2023 LibreChat (Danny Avila)
Copyright (c) 2023 Hanzo AI Inc
Originally based on LibreChat by Berri AI.
+28
View File
@@ -90,6 +90,34 @@ CREDS_KEY= CREDS_IV= # Credential encryption
- `uv` bundled for MCP server support
- `dompurify` must be in `client/package.json` (externalized by bundler)
## Guest Chat (anonymous preview)
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, unauthenticated
visitors get a per-IP free quota (`GUEST_MESSAGE_MAX`, default 3) on the free
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
(`api.hanzo.ai`). Exhausting the quota returns `402 {type:'GUEST_LIMIT'}` and
the client opens the existing OpenID/hanzo.id login.
Security model (fail-closed, server-enforced):
- `POST /api/auth/guest` issues a short-lived guest JWT (`{guest:true}`,
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP.
- `requireGuestOrJwtAuth` (chat-completion route ONLY) accepts guest tokens;
the standard `jwt` strategy rejects them everywhere else (no DB user), so
every other route stays closed. `enforceGuestScope` pins endpoint+model and
strips agents/tools/files/spec/preset. `guestMessageLimiter` (reuses the
Redis `limiterCache`) enforces the per-IP quota.
- 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`,
router wiring in `api/server/routes/agents/index.js`. Client:
`client/src/hooks/useGuestAuth.ts`, `AuthContext.tsx`,
`components/Auth/GuestLimitDialog.tsx`.
- Env: `ALLOW_GUEST_CHAT`, `GUEST_MESSAGE_MAX`, `GUEST_ENDPOINT`, `GUEST_MODEL`,
`GUEST_TOKEN_EXPIRY`, `GUEST_TOKEN_MAX`, `GUEST_TOKEN_WINDOW`. Requires
`HANZO_API_KEY` (the free publishable gateway key) and `USE_REDIS` for the
shared per-IP quota across replicas.
## Internal Package Names
These are kept as-is from upstream (npm deps, not worth renaming):
+6
View File
@@ -0,0 +1,6 @@
chat
Copyright (c) 2023 Hanzo AI Inc.
This product includes software from LibreChat (https://github.com/danny-avila/LibreChat), licensed under MIT:
Copyright (c) 2023 LibreChat (Danny Avila)
+2
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="chat" width="880"></p>
# Hanzo AI Chat
AI-powered chat platform with enterprise features, using Hanzo's cloud API or local deployment.
+106 -44
View File
@@ -1,5 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { billingSubject } = require('@hanzochat/api');
const { createAutoRefillTransaction } = require('./Transaction');
const { logViolation } = require('~/cache');
const { getMultiplier } = require('./tx');
@@ -22,6 +23,21 @@ function getMinBalance() {
return 0;
}
/**
* Minimum balance in COMMERCE cents (commerce balances are in cents).
* Derived from HANZO_MIN_BALANCE (USD). Used by the commerce-first money gate.
*/
function getMinBalanceCents() {
const envVal = process.env.HANZO_MIN_BALANCE;
if (envVal != null && envVal !== '') {
const usd = parseFloat(envVal);
if (!isNaN(usd) && usd > 0) {
return Math.round(usd * 100);
}
}
return 0;
}
function isInvalidDate(date) {
return isNaN(date);
}
@@ -50,10 +66,24 @@ const checkModelAccess = async function (userId, model) {
};
/**
* Simple check method that calculates token cost and returns balance info.
* Integrates with Commerce balance gate when configured (fail-open).
* Calculates token cost and decides whether the request may proceed.
*
* MONEY GATE — Commerce-first and FAIL CLOSED (this is the money path; better to
* block a user than bleed). When Commerce is configured and we can identify the
* user's billing org (their IAM `organization`), Commerce is AUTHORITATIVE:
* - sufficient org balance (>= HANZO_MIN_BALANCE) → allowed (decisive; we do
* NOT fall through to the local tokenCredits gate, so startBalance:0 never
* false-blocks a Commerce-funded user);
* - insufficient / unreachable / no billing identity → BLOCKED.
* The cloud gateway separately debits the user's own hk- key against this same
* org, so this check is the pre-flight that yields a clean "claim credit" UX
* instead of a raw gateway 402.
*
* When Commerce is not configured (or no billing identity), falls back to the
* legacy local tokenCredits gate.
*/
const checkBalanceRecord = async function ({
req,
user,
model,
endpoint,
@@ -65,7 +95,79 @@ const checkBalanceRecord = async function ({
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
const tokenCost = amount * multiplier;
// Retrieve the balance record
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
// two ways, neither of which is an authed user's org balance: (1) the per-IP
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
// balance the cloud gateway debits and 402s when empty. Running them through the
// Commerce/local gate (startBalance:0) would block the free tier entirely.
if (req?.user?.guest === true) {
return { canSpend: true, balance: 0, tokenCost };
}
const commerceClient = getCommerceClient();
const billingOrg = (req?.user?.organization ?? '').toString().trim();
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
// the authoritative IAM identity; otherwise derive it from organization + email
// (name == email for individual signups). This keys the gate on the SAME
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
// the shared org balance.
const subject =
(req?.user?.billingSubject ?? '').toString().trim() ||
billingSubject(billingOrg, req?.user?.email);
// Commerce-first authoritative gate (per-subject, fail closed).
if (commerceClient && subject) {
let commerceBalance;
try {
commerceBalance = await commerceClient.checkBalance(subject);
} catch (err) {
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
user,
subject,
error: err.message,
});
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
}
const available = commerceBalance.available || 0;
const minCents = Math.max(getMinBalanceCents(), 1);
if (available < minCents) {
logger.debug('[Balance.check] Commerce balance insufficient', {
user,
subject,
available,
minCents,
});
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
}
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
if (model) {
try {
const modelAccess = await commerceClient.isModelAllowed(subject, model);
if (!modelAccess.allowed) {
return {
canSpend: false,
balance: available,
tokenCost,
reason: 'model_not_allowed',
tier: modelAccess.tier,
allowedModels: modelAccess.allowedModels,
};
}
} catch (err) {
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
error: err.message,
});
}
}
// Decisive: Commerce says funded → allow. Do not consult local credits.
return { canSpend: true, balance: available, tokenCost };
}
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
let record = await Balance.findOne({ user }).lean();
if (!record) {
logger.debug('[Balance.check] No balance record found for user', { user });
@@ -76,46 +178,6 @@ const checkBalanceRecord = async function ({
};
}
// Commerce balance gate: if configured, check Commerce first (fail-open)
const commerceClient = getCommerceClient();
if (commerceClient && record.commerceUserId) {
try {
const commerceBalance = await commerceClient.checkBalance(record.commerceUserId);
if (!commerceBalance.sufficient) {
logger.debug('[Balance.check] Commerce balance insufficient', {
user,
commerceUserId: record.commerceUserId,
available: commerceBalance.available,
});
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_insufficient' };
}
// Check model access via Commerce tier
if (model) {
const modelAccess = await commerceClient.isModelAllowed(record.commerceUserId, model);
if (!modelAccess.allowed) {
logger.debug('[Balance.check] Model not allowed for tier', {
user,
model,
tier: modelAccess.tier,
allowedModels: modelAccess.allowedModels,
});
return {
canSpend: false,
balance: record.tokenCredits,
tokenCost,
reason: 'model_not_allowed',
tier: modelAccess.tier,
allowedModels: modelAccess.allowedModels,
};
}
}
} catch (err) {
// Fail-open: Commerce error → fall through to local check
logger.warn('[Balance.check] Commerce check failed, falling back to local', { error: err.message });
}
}
let balance = record.tokenCredits;
// Check if credits have expired
@@ -224,7 +286,7 @@ const addIntervalToDate = (date, value, unit) => {
* @throws {Error} Throws an error if there's an issue with the balance check.
*/
const checkBalance = async ({ req, res, txData }) => {
const result = await checkBalanceRecord(txData);
const result = await checkBalanceRecord({ ...txData, req });
if (result.canSpend) {
return true;
}
+122
View File
@@ -0,0 +1,122 @@
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
// (per-org via service token + X-Hanzo-Org; the cloud gateway debits the user's
// own hk- key against the SAME org). Proves both directions: unmetered AI never
// leaks (block on any ambiguity), and funded users + capped guests are never
// wrongly blocked.
const mockClient = {
checkBalance: jest.fn(),
isModelAllowed: jest.fn(),
};
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/server/services/CommerceClient', () => ({
getCommerceClient: jest.fn(() => mockClient),
}));
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
}));
const { checkBalance } = require('./balanceMethods');
const { getCommerceClient } = require('~/server/services/CommerceClient');
const { Balance } = require('~/db/models');
const argsFor = (user) => ({
req: { user },
res: {},
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
});
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
// `reason`) when blocked.
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
try {
const ok = await checkBalance(argsFor(user));
return { allowed: ok, reason: null };
} catch (err) {
let reason = null;
try {
reason = JSON.parse(err.message).reason;
} catch (_) {
reason = err.message;
}
return { allowed: false, reason };
}
};
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
beforeEach(() => {
jest.clearAllMocks();
getCommerceClient.mockReturnValue(mockClient);
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
});
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
const { allowed } = await decide({ id: 'g1', guest: true });
expect(allowed).toBe(true);
expect(mockClient.checkBalance).not.toHaveBeenCalled();
expect(Balance.findOne).not.toHaveBeenCalled();
});
it('ALLOWS a funded org (available >= min)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
const { allowed, reason } = await decide();
expect(allowed).toBe(true);
expect(reason).toBeNull();
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
});
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_insufficient');
});
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_insufficient');
});
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_unavailable');
});
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
mockClient.isModelAllowed.mockResolvedValue({
allowed: false,
tier: 'free',
allowedModels: ['zen3-nano'],
});
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('model_not_allowed');
});
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
const { allowed } = await decide();
expect(allowed).toBe(true);
});
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
const { allowed } = await decide({ id: 'u2', organization: '' });
expect(allowed).toBe(false);
expect(mockClient.checkBalance).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,10 @@
const { getEndpointsConfig } = require('~/server/services/Config');
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
async function endpointController(req, res) {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
const endpointsConfig = await getEndpointsConfig(req);
res.send(JSON.stringify(endpointsConfig));
}
@@ -72,6 +72,9 @@ const updateFavoritesController = async (req, res) => {
const getFavoritesController = async (req, res) => {
try {
if (req.user?.guest === true) {
return res.status(200).json([]);
}
const userId = req.user.id;
const user = await getUserById(userId, 'favorites');
@@ -1,6 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
const { getLogStores } = require('~/cache');
/**
@@ -39,6 +40,9 @@ async function loadModels(req) {
async function modelController(req, res) {
try {
if (req.user?.guest === true) {
return res.send(buildGuestModelsConfig());
}
const modelConfig = await loadModels(req);
res.send(modelConfig);
} catch (error) {
+4
View File
@@ -40,12 +40,16 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { buildGuestUser } = require('~/server/services/guestConfig');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const getUserController = async (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {IUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
@@ -0,0 +1,91 @@
/**
* Guest-scoped bootstrap controllers: verify that a guest principal receives
* safe, scoped data (and never triggers a DB lookup) on the read-only endpoints
* the chat composer hard-requires.
*/
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
CacheKeys: { CONFIG_STORE: 'CONFIG_STORE', MODELS_CONFIG: 'MODELS_CONFIG' },
Tools: {},
Constants: {},
FileSources: { local: 'local', s3: 's3' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
webSearchKeys: [],
}));
// --- EndpointController deps ---
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: jest.fn(),
loadDefaultModels: jest.fn(),
loadConfigModels: jest.fn(),
getAppConfig: jest.fn(),
}));
jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }));
const { getEndpointsConfig } = require('~/server/services/Config');
const endpointController = require('~/server/controllers/EndpointController');
const { modelController } = require('~/server/controllers/ModelController');
const guestReq = (overrides = {}) => ({
user: { id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true },
...overrides,
});
const mockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
describe('guest bootstrap controllers', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.ALLOW_GUEST_CHAT = 'true';
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen5-mini';
});
afterAll(() => {
process.env = originalEnv;
});
describe('GET /api/endpoints', () => {
it('returns ONLY the guest endpoint, no DB/config read', async () => {
const req = guestReq();
const res = mockRes();
await endpointController(req, res);
expect(getEndpointsConfig).not.toHaveBeenCalled();
const payload = JSON.parse(res.send.mock.calls[0][0]);
expect(Object.keys(payload)).toEqual(['Hanzo']);
expect(payload.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
});
it('falls through to the real config for non-guest users', async () => {
getEndpointsConfig.mockResolvedValue({ Hanzo: {}, openAI: {} });
const req = { user: { id: 'real', role: 'USER' } };
const res = mockRes();
await endpointController(req, res);
expect(getEndpointsConfig).toHaveBeenCalledTimes(1);
});
});
describe('GET /api/models', () => {
it('returns ONLY the single guest model under the guest endpoint', async () => {
const req = guestReq();
const res = mockRes();
await modelController(req, res);
expect(res.send).toHaveBeenCalledWith({ Hanzo: ['zen5-mini'] });
});
});
});
+12 -1
View File
@@ -891,9 +891,20 @@ class AgentClient extends BaseClient {
config.signal = null;
};
/**
* Capture before running the graph. `run.processStream` (via
* `@librechat/agents`) treats the passed `config` as owned and, during its
* post-stream cleanup, sets `config.configurable = undefined` to break the
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
* therefore throws `Cannot read properties of undefined`, which surfaces as
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
* Chain filter working without depending on post-run config state.
*/
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);
/** @deprecated Agent Chain */
if (config.configurable.hide_sequential_outputs) {
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
// Include parts that are either:
// 1. At or after the finalContentStart index
@@ -15,6 +15,7 @@ jest.mock('@hanzochat/api', () => ({
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
createRun: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
@@ -2257,3 +2258,122 @@ describe('AgentClient - titleConvo', () => {
});
});
});
describe('AgentClient - chatCompletion post-reply banner regression', () => {
/**
* Regression for the post-reply error banner:
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
*
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
* `config` as owned and, in its post-stream cleanup, sets
* `config.configurable = undefined` to break the AsyncLocalStorage reference
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
* AFTER the stream completes, or it throws and pushes an ERROR content part
* (the red banner) which also aborts the post-stream finalize (auto-titling).
* This test reproduces that mutation and asserts no ERROR part is produced.
*/
const { createRun } = require('@hanzochat/api');
const { ContentTypes } = require('librechat-data-provider');
let processStreamMock;
const makeClient = (agentOverrides = {}) => {
const agent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
...agentOverrides,
};
const req = {
user: { id: 'user-123' },
body: {},
config: { endpoints: {} },
};
const client = new AgentClient({
req,
res: {},
agent,
endpoint: EModelEndpoint.openAI,
contentParts: [],
collectedUsage: [],
artifactPromises: [],
endpointTokenConfig: {},
});
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
client.user = 'user-123';
// Avoid DB / memory side effects unrelated to this regression.
client.recordCollectedUsage = jest.fn().mockResolvedValue();
client.runMemory = jest.fn().mockResolvedValue(undefined);
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
return client;
};
beforeEach(() => {
jest.clearAllMocks();
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
// the caller's `config.configurable` after the stream finishes.
processStreamMock = jest.fn(async (_inputs, config) => {
config.configurable = undefined;
return [];
});
createRun.mockResolvedValue({
Graph: {},
processStream: processStreamMock,
});
});
const errorParts = (client) =>
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
it('does not push an error content part when processStream nulls config.configurable', async () => {
const client = makeClient();
await expect(
client.sendCompletion('hello', { abortController: new AbortController() }),
).resolves.toBeDefined();
expect(processStreamMock).toHaveBeenCalledTimes(1);
// The config passed to processStream had configurable nulled (reproduction sanity check).
const passedConfig = processStreamMock.mock.calls[0][1];
expect(passedConfig.configurable).toBeUndefined();
// The fix: no banner-producing ERROR part despite the nulled configurable.
expect(errorParts(client)).toHaveLength(0);
});
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
const client = makeClient({ hide_sequential_outputs: true });
// Seed multiple content parts; the filter keeps only the last part plus
// tool_call parts. With the value captured before the stream, the filter
// must run even though config.configurable is undefined afterwards.
client.contentParts = [
{ type: 'text', text: 'intermediate-1' },
{ type: 'text', text: 'intermediate-2' },
{ type: 'text', text: 'final' },
];
await client.sendCompletion('hello', { abortController: new AbortController() });
expect(errorParts(client)).toHaveLength(0);
// hide_sequential_outputs collapses to the final part only (no tool calls present).
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
});
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
const client = makeClient({ hide_sequential_outputs: false });
client.contentParts = [
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
];
await client.sendCompletion('hello', { abortController: new AbortController() });
expect(errorParts(client)).toHaveLength(0);
expect(client.contentParts).toEqual([
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
]);
});
});
@@ -0,0 +1,45 @@
const { randomUUID } = require('node:crypto');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
/**
* Issues a short-lived guest token for anonymous preview chat.
*
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
* per-token random id, so guest principals are ephemeral and isolated from each
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
* which keeps every non-chat route closed to guests.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
*/
const guestTokenController = async (req, res) => {
const config = getGuestConfig();
if (!config.enabled) {
return res.status(404).json({ message: 'Guest chat is not enabled' });
}
if (!process.env.JWT_SECRET) {
logger.error('[guestTokenController] JWT_SECRET is not configured');
return res.status(500).json({ message: 'Server misconfiguration' });
}
const id = `guest_${randomUUID()}`;
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
expiresIn: expiresInSeconds,
});
return res.status(200).json({
token,
expiresIn: expiresInSeconds,
endpoint: config.endpoint,
model: config.model,
messageMax: config.messageMax,
});
};
module.exports = { guestTokenController };
+1 -1
View File
@@ -136,7 +136,7 @@ const startServer = async () => {
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat; frame-ancestors 'none';",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
);
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
@@ -0,0 +1,118 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', agents: 'agents' },
}));
jest.mock('~/server/services/guestConfig', () => ({
getGuestConfig: jest.fn(),
}));
const { getGuestConfig } = require('~/server/services/guestConfig');
const enforceGuestScope = require('../enforceGuestScope');
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
describe('enforceGuestScope', () => {
beforeEach(() => {
jest.clearAllMocks();
getGuestConfig.mockReturnValue({
enabled: true,
endpoint: 'Hanzo',
model: 'zen3-nano',
messageMax: 3,
});
});
it('passes non-guest requests through untouched', () => {
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('OpenAI');
expect(req.body.model).toBe('gpt-4o');
});
it('rejects a guest naming a different endpoint (403)', () => {
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('rejects a guest naming a different (paid) model (403)', () => {
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('pins endpoint, type, and model for a compliant guest request', () => {
const req = {
user: { id: 'g1', guest: true },
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
};
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('Hanzo');
expect(req.body.endpointType).toBe('custom');
expect(req.body.model).toBe('zen3-nano');
});
it('pins endpoint/model even when the guest omits them', () => {
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('Hanzo');
expect(req.body.model).toBe('zen3-nano');
});
it('strips every privileged capability from a guest request', () => {
const req = {
user: { id: 'g1', guest: true },
body: {
endpoint: 'Hanzo',
model: 'zen3-nano',
agent_id: 'agent_evil',
spec: 'paid-spec',
preset: { foo: 'bar' },
files: [{ file_id: 'f1' }],
tools: ['execute_code'],
tool_resources: { x: 1 },
resendFiles: true,
promptPrefix: 'jailbreak',
web_search: true,
},
};
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.agent_id).toBeUndefined();
expect(req.body.spec).toBeUndefined();
expect(req.body.preset).toBeUndefined();
expect(req.body.files).toBeUndefined();
expect(req.body.tools).toBeUndefined();
expect(req.body.tool_resources).toBeUndefined();
expect(req.body.resendFiles).toBeUndefined();
expect(req.body.promptPrefix).toBeUndefined();
expect(req.body.web_search).toBeUndefined();
});
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
const req = { user: { id: 'g1', guest: true }, body: {} };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,132 @@
/**
* Integration test for the guest chat middleware chain composition:
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
* that keeps management routes (abort/active/...) on the JWT-only parent router.
*/
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
limiterCache: () => undefined,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', agents: 'agents' },
}));
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
}));
jest.mock('../requireJwtAuth', () =>
jest.fn((req, res, next) => {
req.user = { id: 'real-user', role: 'USER' };
next();
}),
);
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
const enforceGuestScope = require('../enforceGuestScope');
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
const JWT_SECRET = 'test-guest-secret';
const buildRouter = () => {
const router = express.Router();
const RESERVED = new Set(['abort', 'active']);
const chatRouter = express.Router();
chatRouter.use((req, res, next) => {
const subpath = req.path.split('/').filter(Boolean)[0];
if (RESERVED.has(subpath)) {
return next('router');
}
return next();
});
chatRouter.use(requireGuestOrJwtAuth);
chatRouter.use(enforceGuestScope);
chatRouter.use(guestMessageLimiter);
chatRouter.post('/', (req, res) =>
res
.status(200)
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
);
router.use('/chat', chatRouter);
// JWT-only management route on the parent (after the guest router)
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
res.status(200).json({ aborted: true, role: req.user.role }),
);
return router;
};
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/api/agents', buildRouter());
return app;
};
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
describe('guest chat middleware chain', () => {
beforeEach(() => {
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
process.env.GUEST_MESSAGE_MAX = '2';
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen3-nano';
});
afterEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
delete process.env.GUEST_MESSAGE_MAX;
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
const res = await request(buildApp())
.post('/api/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.1')
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
});
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
const app = buildApp();
const ip = '10.1.0.2';
const send = () =>
request(app)
.post('/api/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', ip)
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
expect((await send()).status).toBe(200);
expect((await send()).status).toBe(200);
const blocked = await send();
expect(blocked.status).toBe(402);
expect(blocked.body.type).toBe('GUEST_LIMIT');
});
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
const res = await request(buildApp())
.post('/api/agents/chat/abort')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.3')
.send({ streamId: 'x' });
// requireJwtAuth mock forces a real USER; guest never reaches abort.
expect(res.status).toBe(200);
expect(res.body).toEqual({ aborted: true, role: 'USER' });
});
});
@@ -0,0 +1,86 @@
const jwt = require('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
const requireJwtAuth = require('../requireJwtAuth');
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
const JWT_SECRET = 'test-guest-secret';
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
const guestToken = (claims = {}) =>
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
describe('requireGuestOrJwtAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
});
afterEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
});
it('delegates to requireJwtAuth when guest chat is disabled', () => {
process.env.ALLOW_GUEST_CHAT = 'false';
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
const next = jest.fn();
requireGuestOrJwtAuth(req, mockRes(), next);
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
const next = jest.fn();
requireGuestOrJwtAuth(req, mockRes(), next);
expect(requireJwtAuth).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith();
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
});
it('delegates to requireJwtAuth when no bearer token is present', () => {
const req = { headers: {} };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${userToken}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
const req = { headers: { authorization: `Bearer ${forged}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth when guest claim is missing', () => {
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth when guest token lacks an id', () => {
const token = jwt.sign({ guest: true }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
});
@@ -0,0 +1,81 @@
/**
* Regression: a logged-in user in "My Agents" with no agent created/selected
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
*
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
* The downstream build-options fix point is covered by
* ../../services/Endpoints/agents/build.spec.js.
*/
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
ResourceType: { AGENT: 'agent' },
// Real predicate semantics (mirrors packages/data-provider/src):
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
}));
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
req.__aclChecked = true;
return next();
});
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const makeReqRes = (body) => {
const req = { body, params: {} };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
const next = jest.fn();
return { req, res, next };
};
describe('agent_id missing → ephemeral fallback (no 400)', () => {
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
});
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
test('explicit ephemeral id proceeds', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
await mw(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
test('a real agent_id still runs the per-agent ACL check', async () => {
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
await mw(req, res, next);
// The middleware builds the ACL check with the real agent id as resourceIdParam,
// then runs it (which here calls next via the mock).
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
expect(next).toHaveBeenCalledTimes(1);
});
});
@@ -60,15 +60,11 @@ const canAccessAgentFromBody = (options) => {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
// Real agent IDs always start with "agent_", so anything else is ephemeral
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
// not an error: "type a message and get a reply" must work before the user
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
// so skip the permission check and fall through to plain chat. A missing id
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
if (isEphemeralAgentId(agentId)) {
return next();
}
@@ -104,13 +104,15 @@ describe('canAccessAgentFromBody middleware', () => {
});
describe('primary agent checks', () => {
test('returns 400 when agent_id is missing on agents endpoint', async () => {
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
// must work out of the box; a missing id is ephemeral, not a 400.
req.body.agent_id = undefined;
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('proceeds for ephemeral primary agent without addedConvo', async () => {
@@ -0,0 +1,61 @@
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { getGuestConfig } = require('~/server/services/guestConfig');
/**
* Server-side capability scope for guest principals.
*
* Runs after guest authentication and before `buildEndpointOption`. For guest
* requests it pins the endpoint and model to the configured free Zen endpoint and
* strips every capability a guest must not reach (paid models, agents, tools,
* files, model specs, presets). The client is never trusted: any guest request
* that names a different endpoint/model is rejected rather than silently rewritten.
*
* Non-guest requests pass through untouched.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
* @param {import('express').NextFunction} next
*/
const enforceGuestScope = (req, res, next) => {
if (req.user?.guest !== true) {
return next();
}
const config = getGuestConfig();
if (!config.enabled) {
return res.status(404).json({ message: 'Guest chat is not enabled' });
}
const body = req.body || {};
const { endpoint, model } = body;
if (endpoint != null && endpoint !== config.endpoint) {
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
return res.status(403).json({ message: 'Guests may only use the free preview model' });
}
if (model != null && model !== config.model) {
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
return res.status(403).json({ message: 'Guests may only use the free preview model' });
}
body.endpoint = config.endpoint;
body.endpointType = EModelEndpoint.custom;
body.model = config.model;
delete body.agent_id;
delete body.spec;
delete body.preset;
delete body.files;
delete body.tools;
delete body.tool_resources;
delete body.resendFiles;
delete body.promptPrefix;
delete body.web_search;
req.body = body;
return next();
};
module.exports = enforceGuestScope;
+4
View File
@@ -10,6 +10,8 @@ const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
const enforceGuestScope = require('./enforceGuestScope');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
@@ -36,6 +38,8 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
requireGuestOrJwtAuth,
enforceGuestScope,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
@@ -0,0 +1,28 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { removePorts } = require('~/server/utils');
const { GUEST_TOKEN_WINDOW = 60, GUEST_TOKEN_MAX = 20 } = process.env;
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
const max = GUEST_TOKEN_MAX;
const windowInMinutes = windowMs / 60000;
const handler = (req, res) => {
return res.status(429).json({
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
});
};
/**
* Per-IP rate limiter for guest token issuance.
*/
const guestTokenLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('guest_token_limiter'),
});
module.exports = { guestTokenLimiter };
@@ -0,0 +1,32 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { removePorts } = require('~/server/utils');
const { getGuestConfig } = require('~/server/services/guestConfig');
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
const handler = (req, res) => {
return res.status(402).json({
type: 'GUEST_LIMIT',
message: 'Guest message limit reached. Log in to continue.',
});
};
/**
* Per-IP quota limiter for anonymous guest chat completions.
*
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
* message limiters). It only counts requests made by an authenticated guest
* principal; logged-in users skip the counter entirely. On exhaustion it returns
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
*/
const guestMessageLimiter = rateLimit({
windowMs: GUEST_LIMIT_WINDOW_MS,
max: () => getGuestConfig().messageMax,
handler,
keyGenerator: removePorts,
skip: (req) => req.user?.guest !== true,
store: limiterCache('guest_message_limiter'),
});
module.exports = { guestMessageLimiter };
@@ -0,0 +1,76 @@
const express = require('express');
const request = require('supertest');
jest.mock('@hanzochat/api', () => ({
limiterCache: () => undefined,
}));
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
}));
jest.mock('~/server/services/guestConfig', () => ({
getGuestConfig: jest.fn(),
}));
const { getGuestConfig } = require('~/server/services/guestConfig');
const { guestMessageLimiter } = require('./guestMessageLimiter');
const buildApp = (user) => {
const app = express();
app.use((req, _res, next) => {
req.user = user;
next();
});
app.use(guestMessageLimiter);
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
return app;
};
describe('guestMessageLimiter', () => {
beforeEach(() => {
jest.clearAllMocks();
getGuestConfig.mockReturnValue({
enabled: true,
messageMax: 3,
endpoint: 'Hanzo',
model: 'zen3-nano',
});
});
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
const app = buildApp({ id: 'g1', guest: true });
const ip = '10.0.0.1';
for (let i = 0; i < 3; i++) {
const res = await request(app).post('/chat').set('x-test-ip', ip);
expect(res.status).toBe(200);
}
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
expect(blocked.status).toBe(402);
expect(blocked.body.type).toBe('GUEST_LIMIT');
});
it('counts quota per IP independently', async () => {
const app = buildApp({ id: 'g1', guest: true });
for (let i = 0; i < 3; i++) {
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
}
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
expect(blocked.status).toBe(402);
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
expect(fresh.status).toBe(200);
});
it('never counts or blocks authenticated (non-guest) users', async () => {
const app = buildApp({ id: 'real-user', role: 'USER' });
for (let i = 0; i < 10; i++) {
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
expect(res.status).toBe(200);
}
});
});
+4
View File
@@ -2,6 +2,8 @@ const createTTSLimiters = require('./ttsLimiters');
const createSTTLimiters = require('./sttLimiters');
const loginLimiter = require('./loginLimiter');
const { guestTokenLimiter } = require('./guestLimiters');
const { guestMessageLimiter } = require('./guestMessageLimiter');
const importLimiters = require('./importLimiters');
const uploadLimiters = require('./uploadLimiters');
const forkLimiters = require('./forkLimiters');
@@ -17,6 +19,8 @@ module.exports = {
...messageLimiters,
...forkLimiters,
loginLimiter,
guestTokenLimiter,
guestMessageLimiter,
registerLimiter,
toolCallLimiter,
createTTSLimiters,
@@ -0,0 +1,59 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const requireJwtAuth = require('./requireJwtAuth');
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
/**
* Extracts a bearer token from the Authorization header.
* @param {ServerRequest} req
* @returns {string|null}
*/
const getBearerToken = (req) => {
const header = req.headers?.authorization;
if (!header || !header.startsWith('Bearer ')) {
return null;
}
return header.slice('Bearer '.length).trim();
};
/**
* Guest-aware authentication, applied ONLY to chat-completion routes.
*
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
* on `req.user` and the request proceeds. Otherwise the request falls through to
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
* @param {import('express').NextFunction} next
*/
const requireGuestOrJwtAuth = (req, res, next) => {
const config = getGuestConfig();
if (!config.enabled) {
return requireJwtAuth(req, res, next);
}
const token = getBearerToken(req);
if (!token) {
return requireJwtAuth(req, res, next);
}
let payload;
try {
payload = jwt.verify(token, process.env.JWT_SECRET);
} catch (_err) {
return requireJwtAuth(req, res, next);
}
if (payload?.guest !== true || !payload.id) {
return requireJwtAuth(req, res, next);
}
req.user = buildGuestPrincipal(payload.id);
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
return next();
};
module.exports = requireGuestOrJwtAuth;
@@ -0,0 +1,136 @@
/**
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
* a real signed guest JWT.
*
* Proves:
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
* - /api/endpoints, /api/user, /api/convos return guest-scoped data,
* - a non-bootstrap protected route still 401s for a guest.
*/
const jwt = require('jsonwebtoken');
const express = require('express');
const passport = require('passport');
const request = require('supertest');
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
SystemRoles: { USER: 'USER' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
jest.mock('~/models', () => ({
getUserById: jest.fn(async (id) => {
if (String(id).startsWith('guest_')) {
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
}
return null;
}),
updateUser: jest.fn(),
}));
const { getUserById } = require('~/models');
const jwtLogin = require('~/strategies/jwtStrategy');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
const JWT_SECRET = 'integration-guest-secret';
const buildApp = () => {
const app = express();
app.use(express.json());
app.use(passport.initialize());
passport.use(jwtLogin());
// Bootstrap (guest-aware) routes.
app.get('/api/endpoints', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
});
app.get('/api/user', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
return res.status(200).send(req.user);
});
app.get('/api/convos', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).json({ conversations: [], nextCursor: null });
}
return res.status(200).json({ conversations: ['real'] });
});
// Non-bootstrap protected route (JWT-only): must reject guests.
app.get('/api/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
return app;
};
describe('guest bootstrap auth chain (integration)', () => {
let app;
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
beforeAll(() => {
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen5-mini';
app = buildApp();
});
afterEach(() => jest.clearAllMocks());
it('GET /api/endpoints → 200 with ONLY the guest endpoint', async () => {
const res = await request(app)
.get('/api/endpoints')
.set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
const body = JSON.parse(res.text);
expect(Object.keys(body)).toEqual(['Hanzo']);
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
const res = await request(app).get('/api/user').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
expect(res.body).not.toHaveProperty('email');
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/convos → 200 empty list for a guest', async () => {
const res = await request(app).get('/api/convos').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ conversations: [], nextCursor: null });
});
it('GET /api/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(401);
// The guest id must never reach getUserById (that is what caused the CastError → 500).
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /api/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
const res = await request(app).get('/api/prompts').set('Authorization', `Bearer ${realToken}`);
expect(res.status).toBe(401);
expect(getUserById).toHaveBeenCalledTimes(1);
});
it('GET /api/prompts → 401 with no token (fail closed)', async () => {
const res = await request(app).get('/api/prompts');
expect(res.status).toBe(401);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* Regression test for the OAuth login rate-limiter scoping fix.
*
* Background: `loginLimiter` (LOGIN_MAX per 5 min, keyed by IP) used to be
* applied as blanket router middleware (`router.use(loginLimiter)`), which also
* covered the OIDC/social callback routes. Behind a shared LB/CDN IP, with
* OPENID_AUTO_REDIRECT firing `/oauth/openid` on every page load, the per-IP
* budget was exhausted instantly and the IdP code-exchange callback got 429 -
* aborting the token exchange and breaking login.
*
* The fix scopes `loginLimiter` to the human-initiated entry points (the GETs
* that REDIRECT to the IdP) and leaves the machine-driven callback routes
* (the IdP returning a one-time code) un-limited.
*
* This test mounts the REAL oauth router with the REAL `loginLimiter` and only
* stubs the heavy leaf deps (passport, openid-client, controllers, config, db),
* mirroring server/routes/__tests__/convos-duplicate-ratelimit.spec.js.
*/
const express = require('express');
const request = require('supertest');
// passport.authenticate(...) -> a middleware that signals "reached the handler"
// (in prod this would redirect to the IdP for initiation, or exchange the code
// for callbacks). Returning 200 lets us detect whether the limiter intercepted
// the request with 429 BEFORE control reached the auth handler.
jest.mock('passport', () => ({
authenticate: jest.fn(() => (req, res) => res.status(200).json({ reached: true })),
}));
jest.mock('openid-client', () => ({
randomState: jest.fn(() => 'test-state'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('librechat-data-provider', () => ({
ErrorTypes: { AUTH_FAILED: 'AUTH_FAILED' },
ViolationTypes: { LOGINS: 'logins' },
}));
// `limiterCache: () => undefined` makes express-rate-limit fall back to its
// built-in in-process MemoryStore (no Redis), same as the convos limiter test.
jest.mock('@hanzochat/api', () => ({
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
limiterCache: jest.fn(() => undefined),
}));
// loginLimiter.js does `require('~/cache')` (the index, which also pulls
// getLogStores) and `require('~/server/utils')` (index, which pulls
// sendEmail/files/queue). Mock both indexes to the minimal real/inert surface
// the limiter actually uses: a no-op violation logger and the dependency-free
// `removePorts` IP key generator.
jest.mock('~/cache', () => ({
logViolation: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/utils', () => ({
removePorts: jest.requireActual('~/server/utils/removePorts'),
}));
// oauth.js imports { checkDomainAllowed, loginLimiter, logHeaders } from the
// middleware index, whose full require graph is too heavy for a hermetic unit
// test. Mock the index but expose the REAL `loginLimiter` (the unit under test)
// via requireActual of the leaf file — mirroring how
// convos-duplicate-ratelimit.spec.js pulls the real forkLimiters. The other two
// are inert passthroughs (not under test here).
jest.mock('~/server/middleware', () => {
const loginLimiter = jest.requireActual('~/server/middleware/limiters/loginLimiter');
return {
loginLimiter,
logHeaders: (req, res, next) => next(),
checkDomainAllowed: (req, res, next) => next(),
};
});
jest.mock('~/server/controllers/auth/oauth', () => ({
createOAuthHandler: jest.fn(() => (req, res) => res.status(200).json({ handler: true })),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/db/models', () => ({
Balance: {},
}));
const LOGIN_INITIATION = '/oauth/openid';
const LOGIN_CALLBACK = '/oauth/openid/callback';
describe('OAuth router — loginLimiter scoping', () => {
const savedEnv = {};
beforeAll(() => {
savedEnv.LOGIN_MAX = process.env.LOGIN_MAX;
savedEnv.LOGIN_WINDOW = process.env.LOGIN_WINDOW;
savedEnv.DOMAIN_CLIENT = process.env.DOMAIN_CLIENT;
savedEnv.DOMAIN_SERVER = process.env.DOMAIN_SERVER;
});
afterAll(() => {
for (const key of Object.keys(savedEnv)) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
// The limiter reads LOGIN_MAX/LOGIN_WINDOW at module-load time, so build the
// app inside isolateModules AFTER setting env to get a fresh limiter+store.
const buildApp = () => {
let oauthRouter;
jest.isolateModules(() => {
oauthRouter = require('../oauth');
});
const app = express();
app.use(express.json());
app.use('/oauth', oauthRouter);
return app;
};
beforeEach(() => {
jest.clearAllMocks();
process.env.LOGIN_MAX = '1';
process.env.LOGIN_WINDOW = '5';
process.env.DOMAIN_CLIENT = 'http://localhost:3080';
process.env.DOMAIN_SERVER = 'http://localhost:3080';
});
describe('initiation route (redirect-to-IdP) IS rate-limited', () => {
it('returns 429 once an initiation route exceeds LOGIN_MAX', async () => {
const app = buildApp();
const max = parseInt(process.env.LOGIN_MAX, 10);
for (let i = 0; i < max; i++) {
const ok = await request(app).get(LOGIN_INITIATION);
expect(ok.status).toBe(200);
expect(ok.body.reached).toBe(true);
}
const limited = await request(app).get(LOGIN_INITIATION);
expect(limited.status).toBe(429);
expect(limited.body.message).toMatch(/too many/i);
});
});
describe('callback route (IdP-returns-code) is NOT rate-limited', () => {
it('never returns 429 even when hammered well past LOGIN_MAX', async () => {
const app = buildApp();
const hits = parseInt(process.env.LOGIN_MAX, 10) + 5;
for (let i = 0; i < hits; i++) {
const res = await request(app).get(LOGIN_CALLBACK);
// The callback may legitimately 200/redirect/fail auth — it must just
// never be throttled with 429.
expect(res.status).not.toBe(429);
}
});
it('does not consume the initiation budget (callback hits never trip the limiter)', async () => {
const app = buildApp();
// Hammer the callback far past the limit first.
for (let i = 0; i < 10; i++) {
const res = await request(app).get(LOGIN_CALLBACK);
expect(res.status).not.toBe(429);
}
// The single allowed initiation request must still succeed: callbacks and
// initiations do not share a (broken) blanket budget.
const ok = await request(app).get(LOGIN_INITIATION);
expect(ok.status).toBe(200);
});
});
describe('router wiring (defense-in-depth structural assertion)', () => {
// Even independent of runtime behavior, assert the limiter is present in the
// initiation route's middleware stack and absent from every callback stack.
const limiterStackByPath = () => {
let oauthRouter;
let loginLimiter;
jest.isolateModules(() => {
oauthRouter = require('../oauth');
// Same instance the router received (the mocked index re-exports the
// real leaf limiter), so reference-equality in the stack is meaningful.
({ loginLimiter } = require('~/server/middleware'));
});
const byPath = {};
for (const layer of oauthRouter.stack) {
if (!layer.route) continue; // skip router.use(logHeaders) etc.
const handles = layer.route.stack.map((s) => s.handle);
byPath[layer.route.path] = handles.includes(loginLimiter);
}
return byPath;
};
it('applies loginLimiter to every initiation route and to NO callback route', () => {
const limited = limiterStackByPath();
const initiationRoutes = [
'/google',
'/facebook',
'/openid',
'/github',
'/discord',
'/apple',
'/saml',
];
const callbackRoutes = [
'/google/callback',
'/facebook/callback',
'/openid/callback',
'/github/callback',
'/discord/callback',
'/apple/callback',
'/saml/callback',
];
for (const path of initiationRoutes) {
expect(limited[path]).toBe(true);
}
for (const path of callbackRoutes) {
expect(limited[path]).toBe(false);
}
});
});
});
+61 -14
View File
@@ -8,6 +8,9 @@ const {
messageIpLimiter,
configMiddleware,
messageUserLimiter,
enforceGuestScope,
guestMessageLimiter,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const { saveMessage } = require('~/models');
const openai = require('./openai');
@@ -33,6 +36,64 @@ router.use('/v1/responses', responses);
*/
router.use('/v1', openai);
/**
* Guest-capable chat completion router.
*
* Mounted before the JWT-only routes below so anonymous guests (when
* `ALLOW_GUEST_CHAT` is enabled) can reach ONLY the completion endpoints. Auth
* here accepts either a guest token or a real JWT; `enforceGuestScope` then pins
* guests to the free Zen endpoint/model and strips every other capability, and
* `guestMessageLimiter` enforces the per-IP guest quota. Every other agents route
* (management, CRUD, files) remains gated by the strict `requireJwtAuth` below,
* which rejects guest tokens.
*/
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
const chatRouter = express.Router();
/**
* Defer reserved management subpaths (stream/active/status/abort) to the
* JWT-only handlers defined on the parent router. Without this guard the
* completion route's `POST /:endpoint` would shadow `POST /chat/abort`.
*/
chatRouter.use((req, res, next) => {
const subpath = req.path.split('/').filter(Boolean)[0];
if (RESERVED_CHAT_SUBPATHS.has(subpath)) {
return next('router');
}
return next();
});
chatRouter.use(requireGuestOrJwtAuth);
chatRouter.use(checkBan);
chatRouter.use(uaParser);
chatRouter.use(configMiddleware);
chatRouter.use(enforceGuestScope);
chatRouter.use(guestMessageLimiter);
if (isEnabled(LIMIT_MESSAGE_IP)) {
chatRouter.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use(messageUserLimiter);
}
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
/**
* Guest-safe active-jobs poll. Guests never own a generation job, so this
* returns an empty set without a DB/user lookup. Registered BEFORE the strict
* JWT guard below so the composer's bootstrap poll doesn't 401-loop for guests;
* every other agents route stays JWT-only and rejects guest tokens.
*/
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
if (req.user?.guest === true) {
return res.json({ activeJobIds: [] });
}
return next();
});
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
@@ -269,18 +330,4 @@ router.post('/chat/abort', async (req, res) => {
return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
});
const chatRouter = express.Router();
chatRouter.use(configMiddleware);
if (isEnabled(LIMIT_MESSAGE_IP)) {
chatRouter.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use(messageUserLimiter);
}
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
module.exports = router;
+2
View File
@@ -17,6 +17,7 @@ const {
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
const { getAppConfig } = require('~/server/services/Config');
const middleware = require('~/server/middleware');
const { Balance } = require('~/db/models');
@@ -41,6 +42,7 @@ router.post(
loginController,
);
router.post('/refresh', refreshController);
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
router.post(
'/register',
middleware.registerLimiter,
+5
View File
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
const { getGuestConfig } = require('~/server/services/guestConfig');
const { getProjectByName } = require('~/models/Project');
const { getLogStores } = require('~/cache');
@@ -59,6 +60,8 @@ router.get('/', async function (req, res) {
const balanceConfig = getBalanceConfig(appConfig);
const guestConfig = getGuestConfig();
// Strip server-internal fields from balance config before sending to client.
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
const clientBalanceConfig = balanceConfig
@@ -121,6 +124,8 @@ router.get('/', async function (req, res) {
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens,
allowGuestChat: guestConfig.enabled,
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
+14 -2
View File
@@ -15,6 +15,7 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const { importConversations } = require('~/server/utils/import');
const { deleteToolCalls } = require('~/models/ToolCall');
const getLogStores = require('~/cache/getLogStores');
@@ -25,9 +26,17 @@ const assistantClients = {
};
const router = express.Router();
router.use(requireJwtAuth);
router.get('/', async (req, res) => {
/**
* Guest-accessible conversation list. Guests have no persisted conversations, so
* this returns an empty, well-formed page — enough for the UI to mount the chat
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
*/
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
if (req.user?.guest === true) {
return res.status(200).json({ conversations: [], nextCursor: null });
}
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
@@ -57,6 +66,9 @@ router.get('/', async (req, res) => {
}
});
/** Every route below reads or writes persisted user data — JWT-only, no guests. */
router.use(requireJwtAuth);
router.get('/:conversationId', async (req, res) => {
const { conversationId } = req.params;
const convo = await getConvo(req.user.id, conversationId);
+2 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const endpointController = require('~/server/controllers/EndpointController');
const router = express.Router();
router.get('/', requireJwtAuth, endpointController);
router.get('/', requireGuestOrJwtAuth, endpointController);
module.exports = router;
+2 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const { modelController } = require('~/server/controllers/ModelController');
const { requireJwtAuth } = require('~/server/middleware/');
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
const router = express.Router();
router.get('/', requireJwtAuth, modelController);
router.get('/', requireGuestOrJwtAuth, modelController);
module.exports = router;
+8 -3
View File
@@ -1,4 +1,4 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
// file deepcode ignore NoRateLimitingForLogin: `loginLimiter` is applied per-route to the IdP-initiation GETs (not the machine-driven /callback routes, which would break the OIDC code exchange)
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
@@ -23,7 +23,6 @@ const domains = {
};
router.use(logHeaders);
router.use(loginLimiter);
const oauthHandler = createOAuthHandler();
@@ -42,6 +41,7 @@ router.get('/error', (req, res) => {
*/
router.get(
'/google',
loginLimiter,
passport.authenticate('google', {
scope: ['openid', 'profile', 'email'],
session: false,
@@ -66,6 +66,7 @@ router.get(
*/
router.get(
'/facebook',
loginLimiter,
passport.authenticate('facebook', {
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
@@ -90,7 +91,7 @@ router.get(
/**
* OpenID Routes
*/
router.get('/openid', (req, res, next) => {
router.get('/openid', loginLimiter, (req, res, next) => {
return passport.authenticate('openid', {
session: false,
state: randomState(),
@@ -114,6 +115,7 @@ router.get(
*/
router.get(
'/github',
loginLimiter,
passport.authenticate('github', {
scope: ['user:email', 'read:user'],
session: false,
@@ -138,6 +140,7 @@ router.get(
*/
router.get(
'/discord',
loginLimiter,
passport.authenticate('discord', {
scope: ['identify', 'email'],
session: false,
@@ -162,6 +165,7 @@ router.get(
*/
router.get(
'/apple',
loginLimiter,
passport.authenticate('apple', {
session: false,
}),
@@ -184,6 +188,7 @@ router.post(
*/
router.get(
'/saml',
loginLimiter,
passport.authenticate('saml', {
session: false,
}),
+3 -2
View File
@@ -3,11 +3,12 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
const router = express.Router();
router.get('/favorites', requireJwtAuth, getFavoritesController);
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
module.exports = router;
+2 -1
View File
@@ -13,6 +13,7 @@ const {
configMiddleware,
canDeleteAccount,
requireJwtAuth,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const settings = require('./settings');
@@ -20,7 +21,7 @@ const settings = require('./settings');
const router = express.Router();
router.use('/settings', settings);
router.get('/', requireJwtAuth, getUserController);
router.get('/', requireGuestOrJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
+77 -49
View File
@@ -43,14 +43,31 @@ class CommerceClient {
}
/**
* Check user's balance. Returns cached result if fresh, triggers async
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
* The Commerce namespace (X-Hanzo-Org) for a billing subject. The subject is
* object.BillingSubject(owner, name): "owner/name" (per-user) or "owner"
* (pooled) — so the namespace is always the part before the first "/", or the
* whole subject. Deriving it here keeps every read/write scoped to the right
* tenant without callers having to thread the org separately.
*
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
* @param {string} subject
* @returns {string}
*/
_namespaceOf(subject) {
const s = (subject ?? '').toString();
const i = s.indexOf('/');
return i > 0 ? s.slice(0, i) : s;
}
/**
* Check a billing subject's balance. Returns cached result if fresh, triggers
* async refresh if stale, synchronous fetch on cache miss. Fails CLOSED (the
* cold-miss fetch throws) so the caller blocks rather than bleeding.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{sufficient: boolean, available: number}>}
*/
async checkBalance(userId) {
const cached = this._balanceCache.get(userId);
async checkBalance(subject) {
const cached = this._balanceCache.get(subject);
const now = Date.now();
if (cached) {
@@ -61,13 +78,13 @@ class CommerceClient {
// Stale: serve cached, refresh async
if (!cached.refreshing) {
cached.refreshing = true;
this._fetchBalance(userId).catch(() => {});
this._fetchBalance(subject).catch(() => {});
}
return cached.data;
}
// Cache miss: synchronous fetch
return this._fetchBalance(userId);
return this._fetchBalance(subject);
}
/**
@@ -146,30 +163,31 @@ class CommerceClient {
}
/**
* Create a trial credit grant for a new user.
* Ensure a subject has the one-time $5 starter credit, idempotently.
*
* @param {string} userId - Commerce user ID
* @param {number} amountCents - Grant amount in cents (e.g. 500 = $5)
* @param {number} expiryDays - Days until expiry
* @param {string[]} [eligibility] - Meter IDs (empty = all meters)
* @returns {Promise<Object|null>} Grant object or null on failure
* This posts to /v1/billing/grant-starter, which creates a real Deposit
* transaction (tag "starter-credit", $5, 30-day expiry) — so it nets into
* GET /v1/billing/balance, the account the gateway gate reads and debits.
* (NOT a credit-grant record: those live in a separate ledger the balance
* endpoint does not read, so they would never unblock the gate.)
*
* Idempotent + race-safe in Commerce (tag-deduped inside a transaction): safe
* to call on every first chat; duplicate/concurrent calls never double-grant.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{granted: boolean}|null>} or null on failure
*/
async createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
async grantStarter(subject) {
try {
const expiresIn = `${expiryDays * 24}h`;
const resp = await this._request('POST', '/v1/billing/credit-grants', {
userId,
name: 'Trial Credit',
amountCents,
currency: 'usd',
expiresIn,
priority: 100, // Trial burns before purchased (200)
eligibility,
tags: 'trial',
});
const resp = await this._request(
'POST',
'/v1/billing/grant-starter',
{ user: subject, trigger: 'chat_first_use' },
this._namespaceOf(subject),
);
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to create trial grant', err);
logger.error('[CommerceClient] Failed to ensure starter credit', err);
return null;
}
}
@@ -185,6 +203,8 @@ class CommerceClient {
const resp = await this._request(
'GET',
`/v1/billing/credit-balance/breakdown?userId=${encodeURIComponent(userId)}`,
undefined,
this._namespaceOf(userId),
);
const breakdown = resp.breakdown || {};
return {
@@ -200,27 +220,30 @@ class CommerceClient {
// ── Internal methods ──
async _fetchBalance(userId) {
try {
const resp = await this._request(
'GET',
`/v1/billing/balance?user=${encodeURIComponent(userId)}&currency=usd`,
);
const data = {
sufficient: (resp.available || 0) > 0,
available: resp.available || 0,
};
this._balanceCache.set(userId, {
data,
fetchedAt: Date.now(),
refreshing: false,
});
return data;
} catch (err) {
logger.warn('[CommerceClient] Balance check failed, failing open', { userId, error: err.message });
// Fail open
return { sufficient: true, available: 0 };
}
async _fetchBalance(subject) {
// FAIL CLOSED: this is the money gate. On error we THROW so the caller blocks
// the request rather than letting unfunded/unknown users spend. The cache
// (serve-stale on refresh) smooths transient blips for already-known users;
// only a cold miss + error propagates. `subject` is the billing account
// (object.BillingSubject) used as `?user=`; the namespace (X-Hanzo-Org) is
// its org prefix — matching the gateway's keying so chat reads the SAME
// account the gateway debits.
const resp = await this._request(
'GET',
`/v1/billing/balance?user=${encodeURIComponent(subject)}&currency=usd`,
undefined,
this._namespaceOf(subject),
);
const data = {
sufficient: (resp.available || 0) > 0,
available: resp.available || 0,
};
this._balanceCache.set(subject, {
data,
fetchedAt: Date.now(),
refreshing: false,
});
return data;
}
async _fetchTier(userId, tierName) {
@@ -229,7 +252,7 @@ class CommerceClient {
if (tierName) {
url += `&tier=${encodeURIComponent(tierName)}`;
}
const resp = await this._request('GET', url);
const resp = await this._request('GET', url, undefined, this._namespaceOf(userId));
const tier = resp.tier || null;
if (tier) {
this._tierCache.set(userId, {
@@ -275,12 +298,17 @@ class CommerceClient {
* @param {Object} [body]
* @returns {Promise<Object>}
*/
async _request(method, path, body) {
async _request(method, path, body, orgId) {
const url = `${this.endpoint}${path}`;
const headers = { 'Content-Type': 'application/json' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
// Scope the service-token call to the tenant's commerce namespace so reads/
// writes are correctly per-org (not the service token's default namespace).
if (orgId) {
headers['X-Hanzo-Org'] = orgId;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
@@ -7,7 +7,10 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const agentPromise = loadAgent({
req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
// No agent selected on the agents endpoint → ad-hoc (ephemeral) chat, so the
// user gets a reply without first creating/selecting an agent. Mirrors the
// access middleware, which treats a missing id as ephemeral.
agent_id: isAgentsEndpoint(endpoint) && agent_id ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
}).catch((error) => {
@@ -0,0 +1,53 @@
/**
* Regression: when no agent is created/selected, the agents endpoint must resolve
* to an ephemeral (ad-hoc, plain-model) agent so the user gets a reply — instead
* of loadAgent returning null because agent_id was missing.
*
* Hermetic: mocks data-schemas (no winston) and ~/models/Agent (no MongoDB).
*/
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
removeNullishValues: (obj) =>
Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)),
}));
const mockLoadAgent = jest.fn(() => Promise.resolve({ id: 'ephemeral', model: 'zen3-nano' }));
jest.mock('~/models/Agent', () => ({ loadAgent: mockLoadAgent }));
const { buildOptions } = require('./build');
const EPHEMERAL = 'ephemeral';
describe('agents buildOptions — missing agent_id resolves to ephemeral', () => {
beforeEach(() => mockLoadAgent.mockClear());
test('no agent_id on agents endpoint → loadAgent called with EPHEMERAL_AGENT_ID', async () => {
const req = { body: {}, user: { id: 'u1' } };
// parsedBody as built from "My Agents" with no agent selected (no agent_id).
const options = buildOptions(req, 'agents', { model: 'zen3-nano' });
await options.agent;
expect(mockLoadAgent).toHaveBeenCalledTimes(1);
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
});
test('real agent_id on agents endpoint is passed through unchanged', async () => {
const req = { body: {}, user: { id: 'u1' } };
const options = buildOptions(req, 'agents', { model: 'zen3-nano', agent_id: 'agent_real_42' });
await options.agent;
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe('agent_real_42');
});
test('non-agents endpoint always resolves to ephemeral', async () => {
const req = { body: {}, user: { id: 'u1' } };
const options = buildOptions(req, 'openAI', { model: 'zen3-nano', agent_id: 'agent_real_42' });
await options.agent;
expect(mockLoadAgent.mock.calls[0][0].agent_id).toBe(EPHEMERAL);
});
});
+118
View File
@@ -0,0 +1,118 @@
const { isEnabled } = require('@hanzochat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const GUEST_ROLE = 'GUEST';
const GUEST_NAME = 'Guest';
const DEFAULT_GUEST_MESSAGE_MAX = 3;
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
const DEFAULT_GUEST_MODEL = 'zen3-nano';
/**
* Resolves the guest-chat configuration from the environment.
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
*
* @returns {{
* enabled: boolean,
* messageMax: number,
* tokenExpiryMs: number,
* endpoint: string,
* model: string,
* }}
*/
const getGuestConfig = () => {
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
return {
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
messageMax:
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
tokenExpiryMs:
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
? tokenExpiryMs
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
};
};
/**
* Builds the ephemeral guest principal for a verified guest token.
*
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
* plain object — never a DB document — so no route ever reads or writes real
* user data on behalf of a guest. No email, no DB id.
*
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
* @returns {{ id: string, role: string, name: string, guest: true }}
*/
const buildGuestPrincipal = (id) => ({
id,
role: GUEST_ROLE,
name: GUEST_NAME,
guest: true,
});
/**
* Builds the guest-scoped `/api/user` response: the ephemeral principal only.
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
*
* @param {{ id: string }} principal
* @returns {object}
*/
const buildGuestUser = (principal) => ({
id: principal.id,
username: GUEST_NAME,
name: GUEST_NAME,
role: GUEST_ROLE,
provider: 'guest',
emailVerified: false,
guest: true,
});
/**
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
* with no builder/agent/file/preset capabilities. Everything else is omitted so
* the client cannot surface any other endpoint to a guest.
*
* @returns {Record<string, object>}
*/
const buildGuestEndpointsConfig = () => {
const { endpoint } = getGuestConfig();
return {
[endpoint]: {
type: EModelEndpoint.custom,
userProvide: false,
modelDisplayLabel: endpoint,
order: 0,
},
};
};
/**
* Builds the guest-scoped models config: the single configured guest model under
* the guest endpoint. The client pins the composer to exactly this one model.
*
* @returns {Record<string, string[]>}
*/
const buildGuestModelsConfig = () => {
const { endpoint, model } = getGuestConfig();
return {
[endpoint]: [model],
};
};
module.exports = {
getGuestConfig,
buildGuestPrincipal,
buildGuestUser,
buildGuestEndpointsConfig,
buildGuestModelsConfig,
GUEST_ROLE,
GUEST_NAME,
DEFAULT_GUEST_MESSAGE_MAX,
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
DEFAULT_GUEST_ENDPOINT,
DEFAULT_GUEST_MODEL,
};
+120
View File
@@ -0,0 +1,120 @@
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
}));
const {
getGuestConfig,
buildGuestPrincipal,
buildGuestUser,
buildGuestEndpointsConfig,
buildGuestModelsConfig,
GUEST_ROLE,
GUEST_NAME,
DEFAULT_GUEST_MESSAGE_MAX,
DEFAULT_GUEST_ENDPOINT,
DEFAULT_GUEST_MODEL,
} = require('./guestConfig');
describe('getGuestConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
delete process.env.GUEST_MESSAGE_MAX;
delete process.env.GUEST_TOKEN_EXPIRY;
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
afterAll(() => {
process.env = originalEnv;
});
it('is disabled by default (fail closed)', () => {
expect(getGuestConfig().enabled).toBe(false);
});
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
process.env.ALLOW_GUEST_CHAT = 'true';
expect(getGuestConfig().enabled).toBe(true);
});
it('uses defaults when env is unset', () => {
const config = getGuestConfig();
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
});
it('honors GUEST_MESSAGE_MAX', () => {
process.env.GUEST_MESSAGE_MAX = '7';
expect(getGuestConfig().messageMax).toBe(7);
});
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
process.env.GUEST_MESSAGE_MAX = 'abc';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
process.env.GUEST_MESSAGE_MAX = '0';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
process.env.GUEST_MESSAGE_MAX = '-5';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
});
it('honors the configurable guest endpoint and model', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen4-mini';
const config = getGuestConfig();
expect(config.endpoint).toBe('Hanzo');
expect(config.model).toBe('zen4-mini');
});
it('exposes GUEST_ROLE constant', () => {
expect(GUEST_ROLE).toBe('GUEST');
});
});
describe('guest principal + scoped-config builders', () => {
beforeEach(() => {
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
const principal = buildGuestPrincipal('guest_abc');
expect(principal).toEqual({
id: 'guest_abc',
role: GUEST_ROLE,
name: GUEST_NAME,
guest: true,
});
expect(principal).not.toHaveProperty('email');
expect(principal).not.toHaveProperty('_id');
});
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
expect(user.id).toBe('guest_abc');
expect(user.role).toBe(GUEST_ROLE);
expect(user.name).toBe(GUEST_NAME);
expect(user.guest).toBe(true);
expect(user).not.toHaveProperty('email');
expect(user).not.toHaveProperty('password');
});
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
const config = buildGuestEndpointsConfig();
expect(Object.keys(config)).toEqual(['Hanzo']);
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
});
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen5-mini';
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
});
});
+10
View File
@@ -12,6 +12,16 @@ const jwtLogin = () =>
},
async (payload, done) => {
try {
/**
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
* strategy fails with a 401 instead of letting `getUserById` throw a
* Mongoose CastError (→ 500). Guests reach their scoped routes through
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
*/
if (payload?.guest === true) {
return done(null, false);
}
const user = await getUserById(payload?.id, '-password -__v -totpSecret -backupCodes');
if (user) {
user.id = user._id.toString();
+87
View File
@@ -0,0 +1,87 @@
const { SystemRoles } = require('librechat-data-provider');
// --- Capture the verify callback passed to JwtStrategy ---
let capturedVerifyCallback;
jest.mock('passport-jwt', () => ({
Strategy: jest.fn((_opts, verifyCallback) => {
capturedVerifyCallback = verifyCallback;
return { name: 'jwt' };
}),
ExtractJwt: {
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
},
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('~/models', () => ({
getUserById: jest.fn(),
updateUser: jest.fn(),
}));
const { getUserById, updateUser } = require('~/models');
const jwtLogin = require('./jwtStrategy');
// Helper: invoke the captured verify callback as a promise
const invokeVerify = (payload) =>
new Promise((resolve, reject) => {
capturedVerifyCallback(payload, (err, user, info) => {
if (err) {
return reject(err);
}
resolve({ user, info });
});
});
describe('jwtStrategy verify callback', () => {
beforeEach(() => {
jest.clearAllMocks();
// Instantiate to capture the verify callback.
jwtLogin();
});
it('rejects a guest token cleanly without touching the DB (no CastError → 401)', async () => {
const { user } = await invokeVerify({ id: 'guest_abc-123', guest: true, role: 'GUEST' });
// done(null, false) → passport responds 401, never 500.
expect(user).toBe(false);
expect(getUserById).not.toHaveBeenCalled();
});
it('never calls getUserById for a guest id, even if it would CastError', async () => {
getUserById.mockRejectedValue(new Error('CastError: not an ObjectId'));
const { user } = await invokeVerify({ id: 'guest_xyz', guest: true });
expect(user).toBe(false);
expect(getUserById).not.toHaveBeenCalled();
});
it('resolves a real user for a non-guest token', async () => {
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
const { user } = await invokeVerify({ id: 'real-id' });
expect(getUserById).toHaveBeenCalledWith('real-id', expect.any(String));
expect(user).toMatchObject({ id: 'real-id', role: SystemRoles.USER });
});
it('returns false when a non-guest user is not found', async () => {
getUserById.mockResolvedValue(null);
const { user } = await invokeVerify({ id: 'missing-id' });
expect(user).toBe(false);
});
it('propagates a DB error for a non-guest token (done(err, false))', async () => {
getUserById.mockRejectedValue(new Error('db down'));
await expect(invokeVerify({ id: 'real-id' })).rejects.toThrow('db down');
});
it('does not treat guest:false as a guest token', async () => {
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' }, role: SystemRoles.USER });
await invokeVerify({ id: 'real-id', guest: false });
expect(getUserById).toHaveBeenCalledTimes(1);
});
it('backfills a missing role for a real user', async () => {
getUserById.mockResolvedValue({ _id: { toString: () => 'real-id' } });
const { user } = await invokeVerify({ id: 'real-id' });
expect(updateUser).toHaveBeenCalledWith('real-id', { role: SystemRoles.USER });
expect(user.role).toBe(SystemRoles.USER);
});
});
+2
View File
@@ -9,6 +9,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="Hanzo Chat - AI chat platform with support for multiple AI models" />
<title>Hanzo Chat</title>
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png" />
<link rel="apple-touch-icon" href="assets/apple-touch-icon-180x180.png" />
+1 -1
View File
@@ -35,7 +35,7 @@
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",
"@hanzo/iam": "^0.4.0",
"@hanzo/iam": "^0.13.1",
"@headlessui/react": "^2.1.2",
"@librechat/client": "workspace:*",
"@marsidev/react-turnstile": "^1.1.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+8 -1
View File
@@ -1 +1,8 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#f2f2f2"><path d="M3 2 H7 V10 H17 V2 H21 V22 H17 V14 H7 V22 H3 Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 443 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+2 -1
View File
@@ -4,7 +4,8 @@ async function postBuild() {
try {
await fs.copy('public/assets', 'dist/assets');
await fs.copy('public/robots.txt', 'dist/robots.txt');
console.log('✅ PWA icons and robots.txt copied successfully. Glob pattern warnings resolved.');
await fs.copy('public/favicon.ico', 'dist/favicon.ico');
console.log('✅ PWA icons, favicon.ico, and robots.txt copied successfully. Glob pattern warnings resolved.');
} catch (err) {
console.error('❌ Error copying files:', err);
process.exit(1);
+13 -3
View File
@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom';
import * as RadixToast from '@radix-ui/react-toast';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
@@ -14,6 +13,13 @@ import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
import { router } from './routes';
// Dev-only: lazily loaded so the devtools bundle never ships in production builds.
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
)
: null;
const App = () => {
const { setError } = useApiErrorBoundary();
@@ -63,7 +69,11 @@ const App = () => {
<DndProvider backend={HTML5Backend}>
<RouterProvider router={router} />
<WakeLockManager />
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
{import.meta.env.DEV && ReactQueryDevtools && (
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
</Suspense>
)}
<Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
</DndProvider>
+1
View File
@@ -453,6 +453,7 @@ export type TAuthContext = {
user: t.TUser | undefined;
token: string | undefined;
isAuthenticated: boolean;
isGuest: boolean;
error: string | undefined;
login: (data: t.TLoginUser) => void;
logout: (redirect?: string) => void;
+1 -1
View File
@@ -57,7 +57,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Set page title
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
useDocumentTitle(`${localize('com_agents_marketplace')} | Hanzo Chat`);
// Ensure right sidebar is always visible in marketplace
useEffect(() => {
@@ -197,7 +197,7 @@ describe('AgentCard', () => {
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
});
it('displays Feather icon fallback when no avatar is provided', () => {
it('displays the Hanzo mark fallback when no avatar is provided', () => {
const agentWithoutAvatar = {
...mockAgent,
avatar: undefined,
@@ -209,9 +209,9 @@ describe('AgentCard', () => {
</Wrapper>,
);
// Check for Feather icon presence by looking for the svg with lucide-feather class
const featherIcon = document.querySelector('.lucide-feather');
expect(featherIcon).toBeInTheDocument();
// The fallback renders the Hanzo H-mark (aria-label="Hanzo")
const hanzoMark = document.querySelector('[aria-label="Hanzo"]');
expect(hanzoMark).toBeInTheDocument();
});
it('card is clickable and has dialog trigger', () => {
@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from 'react';
import { OGDialog, OGDialogTemplate, Button } from '@librechat/client';
import { getHanzoIamSdk, isStaticIamMode } from '~/utils/iam';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
/**
* Login gate shown when an anonymous guest exhausts the free message quota.
* Reuses the existing OpenID / Hanzo IAM login flow — it does not implement a
* new login. Listens for the `guestLimitReached` window event dispatched by the
* chat submission path on a `402 { type: 'GUEST_LIMIT' }` response.
*/
export default function GuestLimitDialog() {
const [open, setOpen] = useState(false);
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
useEffect(() => {
const handler = () => setOpen(true);
window.addEventListener('guestLimitReached', handler);
return () => window.removeEventListener('guestLimitReached', handler);
}, []);
const handleLogin = useCallback(() => {
const iamSdk = getHanzoIamSdk();
if (isStaticIamMode() && iamSdk) {
iamSdk.signinRedirect();
return;
}
if (startupConfig?.openidLoginEnabled && startupConfig?.serverDomain) {
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
return;
}
window.location.href = '/login';
}, [startupConfig]);
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTemplate
title={localize('com_auth_guest_limit_title')}
className="max-w-md"
main={
<div className="text-sm text-text-secondary">
{localize('com_auth_guest_limit_message')}
</div>
}
buttons={
<Button variant="submit" onClick={handleLogin}>
{localize('com_auth_guest_limit_login')}
</Button>
}
/>
</OGDialog>
);
}
+2 -2
View File
@@ -34,8 +34,8 @@ export default function OAuthCallback() {
try {
const tokens = await iamSdk.handleCallback();
if (tokens.access_token) {
setTokenHeader(tokens.access_token);
if (tokens.accessToken) {
setTokenHeader(tokens.accessToken);
}
navigate('/c/new', { replace: true });
@@ -93,7 +93,7 @@ export default function MCPConfigDialog({
}
selection={{
selectHandler: handleSubmit(onFormSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
}}
buttons={
@@ -1,5 +1,5 @@
import { memo } from 'react';
import { Feather } from 'lucide-react';
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider';
import {
Plugin,
@@ -110,7 +110,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
) : (
<div className="h-6 w-6">
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Feather className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
<HanzoLogoIcon className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
</div>
</div>
),
@@ -1,4 +1,4 @@
import { Feather } from 'lucide-react';
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
import {
Sparkles,
@@ -43,7 +43,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
[EModelEndpoint.assistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
[EModelEndpoint.azureAssistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
[EModelEndpoint.agents]: {
icon: <Feather className="icon-sm" aria-hidden="true" />,
icon: <HanzoLogoIcon className="icon-sm" aria-hidden="true" />,
name: props.modelLabel ?? alternateName[EModelEndpoint.agents],
},
[EModelEndpoint.bedrock]: {
@@ -83,7 +83,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
}
selection={{
selectHandler: submitPreset,
selectClasses: 'bg-green-500 hover:bg-green-600 dark:hover:bg-green-600 text-white',
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
selectText: localize('com_ui_save'),
}}
/>
@@ -51,7 +51,7 @@ export default function FileListItem2({
return (
<span
key={index}
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
className="ml-2 mt-1 content-center rounded-full bg-surface-secondary px-2 text-xs text-text-secondary"
>
{vectorStore.name}
</span>
@@ -84,7 +84,7 @@ export default function FilePreview() {
&nbsp; Status
</span>
<div className="w-1/2 sm:w-3/4 md:w-3/5">
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-surface-secondary p-1 text-text-secondary">
<CheckMark className="m-0 p-0" />
<div>{file.object}</div>
</span>
@@ -88,7 +88,7 @@ export const fileTableColumns: ColumnDef<TFile>[] = [
return null;
}
return (
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
<span key={index} className="ml-2 mt-2 rounded-full bg-surface-secondary px-2 text-text-secondary">
{vectorStore.name}
</span>
);
@@ -139,7 +139,7 @@ export default function VectorStorePreview() {
</span>
<div className="w-1/2 md:w-3/5">
<p className="text-gray-500">
<span className="text-[#91c561]">0 KB hours</span>
<span className="text-text-secondary">0 KB hours</span>
&nbsp; Free until end of 2024
</p>
</div>
+16 -16
View File
@@ -13,9 +13,9 @@ const colors = {
mutedFg: 'hsla(0, 0%, 70%, 0.85)',
border: 'hsla(0, 0%, 40%, 0.2)',
fg: 'hsl(0, 0%, 96%)',
brand: '#fd4444',
brandDim: 'rgba(253, 68, 68, 0.10)',
brandGlow: 'rgba(253, 68, 68, 0.04)',
brand: '#ffffff',
brandDim: 'rgba(255, 255, 255, 0.10)',
brandGlow: 'rgba(255, 255, 255, 0.06)',
secondary: '#1f1f1f',
} as const;
@@ -152,7 +152,7 @@ export default function LandingPage() {
return (
<div
className="min-h-screen selection:bg-[#fd4444]/30"
className="min-h-screen selection:bg-white/20"
style={{
backgroundColor: colors.bg,
color: colors.fg,
@@ -169,7 +169,7 @@ export default function LandingPage() {
>
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
<div className="flex items-center gap-2.5">
<HanzoLogo className="size-5 text-[#fd4444]" />
<HanzoLogo className="size-5 text-white" />
<span className="text-sm font-bold tracking-tight">Hanzo Chat</span>
</div>
<div className="flex items-center gap-3">
@@ -199,7 +199,7 @@ export default function LandingPage() {
href={loginHref}
onClick={handleLoginClick}
className="rounded-full px-5 py-2 text-sm font-medium tracking-tight transition-colors"
style={{ backgroundColor: colors.brand, color: '#fff' }}
style={{ backgroundColor: colors.brand, color: '#000' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -215,7 +215,7 @@ export default function LandingPage() {
className="relative flex min-h-[600px] flex-col overflow-hidden rounded-2xl"
style={{
border: `1px solid ${colors.border}`,
background: `linear-gradient(135deg, rgba(253,68,68,0.08) 0%, transparent 50%, rgba(253,68,68,0.04) 100%)`,
background: `linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%, rgba(255,255,255,0.04) 100%)`,
}}
>
{/* Grid pattern overlay */}
@@ -232,7 +232,7 @@ export default function LandingPage() {
<div
className="flex w-fit items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium"
style={{
border: `1px solid rgba(253,68,68,0.5)`,
border: `1px solid rgba(255,255,255,0.5)`,
color: colors.brand,
}}
>
@@ -258,7 +258,7 @@ export default function LandingPage() {
href={loginHref}
onClick={handleLoginClick}
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
style={{ backgroundColor: colors.brand, color: '#fff' }}
style={{ backgroundColor: colors.brand, color: '#000' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -363,8 +363,8 @@ zen4-coder: I'll help you refactor the auth module.
<div
className="rounded-2xl p-6 text-sm shadow-lg md:p-8"
style={{
backgroundColor: 'rgba(253, 68, 68, 0.06)',
border: `1px solid rgba(253, 68, 68, 0.15)`,
backgroundColor: 'rgba(255, 255, 255, 0.06)',
border: `1px solid rgba(255, 255, 255, 0.15)`,
}}
>
<IconBolt className="mb-4 size-8" style={{ color: colors.brand }} />
@@ -383,7 +383,7 @@ zen4-coder: I'll help you refactor the auth module.
border: `1px solid ${colors.border}`,
backgroundColor: 'rgba(0,0,0,0.2)',
}}
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(253, 68, 68, 0.3)')}
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.3)')}
onMouseOut={(e) => (e.currentTarget.style.borderColor = colors.border)}
>
<div className="mb-2 flex items-center gap-2">
@@ -480,11 +480,11 @@ zen4-coder: I'll help you refactor the auth module.
<div
className="rounded-xl p-6"
style={{
border: `1px solid rgba(253, 68, 68, 0.3)`,
backgroundColor: 'rgba(253, 68, 68, 0.04)',
border: `1px solid rgba(255, 255, 255, 0.3)`,
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(253, 68, 68, 0.7)' }}>
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Free credit
</p>
<p className="text-3xl font-bold">$5</p>
@@ -528,7 +528,7 @@ zen4-coder: I'll help you refactor the auth module.
href={loginHref}
onClick={handleLoginClick}
className="inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-medium tracking-tight transition-colors"
style={{ backgroundColor: colors.brand, color: '#fff' }}
style={{ backgroundColor: colors.brand, color: '#000' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -125,11 +125,12 @@ const errorMessages = {
);
}
// Commerce insufficient balance
// Commerce insufficient balance — new users claim the $5 starter credit,
// spent-out users add funds. Both live at billing.hanzo.ai.
if (reason === 'commerce_insufficient') {
return (
<>
{'Your account balance is empty. Please add funds to continue.'}
{'You have no Hanzo Cloud balance. Claim your $5 starter credit (or add funds) to start chatting.'}
<br />
<br />
<a
@@ -138,12 +139,18 @@ const errorMessages = {
rel="noopener noreferrer"
className="text-hanzo-red underline"
>
Add funds to your Hanzo account
Claim your $5 credit at billing.hanzo.ai
</a>
</>
);
}
// Commerce unreachable — fail closed (we block rather than bleed). The user
// may well be funded; do NOT tell them to add funds.
if (reason === 'commerce_unavailable') {
return 'Billing is temporarily unavailable, so we could not verify your balance. Please try again in a moment.';
}
// Convert tokenCredits to USD: 1,000,000 tokenCredits = $1 USD
const balanceUsd = (balance / 1000000).toFixed(4);
const costUsd = (tokenCost / 1000000).toFixed(4);
@@ -80,7 +80,7 @@ describe('About', () => {
expect(mockCopy).toHaveBeenCalledTimes(1);
const [blob, options] = mockCopy.mock.calls[0] as [string, { format: string }];
expect(options).toEqual({ format: 'text/plain' });
expect(blob).toContain(`LibreChat version: ${Constants.VERSION}`);
expect(blob).toContain(`Hanzo Chat version: ${Constants.VERSION}`);
expect(blob).toContain(`Commit: ${populatedBuildInfo.commit}`);
expect(blob).toContain(`Branch: ${populatedBuildInfo.branch}`);
expect(blob).toContain('Build date: 2026-04-20 12:00:00 UTC');
@@ -27,7 +27,7 @@ function buildDiagnosticsBlob(
buildInfo: TStartupConfig['buildInfo'] | undefined,
): string {
const lines: string[] = [
`LibreChat version: ${version}`,
`Hanzo Chat version: ${version}`,
`Commit: ${buildInfo?.commit ?? UNKNOWN_PLACEHOLDER}`,
`Branch: ${buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}`,
`Build date: ${formatBuildDate(buildInfo?.buildDate)}`,
@@ -274,7 +274,7 @@ export default function ActionsInput({
<button
disabled={!functions || !functions.length}
onClick={saveAction}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
type="button"
>
{getButtonContent()}
@@ -121,7 +121,7 @@ export default function AgentFooter({
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold"
type="submit"
disabled={isSaving}
aria-busy={isSaving}
@@ -95,7 +95,7 @@ export default function ApiKeyDialog({
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
selectText: localize('com_ui_save'),
}}
buttons={
@@ -245,7 +245,7 @@ export default function ApiKeyDialog({
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
selectText: localize('com_ui_save'),
}}
buttons={
@@ -286,7 +286,7 @@ export default function ActionsInput({
<button
disabled={!functions || !functions.length}
onClick={saveAction}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
type="button"
>
{submitContext()}
@@ -237,7 +237,7 @@ export default function AssistantPanel({
{/* Select Button */}
{assistant_id && (
<button
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded px-4 py-2 font-semibold focus:outline-none focus:ring-0"
type="button"
disabled={!assistant_id}
onClick={(e) => {
@@ -460,7 +460,7 @@ export default function AssistantPanel({
/>
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold"
type="submit"
>
{submitContext}
@@ -183,7 +183,7 @@ export default function Parameters() {
<div className="mt-2 flex justify-center">
<button
onClick={openDialog}
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold"
type="button"
>
{localize('com_endpoint_save_as_preset')}
+1 -1
View File
@@ -95,7 +95,7 @@ export function CompositeCitation(props: CompositeCitationProps) {
href={currentSource.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
>
{currentSource.attribution}
</a>
@@ -114,7 +114,7 @@ export function SourceHovercard({
{isFile ? (
<button
onClick={onClick}
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
className="line-clamp-2 cursor-pointer overflow-hidden text-left text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || source.title || localize('com_file_source')}
</button>
@@ -123,7 +123,7 @@ export function SourceHovercard({
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || domain}
</a>
+1 -1
View File
@@ -101,7 +101,7 @@ function SourceItem({ source, expanded = false }: SourceItemProps) {
href={source.link}
target="_blank"
rel="noopener noreferrer"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-[#0066cc] hover:underline dark:text-blue-400 md:line-clamp-3"
className="line-clamp-2 cursor-pointer overflow-hidden text-sm font-bold text-text-primary hover:underline dark:text-blue-400 md:line-clamp-3"
>
{source.attribution || domain}
</a>
@@ -0,0 +1,40 @@
import React from 'react';
export interface HanzoLogoIconProps extends React.SVGProps<SVGSVGElement> {
/** Pixel size; when set, applies to width and height. */
size?: number | string;
/** Accepted for drop-in parity with lucide icons; ignored (mark is fill-based). */
strokeWidth?: number;
}
/**
* Official Hanzo /H mark monochrome, `currentColor` fill.
*
* Canonical brand source: `@hanzo/logo` (hanzo.app / hanzo.ai favicon set).
* Drop-in replacement for the upstream LibreChat lucide `Feather` brand fallback.
*/
export default function HanzoLogoIcon({
className = '',
size,
strokeWidth: _strokeWidth,
...props
}: HanzoLogoIconProps) {
return (
<svg
viewBox="0 0 67 67"
className={className}
{...(size != null ? { width: size, height: size } : {})}
fill="currentColor"
role="img"
aria-label="Hanzo"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M22.21 67V44.6369H0V67H22.21Z" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
<path d="M22.21 0H0V22.3184H22.21V0Z" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
</svg>
);
}
+60 -7
View File
@@ -19,8 +19,10 @@ import {
useLoginUserMutation,
useLogoutUserMutation,
useRefreshTokenMutation,
useGetStartupConfig,
} from '~/data-provider';
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
import useGuestAuth from './useGuestAuth';
import useTimeout from './useTimeout';
import store from '~/store';
@@ -37,8 +39,12 @@ const AuthContextProvider = ({
const [token, setToken] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isGuest, setIsGuest] = useState<boolean>(false);
const logoutRedirectRef = useRef<string | undefined>(undefined);
const { data: startupConfig } = useGetStartupConfig();
const { acquireGuestToken } = useGuestAuth();
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
enabled: !!(isAuthenticated && (user?.role ?? '')),
});
@@ -125,12 +131,44 @@ const AuthContextProvider = ({
[logoutUser],
);
const userQuery = useGetUserQuery({ enabled: !!(token ?? '') });
const userQuery = useGetUserQuery({ enabled: !!(token ?? '') && !isGuest });
const acquireGuest = useCallback(async (): Promise<boolean> => {
const session = await acquireGuestToken();
if (!session) {
return false;
}
setUser(session.user);
setToken(session.token);
setTokenHeader(session.token);
setIsGuest(true);
setIsAuthenticated(false);
return true;
}, [acquireGuestToken, setUser]);
const login = (data: t.TLoginUser) => {
loginUser.mutate(data);
};
/**
* Ref-held guest fallback so `silentRefresh` (intentionally dep-free) always
* sees the latest config/handler without resubscribing. When no session
* exists and guest chat is enabled, acquire a guest token; otherwise let the
* root route show the landing/login gate.
*/
const guestFallbackRef = useRef<() => void>(() => {});
guestFallbackRef.current = () => {
if (startupConfig?.allowGuestChat === true) {
void acquireGuest().then((ok) => {
if (!ok) {
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
}
});
return;
}
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
};
const silentRefresh = useCallback(() => {
if (authConfig?.test === true) {
console.log('Test mode. Skipping silent refresh.');
@@ -150,8 +188,7 @@ const AuthContextProvider = ({
if (authConfig?.test === true) {
return;
}
// Let the root route show the landing page instead of forcing /login
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
guestFallbackRef.current();
}
},
onError: (error) => {
@@ -159,8 +196,7 @@ const AuthContextProvider = ({
if (authConfig?.test === true) {
return;
}
// Let the root route show the landing page instead of forcing /login
setUserContext({ token: undefined, isAuthenticated: false, user: undefined });
guestFallbackRef.current();
},
});
}, []);
@@ -175,11 +211,12 @@ const AuthContextProvider = ({
if (error != null && error && isAuthenticated) {
doSetError(undefined);
}
if (token == null || !token || !isAuthenticated) {
if (!isGuest && (token == null || !token || !isAuthenticated)) {
silentRefresh();
}
}, [
token,
isGuest,
isAuthenticated,
userQuery.data,
userQuery.isError,
@@ -191,6 +228,21 @@ const AuthContextProvider = ({
setUserContext,
]);
// Acquire a guest session once startup config confirms guest chat is enabled.
// silentRefresh's fallback can run before startupConfig has loaded (when
// `allowGuestChat` is still undefined), and it is not retried — so this effect
// closes that race by acquiring the guest token when the flag becomes true.
useEffect(() => {
if (
startupConfig?.allowGuestChat === true &&
!isAuthenticated &&
!isGuest &&
(token == null || !token)
) {
void acquireGuest();
}
}, [startupConfig?.allowGuestChat, isAuthenticated, isGuest, token, acquireGuest]);
useEffect(() => {
const handleTokenUpdate = (event) => {
console.log('tokenUpdated event received event');
@@ -223,9 +275,10 @@ const AuthContextProvider = ({
[SystemRoles.ADMIN]: adminRole,
},
isAuthenticated,
isGuest,
}),
[user, error, isAuthenticated, token, userRole, adminRole],
[user, error, isAuthenticated, isGuest, token, userRole, adminRole],
);
return <AuthContext.Provider value={memoedValue}>{children}</AuthContext.Provider>;
+2 -2
View File
@@ -1,4 +1,4 @@
import { Feather } from 'lucide-react';
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import { EModelEndpoint } from 'librechat-data-provider';
import {
GPTIcon,
@@ -51,7 +51,7 @@ const AgentAvatar = ({ className = '', avatar = '', agentName, size }: AgentIcon
);
}
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
return <HanzoLogoIcon className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
};
const Bedrock = ({ className = '' }: IconMapProps) => {
+11 -1
View File
@@ -594,8 +594,18 @@ export default function useResumableSSE(
console.error('[ResumableSSE] Error starting generation:', lastError);
const axiosError = lastError as { response?: { data?: Record<string, unknown> } };
const axiosError = lastError as {
response?: { status?: number; data?: Record<string, unknown> };
};
const errorData = axiosError?.response?.data;
// Guest quota exhausted: signal the UI to open the login gate (existing OpenID flow).
if (axiosError?.response?.status === 402 && errorData?.type === 'GUEST_LIMIT') {
window.dispatchEvent(new CustomEvent('guestLimitReached'));
setIsSubmitting(false);
return null;
}
if (errorData) {
errorHandler({
data: { text: JSON.stringify(errorData) } as unknown as Parameters<
+38
View File
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { dataService } from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider';
const GUEST_ROLE = 'GUEST';
export type GuestSession = {
token: string;
user: TUser;
model: string;
endpoint: string;
};
/**
* Acquires an ephemeral guest session for anonymous preview chat.
*
* Returns `null` when guest issuance fails (e.g. quota for new sessions reached
* or guest chat disabled), letting the caller fall back to the login gate.
*/
export default function useGuestAuth() {
const acquireGuestToken = useCallback(async (): Promise<GuestSession | null> => {
try {
const data = await dataService.getGuestToken();
const user = {
id: 'guest',
role: GUEST_ROLE,
username: 'guest',
name: 'Guest',
email: '',
} as unknown as TUser;
return { token: data.token, user, model: data.model, endpoint: data.endpoint };
} catch (_error) {
return null;
}
}, []);
return { acquireGuestToken };
}
+3
View File
@@ -173,6 +173,9 @@
"com_auth_email_verifying_ellipsis": "Verifying...",
"com_auth_error_create": "There was an error attempting to register your account. Please try again.",
"com_auth_error_invalid_reset_token": "This password reset token is no longer valid.",
"com_auth_guest_limit_title": "You've reached the free preview limit",
"com_auth_guest_limit_message": "Log in to keep chatting with full access to models, tools, and your conversation history.",
"com_auth_guest_limit_login": "Log in to continue",
"com_auth_error_login": "Unable to login with the information provided. Please check your credentials and try again.",
"com_auth_error_login_ban": "Your account has been temporarily banned due to violations of our service.",
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
+8 -2
View File
@@ -22,6 +22,7 @@ import { TermsAndConditionsModal } from '~/components/ui';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
import LandingPage from '~/components/Landing/LandingPage';
import GuestLimitDialog from '~/components/Auth/GuestLimitDialog';
import ChatHanzoHeader from '~/components/Nav/HanzoHeader';
export default function Root() {
@@ -32,9 +33,13 @@ export default function Root() {
return savedNavVisible !== null ? JSON.parse(savedNavVisible) : true;
});
const { isAuthenticated, logout, token } = useAuthContext();
const { isAuthenticated, isGuest, logout, token } = useAuthContext();
const [authChecked, setAuthChecked] = useState(false);
// Guests get the chat UI without a full session. Capability-scoped hooks
// below stay gated on `isAuthenticated`, so guests never query agents/files/search.
const showChat = isAuthenticated || isGuest;
// Wait for the initial silent refresh before deciding to show landing vs chat
useEffect(() => {
if (isAuthenticated || token !== undefined) {
@@ -86,7 +91,7 @@ export default function Root() {
);
}
if (!isAuthenticated) {
if (!showChat) {
return <LandingPage />;
}
@@ -97,6 +102,7 @@ export default function Root() {
<AgentsMapContext.Provider value={agentsMap}>
<PromptGroupsProvider>
<ChatHanzoHeader />
{isGuest && <GuestLimitDialog />}
<Banner onHeightChange={setBannerHeight} />
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
<div className="relative z-0 flex h-full w-full overflow-hidden">
+13 -3
View File
@@ -1,14 +1,24 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
export default function useAuthRedirect() {
const { user, roles, isAuthenticated } = useAuthContext();
const { user, roles, isAuthenticated, isGuest } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const navigate = useNavigate();
useEffect(() => {
// Wait until startup config is known before deciding — otherwise a fresh
// guest visitor is bounced to /login before `allowGuestChat` (and the guest
// session) has loaded.
if (startupConfig == null) {
return;
}
const timeout = setTimeout(() => {
if (!isAuthenticated) {
// Guests (and any context where guest chat is enabled) stay on the chat
// surface; only truly unauthenticated, non-guest users go to /login.
if (!isAuthenticated && !isGuest && startupConfig.allowGuestChat !== true) {
navigate('/login', { replace: true });
}
}, 300);
@@ -16,7 +26,7 @@ export default function useAuthRedirect() {
return () => {
clearTimeout(timeout);
};
}, [isAuthenticated, navigate]);
}, [isAuthenticated, isGuest, startupConfig, navigate]);
return {
user,
+44 -53
View File
@@ -18,17 +18,18 @@
--gray-800: #212121;
--gray-850: #171717;
--gray-900: #0d0d0d;
--green-50: #fff5f5;
--green-100: #ffe3e3;
--green-200: #ffc4c4;
--green-300: #ff9494;
--green-400: #ff6b6b;
--green-500: #fd4444;
--green-600: #e03e3e;
--green-700: #c53030;
--green-800: #9b2c2c;
--green-900: #742a2a;
--green-950: #4a1d1d;
/* Monochrome: legacy "green" (was brand red #fd4444) collapsed to the grey ramp. */
--green-50: #f7f7f8;
--green-100: #ececec;
--green-200: #e3e3e3;
--green-300: #cdcdcd;
--green-400: #999696;
--green-500: #595959;
--green-600: #424242;
--green-700: #2f2f2f;
--green-800: #212121;
--green-900: #0d0d0d;
--green-950: #0a0a0a;
--red-50: #fef2f2;
--red-100: #fee2e2;
--red-200: #fecaca;
@@ -51,7 +52,7 @@
--amber-800: #92400e;
--amber-900: #78350f;
--amber-950: #451a03;
--brand-purple: #ab68ff;
--brand-purple: #999696;
--gizmo-gray-500: #999;
--gizmo-gray-600: #666;
--gizmo-gray-950: #0f0f0f;
@@ -63,13 +64,13 @@
--markdown-font-size: 1rem;
}
html {
--brand-purple: #ab68ff;
--brand-purple: #999696;
--presentation: var(--white);
--text-primary: var(--gray-800);
--text-secondary: var(--gray-600);
--text-secondary-alt: var(--gray-500);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--text-warning: var(--gray-400);
--text-destructive: var(--red-600);
--ring-primary: var(--gray-500);
--header-primary: var(--white);
@@ -126,13 +127,13 @@ html {
--switch-unchecked: 0 0% 58%;
}
.dark {
--brand-purple: #ab68ff;
--brand-purple: #999696;
--presentation: var(--gray-800);
--text-primary: var(--gray-100);
--text-secondary: var(--gray-300);
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--text-warning: var(--gray-400);
--text-destructive: var(--red-600);
--header-primary: var(--gray-700);
--header-hover: var(--gray-600);
@@ -162,24 +163,24 @@ html {
--border-destructive: var(--red-500);
/* Hanzo dark palette */
--background: 222 47% 5%;
--background: 0 0% 5%;
--foreground: 0 0% 95%;
--card: 222 47% 7%;
--card: 0 0% 7%;
--card-foreground: 0 0% 95%;
--primary: 0 0% 95%;
--primary-foreground: 222 47% 9%;
--secondary: 222 30% 18%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 18%;
--secondary-foreground: 0 0% 95%;
--muted: 222 30% 12%;
--muted-foreground: 215 16% 56%;
--accent: 222 30% 18%;
--muted: 0 0% 12%;
--muted-foreground: 0 0% 56%;
--accent: 0 0% 18%;
--accent-foreground: 0 0% 95%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 222 30% 18%;
--input: 222 30% 18%;
--ring: 215 16% 56%;
--chart-1: 220 70% 50%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 56%;
--chart-1: 0 0% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
@@ -1353,14 +1354,12 @@ button {
opacity: 0.5;
}
.btn-primary {
--tw-bg-opacity: 1;
--tw-text-opacity: 1;
background-color: rgba(16, 163, 127, var(--tw-bg-opacity));
color: rgba(255, 255, 255, var(--tw-text-opacity));
/* Monochrome primary: adaptive white-on-dark / black-on-light (matches send button). */
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
--tw-bg-opacity: 1;
background-color: rgba(26, 127, 100, var(--tw-bg-opacity));
background-color: hsl(var(--primary) / 0.9);
}
.btn-primary:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width)
@@ -1376,36 +1375,28 @@ button {
var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 transparent);
}
.btn-primary.focus-visible {
--tw-ring-opacity: 1;
--tw-ring-color: rgba(25, 195, 125, var(--tw-ring-opacity));
--tw-ring-color: hsl(var(--ring));
}
.btn-primary:focus-visible {
--tw-ring-opacity: 1;
--tw-ring-color: rgba(25, 195, 125, var(--tw-ring-opacity));
--tw-ring-color: hsl(var(--ring));
}
.btn-primary:disabled:hover {
--tw-bg-opacity: 1;
background-color: rgba(16, 163, 127, var(--tw-bg-opacity));
background-color: hsl(var(--primary));
}
.btn-secondary {
--tw-border-opacity: 1;
--tw-bg-opacity: 1;
--tw-text-opacity: 1;
background-color: transparent;
background-color: rgba(16, 163, 156, var(--tw-bg-opacity));
color: rgba(255, 255, 255, var(--tw-text-opacity));
background-color: var(--surface-secondary);
color: var(--text-primary);
}
.btn-secondary:hover {
--tw-bg-opacity: 1;
border-color: rgba(0, 0, 0, 0.1);
color: rgba(64, 65, 79, var(--tw-text-opacity));
background-color: rgba(236, 236, 241, var(--tw-bg-opacity));
color: var(--text-primary);
background-color: var(--surface-hover);
}
.dark .btn-secondary:hover {
--tw-bg-opacity: 1;
border-color: rgba(8, 82, 78, var(--tw-border-opacity));
color: rgba(255, 255, 240, var(--tw-text-opacity));
background-color: rgba(8, 82, 78, var(--tw-bg-opacity));
border-color: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
background-color: var(--surface-hover);
}
.btn-neutral {
--tw-bg-opacity: 1;
@@ -1719,12 +1710,12 @@ html {
--tw-ring-inset: var(--tw-empty, /*!*/ /*!*/);
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #2563eb;
--tw-ring-color: #52525b;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width)
var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width))
var(--tw-ring-color);
border-color: #2563eb;
border-color: #52525b;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
+3 -18
View File
@@ -4,20 +4,6 @@ import '@testing-library/jest-dom';
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
import type t from 'librechat-data-provider';
// Mock the Feather icon from lucide-react
jest.mock('lucide-react', () => ({
Feather: ({ className, strokeWidth, ...props }: any) => (
<svg
data-testid="feather-icon"
className={className}
data-stroke-width={strokeWidth}
{...props}
>
<title>{/* eslint-disable-line i18next/no-literal-string */}Feather Icon</title>
</svg>
),
}));
describe('Agent Utilities', () => {
describe('getAgentAvatarUrl', () => {
it('should return null for null agent', () => {
@@ -77,7 +63,7 @@ describe('Agent Utilities', () => {
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
});
it('should render Feather icon fallback when no avatar', () => {
it('should render the Hanzo mark fallback when no avatar', () => {
const agent = {
id: '1',
name: 'Test Agent',
@@ -85,9 +71,8 @@ describe('Agent Utilities', () => {
render(<div>{renderAgentAvatar(agent)}</div>);
const featherIcon = screen.getByTestId('feather-icon');
expect(featherIcon).toBeInTheDocument();
expect(featherIcon).toHaveAttribute('data-stroke-width', '1.5');
const hanzoMark = screen.getByLabelText('Hanzo');
expect(hanzoMark).toBeInTheDocument();
});
it('should apply different size classes', () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Feather } from 'lucide-react';
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import { Skeleton } from '@librechat/client';
import type t from 'librechat-data-provider';
@@ -116,7 +116,7 @@ export const renderAgentAvatar = (
return (
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
<Feather className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />
<HanzoLogoIcon className={`text-text-primary ${iconSizeClasses[size]}`} />
</div>
);
};
+4 -4
View File
@@ -5,12 +5,12 @@
* from the Vite environment. Returns null when IAM is not configured
* (i.e. the app is running in backend-proxied mode).
*/
import { BrowserIamSdk } from '@hanzo/iam';
import { IAM } from '@hanzo/iam';
let instance: BrowserIamSdk | null = null;
let instance: IAM | null = null;
let checked = false;
export function getHanzoIamSdk(): BrowserIamSdk | null {
export function getHanzoIamSdk(): IAM | null {
if (checked) {
return instance;
}
@@ -23,7 +23,7 @@ export function getHanzoIamSdk(): BrowserIamSdk | null {
return null;
}
instance = new BrowserIamSdk({
instance = new IAM({
serverUrl,
clientId,
redirectUri: `${window.location.origin}/auth/callback`,
+35 -13
View File
@@ -1,5 +1,23 @@
// const { fontFamily } = require('tailwindcss/defaultTheme');
// Hanzo monochrome brand — every accent scale collapses to ONE neutral grey ramp.
// Black / white / grey only; no color in the UI chrome. Destructive semantics keep a
// single muted red via the --surface-destructive / --text-destructive CSS vars (style.css).
const mono = {
50: '#f7f7f8',
100: '#ececec',
200: '#e3e3e3',
300: '#cdcdcd',
400: '#999696',
500: '#595959',
550: '#4d4d4d',
600: '#424242',
700: '#2f2f2f',
800: '#212121',
900: '#0d0d0d',
950: '#0a0a0a',
};
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
@@ -72,19 +90,23 @@ module.exports = {
850: '#171717',
900: '#0d0d0d',
},
green: {
50: '#fff5f5',
100: '#ffe3e3',
200: '#ffc4c4',
300: '#ff9494',
400: '#ff6b6b',
500: '#fd4444',
550: '#e03e3e',
600: '#c53030',
700: '#9b2c2c',
800: '#742a2a',
900: '#4a1d1d',
},
green: mono,
red: mono,
blue: mono,
sky: mono,
cyan: mono,
teal: mono,
emerald: mono,
lime: mono,
yellow: mono,
amber: mono,
orange: mono,
indigo: mono,
violet: mono,
purple: mono,
fuchsia: mono,
pink: mono,
rose: mono,
'brand-purple': 'var(--brand-purple)',
presentation: 'var(--presentation)',
'text-primary': 'var(--text-primary)',

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