Compare commits

..
Author SHA1 Message Date
Antje Worring 61cb192134 fix(api): restore deleted Message/Conversation/Preset/Prompt models (runtime MODULE_NOT_FOUND)
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.
2026-06-20 01:08:47 -07:00
164 changed files with 973 additions and 6449 deletions
+19 -40
View File
@@ -117,17 +117,6 @@ PROXY=
# Endpoint: https://api.hanzo.ai/v1
HANZO_API_KEY=
# Canonical Hanzo Cloud agents (/v1/agents). Lets signed-in users run their own
# cloud agents from chat via `/agent <name>` or the @mention picker. The chat
# backend proxies /api/agents/cloud/* to this host, forwarding the user's
# hanzo.id token server-side (never to the browser); cloud scopes to their org.
# Optional: if unset, derived from OPENAI_BASE_URL (its host, minus /v1).
# HANZO_CLOUD_URL=https://api.hanzo.ai
# A cloud-agent run is a real billable completion; these bound abuse of the proxy.
# CLOUD_AGENT_USER_MAX=30 # max cloud-agent requests per user per window
# CLOUD_AGENT_WINDOW=1 # rate-limit window, minutes
# CLOUD_AGENT_MAX_CONCURRENT=50 # process-wide in-flight ceiling to cloud (503 past it)
#===================================#
# Known Endpoints - chat.yaml #
#===================================#
@@ -474,21 +463,6 @@ 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
@@ -836,26 +810,31 @@ OPENWEATHER_API_KEY=
# Hanzo Chat Code Interpreter API #
#====================================#
# "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
# https://hanzo.ai/docs/chat/code-interpreter
# LIBRECHAT_CODE_API_KEY=your-key
#======================#
# Web Search #
#======================#
# 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`.
# 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:
# https://hanzo.ai/docs/chat/features/web_search
# 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).
# 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
#======================#
# MCP Configuration #
+3 -13
View File
@@ -5,19 +5,9 @@
OPENAI_API_KEY=sk-hanzo-your-api-key-here
OPENAI_BASE_URL=https://api.hanzo.ai/v1
# 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
# Runtime Execution (Code Interpreter)
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
RUNTIME_API_KEY=${OPENAI_API_KEY}
# ====== BRANDING ======
APP_TITLE=Hanzo Chat
-9
View File
@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-9
View File
@@ -42,15 +42,6 @@ RUN \
COPY --chown=node:node . .
# Bake the Umami analytics website id into the Vite build so the index.html
# %VITE_ANALYTICS_SITE_ID% placeholder is substituted at build time. Vite's HTML
# env replacement (loadEnv, envPrefix includes VITE_) picks this up from process.env.
# Without it the tracker POSTs a literal "%VITE_ANALYTICS_SITE_ID%" as the website
# id and analytics.hanzo.ai/api/send answers 400. Public site identifier (it appears
# in the served HTML), safe to default here; override with --build-arg if needed.
ARG VITE_ANALYTICS_SITE_ID=2f72b944-f1f8-4d2d-8f6c-26063bde0d1a
ENV VITE_ANALYTICS_SITE_ID=$VITE_ANALYTICS_SITE_ID
RUN \
# React client build with configurable memory
NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" pnpm run frontend; \
-1
View File
@@ -5,7 +5,6 @@ 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.
-206
View File
@@ -90,212 +90,6 @@ 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, the landing IS the chat
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
(`api.hanzo.ai`). Prod uses `GUEST_MESSAGE_MAX=2`. Exhausting the quota returns
`402 {type:'GUEST_LIMIT'}` and the client opens the existing OpenID/hanzo.id login.
Client render path (guest === chat, not a marketing gate):
- `AuthContext` auto-acquires a guest token when `startupConfig.allowGuestChat`
is true (`silentRefresh` fallback + a dedicated effect closing the config race);
sets `isGuest=true`, `isAuthenticated=false`. `useAuthRedirect` keeps guests on
the chat surface (only truly anonymous, non-guest, non-guest-enabled users go to
`/login`). `Root` shows the chat shell for `isAuthenticated || isGuest`.
- `ChatRoute` renders `ChatView` for `canChat = isAuthenticated || isGuest`; the
`/api/models` + `/api/endpoints` queries run for guests (both routes use
`requireGuestOrJwtAuth` and return the guest-scoped single-model config), and the
roles gate treats a guest as loaded (guests have no agent access). Files:
`client/src/routes/{ChatRoute,useAuthRedirect}.tsx`, `hooks/useGuestAuth.ts`,
`hooks/AuthContext.tsx`, `components/Auth/GuestLimitDialog.tsx`.
Security model (fail-closed, server-enforced):
- `POST /api/auth/guest` issues a short-lived guest JWT (`{guest:true}`,
per-token random id) signed with `JWT_SECRET`. Rate-limited per IP
(`guestTokenLimiter`, `GUEST_TOKEN_MAX`/`GUEST_TOKEN_WINDOW`) so tokens can't be
spam-minted.
- `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. Guests always use the shared, capped
`HANZO_API_KEY` (per-user `hk-` billing is skipped for `guest` principals).
- `guestMessageLimiter` enforces the quota against the REAL client IP
(`utils/guestClientIp` → Cloudflare `CF-Connecting-IP`, falls back to `req.ip`),
NOT the token — clearing cookies / incognito / minting a fresh token does NOT
reset it. Backed by the shared Redis `limiterCache` so it holds across replicas.
`USE_REDIS=true` is MANDATORY (a memory store would let a visitor round-robin
pods to multiply their 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`,
`api/server/utils/guestClientIp.js`,
router wiring in `api/server/routes/agents/index.js`.
- 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.
## Cloud Agents (canonical /v1/agents)
Chat can RUN a user's canonical Hanzo Cloud agents (cloud `/v1/agents`, the ONE
production agent registry) from the thread — alongside the LibreChat-legacy local
agent builder, which is untouched.
- Two surfaces, ONE run path: the `/agent <name> [prompt]` slash command and the
@mention picker (cloud agents appear as a `cloudAgent` type). Both funnel
through `useRunCloudAgent``POST /api/agents/cloud/:name/run`. The @mention /
`/agent` picker arms `/agent <name> ` in the composer; submit is intercepted in
`ChatForm` (`parseAgentCommand`) and dispatched to the run path.
- Server proxy + auth (token never reaches the browser): the chat backend reads
the user's hanzo.id token from the server-side session
(`req.session.openidTokens.idToken`, then `accessToken`, then the httpOnly
cookies) and forwards it as `Authorization: Bearer` to cloud. Cloud's
`SanitizeIdentity` (HIP-0026) validates it and pins `X-Org-Id` from the `owner`
claim, so a user only ever reaches their OWN org's agents — chat never asserts
an org. `requireJwtAuth` gates the proxy (guests rejected); missing token →
honest 401, never a service-token fallback (fail-secure). Agent name is
validated against cloud's handle grammar (traversal/SSRF guard); it is NOT an
open proxy (three fixed endpoints). Principal guard: the on-behalf-of decision
keys off the VALIDATED principal (`req.user.provider==='openid'`), not the
mutable `token_provider` refresh-strategy cookie, so a local user (who never
carries a hanzo.id token) can't run under a stale OpenID session. EVERY
forwarded token — id_token preferred, access_token fallback, from session or
the httpOnly cookie — must pass `isForwardableToken`: a decodable JWT whose
`sub` EQUALS `req.user.openidId` (binding MANDATORY — absent `openidId`/`sub`
or a mismatch ⇒ no forward, no fail-open) and is unexpired. Decode-only is
sound because cloud does the authoritative JWKS validation over the SAME claims,
so it runs as exactly that `sub`; the gate only ever removes a token. An
unbindable/expired/foreign token yields an honest 401, never a fabricated or
wrong-principal run.
- id_token persistence is DECOUPLED from the refresh strategy: OIDC login ALWAYS
persists the on-behalf-of BEARER (id_token + access_token) to
`req.session.openidTokens` (server-side only), regardless of
`OPENID_REUSE_TOKENS`. It does NOT persist the OIDC refresh credential in the
decoupled default — the session refreshes via the local JWT cookie, so
`session.openidTokens.refreshToken` is written ONLY in REUSE mode (where
`refreshController`/`logoutController` read it). That keeps login, refresh AND
logout on the local-JWT path byte-identical to a non-OpenID login; that flag
SOLELY gates whether `/api/auth/refresh` performs the OIDC refresh-grant.
The ~1h id_token is used while valid; durable refresh (hanzo.id/Casdoor OIDC
refresh or an RFC-8693 token-exchange from the chat session) is a tracked
FOLLOW-UP — the login-breaking refresh-grant is NOT enabled here.
- Abuse limits (a run is a real billable completion): a per-user rate limiter
(`cloudAgentLimiter`, `CLOUD_AGENT_USER_MAX`/`CLOUD_AGENT_WINDOW`) guards the
whole `/cloud` router; the client caps input by UTF-8 **bytes** (128 KiB), caps
the buffered response (4 MiB → 502), and sheds load past a process-wide in-flight
ceiling (`CLOUD_AGENT_MAX_CONCURRENT`, 503).
- Key files: backend `api/server/services/CloudAgentsClient.js`,
`api/server/routes/agents/cloud.js` (mounted `/cloud` in
`api/server/routes/agents/index.js`); data layer
`packages/data-provider/src/{types/cloudAgents.ts,api-endpoints.ts,data-service.ts}`;
client `client/src/hooks/Agents/useRunCloudAgent.ts`,
`client/src/utils/agentCommand.ts`,
`client/src/components/Chat/Input/AgentsCommand.tsx`, and the @mention wiring in
`client/src/hooks/Input/useMentions.ts` + `Mention.tsx`.
- Env: `HANZO_CLOUD_URL` (optional; falls back to the `OPENAI_BASE_URL` host).
- Convergence path (later): chat's LibreChat-legacy `/api/agents` CRUD should
converge onto cloud `/v1/agents`; this step only ADDS cloud-agent RUN.
## Unified cloud architecture (2026-07) — investigate-before-ripping map
hanzo.chat is the **chat view** of the Hanzo AI cloud (sibling to hanzo.app =
builder, console = admin). This section is the honest map of what is ALREADY
unified onto the Go backend (`api.hanzo.ai/v1`) vs the one real seam that is not.
Verified by full call-graph + route-table trace; do NOT rip blind.
### What already routes through the Go backend `api.hanzo.ai/v1` (no shadow LLM)
- **Chat completions**: client `useSSE``POST /api/agents/chat/Hanzo` (all
chat, incl. plain-model, goes through the agents framework) → custom-endpoint
resolver (`packages/api/src/endpoints/custom/initialize.ts`) reads
`HANZO_API_KEY` + literal `baseURL https://api.hanzo.ai/v1` from the loaded
config → LangChain OpenAI client → **`POST https://api.hanzo.ai/v1/chat/completions`**
(SSE stream, resumable via `GenerationJobManager`). Per-user `hk-` key +
per-org Commerce debit; fail-closed 402. THIS is the one inference path.
- **Code interpreter** → `LIBRECHAT_CODE_BASEURL` = cloud `/v1/exec`.
- **Web search** → `webSearch` block (searxng+firecrawl contracts) = cloud
`/v1/websearch`.
- **Cloud agents** → `POST /api/agents/cloud/:name/run` server-proxies to cloud
`/v1/agents` with the user's hanzo.id bearer (see "Cloud Agents" section).
- **Model list**: curated **zen-only** (`fetch:false`) in the loaded config —
NO raw upstream names (brand policy). Authoritative prod list lives in the
`chat-config` ConfigMap (`universe infra/k8s/chat/configmap.yaml`); repo
`librechat.yaml` mirrors it (one way).
### DEAD residue — do NOT treat as a live backend
- `config.yaml` (LiteLLM `model_list`/`litellm_params`), `docker/Dockerfile.{simple,dev,custom_ui}`
(`CMD litellm`), `deploy/migrations/*` (`LiteLLM_*` Prisma tables),
`CONTRIBUTING.md` (upstream LiteLLM's), `scripts/cleanup-{litellm,hanzo-chat}.sh`:
all **unreferenced** by any compose/k8s/helm/Dockerfile.multi. Prod runs
`node server/index.js` and hits `api.hanzo.ai/v1` directly — NO local litellm
sidecar. This is upstream merge residue; safe to delete in a dedicated sweep.
### The ONE real parallel store (FLAG — needs a Go-backend home)
LibreChat's Express backend owns, in **MongoDB** (`HanzoChat` DB), all of:
`convos`, `messages`, `presets`, `prompts`/`promptGroups`, `users`, `balances`/
`transactions`, `files`, `sessions` (refresh-token hashes), plus agents/assistants/
memory/RBAC. Schemas: `packages/data-schemas/src/schema/*`. This is the shadow
store that is NOT on the Go backend.
- The Go backend (`hanzoai/ai`, mounted at bare `/v1/*` in cloud) DOES have a
persistence home, but under **casibase names** (`/v1/get-chats`, `/v1/get-chat`,
`/v1/add-chat`, `/v1/get-messages`, `/v1/add-message`, `/v1/get-usages`) — a
different schema/shape than LibreChat's Mongo.
- The canonical OpenAPI repo has `chat/openapi.yaml` describing the INTENDED
LibreChat-shaped REST surface (`/v1/chat/convos`, `/v1/chat/messages`,
`/v1/chat/presets`, `/v1/chat/balance`, `/v1/chat/auth/*`) — but the Go binary
**does not implement it yet**, and `ai/openapi.yaml` under-documents the real
casibase routes.
- To truly kill the parallel store WITHOUT breaking live chat: the Go backend
(or Base) must implement `chat/openapi.yaml` (conversations/messages/presets),
then repoint chat's data layer at it behind a flag and dual-write during
cutover. Until then Mongo stays (ripping it = data loss + dead chat).
**Coordinate with the openapi agent** (canonical spec + SDK regen).
### IAM-native auth (HIP-0111) — federated to hanzo.id, LIVE
- **Prod (backend-proxied)**: LibreChat passport `openid-client` strategy,
OIDC **discovery** from `${OPENID_ISSUER}` = `https://hanzo.id`
(`/.well-known/openid-configuration`; discovery fetched via in-cluster
`iam.hanzo.svc` to dodge the CF hairpin), client_id **`hanzo-chat`**, callback
`/oauth/openid/callback`. Local email/password is OFF in prod
(`ALLOW_EMAIL_LOGIN=false`, `ALLOW_REGISTRATION=false`); social OIDC only.
Files: `api/strategies/openidStrategy.js`, `api/server/socialLogins.js`,
`api/server/routes/oauth.js`. This IS IAM-native (federated), just not the
console `@hanzo/iam-js-sdk` shape.
- **Static/IAM SPA mode** (`Dockerfile.static`, not the live prod deploy): browser
`@hanzo/iam` `BrowserIamSdk` PKCE straight to hanzo.id
(`client/src/utils/iam.ts`, `OAuthCallback.tsx`). ⚠️ INCONSISTENCY: uses
client_id **`app-chat`** while prod uses `hanzo-chat` — align to `hanzo-chat`.
`@hanzo/iam` is pinned `^0.4.0` (HIP-0111 wants ≥0.11.0); this path is dormant.
### One brand system, but pick the RIGHT one for a Tailwind app
`@hanzo/ui` and `@hanzo/gui` are **two different, non-overlapping** design systems:
- **`@hanzo/ui`** = shadcn/ui + Tailwind + Radix (multi-framework). chat is
Vite + React 18 + Tailwind, so THIS is the correct shared lib. Already used:
`client/src/components/Nav/HanzoHeader.tsx` mounts `@hanzo/ui/navigation`
`HanzoHeader` for cross-app chrome. Monochrome rebrand already done (grey ramp,
H mark, favicon = hanzo.app set).
- **`@hanzo/gui`** = a **Tamagui** fork (Next.js 15 / React 19, RN-web) — console's
stack. Forcing it into the Vite/React18 LibreChat client = a ground-up rewrite
of a live product; NOT done. Unify by widening `@hanzo/ui` adoption + matching
console's monochrome tokens, NOT by swapping component frameworks.
### Config filename caveat
`loadCustomConfig.js` defaults to **`chat.yaml`** (`CONFIG_PATH || <root>/chat.yaml`).
Prod sets `CONFIG_PATH=/app/chat.yaml` (ConfigMap mount). Repo ships
`librechat.yaml` (reference); a deploy that doesn't set `CONFIG_PATH` to it (or
provide `chat.yaml`) falls back to the built-in `openAI` endpoint. `OPENAI_BASE_URL`
in `compose.prod.yml` is inert here (built-in openAI reads `OPENAI_REVERSE_PROXY`).
## Internal Package Names
These are kept as-is from upstream (npm deps, not worth renaming):
-6
View File
@@ -1,6 +0,0 @@
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,5 +1,3 @@
<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.
+44 -106
View File
@@ -1,6 +1,5 @@
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');
@@ -23,21 +22,6 @@ 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);
}
@@ -66,24 +50,10 @@ const checkModelAccess = async function (userId, model) {
};
/**
* 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.
* Simple check method that calculates token cost and returns balance info.
* Integrates with Commerce balance gate when configured (fail-open).
*/
const checkBalanceRecord = async function ({
req,
user,
model,
endpoint,
@@ -95,79 +65,7 @@ const checkBalanceRecord = async function ({
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
const tokenCost = amount * multiplier;
// 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) ──
// Retrieve the balance record
let record = await Balance.findOne({ user }).lean();
if (!record) {
logger.debug('[Balance.check] No balance record found for user', { user });
@@ -178,6 +76,46 @@ 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
@@ -286,7 +224,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, req });
const result = await checkBalanceRecord(txData);
if (result.canSpend) {
return true;
}
-122
View File
@@ -1,122 +0,0 @@
// 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,10 +1,6 @@
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,9 +72,6 @@ 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,7 +1,6 @@
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');
/**
@@ -40,9 +39,6 @@ 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,16 +40,12 @@ 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 };
@@ -1,91 +0,0 @@
/**
* 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'] });
});
});
});
+1 -12
View File
@@ -891,20 +891,9 @@ 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 (hideSequentialOutputs) {
if (config.configurable.hide_sequential_outputs) {
this.contentParts = this.contentParts.filter((part, index) => {
// Include parts that are either:
// 1. At or after the finalContentStart index
@@ -15,7 +15,6 @@ jest.mock('@hanzochat/api', () => ({
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
createRun: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
@@ -2258,122 +2257,3 @@ 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' },
]);
});
});
@@ -1,45 +0,0 @@
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 };
+8 -31
View File
@@ -7,11 +7,7 @@ const {
generateAdminExchangeCode,
} = require('@hanzochat/api');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const {
setAuthTokens,
setOpenIDAuthTokens,
persistOpenIDTokensToSession,
} = require('~/server/services/AuthService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const getLogStores = require('~/cache/getLogStores');
const { checkBan } = require('~/server/middleware');
const { generateToken } = require('~/models');
@@ -60,32 +56,13 @@ function createOAuthHandler(redirectUri = domains.client) {
}
/** Standard OAuth flow - set cookies and redirect */
if (req.user && req.user.provider == 'openid') {
if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
/**
* REUSE path: the OIDC tokenset drives BOTH the app auth token and the
* refresh grant (`/api/auth/refresh` performs an OIDC refresh). Unchanged.
* setOpenIDAuthTokens already persists the id_token to the session.
*/
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
} else {
/**
* Decoupled path (the live default, REUSE disabled). Keep refresh on the
* local-JWT path so login/refresh cookies stay byte-identical, but ALSO
* persist the id_token server-side so downstream on-behalf-of cloud calls
* (POST /api/agents/cloud/:name/run -> cloud /v1/agents) can run as this
* hanzo.id principal. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
* refresh-grant; it no longer gates id_token persistence.
*/
await setAuthTokens(req.user._id, res);
try {
persistOpenIDTokensToSession(req, req.user.tokenset);
} catch (persistErr) {
/** On-behalf-of is best-effort; NEVER let it break the login redirect. */
logger.warn('[OAuth] Failed to persist OpenID tokens for on-behalf-of:', persistErr);
}
}
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
}
+1 -86
View File
@@ -5,11 +5,10 @@ const mockGenerateAdminExchangeCode = jest.fn();
const mockSyncUserEntraGroupMemberships = jest.fn();
const mockSetAuthTokens = jest.fn();
const mockSetOpenIDAuthTokens = jest.fn();
const mockPersistOpenIDTokensToSession = jest.fn();
const mockGetLogStores = jest.fn();
const mockCheckBan = jest.fn();
const mockGenerateToken = jest.fn();
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
const mockLogger = { info: jest.fn(), error: jest.fn() };
jest.mock('librechat-data-provider', () => ({
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
@@ -34,7 +33,6 @@ jest.mock('~/server/services/PermissionService', () => ({
jest.mock('~/server/services/AuthService', () => ({
setAuthTokens: (...args) => mockSetAuthTokens(...args),
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
}));
jest.mock(
@@ -150,87 +148,4 @@ describe('createOAuthHandler', () => {
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
// The decouple: id_token persistence for downstream on-behalf-of is split from
// the refresh strategy. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
// refresh-grant; it no longer gates whether the id_token is persisted.
describe('standard OpenID flow — id_token persistence decouple', () => {
beforeEach(() => {
/** Not an admin cross-origin redirect — exercise the cookie/session path. */
mockIsAdminPanelRedirect.mockReturnValue(false);
});
it('REUSE_TOKENS=false: keeps local-JWT refresh AND persists the id_token to session', async () => {
process.env.OPENID_REUSE_TOKENS = 'false';
const handler = createOAuthHandler('http://localhost:3080');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
/** Refresh stays on the local-JWT path — login/refresh cookies unchanged. */
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
/** id_token IS persisted for on-behalf-of, regardless of REUSE_TOKENS. */
expect(mockPersistOpenIDTokensToSession).toHaveBeenCalledWith(req, req.user.tokenset);
/** The OIDC-reuse writer (which flips refresh to the OIDC grant) is NOT used. */
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
expect(next).not.toHaveBeenCalled();
});
it('REUSE_TOKENS=true: uses the OIDC writer (refresh path unchanged); no separate persist call', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
const handler = createOAuthHandler('http://localhost:3080');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockSyncUserEntraGroupMemberships).toHaveBeenCalledWith(
req.user,
'openid-access-token',
);
expect(mockSetOpenIDAuthTokens).toHaveBeenCalledWith(req.user.tokenset, req, res, 'user-123');
/** setOpenIDAuthTokens already persists the id_token; no separate call. */
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
expect(next).not.toHaveBeenCalled();
});
it('local user: setAuthTokens only, never persists an OpenID token', async () => {
process.env.OPENID_REUSE_TOKENS = 'false';
const handler = createOAuthHandler('http://localhost:3080');
const req = buildReq({ user: { _id: 'local-1', provider: 'local', email: 'l@example.com' } });
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockSetAuthTokens).toHaveBeenCalledWith('local-1', res);
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
});
it('login redirect survives a persistence failure (login is paramount)', async () => {
process.env.OPENID_REUSE_TOKENS = 'false';
mockPersistOpenIDTokensToSession.mockImplementation(() => {
throw new Error('session store down');
});
const handler = createOAuthHandler('http://localhost:3080');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
/** The redirect still happens; the error is swallowed (best-effort). */
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
expect(next).not.toHaveBeenCalled();
});
});
});
+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' 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';",
"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';",
);
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
@@ -1,118 +0,0 @@
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();
});
});
@@ -1,134 +0,0 @@
/**
* 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,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || 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' });
});
});
@@ -1,86 +0,0 @@
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();
});
});
@@ -1,81 +0,0 @@
/**
* 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,11 +60,15 @@ const canAccessAgentFromBody = (options) => {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
// 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 (!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
if (isEphemeralAgentId(agentId)) {
return next();
}
@@ -104,15 +104,13 @@ describe('canAccessAgentFromBody middleware', () => {
});
describe('primary agent checks', () => {
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.
test('returns 400 when agent_id is missing on agents endpoint', async () => {
req.body.agent_id = undefined;
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
});
test('proceeds for ephemeral primary agent without addedConvo', async () => {
@@ -1,61 +0,0 @@
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,8 +10,6 @@ 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');
@@ -38,8 +36,6 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
requireGuestOrJwtAuth,
enforceGuestScope,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
@@ -1,44 +0,0 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { ViolationTypes } = require('librechat-data-provider');
const logViolation = require('~/cache/logViolation');
/**
* Per-user rate limiter for the cloud-agents proxy (`/api/agents/cloud/*`). A run
* is a real, billable cloud chat completion that holds an upstream socket for up
* to 30s, yet it bypasses the message limiters that guard the sibling completion
* path. This caps a signed-in user to CLOUD_AGENT_USER_MAX requests per window so
* a loop cannot degrade the shared backend or run up denial-of-wallet cost.
* Keyed by user id (the route is JWT-only; guests never reach it).
*/
const {
CLOUD_AGENT_USER_MAX = 30,
CLOUD_AGENT_WINDOW = 1,
CLOUD_AGENT_VIOLATION_SCORE: score,
} = process.env;
const windowMs = CLOUD_AGENT_WINDOW * 60 * 1000;
const max = Number(CLOUD_AGENT_USER_MAX);
const handler = async (req, res) => {
const type = ViolationTypes.TOOL_CALL_LIMIT;
const errorMessage = {
type,
max,
limiter: 'user',
windowInMinutes: windowMs / 60000,
};
await logViolation(req, res, type, errorMessage, score);
res.status(429).json({ message: 'Too many cloud agent requests. Try again later.' });
};
const cloudAgentLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: (req) => req.user?.id,
store: limiterCache('cloud_agent_limiter'),
});
module.exports = cloudAgentLimiter;
@@ -1,41 +0,0 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = require('~/server/utils');
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
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.
*
* Caps how many guest tokens a single client IP can mint per window so nobody can
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`) and backed by the shared
* Redis `limiterCache` so the cap holds across replicas. Note this is defense in
* depth only: even unlimited tokens cannot multiply the message quota, which is
* itself keyed per-IP (see `guestMessageLimiter`).
*/
const guestTokenLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: guestClientIp,
store: limiterCache('guest_token_limiter'),
});
module.exports = { guestTokenLimiter };
@@ -1,36 +0,0 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = 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), so the count is shared across replicas — a guest cannot
* multiply their quota by round-robining pods. The key is the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`), NOT the guest token, so
* clearing cookies, opening incognito, or minting a fresh guest token does not
* reset the count. 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: guestClientIp,
skip: (req) => req.user?.guest !== true,
store: limiterCache('guest_message_limiter'),
});
module.exports = { guestMessageLimiter };
@@ -1,78 +0,0 @@
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,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || 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);
}
});
});
-6
View File
@@ -2,14 +2,11 @@ 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');
const registerLimiter = require('./registerLimiter');
const toolCallLimiter = require('./toolCallLimiter');
const cloudAgentLimiter = require('./cloudAgentLimiter');
const messageLimiters = require('./messageLimiters');
const verifyEmailLimiter = require('./verifyEmailLimiter');
const resetPasswordLimiter = require('./resetPasswordLimiter');
@@ -20,11 +17,8 @@ module.exports = {
...messageLimiters,
...forkLimiters,
loginLimiter,
guestTokenLimiter,
guestMessageLimiter,
registerLimiter,
toolCallLimiter,
cloudAgentLimiter,
createTTSLimiters,
createSTTLimiters,
verifyEmailLimiter,
@@ -1,59 +0,0 @@
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;
@@ -1,136 +0,0 @@
/**
* 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
@@ -1,230 +0,0 @@
/**
* 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);
}
});
});
});
@@ -1,266 +0,0 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() },
}));
// The proxy keys the on-behalf-of decision off the VALIDATED principal
// (`req.user.provider`), not a cookie. requireJwtAuth is exercised elsewhere;
// here it simply installs the principal under test so the token-resolution +
// honest-error logic is isolated. Set `mockPrincipal` per test (the `mock`
// prefix is required for a jest.mock factory to reference it).
let mockPrincipal;
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, _res, next) => {
req.user = mockPrincipal;
next();
},
// Rate limiter is exercised in its own layer; here it is a pass-through so the
// proxy logic (token resolution, honest errors, name guard) is under test.
cloudAgentLimiter: (_req, _res, next) => next(),
}));
const mockClient = {
list: jest.fn(),
get: jest.fn(),
run: jest.fn(),
};
jest.mock('~/server/services/CloudAgentsClient', () => ({
getCloudAgentsClient: jest.fn(() => mockClient),
// Boundary name validation in the route uses the real grammar.
AGENT_NAME_RE: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
}));
const jwt = require('jsonwebtoken');
const express = require('express');
const request = require('supertest');
const { getCloudAgentsClient } = require('~/server/services/CloudAgentsClient');
const cloudRouter = require('../cloud');
const OPENID_SUB = 'sub-abc-123';
const OPENID_USER = { id: 'u_openid', provider: 'openid', openidId: OPENID_SUB };
const LOCAL_USER = { id: 'u_local', provider: 'local' };
/**
* Mint a decodable id_token. The signature is irrelevant to the proxy — it
* decode-only checks `exp` + `sub`; cloud performs the authoritative JWKS
* validation. So a throwaway secret is exactly right here.
*/
function mintIdToken({ sub = OPENID_SUB, exp = Math.floor(Date.now() / 1000) + 3600 } = {}) {
return jwt.sign({ sub, exp }, 'test-only-not-verified');
}
/**
* Build an app whose session carries (or omits) the OpenID tokens the proxy
* forwards to cloud. The authenticated principal is `mockPrincipal` (default: an
* OpenID user); `cookie` models the httpOnly no-session fallback.
*/
function buildApp(session, cookie) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.session = session;
if (cookie != null && req.headers.cookie == null) {
req.headers.cookie = cookie;
}
next();
});
app.use('/api/agents/cloud', cloudRouter);
return app;
}
const VALID_ID = mintIdToken();
const withToken = { openidTokens: { idToken: VALID_ID, accessToken: 'ACC' } };
describe('cloud agents proxy route', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPrincipal = OPENID_USER;
getCloudAgentsClient.mockReturnValue(mockClient);
});
describe('token handling (no leak, fail-secure)', () => {
it('401s when the openid principal has no hanzo.id session token', async () => {
const res = await request(buildApp({})).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('forwards the session id_token (never the browser) to cloud', async () => {
mockClient.list.mockResolvedValue({ agents: [{ name: 'researcher' }] });
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
expect(res.status).toBe(200);
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
expect(res.body).toEqual({ agents: [{ name: 'researcher' }], enabled: true });
});
it('falls back to a principal-bound access_token JWT when there is no id_token', async () => {
mockClient.list.mockResolvedValue({ agents: [] });
// hanzo.id issues JWT access tokens too; the fallback stays principal-bound.
const accJwt = mintIdToken();
const app = buildApp({ openidTokens: { accessToken: accJwt } });
await request(app).get('/api/agents/cloud');
expect(mockClient.list).toHaveBeenCalledWith(accJwt);
});
it('401s (never forwards) an opaque access_token that cannot be principal-bound', async () => {
// A non-JWT access_token has no `sub` to bind against — fail-secure, do not
// forward. (This is the path a selective `openid_access_token` cookie
// injection would take; the binding requirement closes it.)
const app = buildApp({ openidTokens: { accessToken: 'OPAQUE_NO_BINDING' } });
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('401s (never forwards) an access_token JWT that names a different principal', async () => {
const foreignAcc = mintIdToken({ sub: 'sub-someone-else' });
const app = buildApp({ openidTokens: { accessToken: foreignAcc } });
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('reads the httpOnly openid_id_token cookie when the session is empty', async () => {
mockClient.list.mockResolvedValue({ agents: [] });
const app = buildApp({}, `openid_id_token=${VALID_ID}`);
await request(app).get('/api/agents/cloud');
expect(mockClient.list).toHaveBeenCalledWith(VALID_ID);
});
});
describe('honest expiry + principal binding (never a fabricated session)', () => {
it('honest 401 when the id_token is past its own exp (no forward)', async () => {
const expired = mintIdToken({ exp: Math.floor(Date.now() / 1000) - 60 });
const app = buildApp({ openidTokens: { idToken: expired } });
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('honest 401 when the id_token names a different principal (sub mismatch)', async () => {
const foreign = mintIdToken({ sub: 'sub-someone-else' });
const app = buildApp({ openidTokens: { idToken: foreign } });
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('honest 401 when the id_token is not a decodable JWT', async () => {
const app = buildApp({ openidTokens: { idToken: 'not-a-jwt' } });
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
});
describe('principal guard (no confused deputy)', () => {
it('401s and forwards nothing for a LOCAL principal, even with a valid openid session', async () => {
// A local-JWT user whose browser still holds a valid prior openid session.
// Forwarding those tokens would run as the wrong principal — deny at the
// identity layer (req.user.provider), independent of any cookie.
mockPrincipal = LOCAL_USER;
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('401s for a principal that carries no provider', async () => {
mockPrincipal = { id: 'u_unknown' };
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('401s for an openid principal with no openidId to bind against (no fail-open)', async () => {
// provider==='openid' but the record has no openidId: the binding cannot be
// asserted, so NO token is forwarded — even one whose sub would have matched
// a normal user. Fail-secure closes the null-binding gap.
mockPrincipal = { id: 'u_openid_no_sub', provider: 'openid' };
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
it('401s when a foreign id_token is injected via cookie with an empty session (confused-deputy denied)', async () => {
// Attacker pairs their own openid app-token (req.user) with a victim's
// id_token in the openid_id_token cookie and an empty session. The sub
// binding rejects it — the forwarded principal can only ever be req.user.
const foreign = mintIdToken({ sub: 'victim-sub-xyz' });
const app = buildApp({}, `openid_id_token=${foreign}`);
const res = await request(app).get('/api/agents/cloud');
expect(res.status).toBe(401);
expect(mockClient.list).not.toHaveBeenCalled();
});
});
describe('disabled deployment', () => {
it('returns an empty, disabled list when cloud agents are not configured', async () => {
getCloudAgentsClient.mockReturnValue(null);
const res = await request(buildApp(withToken)).get('/api/agents/cloud');
expect(res.status).toBe(200);
expect(res.body).toEqual({ agents: [], enabled: false });
});
});
describe('run', () => {
it('forwards {input} and returns the RunResult', async () => {
mockClient.run.mockResolvedValue({ id: 'run_1', status: 'ok', output: 'done' });
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.send({ input: 'summarize' });
expect(res.status).toBe(200);
expect(mockClient.run).toHaveBeenCalledWith(VALID_ID, 'researcher', 'summarize');
expect(res.body.output).toBe('done');
});
it('surfaces an upstream error body with its status (honest failure)', async () => {
const err = Object.assign(new Error('bad gateway'), {
status: 502,
body: { status: 'error', error: 'model down' },
});
mockClient.run.mockRejectedValue(err);
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.send({ input: 'x' });
expect(res.status).toBe(502);
expect(res.body).toEqual({ status: 'error', error: 'model down' });
});
it('maps a client-side validation error (bad name) to 400', async () => {
const err = Object.assign(new Error('invalid agent name'), { status: 400 });
mockClient.run.mockRejectedValue(err);
const res = await request(buildApp(withToken))
.post('/api/agents/cloud/researcher/run')
.send({ input: 'x' });
// simulate the client rejecting after the boundary let a valid name through
expect(res.status).toBe(400);
});
});
describe('boundary name validation (traversal / injection guard)', () => {
// Each decodes (per Express) to a value outside cloud's handle grammar; the
// route must reject at the boundary BEFORE constructing any client call.
const smuggles = [
'..%2Fetc', // -> ../etc
'%2e%2e%2fadmin', // -> ../admin
'..%5Cevil', // -> ..\evil
'a%00b', // -> null byte
'a%0dHost', // -> CR injection
'a%20b', // -> space
];
for (const name of smuggles) {
it(`rejects "${name}" with 400 and never calls the client`, async () => {
const res = await request(buildApp(withToken))
.post(`/api/agents/cloud/${name}/run`)
.send({ input: 'x' });
expect(res.status).toBe(400);
expect(mockClient.run).not.toHaveBeenCalled();
});
}
it('rejects a bad name on GET /:name too', async () => {
const res = await request(buildApp(withToken)).get('/api/agents/cloud/..%2Fetc');
expect(res.status).toBe(400);
expect(mockClient.get).not.toHaveBeenCalled();
});
});
});
-201
View File
@@ -1,201 +0,0 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { requireJwtAuth, cloudAgentLimiter } = require('~/server/middleware');
const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/CloudAgentsClient');
/**
* Cloud agents router — lets a signed-in chat user RUN their own canonical Hanzo
* Cloud agents (`/v1/agents`) from the chat thread. Mounted at `/api/agents/cloud`.
*
* GET /api/agents/cloud list the caller's cloud agents
* GET /api/agents/cloud/:name one agent's detail + recent runs
* POST /api/agents/cloud/:name/run run the agent {input} -> RunResult
*
* Auth: `requireJwtAuth` gates every route (guests are rejected). The chat
* backend then forwards the user's hanzo.id id_token to cloud as a Bearer;
* cloud validates it and scopes to the user's org (see CloudAgentsClient). The
* token is read from the server-side session and NEVER returned to the browser.
*/
const router = express.Router();
/**
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
* mandatory — the function only ever REMOVES a token from consideration, never
* admits one:
* - Decodable JWT: an opaque/garbage token cannot be principal-bound, so it is
* never forwarded. hanzo.id — the only cloud IdP — always issues JWTs.
* - Principal binding (MANDATORY): the token must NAME the authenticated user
* (`sub === user.openidId`). If we cannot ASSERT the binding — no `openidId`
* on the principal, no `sub` on the token, or a mismatch — we do NOT forward.
* This keeps "forwarded principal == req.user" true by construction, closing
* the credential-mixing confused deputy (a session/cookie carrying a
* different user's token) with no fail-open when binding data is absent.
* - Expiry: never forward a token past its own `exp`.
*
* Decode-only (no signature verification): cloud performs the authoritative
* JWKS + claim validation over the SAME claims, so it runs as exactly this
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
* binding; an intact `sub` is rejected by cloud on signature.
*
* @param {string|undefined} token
* @param {{openidId?: string}} user
* @returns {boolean}
*/
function isForwardableToken(token, user) {
if (!token || !user?.openidId) {
return false;
}
const claims = jwt.decode(token);
if (!claims || typeof claims !== 'object') {
return false;
}
if (claims.sub !== user.openidId) {
return false;
}
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
return false;
}
return true;
}
/**
* Resolve the caller's hanzo.id bearer for the on-behalf-of call to cloud.
*
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
* authoritative DB user loaded by requireJwtAuth), NOT any cookie. A local user
* never carries a hanzo.id token, so a stale OpenID session left in the browser
* can never be forwarded under a local identity — the confused deputy is denied
* at the identity layer.
*
* EVERY candidate — session first, then the httpOnly no-session cookie fallback;
* id_token preferred, access_token second — must pass `isForwardableToken`:
* principal-bound to req.user and unexpired. Returns null — for an honest 401,
* never a wrong-principal, expired, unbound, or fabricated call — otherwise.
*
* @param {import('express').Request} req
* @returns {string|null}
*/
function getUserCloudBearer(req) {
if (req.user?.provider !== 'openid') {
return null;
}
const parsed = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const session = req.session?.openidTokens;
const idToken = session?.idToken || parsed.openid_id_token;
if (isForwardableToken(idToken, req.user)) {
return idToken;
}
/**
* access_token fallback (rare: the session/cookie carries no id_token).
* hanzo.id issues JWT access tokens too, so this stays principal-bound; an
* opaque or foreign access_token fails the gate and is never forwarded.
*/
const accessToken = session?.accessToken || parsed.openid_access_token;
if (isForwardableToken(accessToken, req.user)) {
return accessToken;
}
return null;
}
router.use(requireJwtAuth);
/**
* Per-user rate limit. A run is a real, billable cloud completion holding an
* upstream socket; without this it escapes the throttle that guards the sibling
* chat-completion path. Applies to every cloud route (list/get/run) so no proxy
* op can be looped to degrade the shared backend. Runs after requireJwtAuth so
* the limiter keys on a real user id.
*/
router.use(cloudAgentLimiter);
/**
* Validate the :name path segment at the HTTP boundary — the same cloud handle
* grammar the client enforces, applied here so a malformed/decoded name
* (traversal, null byte, CRLF, backslash) is rejected before any client call is
* even constructed. Defense at the boundary, not only in the client.
*/
router.param('name', (req, res, next, name) => {
if (!AGENT_NAME_RE.test((name ?? '').toString().trim())) {
return res.status(400).json({ error: 'invalid agent name' });
}
return next();
});
/**
* Map a CloudAgentsClient error to an HTTP response. Upstream failures that
* carry a run body (cloud's 502 with a recorded error run) are passed through so
* the client can render the honest failure; everything else is normalized.
* @param {import('express').Response} res
* @param {Error & {status?: number, body?: any}} err
* @param {string} action
*/
function sendCloudError(res, err, action) {
const status = err.status && err.status >= 400 && err.status < 600 ? err.status : 502;
if (status >= 500) {
logger.warn(`[cloudAgents] ${action} failed`, { status, message: err.message });
}
if (err.body && typeof err.body === 'object') {
return res.status(status).json(err.body);
}
return res.status(status).json({ error: err.message || 'cloud agents request failed' });
}
/** GET /api/agents/cloud — list the caller's cloud agents. */
router.get('/', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
return res.json({ agents: [], enabled: false });
}
const bearer = getUserCloudBearer(req);
if (!bearer) {
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
}
try {
const data = await client.list(bearer);
return res.json({ ...data, enabled: true });
} catch (err) {
return sendCloudError(res, err, 'list');
}
});
/** GET /api/agents/cloud/:name — one agent's detail + recent runs. */
router.get('/:name', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
return res.status(404).json({ error: 'cloud agents not configured' });
}
const bearer = getUserCloudBearer(req);
if (!bearer) {
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
}
try {
const data = await client.get(bearer, req.params.name);
return res.json(data);
} catch (err) {
return sendCloudError(res, err, 'get');
}
});
/** POST /api/agents/cloud/:name/run — run the agent, return its RunResult. */
router.post('/:name/run', async (req, res) => {
const client = getCloudAgentsClient();
if (!client) {
return res.status(404).json({ error: 'cloud agents not configured' });
}
const bearer = getUserCloudBearer(req);
if (!bearer) {
return res.status(401).json({ error: 'cloud agents require hanzo.id sign-in' });
}
try {
const run = await client.run(bearer, req.params.name, req.body?.input ?? '');
return res.json(run);
} catch (err) {
return sendCloudError(res, err, 'run');
}
});
module.exports = router;
+14 -70
View File
@@ -8,15 +8,11 @@ const {
messageIpLimiter,
configMiddleware,
messageUserLimiter,
enforceGuestScope,
guestMessageLimiter,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const { saveMessage } = require('~/models');
const openai = require('./openai');
const responses = require('./responses');
const { v1 } = require('./v1');
const cloud = require('./cloud');
const chat = require('./chat');
const { LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@@ -37,76 +33,10 @@ 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);
/**
* Canonical Hanzo Cloud agents (`/v1/agents`). Mounted BEFORE the legacy `/`
* (v1) router so `/cloud/*` is not shadowed by v1's `GET /:id`. The cloud router
* carries its own `requireJwtAuth` and forwards the user's hanzo.id bearer to
* cloud server-side (see cloud.js). Legacy `/api/agents` CRUD stays untouched.
*/
router.use('/cloud', cloud);
router.use('/', v1);
/**
@@ -339,4 +269,18 @@ 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,7 +17,6 @@ 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');
@@ -42,7 +41,6 @@ router.post(
loginController,
);
router.post('/refresh', refreshController);
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
router.post(
'/register',
middleware.registerLimiter,
-5
View File
@@ -5,7 +5,6 @@ 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');
@@ -60,8 +59,6 @@ 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
@@ -124,8 +121,6 @@ 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,
+2 -14
View File
@@ -15,7 +15,6 @@ 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');
@@ -26,17 +25,9 @@ const assistantClients = {
};
const router = express.Router();
router.use(requireJwtAuth);
/**
* 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 });
}
router.get('/', async (req, res) => {
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
@@ -66,9 +57,6 @@ router.get('/', requireGuestOrJwtAuth, 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 requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const endpointController = require('~/server/controllers/EndpointController');
const router = express.Router();
router.get('/', requireGuestOrJwtAuth, endpointController);
router.get('/', requireJwtAuth, endpointController);
module.exports = router;
+2 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const { modelController } = require('~/server/controllers/ModelController');
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
const { requireJwtAuth } = require('~/server/middleware/');
const router = express.Router();
router.get('/', requireGuestOrJwtAuth, modelController);
router.get('/', requireJwtAuth, modelController);
module.exports = router;
+3 -8
View File
@@ -1,4 +1,4 @@
// 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)
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
@@ -23,6 +23,7 @@ const domains = {
};
router.use(logHeaders);
router.use(loginLimiter);
const oauthHandler = createOAuthHandler();
@@ -41,7 +42,6 @@ router.get('/error', (req, res) => {
*/
router.get(
'/google',
loginLimiter,
passport.authenticate('google', {
scope: ['openid', 'profile', 'email'],
session: false,
@@ -66,7 +66,6 @@ router.get(
*/
router.get(
'/facebook',
loginLimiter,
passport.authenticate('facebook', {
scope: ['public_profile'],
profileFields: ['id', 'email', 'name'],
@@ -91,7 +90,7 @@ router.get(
/**
* OpenID Routes
*/
router.get('/openid', loginLimiter, (req, res, next) => {
router.get('/openid', (req, res, next) => {
return passport.authenticate('openid', {
session: false,
state: randomState(),
@@ -115,7 +114,6 @@ router.get(
*/
router.get(
'/github',
loginLimiter,
passport.authenticate('github', {
scope: ['user:email', 'read:user'],
session: false,
@@ -140,7 +138,6 @@ router.get(
*/
router.get(
'/discord',
loginLimiter,
passport.authenticate('discord', {
scope: ['identify', 'email'],
session: false,
@@ -165,7 +162,6 @@ router.get(
*/
router.get(
'/apple',
loginLimiter,
passport.authenticate('apple', {
session: false,
}),
@@ -188,7 +184,6 @@ router.post(
*/
router.get(
'/saml',
loginLimiter,
passport.authenticate('saml', {
session: false,
}),
+2 -3
View File
@@ -3,12 +3,11 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
router.get('/favorites', requireJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
module.exports = router;
+1 -2
View File
@@ -13,7 +13,6 @@ const {
configMiddleware,
canDeleteAccount,
requireJwtAuth,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const settings = require('./settings');
@@ -21,7 +20,7 @@ const settings = require('./settings');
const router = express.Router();
router.use('/settings', settings);
router.get('/', requireGuestOrJwtAuth, getUserController);
router.get('/', requireJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
+9 -50
View File
@@ -413,53 +413,6 @@ const setAuthTokens = async (userId, res, _session = null) => {
}
};
/**
* @function persistOpenIDTokensToSession
* Persist the OpenID tokenset material an on-behalf-of downstream call needs
* (e.g. running the caller's canonical Hanzo Cloud `/v1/agents`) into the
* server-side express-session — and ONLY there, never a browser cookie.
*
* This is DELIBERATELY decoupled from the token-refresh strategy. It runs on
* EVERY OpenID login regardless of `OPENID_REUSE_TOKENS`; that flag alone still
* governs whether `/api/auth/refresh` performs an OIDC refresh-grant. It writes
* only server-side session state (no `token_provider`, `refreshToken`, or other
* auth cookie), so it cannot alter the browser-facing login/refresh cookies — a
* REUSE-disabled login stays byte-identical.
*
* The OIDC `refresh` credential is a SEPARATE concern from the on-behalf-of
* BEARER: it is persisted only when passed EXPLICITLY as `refreshToken`, which
* the REUSE path does (its session refreshes via OIDC and `refreshController` /
* `logoutController` read `session.openidTokens.refreshToken`). The decoupled
* default passes NO refresh credential — its session refreshes via the local
* JWT cookie — so no OIDC refresh token is written here. That keeps the decoupled
* session carrying only bearer material and leaves refresh/logout identical to a
* non-OpenID login (they read the local cookie, never this session field).
*
* @param {Object} req - request carrying the express-session
* @param {import('openid-client').TokenEndpointResponse & Partial<import('openid-client').TokenEndpointResponseHelpers>} tokenset
* @param {string} [refreshToken] - OIDC refresh credential to bind to this session (REUSE mode only); omitted in the decoupled path
* @returns {boolean} true if persisted; false when no session (or tokenset) was available
*/
const persistOpenIDTokensToSession = (req, tokenset, refreshToken) => {
if (!req?.session) {
logger.warn(
'[persistOpenIDTokensToSession] No session available; on-behalf-of tokens not stored',
);
return false;
}
if (!tokenset) {
return false;
}
const expiryInMilliseconds = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
req.session.openidTokens = {
accessToken: tokenset.access_token,
idToken: tokenset.id_token,
refreshToken,
expiresAt: Date.now() + expiryInMilliseconds,
};
return true;
};
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
@@ -525,8 +478,15 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
sameSite: 'lax',
});
/** Store tokens server-side in session to avoid large cookies (one writer). */
if (!persistOpenIDTokensToSession(req, tokenset, refreshToken)) {
/** Store tokens server-side in session to avoid large cookies */
if (req.session) {
req.session.openidTokens = {
accessToken: tokenset.access_token,
idToken: tokenset.id_token,
refreshToken: refreshToken,
expiresAt: expirationDate.getTime(),
};
} else {
logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies');
res.cookie('openid_access_token', tokenset.access_token, {
expires: expirationDate,
@@ -636,7 +596,6 @@ module.exports = {
setAuthTokens,
resetPassword,
setOpenIDAuthTokens,
persistOpenIDTokensToSession,
requestPasswordReset,
resendVerificationEmail,
};
+1 -75
View File
@@ -36,8 +36,7 @@ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
const { shouldUseSecureCookie } = require('@hanzochat/api');
const { setOpenIDAuthTokens, persistOpenIDTokensToSession } = require('./AuthService');
const { logger } = require('@librechat/data-schemas');
const { setOpenIDAuthTokens } = require('./AuthService');
/** Helper to build a mock Express response */
function mockResponse() {
@@ -268,76 +267,3 @@ describe('setOpenIDAuthTokens', () => {
});
});
});
/**
* The on-behalf-of session writer, decoupled from the refresh strategy. Called on
* every OpenID login (regardless of OPENID_REUSE_TOKENS) so downstream cloud
* calls can run as the hanzo.id principal — and ONLY ever touches server-side
* session state, never a browser cookie.
*/
describe('persistOpenIDTokensToSession', () => {
const env = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...env };
delete process.env.REFRESH_TOKEN_EXPIRY; // fall back to DEFAULT_REFRESH_TOKEN_EXPIRY
});
afterAll(() => {
process.env = env;
});
it('stores ONLY the bearer material (no OIDC refresh credential) in the decoupled path', () => {
// Decoupled call (no explicit refreshToken arg): the on-behalf-of bearer is
// persisted, but the tokenset's refresh_token is NOT — the decoupled session
// refreshes via the local JWT cookie, and leaking the OIDC refresh token into
// the session would hijack logout's session lookup (findSession by the wrong
// token) and leave the local session un-invalidated.
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = { session: {} };
const before = Date.now();
const result = persistOpenIDTokensToSession(req, tokenset);
expect(result).toBe(true);
expect(req.session.openidTokens).toEqual({
accessToken: 'the-access-token',
idToken: 'the-id-token',
refreshToken: undefined,
expiresAt: expect.any(Number),
});
// Expiry is anchored ~REFRESH_TOKEN_EXPIRY (default 7d) ahead of now.
expect(req.session.openidTokens.expiresAt).toBeGreaterThanOrEqual(before + 604800000);
});
it('persists the OIDC refresh credential only when passed EXPLICITLY (REUSE path)', () => {
const tokenset = { id_token: 'id', access_token: 'acc', refresh_token: 'tokenset-refresh' };
const req = { session: {} };
// The REUSE caller (setOpenIDAuthTokens) resolves and passes the credential.
persistOpenIDTokensToSession(req, tokenset, 'explicit-refresh');
expect(req.session.openidTokens.refreshToken).toBe('explicit-refresh');
});
it('returns false and warns when no session is available (never a cookie)', () => {
const req = { session: null };
const result = persistOpenIDTokensToSession(req, { access_token: 'acc' });
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it('returns false when no tokenset is provided', () => {
const req = { session: {} };
const result = persistOpenIDTokensToSession(req, undefined);
expect(result).toBe(false);
expect(req.session.openidTokens).toBeUndefined();
});
});
-282
View File
@@ -1,282 +0,0 @@
const { logger } = require('@librechat/data-schemas');
/**
* The canonical cloud agent registry is Hanzo Cloud `/v1/agents`
* (github.com/hanzoai/cloud, clients/agents). This client lets chat's backend
* RUN a caller's own cloud agents from the chat thread, without ever exposing
* the user's IAM token to the browser.
*
* Tenant isolation is NOT enforced here — it is enforced BY cloud. This client
* forwards the caller's own hanzo.id bearer (the OpenID id_token) as
* `Authorization: Bearer <token>`; cloud's SanitizeIdentity middleware
* (HIP-0026) validates it and pins `X-Org-Id` from the verified `owner` claim,
* stripping any client-supplied copy. So a chat user can only ever reach their
* OWN org's agents — chat is not trusted to assert the org, and this client
* deliberately never sends an org header for cloud to trust.
*
* This is NOT an open proxy: the base host is fixed from env, the only paths are
* the three hardcoded templates below, and the agent name is validated against
* cloud's own handle grammar before it is ever placed in a URL (no traversal,
* no SSRF).
*
* @example
* const client = getCloudAgentsClient();
* const { agents } = await client.list(userBearer);
* const run = await client.run(userBearer, 'researcher', 'summarize Q3');
*/
/**
* Cloud's org-unique agent handle AND URL path segment. Mirrors nameRE in
* cloud/clients/agents/agents.go exactly — the traversal/SSRF guard at chat's
* boundary, so a malformed name is rejected before it reaches the network.
*/
const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Matches cloud's maxInput (128 KiB) so oversized input fails fast, locally. */
const MAX_INPUT = 128 * 1024;
/**
* Cap the upstream body we buffer. A run's output is bounded; refusing a runaway
* or malformed response keeps one request from pinning the shared backend's
* memory. Availability defense — the proxy is otherwise unbounded by size.
*/
const MAX_RESPONSE = 4 * 1024 * 1024;
/**
* Process-wide ceiling on concurrent in-flight calls to cloud. Each call holds a
* socket + timer for up to `timeout` ms; without a cap, a burst (many users, or
* one user within the per-user rate window) could saturate the shared backend.
* Fail fast (503) past the ceiling rather than queue and hold resources.
*/
const MAX_CONCURRENT = Number(process.env.CLOUD_AGENT_MAX_CONCURRENT) || 50;
class CloudAgentsClient {
/**
* @param {Object} opts
* @param {string} opts.endpoint - Cloud base URL (e.g. https://api.hanzo.ai)
* @param {number} [opts.timeout] - HTTP timeout in ms (default 30000; a run is
* a real chat completion so it needs more headroom than a metadata read)
* @param {number} [opts.maxConcurrent] - process-wide in-flight ceiling
*/
constructor({ endpoint, timeout = 30000, maxConcurrent = MAX_CONCURRENT }) {
this.endpoint = endpoint.replace(/\/+$/, '');
this.timeout = timeout;
this.maxConcurrent = maxConcurrent;
this._inFlight = 0;
}
/**
* Validate an agent name against cloud's handle grammar. Throws a tagged error
* (status 400) on failure so the route surfaces an honest client error.
* @param {string} name
* @returns {string} the trimmed, validated name
*/
static requireValidName(name) {
const n = (name ?? '').toString().trim();
if (!AGENT_NAME_RE.test(n)) {
const err = new Error('invalid agent name');
err.status = 400;
throw err;
}
return n;
}
/**
* List the caller's cloud agents.
* @param {string} bearer - the caller's hanzo.id id_token
* @returns {Promise<{agents: Array}>}
*/
async list(bearer) {
return this._request('GET', '/v1/agents', bearer);
}
/**
* Get one cloud agent (detail + recent runs).
* @param {string} bearer
* @param {string} name
* @returns {Promise<Object>} cloud's AgentDetail
*/
async get(bearer, name) {
const n = CloudAgentsClient.requireValidName(name);
return this._request('GET', `/v1/agents/${encodeURIComponent(n)}`, bearer);
}
/**
* Run one cloud agent with a caller-supplied input. Records a real run in
* cloud and returns the RunResult (status "ok" with output, or an upstream
* failure surfaced honestly).
* @param {string} bearer
* @param {string} name
* @param {string} input
* @returns {Promise<Object>} cloud's RunResult
*/
async run(bearer, name, input) {
const n = CloudAgentsClient.requireValidName(name);
const body = (input ?? '').toString();
// Byte length, not UTF-16 units — matches cloud's byte-based maxInput exactly
// (a multibyte string can be ~3x its .length in UTF-8).
if (Buffer.byteLength(body, 'utf8') > MAX_INPUT) {
const err = new Error('input too large');
err.status = 400;
throw err;
}
return this._request('POST', `/v1/agents/${encodeURIComponent(n)}/run`, bearer, {
input: body,
});
}
/**
* @param {string} method
* @param {string} path - one of the fixed templates above
* @param {string} bearer - the caller's hanzo.id bearer (required)
* @param {Object} [body]
* @returns {Promise<Object>}
*/
async _request(method, path, bearer, body) {
if (!bearer) {
// Fail secure: never fall back to an ambient/service credential — that
// would run as the wrong principal. Absent a user bearer, deny.
const err = new Error('missing user credential');
err.status = 401;
throw err;
}
if (this._inFlight >= this.maxConcurrent) {
// Shed load: the shared backend is saturated with upstream calls. Refusing
// here protects every other tenant — availability over this one request.
const err = new Error('cloud agents temporarily saturated');
err.status = 503;
throw err;
}
const url = `${this.endpoint}${path}`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearer}`,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
this._inFlight += 1;
try {
const opts = { method, headers, signal: controller.signal };
if (body && method !== 'GET') {
opts.body = JSON.stringify(body);
}
const resp = await fetch(url, opts);
const text = await this._readCapped(resp, controller);
let json;
try {
json = text ? JSON.parse(text) : {};
} catch {
json = { error: text };
}
if (!resp.ok) {
// A run that executed but failed upstream comes back as 502 with a run
// body (status/error) — preserve that so the caller can surface it.
const err = new Error(`cloud ${method} ${path} returned ${resp.status}`);
err.status = resp.status;
err.body = json;
throw err;
}
return json;
} finally {
clearTimeout(timeoutId);
this._inFlight -= 1;
}
}
/**
* Read the response body but never buffer more than MAX_RESPONSE bytes. Rejects
* a declared-oversize body up front (Content-Length) and aborts a chunked
* stream the instant it exceeds the cap, so a runaway upstream cannot exhaust
* memory. Falls back to `.text()` when the runtime/mocks expose no stream body.
* @param {Response} resp
* @param {AbortController} controller
* @returns {Promise<string>}
*/
async _readCapped(resp, controller) {
const declared = Number(resp.headers?.get?.('content-length'));
if (Number.isFinite(declared) && declared > MAX_RESPONSE) {
controller.abort();
const err = new Error('cloud response too large');
err.status = 502;
throw err;
}
const reader = resp.body?.getReader?.();
if (!reader) {
return resp.text().catch(() => '');
}
const decoder = new TextDecoder();
let out = '';
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAX_RESPONSE) {
await reader.cancel();
controller.abort();
const err = new Error('cloud response too large');
err.status = 502;
throw err;
}
out += decoder.decode(value, { stream: true });
}
out += decoder.decode();
return out;
}
}
/**
* Singleton, initialized lazily from env. Returns null when cloud agents are not
* configured for this deployment (the route then answers with a disabled state).
*/
let _instance;
function getCloudAgentsClient() {
if (_instance !== undefined) {
return _instance;
}
// Dedicated var first; fall back to the same host the rest of chat already
// talks to (api.hanzo.ai), derived from OPENAI_BASE_URL by stripping the /v1.
let endpoint = (process.env.HANZO_CLOUD_URL || '').trim();
if (!endpoint) {
const base = (process.env.OPENAI_BASE_URL || '').trim();
if (base) {
endpoint = base.replace(/\/v1\/?$/, '');
}
}
if (!endpoint) {
_instance = null;
return null;
}
_instance = new CloudAgentsClient({ endpoint });
logger.info('[CloudAgentsClient] Initialized', { endpoint });
return _instance;
}
/** Reset the memoized singleton (tests only). */
function _resetCloudAgentsClient() {
_instance = undefined;
}
module.exports = {
CloudAgentsClient,
getCloudAgentsClient,
_resetCloudAgentsClient,
AGENT_NAME_RE,
MAX_INPUT,
MAX_RESPONSE,
MAX_CONCURRENT,
};
@@ -1,248 +0,0 @@
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
}));
const {
CloudAgentsClient,
getCloudAgentsClient,
_resetCloudAgentsClient,
AGENT_NAME_RE,
MAX_INPUT,
MAX_RESPONSE,
} = require('./CloudAgentsClient');
/**
* Build a fetch mock that captures the last request and returns a canned response.
*/
function mockFetch({ ok = true, status = 200, body = {} } = {}) {
const calls = [];
const fn = jest.fn(async (url, opts) => {
calls.push({ url, opts });
return {
ok,
status,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
};
});
fn.calls = calls;
return fn;
}
describe('CloudAgentsClient', () => {
const BEARER = 'header.payload.sig';
afterEach(() => {
_resetCloudAgentsClient();
delete global.fetch;
});
describe('name validation (traversal / SSRF guard)', () => {
const bad = ['', '.', '..', '../etc', 'a/b', 'name with space', 'a'.repeat(65), '-lead'];
const good = ['researcher', 'a', 'A.b_c-1', '0abc'];
it('rejects malformed names before any network call', async () => {
const fetch = mockFetch();
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
for (const name of bad) {
await expect(client.get(BEARER, name)).rejects.toMatchObject({ status: 400 });
}
expect(fetch).not.toHaveBeenCalled();
});
it('accepts valid handles matching cloud grammar', () => {
for (const name of good) {
expect(AGENT_NAME_RE.test(name)).toBe(true);
expect(CloudAgentsClient.requireValidName(name)).toBe(name);
}
});
});
describe('bearer forwarding + tenant model', () => {
it('forwards the user bearer and NEVER sends an org header', async () => {
const fetch = mockFetch({ body: { agents: [] } });
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await client.list(BEARER);
const { url, opts } = fetch.calls[0];
expect(url).toBe('https://api.hanzo.ai/v1/agents');
expect(opts.headers.Authorization).toBe(`Bearer ${BEARER}`);
// Tenant isolation is cloud's job; chat must not assert an org.
expect(opts.headers['X-Org-Id']).toBeUndefined();
expect(opts.headers['X-Hanzo-Org']).toBeUndefined();
});
it('fails secure (401) with no fallback credential when bearer is missing', async () => {
const fetch = mockFetch();
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await expect(client.list('')).rejects.toMatchObject({ status: 401 });
await expect(client.run('', 'researcher', 'x')).rejects.toMatchObject({ status: 401 });
expect(fetch).not.toHaveBeenCalled();
});
});
describe('run', () => {
it('posts {input} to the run endpoint and returns the RunResult', async () => {
const fetch = mockFetch({ body: { id: 'run_1', status: 'ok', output: 'hi' } });
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai/' });
const run = await client.run(BEARER, 'researcher', 'summarize');
const { url, opts } = fetch.calls[0];
expect(url).toBe('https://api.hanzo.ai/v1/agents/researcher/run');
expect(opts.method).toBe('POST');
expect(JSON.parse(opts.body)).toEqual({ input: 'summarize' });
expect(run).toEqual({ id: 'run_1', status: 'ok', output: 'hi' });
});
it('rejects oversized input locally (before the network)', async () => {
const fetch = mockFetch();
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
const big = 'x'.repeat(128 * 1024 + 1);
await expect(client.run(BEARER, 'researcher', big)).rejects.toMatchObject({ status: 400 });
expect(fetch).not.toHaveBeenCalled();
});
it('surfaces an upstream failure body (cloud 502 error run) with its status', async () => {
const fetch = mockFetch({
ok: false,
status: 502,
body: { id: 'run_2', status: 'error', error: 'model down' },
});
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await expect(client.run(BEARER, 'researcher', 'x')).rejects.toMatchObject({
status: 502,
body: { status: 'error', error: 'model down' },
});
});
});
describe('input byte cap', () => {
it('rejects on UTF-8 BYTE length, not UTF-16 units (multibyte bypass)', async () => {
const fetch = mockFetch();
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
// Half MAX_INPUT in .length, but 3 bytes/char in UTF-8 => 1.5x MAX_INPUT bytes.
const multibyte = '你'.repeat(MAX_INPUT / 2);
expect(multibyte.length).toBeLessThan(MAX_INPUT); // would pass a naive .length check
expect(Buffer.byteLength(multibyte, 'utf8')).toBeGreaterThan(MAX_INPUT);
await expect(client.run(BEARER, 'researcher', multibyte)).rejects.toMatchObject({
status: 400,
});
expect(fetch).not.toHaveBeenCalled();
});
});
describe('response size cap (availability)', () => {
it('rejects a declared-oversize body up front via Content-Length (502)', async () => {
global.fetch = jest.fn(
async () =>
new Response('ok', {
status: 200,
headers: { 'content-length': String(MAX_RESPONSE + 1) },
}),
);
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 502 });
});
it('aborts a chunked stream once it exceeds the cap (502)', async () => {
const oversize = new Uint8Array(MAX_RESPONSE + 16);
global.fetch = jest.fn(async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(oversize);
controller.close();
},
});
return new Response(stream, { status: 200 }); // chunked: no content-length
});
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 502 });
});
it('reads a normal small streamed body', async () => {
global.fetch = jest.fn(
async () => new Response(JSON.stringify({ agents: [] }), { status: 200 }),
);
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
await expect(client.list(BEARER)).resolves.toEqual({ agents: [] });
});
});
describe('concurrency cap (load shedding)', () => {
it('sheds load with 503 once the in-flight ceiling is reached, before any fetch', async () => {
const fetch = mockFetch({ body: { agents: [] } });
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', maxConcurrent: 2 });
client._inFlight = 2; // simulate the ceiling being saturated
await expect(client.list(BEARER)).rejects.toMatchObject({ status: 503 });
expect(fetch).not.toHaveBeenCalled();
});
it('decrements in-flight after a call completes (no leak)', async () => {
const fetch = mockFetch({ body: { agents: [] } });
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', maxConcurrent: 1 });
await client.list(BEARER);
expect(client._inFlight).toBe(0);
await client.list(BEARER); // ceiling of 1 is reusable across sequential calls
expect(client._inFlight).toBe(0);
});
});
describe('endpoint normalization', () => {
it('strips trailing slashes from the base URL', async () => {
const fetch = mockFetch({ body: {} });
global.fetch = fetch;
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai///' });
await client.list(BEARER);
expect(fetch.calls[0].url).toBe('https://api.hanzo.ai/v1/agents');
});
});
describe('getCloudAgentsClient (env wiring)', () => {
const saved = {};
beforeEach(() => {
saved.HANZO_CLOUD_URL = process.env.HANZO_CLOUD_URL;
saved.OPENAI_BASE_URL = process.env.OPENAI_BASE_URL;
delete process.env.HANZO_CLOUD_URL;
delete process.env.OPENAI_BASE_URL;
_resetCloudAgentsClient();
});
afterEach(() => {
process.env.HANZO_CLOUD_URL = saved.HANZO_CLOUD_URL;
process.env.OPENAI_BASE_URL = saved.OPENAI_BASE_URL;
if (saved.HANZO_CLOUD_URL === undefined) {
delete process.env.HANZO_CLOUD_URL;
}
if (saved.OPENAI_BASE_URL === undefined) {
delete process.env.OPENAI_BASE_URL;
}
_resetCloudAgentsClient();
});
it('returns null when unconfigured', () => {
expect(getCloudAgentsClient()).toBeNull();
});
it('prefers HANZO_CLOUD_URL', () => {
process.env.HANZO_CLOUD_URL = 'https://cloud.example';
expect(getCloudAgentsClient().endpoint).toBe('https://cloud.example');
});
it('derives the host from OPENAI_BASE_URL by stripping /v1', () => {
process.env.OPENAI_BASE_URL = 'https://api.hanzo.ai/v1';
expect(getCloudAgentsClient().endpoint).toBe('https://api.hanzo.ai');
});
});
});
+49 -77
View File
@@ -43,31 +43,14 @@ class CommerceClient {
}
/**
* 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.
* Check user's balance. Returns cached result if fresh, triggers async
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
*
* @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")
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
* @returns {Promise<{sufficient: boolean, available: number}>}
*/
async checkBalance(subject) {
const cached = this._balanceCache.get(subject);
async checkBalance(userId) {
const cached = this._balanceCache.get(userId);
const now = Date.now();
if (cached) {
@@ -78,13 +61,13 @@ class CommerceClient {
// Stale: serve cached, refresh async
if (!cached.refreshing) {
cached.refreshing = true;
this._fetchBalance(subject).catch(() => {});
this._fetchBalance(userId).catch(() => {});
}
return cached.data;
}
// Cache miss: synchronous fetch
return this._fetchBalance(subject);
return this._fetchBalance(userId);
}
/**
@@ -163,31 +146,30 @@ class CommerceClient {
}
/**
* Ensure a subject has the one-time $5 starter credit, idempotently.
* Create a trial credit grant for a new user.
*
* 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
* @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
*/
async grantStarter(subject) {
async createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
try {
const resp = await this._request(
'POST',
'/v1/billing/grant-starter',
{ user: subject, trigger: 'chat_first_use' },
this._namespaceOf(subject),
);
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',
});
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to ensure starter credit', err);
logger.error('[CommerceClient] Failed to create trial grant', err);
return null;
}
}
@@ -203,8 +185,6 @@ 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 {
@@ -220,30 +200,27 @@ class CommerceClient {
// ── Internal methods ──
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 _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 _fetchTier(userId, tierName) {
@@ -252,7 +229,7 @@ class CommerceClient {
if (tierName) {
url += `&tier=${encodeURIComponent(tierName)}`;
}
const resp = await this._request('GET', url, undefined, this._namespaceOf(userId));
const resp = await this._request('GET', url);
const tier = resp.tier || null;
if (tier) {
this._tierCache.set(userId, {
@@ -298,17 +275,12 @@ class CommerceClient {
* @param {Object} [body]
* @returns {Promise<Object>}
*/
async _request(method, path, body, orgId) {
async _request(method, path, body) {
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,10 +7,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const agentPromise = loadAgent({
req,
spec,
// 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,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
}).catch((error) => {
@@ -1,53 +0,0 @@
/**
* 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
@@ -1,118 +0,0 @@
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
@@ -1,120 +0,0 @@
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'] });
});
});
-30
View File
@@ -1,30 +0,0 @@
const removePorts = require('./removePorts');
/**
* Resolves the real client IP for per-IP guest rate limiting.
*
* hanzo.chat is served behind Cloudflare → the DO LB → the ingress. With that
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
* address, which would let anonymous users share/reset their free-message bucket
* (or collapse everyone into one bucket). Cloudflare always sets
* `CF-Connecting-IP` to the true originating client and — unlike a
* client-supplied `X-Forwarded-For` entry — a browser cannot forge it through
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
* request did not transit Cloudflare (e.g. in-cluster/local).
*
* The returned string is the SOLE identity the guest quota keys on, so it must be
* stable across guest tokens, cookie clears, and incognito sessions from the same
* network origin.
*
* @param {import('express').Request} req
* @returns {string}
*/
const guestClientIp = (req) => {
const cf = req.headers?.['cf-connecting-ip'];
if (typeof cf === 'string' && cf.trim()) {
return cf.trim();
}
return removePorts(req);
};
module.exports = guestClientIp;
-2
View File
@@ -1,5 +1,4 @@
const removePorts = require('./removePorts');
const guestClientIp = require('./guestClientIp');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
@@ -8,7 +7,6 @@ const files = require('./files');
module.exports = {
...handleText,
removePorts,
guestClientIp,
sendEmail,
...files,
...queue,
-10
View File
@@ -12,16 +12,6 @@ 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
@@ -1,87 +0,0 @@
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,8 +9,6 @@
<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.13.1",
"@hanzo/iam": "^0.4.0",
"@headlessui/react": "^2.1.2",
"@librechat/client": "workspace:*",
"@marsidev/react-turnstile": "^1.1.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 B

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+1 -8
View File
@@ -1,8 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -4,8 +4,7 @@ async function postBuild() {
try {
await fs.copy('public/assets', 'dist/assets');
await fs.copy('public/robots.txt', 'dist/robots.txt');
await fs.copy('public/favicon.ico', 'dist/favicon.ico');
console.log('✅ PWA icons, favicon.ico, and robots.txt copied successfully. Glob pattern warnings resolved.');
console.log('✅ PWA icons and robots.txt copied successfully. Glob pattern warnings resolved.');
} catch (err) {
console.error('❌ Error copying files:', err);
process.exit(1);
+3 -13
View File
@@ -1,9 +1,10 @@
import { lazy, Suspense, useEffect } from 'react';
import { 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';
@@ -13,13 +14,6 @@ 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();
@@ -69,11 +63,7 @@ const App = () => {
<DndProvider backend={HTML5Backend}>
<RouterProvider router={router} />
<WakeLockManager />
{import.meta.env.DEV && ReactQueryDevtools && (
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
</Suspense>
)}
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
<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,7 +453,6 @@ 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')} | Hanzo Chat`);
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
// Ensure right sidebar is always visible in marketplace
useEffect(() => {
@@ -197,7 +197,7 @@ describe('AgentCard', () => {
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
});
it('displays the Hanzo mark fallback when no avatar is provided', () => {
it('displays Feather icon fallback when no avatar is provided', () => {
const agentWithoutAvatar = {
...mockAgent,
avatar: undefined,
@@ -209,9 +209,9 @@ describe('AgentCard', () => {
</Wrapper>,
);
// The fallback renders the Hanzo H-mark (aria-label="Hanzo")
const hanzoMark = document.querySelector('[aria-label="Hanzo"]');
expect(hanzoMark).toBeInTheDocument();
// Check for Feather icon presence by looking for the svg with lucide-feather class
const featherIcon = document.querySelector('.lucide-feather');
expect(featherIcon).toBeInTheDocument();
});
it('card is clickable and has dialog trigger', () => {
@@ -1,55 +0,0 @@
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.accessToken) {
setTokenHeader(tokens.accessToken);
if (tokens.access_token) {
setTokenHeader(tokens.access_token);
}
navigate('/c/new', { replace: true });
@@ -1,197 +0,0 @@
import { useState, useRef, useEffect, useMemo, memo } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { Spinner, useCombobox } from '@librechat/client';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import type { MentionOption } from '~/common';
import { useListCloudAgentsQuery } from '~/data-provider';
import { removeCharIfLast } from '~/utils';
import { useLocalize } from '~/hooks';
import MentionItem from './MentionItem';
import store from '~/store';
const commandChar = '/';
const ROW_HEIGHT = 44;
/**
* `/agent` command popover — lists the caller's canonical Hanzo Cloud agents
* (`/v1/agents`) as they type `/agent …`. Selecting one sets the composer to
* `/agent <name> ` so the user can add a prompt; submitting runs the agent (the
* run is intercepted in ChatForm and dispatched through the ONE run path,
* useRunCloudAgent). Structurally mirrors PromptsCommand to stay DRY.
*/
function AgentsCommand({
index,
textAreaRef,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
}) {
const localize = useLocalize();
const setShowAgentsPopover = useSetRecoilState(store.showAgentsPopoverFamily(index));
const showAgentsPopover = useRecoilValue(store.showAgentsPopoverFamily(index));
const { data, isLoading } = useListCloudAgentsQuery({ enabled: showAgentsPopover });
const options = useMemo<MentionOption[]>(
() =>
(data?.agents ?? []).map((agent) => ({
type: 'cloudAgent',
value: agent.name,
label: agent.name,
description: agent.description || agent.model,
})),
[data],
);
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options,
});
const handleSelect = (mention?: MentionOption) => {
if (!mention) {
return;
}
setSearchValue('');
setOpen(false);
setShowAgentsPopover(false);
const el = textAreaRef.current;
if (el) {
// Replace the in-progress command with `/agent <name> ` and keep focus so
// the user can type a prompt, then Enter to run.
removeCharIfLast(el, commandChar);
el.value = `/agent ${mention.value} `;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
};
useEffect(() => {
if (!open) {
setActiveIndex(0);
}
}, [open]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
const currentActiveItem = document.getElementById(`agent-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [activeIndex]);
if (!showAgentsPopover) {
return null;
}
const rowRenderer = ({
index: rowIndex,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const mention = matches[rowIndex] as MentionOption;
return (
<MentionItem
index={rowIndex}
type="agent"
key={key}
style={style}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={rowIndex === activeIndex}
/>
);
};
return (
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// Focus transitions to the input field when the popover opens.
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_agents_cloud_command_placeholder')}
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowAgentsPopover(false);
textAreaRef.current?.focus();
} else if (e.key === 'ArrowDown') {
setActiveIndex((prev) => (prev + 1) % Math.max(matches.length, 1));
} else if (e.key === 'ArrowUp') {
setActiveIndex((prev) => (prev - 1 + matches.length) % Math.max(matches.length, 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
handleSelect(matches[activeIndex] as MentionOption | undefined);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowAgentsPopover(false);
textAreaRef.current?.focus();
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowAgentsPopover(false);
}, 150);
}}
/>
<div className="max-h-40 overflow-y-auto">
{isLoading && open ? (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
) : null}
{!isLoading && open ? (
<div className="max-h-40">
<AutoSizer disableHeight>
{({ width }) => (
<List
width={width}
overscanRowCount={5}
rowHeight={ROW_HEIGHT}
rowCount={matches.length}
rowRenderer={rowRenderer}
scrollToIndex={activeIndex}
height={Math.min(matches.length * ROW_HEIGHT, 160)}
/>
)}
</AutoSizer>
</div>
) : null}
</div>
</div>
</div>
);
}
export default memo(AgentsCommand);
+3 -27
View File
@@ -1,7 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import {
useChatContext,
@@ -19,14 +19,12 @@ import {
useSubmitMessage,
useFocusChatEffect,
} from '~/hooks';
import { useRunCloudAgent } from '~/hooks/Agents';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { cn, removeFocusRings, parseAgentCommand } from '~/utils';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AgentsCommand from './AgentsCommand';
import AudioRecorder from './AudioRecorder';
import CollapseChat from './CollapseChat';
import StreamAudio from './StreamAudio';
@@ -64,7 +62,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const setShowAgentsPopover = useSetRecoilState(store.showAgentsPopoverFamily(index));
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
@@ -132,26 +129,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const runCloudAgent = useRunCloudAgent();
/**
* Submit handler that intercepts the `/agent <name> [prompt]` command and
* dispatches it through the single cloud-agent run path; everything else is a
* normal chat message. This is the ONE place the command is turned into a run.
*/
const onSubmit = useCallback(
(data?: { text: string }) => {
const command = parseAgentCommand(data?.text ?? '');
if (command) {
methods.reset();
setShowAgentsPopover(false);
void runCloudAgent(command.name, command.prompt);
return;
}
submitMessage(data);
},
[methods, runCloudAgent, submitMessage, setShowAgentsPopover],
);
const handleKeyUp = useHandleKeyUp({
index,
@@ -226,7 +203,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
return (
<form
onSubmit={methods.handleSubmit(onSubmit)}
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
@@ -260,7 +237,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<AgentsCommand index={index} textAreaRef={textAreaRef} />
<div
onClick={handleContainerClick}
className={cn(
@@ -1,25 +1,10 @@
import { useMemo, useCallback } from 'react';
import { PenLine, Lightbulb, Code2, Sparkles } from 'lucide-react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, getEntity } from '~/utils';
import { useSubmitMessage } from '~/hooks';
type Starter = { text: string; Icon?: typeof PenLine };
/**
* Curated fallback starters for plain-model chats (no agent/assistant-specific
* starters). Keeps the empty state from being bare and matches the ChatGPT/Claude
* suggestion-chip pattern. Category-style so submitting reads naturally.
*/
const DEFAULT_STARTERS: Starter[] = [
{ text: 'Help me write or refine something', Icon: PenLine },
{ text: 'Explain a concept clearly', Icon: Lightbulb },
{ text: 'Review, debug, or improve some code', Icon: Code2 },
{ text: 'Brainstorm ideas and a plan', Icon: Sparkles },
];
const ConversationStarters = () => {
const { conversation } = useChatContext();
const agentsMap = useAgentsMapContext();
@@ -50,25 +35,16 @@ const ConversationStarters = () => {
assistant_id: conversation?.assistant_id,
});
const starters: Starter[] = useMemo(() => {
const toStarters = (list: string[]): Starter[] => list.map((text) => ({ text }));
const conversation_starters = useMemo(() => {
if (entity?.conversation_starters?.length) {
return toStarters(entity.conversation_starters);
return entity.conversation_starters;
}
// Agents may intentionally omit starters — honor that (no defaults).
if (isAgent) {
return [];
}
const docStarters = documentsMap.get(entity?.id ?? '')?.conversation_starters;
if (docStarters?.length) {
return toStarters(docStarters);
}
// Plain-model chat: fall back to curated defaults so the empty state isn't bare.
return DEFAULT_STARTERS;
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
}, [documentsMap, isAgent, entity]);
const { submitMessage } = useSubmitMessage();
@@ -77,26 +53,25 @@ const ConversationStarters = () => {
[submitMessage],
);
if (!starters.length) {
if (!conversation_starters.length) {
return null;
}
return (
<div className="mx-auto mt-6 grid w-full max-w-2xl grid-cols-1 gap-2.5 px-4 sm:grid-cols-2">
{starters.slice(0, Constants.MAX_CONVO_STARTERS).map(({ text, Icon = Sparkles }, index) => (
<button
key={index}
onClick={() => sendConversationStarter(text)}
className="group flex items-center gap-3 rounded-2xl border border-border-light bg-surface-primary-alt px-4 py-3.5 text-start transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-border-medium hover:bg-surface-tertiary hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy motion-reduce:transform-none motion-reduce:transition-none"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-tertiary text-text-secondary transition-colors duration-200 group-hover:text-text-primary">
<Icon className="size-4" aria-hidden="true" />
</span>
<span className="line-clamp-2 text-balance text-sm text-text-secondary transition-colors duration-200 group-hover:text-text-primary">
{text}
</span>
</button>
))}
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
<button
key={index}
onClick={() => sendConversationStarter(text)}
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
>
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
{text}
</p>
</button>
))}
</div>
);
};
@@ -93,7 +93,7 @@ export default function MCPConfigDialog({
}
selection={{
selectHandler: handleSubmit(onFormSubmit),
selectClasses: 'bg-primary hover:bg-primary/90 text-primary-foreground',
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
}}
buttons={
@@ -77,22 +77,6 @@ export default function Mention({
}
};
if (mention.type === 'cloudAgent') {
// Arm the `/agent <name> ` command so the run flows through the single
// cloud-agent run path (intercepted on submit in ChatForm).
setSearchValue('');
setOpen(false);
setShowMentionPopover(false);
const el = textAreaRef.current;
if (el) {
removeCharIfLast(el, commandChar);
el.value = `/agent ${mention.value} `;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
return;
}
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.agents) {
setSearchValue('');
setInputOptions(agentsList ?? []);
@@ -6,7 +6,7 @@ export interface MentionItemProps {
name: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
index: number;
type?: 'prompt' | 'mention' | 'add-convo' | 'agent';
type?: 'prompt' | 'mention' | 'add-convo';
icon?: React.ReactNode;
isActive?: boolean;
description?: string;
@@ -1,6 +1,5 @@
import { memo } from 'react';
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import ZenLogoIcon from '~/components/svg/ZenLogoIcon';
import { Feather } from 'lucide-react';
import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider';
import {
Plugin,
@@ -12,6 +11,7 @@ import {
AssistantIcon,
AnthropicIcon,
AzureMinimalIcon,
CustomMinimalIcon,
} from '@librechat/client';
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
import { IconProps } from '~/common';
@@ -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">
<HanzoLogoIcon className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
<Feather className="h-2/3 w-2/3 text-gray-400" aria-hidden="true" />
</div>
</div>
),
@@ -148,10 +148,8 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
name: alternateName[EModelEndpoint.bedrock],
},
[EModelEndpoint.custom]: {
// hanzo.chat's custom endpoint is Zen-only (Hanzo AI zen* models) — brand it
// with the ensō mark instead of the generic LibreChat "custom" glyph.
icon: <ZenLogoIcon size={size * 0.72} className="text-white" />,
name: 'Zen',
icon: <CustomMinimalIcon size={size * 0.7} />,
name: 'Custom',
},
null: { icon: <GPTIcon size={size * 0.7} />, bg: 'grey', name: 'N/A' },
default: {
@@ -1,4 +1,4 @@
import HanzoLogoIcon from '~/components/svg/HanzoLogoIcon';
import { Feather } from 'lucide-react';
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: <HanzoLogoIcon className="icon-sm" aria-hidden="true" />,
icon: <Feather 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-primary hover:bg-primary/90 text-primary-foreground',
selectClasses: 'bg-green-500 hover:bg-green-600 dark:hover:bg-green-600 text-white',
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-surface-secondary px-2 text-xs text-text-secondary"
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
>
{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-surface-secondary p-1 text-text-secondary">
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
<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-surface-secondary px-2 text-text-secondary">
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
{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-text-secondary">0 KB hours</span>
<span className="text-[#91c561]">0 KB hours</span>
&nbsp; Free until end of 2024
</p>
</div>
+20 -22
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: '#ffffff',
brandDim: 'rgba(255, 255, 255, 0.10)',
brandGlow: 'rgba(255, 255, 255, 0.06)',
brand: '#fd4444',
brandDim: 'rgba(253, 68, 68, 0.10)',
brandGlow: 'rgba(253, 68, 68, 0.04)',
secondary: '#1f1f1f',
} as const;
@@ -97,7 +97,7 @@ const features = [
{
title: '100+ Models',
description:
'The Zen model family plus Claude, GPT-5, Gemini, and more. Every major provider through one interface.',
'Claude, GPT-5, DeepSeek, Qwen, and more. Every major provider through one interface.',
Icon: IconCube,
},
{
@@ -121,12 +121,10 @@ const zenModels = [
{ name: 'zen3-omni', description: 'Multimodal vision and text', context: '128K', params: '32B' },
];
// Independent third-party providers offered via the gateway. NO upstream
// base-model names that would leak the Zen family's provenance (brand policy:
// no raw Qwen/DeepSeek/Kimi/Llama/HuggingFace names).
const thirdPartyModels = [
'GPT-5', 'Claude Opus 4', 'Gemini 2.5', 'Grok',
'Mistral Large', 'Command R+',
'GPT-5', 'Claude Opus 4', 'Gemini 2.5', 'DeepSeek R1',
'Qwen3', 'Llama 4', 'Mistral Large', 'Command R+',
'Grok', 'Phi-4',
];
/* ------------------------------------------------------------------ */
@@ -154,7 +152,7 @@ export default function LandingPage() {
return (
<div
className="min-h-screen selection:bg-white/20"
className="min-h-screen selection:bg-[#fd4444]/30"
style={{
backgroundColor: colors.bg,
color: colors.fg,
@@ -171,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-white" />
<HanzoLogo className="size-5 text-[#fd4444]" />
<span className="text-sm font-bold tracking-tight">Hanzo Chat</span>
</div>
<div className="flex items-center gap-3">
@@ -201,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: '#000' }}
style={{ backgroundColor: colors.brand, color: '#fff' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -217,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(255,255,255,0.08) 0%, transparent 50%, rgba(255,255,255,0.04) 100%)`,
background: `linear-gradient(135deg, rgba(253,68,68,0.08) 0%, transparent 50%, rgba(253,68,68,0.04) 100%)`,
}}
>
{/* Grid pattern overlay */}
@@ -234,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(255,255,255,0.5)`,
border: `1px solid rgba(253,68,68,0.5)`,
color: colors.brand,
}}
>
@@ -260,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: '#000' }}
style={{ backgroundColor: colors.brand, color: '#fff' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -365,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(255, 255, 255, 0.06)',
border: `1px solid rgba(255, 255, 255, 0.15)`,
backgroundColor: 'rgba(253, 68, 68, 0.06)',
border: `1px solid rgba(253, 68, 68, 0.15)`,
}}
>
<IconBolt className="mb-4 size-8" style={{ color: colors.brand }} />
@@ -385,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(255, 255, 255, 0.3)')}
onMouseOver={(e) => (e.currentTarget.style.borderColor = 'rgba(253, 68, 68, 0.3)')}
onMouseOut={(e) => (e.currentTarget.style.borderColor = colors.border)}
>
<div className="mb-2 flex items-center gap-2">
@@ -482,11 +480,11 @@ zen4-coder: I'll help you refactor the auth module.
<div
className="rounded-xl p-6"
style={{
border: `1px solid rgba(255, 255, 255, 0.3)`,
backgroundColor: 'rgba(255, 255, 255, 0.04)',
border: `1px solid rgba(253, 68, 68, 0.3)`,
backgroundColor: 'rgba(253, 68, 68, 0.04)',
}}
>
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
<p className="mb-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'rgba(253, 68, 68, 0.7)' }}>
Free credit
</p>
<p className="text-3xl font-bold">$5</p>
@@ -530,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: '#000' }}
style={{ backgroundColor: colors.brand, color: '#fff' }}
onMouseOver={(e) => (e.currentTarget.style.filter = 'brightness(1.15)')}
onMouseOut={(e) => (e.currentTarget.style.filter = 'none')}
>
@@ -125,12 +125,11 @@ const errorMessages = {
);
}
// Commerce insufficient balance — new users claim the $5 starter credit,
// spent-out users add funds. Both live at billing.hanzo.ai.
// Commerce insufficient balance
if (reason === 'commerce_insufficient') {
return (
<>
{'You have no Hanzo Cloud balance. Claim your $5 starter credit (or add funds) to start chatting.'}
{'Your account balance is empty. Please add funds to continue.'}
<br />
<br />
<a
@@ -139,18 +138,12 @@ const errorMessages = {
rel="noopener noreferrer"
className="text-hanzo-red underline"
>
Claim your $5 credit at billing.hanzo.ai
Add funds to your Hanzo account
</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(`Hanzo Chat version: ${Constants.VERSION}`);
expect(blob).toContain(`LibreChat 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[] = [
`Hanzo Chat version: ${version}`,
`LibreChat 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-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-0 disabled:opacity-50"
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"
type="button"
>
{getButtonContent()}
@@ -180,7 +180,7 @@ export default function AgentConfig() {
return (
<>
<div className="h-auto bg-white px-3 pt-3 dark:bg-transparent sm:px-4">
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
{/* Avatar & Name */}
<div className="mb-4">
<AgentAvatar avatar={agent?.['avatar'] ?? null} />
@@ -206,7 +206,7 @@ export default function AgentConfig() {
/>
<div
className={cn(
'mt-1 w-full max-w-full break-words text-sm text-red-500',
'mt-1 w-56 text-sm text-red-500',
errors.name ? 'visible h-auto' : 'invisible h-0',
)}
role="alert"
@@ -360,7 +360,7 @@ export default function AgentConfig() {
/>
))}
</div>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:gap-2">
<div className="mt-2 flex space-x-2">
{(toolsEnabled ?? false) && (
<button
type="button"

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