Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa2d3e224 | ||
|
|
c3257d2238 | ||
|
|
87b581388d | ||
|
|
2abc855b9c | ||
|
|
fdbb17da6c | ||
|
|
ec32864a00 | ||
|
|
3e83bc6779 | ||
|
|
39e5a71c7e | ||
|
|
1eb2caecec | ||
|
|
d44146f1fc | ||
|
|
dca8e39e69 | ||
|
|
72b061877a | ||
|
|
5a3aede148 | ||
|
|
4f25c3131a | ||
|
|
512289012c | ||
|
|
3af4415366 | ||
|
|
749784a50c | ||
|
|
26e83d7645 | ||
|
|
662f054658 | ||
|
|
150c5fff4d | ||
|
|
abe59a625c | ||
|
|
14bb1bf35a | ||
|
|
9f6c243327 | ||
|
|
34096ffe12 | ||
|
|
a12198a01f | ||
|
|
4abec9f46a | ||
|
|
7c4c1b676c | ||
|
|
0138889e81 | ||
|
|
9ef8cf46a9 | ||
|
|
4d96ee9e4d | ||
|
|
c4f48f6af5 | ||
|
|
f466b6c2ca | ||
|
|
26591fb2ba | ||
|
|
f54d01289b | ||
|
|
1a8456bbb9 | ||
|
|
0dfa7df1da | ||
|
|
44d41f6235 | ||
|
|
0becb68c80 | ||
|
|
04210dea16 | ||
|
|
4233c8760f | ||
|
|
d20ba76651 | ||
|
|
3e36ede65d | ||
|
|
18f3d9ce80 | ||
|
|
5578ef924d | ||
|
|
5e0b6038af | ||
|
|
a5a2498997 | ||
|
|
dbed3bf2fb | ||
|
|
462dbfdc84 | ||
|
|
c2aff88ca6 | ||
|
|
05396959ae | ||
|
|
da475d299b | ||
|
|
2e794d3228 | ||
|
|
9cd97355fe | ||
|
|
b70c5400a2 | ||
|
|
8a89cfed07 | ||
|
|
c3114a1258 | ||
|
|
821b3b59a2 | ||
|
|
8a6c63a869 | ||
|
|
86ecab135e | ||
|
|
196e3c3d61 | ||
|
|
46b636c8c0 | ||
|
|
0cc792d02d | ||
|
|
ca632b467f | ||
|
|
9df97c7393 | ||
|
|
879bba70dd | ||
|
|
ba982d26e1 |
+40
-19
@@ -117,6 +117,17 @@ 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 /v1/chat/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 #
|
||||
#===================================#
|
||||
@@ -463,6 +474,21 @@ ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
|
||||
#=====================================================#
|
||||
# Guest Chat (anonymous preview) #
|
||||
#=====================================================#
|
||||
# Off by default. When enabled, unauthenticated visitors get a small free
|
||||
# quota on the free Zen model routed through api.hanzo.ai. After the quota is
|
||||
# spent the UI surfaces the existing OpenID/hanzo.id login. Guests are scoped
|
||||
# server-side to the free model only and rejected from every other route.
|
||||
ALLOW_GUEST_CHAT=false
|
||||
# GUEST_MESSAGE_MAX=3 # free messages per IP before login is required
|
||||
# GUEST_ENDPOINT=Hanzo # custom endpoint name from librechat.yaml (api.hanzo.ai)
|
||||
# GUEST_MODEL=zen3-nano # free Zen model id guests are pinned to
|
||||
# GUEST_TOKEN_EXPIRY=3600000 # guest JWT lifetime in ms (default 1h)
|
||||
# GUEST_TOKEN_MAX=20 # max guest sessions issued per IP per window
|
||||
# GUEST_TOKEN_WINDOW=60 # guest-session issuance window in minutes
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
|
||||
|
||||
@@ -810,31 +836,26 @@ OPENWEATHER_API_KEY=
|
||||
# Hanzo Chat Code Interpreter API #
|
||||
#====================================#
|
||||
|
||||
# https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_API_KEY=your-key
|
||||
# "Run Code" routes through the Hanzo unified backend (api.hanzo.ai/v1/exec),
|
||||
# which executes in a Hanzo sandbox. execute_code POSTs {BASEURL}/exec with
|
||||
# X-API-Key. https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
# LIBRECHAT_CODE_API_KEY=your-key # prod: KMS chat-secrets/LIBRECHAT_CODE_API_KEY
|
||||
|
||||
#======================#
|
||||
# Web Search #
|
||||
#======================#
|
||||
|
||||
# Note: All of the following variable names can be customized.
|
||||
# Omit values to allow user to provide them.
|
||||
|
||||
# For more information on configuration values, see:
|
||||
# Web Search routes through the Hanzo unified backend ONLY — no external SaaS.
|
||||
# The searxng provider + firecrawl scraper both point at api.hanzo.ai/v1/websearch
|
||||
# (Hanzo metasearch + Hanzo Crawl). See librechat.yaml `webSearch`.
|
||||
# https://hanzo.ai/docs/chat/features/web_search
|
||||
|
||||
# Search Provider (Required)
|
||||
# SERPER_API_KEY=your_serper_api_key
|
||||
|
||||
# Scraper (Required)
|
||||
# FIRECRAWL_API_KEY=your_firecrawl_api_key
|
||||
# Optional: Custom Firecrawl API URL
|
||||
# FIRECRAWL_API_URL=your_firecrawl_api_url
|
||||
|
||||
# Reranker (Required)
|
||||
# JINA_API_KEY=your_jina_api_key
|
||||
# or
|
||||
# COHERE_API_KEY=your_cohere_api_key
|
||||
# SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
# FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
# WEBSEARCH_API_KEY=your-key # prod: KMS chat-secrets/WEBSEARCH_API_KEY
|
||||
#
|
||||
# Do NOT set SERPER_API_KEY / TAVILY_API_KEY / JINA_API_KEY / COHERE_API_KEY:
|
||||
# external providers are intentionally disabled (one way, unified backend).
|
||||
|
||||
#======================#
|
||||
# MCP Configuration #
|
||||
|
||||
+13
-3
@@ -5,9 +5,19 @@
|
||||
OPENAI_API_KEY=sk-hanzo-your-api-key-here
|
||||
OPENAI_BASE_URL=https://api.hanzo.ai/v1
|
||||
|
||||
# Runtime Execution (Code Interpreter)
|
||||
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
|
||||
RUNTIME_API_KEY=${OPENAI_API_KEY}
|
||||
# Code Interpreter ("Run Code") — Hanzo unified backend.
|
||||
# LibreChat's execute_code tool POSTs {LIBRECHAT_CODE_BASEURL}/exec with header
|
||||
# X-API-Key. cloud-api serves /v1/exec (-> sandboxed executor). The key is
|
||||
# KMS-sourced (chat-secrets/LIBRECHAT_CODE_API_KEY). These are the ONLY vars
|
||||
# LibreChat reads — the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were dead.
|
||||
LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
LIBRECHAT_CODE_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# Web Search — Hanzo unified backend (searxng + firecrawl compat over Hanzo
|
||||
# search + crawl). See librechat.yaml `webSearch`. One shared service key.
|
||||
SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
WEBSEARCH_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# ====== BRANDING ======
|
||||
APP_TITLE=Hanzo Chat
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="chat">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">chat</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">AI chat with MCP integration and multi-provider support</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -16,6 +16,6 @@ jobs:
|
||||
service: chat
|
||||
image: ghcr.io/hanzoai/chat
|
||||
port: "3080"
|
||||
health-path: /api/health
|
||||
health-path: /health
|
||||
e2e-dir: e2e
|
||||
secrets: inherit
|
||||
|
||||
@@ -42,6 +42,15 @@ 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; \
|
||||
|
||||
@@ -5,6 +5,7 @@ Portions of this software are licensed as follows:
|
||||
---
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
Copyright (c) 2023 Hanzo AI Inc
|
||||
Originally based on LibreChat by Berri AI.
|
||||
|
||||
|
||||
@@ -90,6 +90,212 @@ 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
|
||||
`/v1/chat/models` + `/v1/chat/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 /v1/chat/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 /v1/chat/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 `/v1/chat/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 `/v1/chat/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 /v1/chat/agents/chat/Hanzo` (all
|
||||
chat, incl. plain-model, goes through the agents framework) → custom-endpoint
|
||||
resolver (`packages/api/src/endpoints/custom/initialize.ts`) 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 /v1/chat/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):
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
chat
|
||||
Copyright (c) 2023 Hanzo AI Inc.
|
||||
|
||||
This product includes software from LibreChat (https://github.com/danny-avila/LibreChat), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="chat" width="880"></p>
|
||||
|
||||
# Hanzo AI Chat
|
||||
|
||||
AI-powered chat platform with enterprise features, using Hanzo's cloud API or local deployment.
|
||||
|
||||
@@ -49,7 +49,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
@@ -63,7 +63,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
|
||||
@@ -162,7 +162,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
@@ -186,7 +186,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
|
||||
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
@@ -367,7 +367,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
@@ -391,7 +391,7 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/v1/chat/placeholder/400/320" alt="placeholder" />\`
|
||||
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
|
||||
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
|
||||
@@ -143,16 +143,26 @@ class DALLE3 extends Tool {
|
||||
throw new Error('Missing required field: prompt');
|
||||
}
|
||||
|
||||
// Model is configurable (DALLE3_MODEL) so this tool drives the Hanzo image
|
||||
// family (e.g. zen3-image) through the same OpenAI /images/generations shape.
|
||||
// `quality` and `style` are DALL-E-3-only knobs — send them ONLY for a dall-e
|
||||
// model so a non-DALL-E backend never sees a parameter it may reject.
|
||||
const model = process.env.DALLE3_MODEL || 'dall-e-3';
|
||||
const isDallE = model.startsWith('dall-e');
|
||||
const genParams = {
|
||||
model,
|
||||
size,
|
||||
prompt: this.replaceUnwantedChars(prompt),
|
||||
n: 1,
|
||||
};
|
||||
if (isDallE) {
|
||||
genParams.quality = quality;
|
||||
genParams.style = style;
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await this.openai.images.generate({
|
||||
model: 'dall-e-3',
|
||||
quality,
|
||||
style,
|
||||
size,
|
||||
prompt: this.replaceUnwantedChars(prompt),
|
||||
n: 1,
|
||||
});
|
||||
resp = await this.openai.images.generate(genParams);
|
||||
} catch (error) {
|
||||
logger.error('[DALL-E-3] Problem generating the image:', error);
|
||||
return this
|
||||
|
||||
@@ -12,6 +12,8 @@ const {
|
||||
loadWebSearchAuth,
|
||||
buildImageToolContext,
|
||||
buildWebSearchContext,
|
||||
resolveHanzoCloudKey,
|
||||
isHanzoPerUserKeyEnabled,
|
||||
} = require('@hanzochat/api');
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
const {
|
||||
@@ -232,7 +234,30 @@ const loadTools = async ({
|
||||
const requestedTools = {};
|
||||
|
||||
if (functions === true) {
|
||||
toolConstructors.dalle = DALLE3;
|
||||
// dalle (image generation) drives the Hanzo image family via a PER-USER hk-
|
||||
// key so generation is metered to the signed-in user — mirroring the chat
|
||||
// per-user key path in custom/initialize.ts. A guest keeps the shared env
|
||||
// key; an authenticated user whose key cannot be resolved FAILS CLOSED
|
||||
// (throws) rather than silently billing the shared org. (Custom constructors
|
||||
// take precedence over the generic toolConstructors path in the loop below.)
|
||||
customConstructors.dalle = async () => {
|
||||
const authFields = getAuthFields('dalle');
|
||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||
const billingUser = options.req?.user;
|
||||
const isAuthenticatedUser = Boolean(
|
||||
billingUser && !billingUser.guest && billingUser.email,
|
||||
);
|
||||
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
|
||||
const perUserKey = await resolveHanzoCloudKey(billingUser);
|
||||
if (!perUserKey) {
|
||||
throw new Error(
|
||||
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
|
||||
);
|
||||
}
|
||||
authValues.DALLE3_API_KEY = perUserKey;
|
||||
}
|
||||
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {ImageGenOptions} */
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.124163c776d287793643ac0e08ac1d35d67ad894-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1771557162318,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2026-02-19.log",
|
||||
"hash": "a54018af15e4552979b0e91a2379ef40b95019fe56fe70074bb13f91952d908f"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.35252977fe148e605b7b92f19cd71e590a6f0a53-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1771557162297,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/error-2026-02-19.log",
|
||||
"hash": "63688154da6c0a28cba1e30ddeef3eb867682460096d9b007dcfd2ddc25202b0"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.5a07238e142f72f47174c381d68b979f0f4a60b3-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1750474453457,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-20.log",
|
||||
"hash": "696c5dfed20dbc87cd7113adb058ad5df3e53b93b9fc24840b7e236724ac4823"
|
||||
},
|
||||
{
|
||||
"date": 1750715218180,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-23.log",
|
||||
"hash": "c802b606c51329d1d65fe6c877dff02200e4d57e6565de3697ce4d8938bd8366"
|
||||
},
|
||||
{
|
||||
"date": 1750883760400,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-25.log",
|
||||
"hash": "202106c1ae63bd10f256f9249f6f32e20e495b9ca7f5ab7844f6dff28716805a"
|
||||
},
|
||||
{
|
||||
"date": 1750950599470,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-26.log",
|
||||
"hash": "e5f73d742f92938cef296a5c97cf8bb7f4e3db0198b8bc53bb0eee3ddf0f1e84"
|
||||
},
|
||||
{
|
||||
"date": 1751049961804,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-27.log",
|
||||
"hash": "15a471f383c1ea303252a3b35f88e44cc4bd1905666617dc9afc4b415d3ddac9"
|
||||
},
|
||||
{
|
||||
"date": 1751124937592,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-06-28.log",
|
||||
"hash": "77a7dd41fff34914a5ce25239c9fd77fafb8c3617a7a118550af69fbcc577643"
|
||||
},
|
||||
{
|
||||
"date": 1751599940774,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/debug-2025-07-03.log",
|
||||
"hash": "c86e337d49f3edad24952270eb6bebba4588201f1e14eab31b2482ee0c68be00"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/Users/z/work/hanzo/chat/api/logs/.b5a17c43715e8a0a84a729c6012dc1ba16b1828b-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1750954055845,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-26.log",
|
||||
"hash": "980f8267840afde6278855f4218760f010a3fb215aa86ef5bb69dc08bb4b1b50"
|
||||
},
|
||||
{
|
||||
"date": 1751049961800,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-27.log",
|
||||
"hash": "cebe79495cabf77d359dbcd3168502d3a5c4d8993e1bed0663fa40d850ccf6d5"
|
||||
},
|
||||
{
|
||||
"date": 1751124937590,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-06-28.log",
|
||||
"hash": "bb29a81af67a7fce02315648194ef210ef2ad3c89e7083220d9a63103dc762cc"
|
||||
},
|
||||
{
|
||||
"date": 1751599940771,
|
||||
"name": "/Users/z/work/hanzo/chat/api/logs/meiliSync-2025-07-03.log",
|
||||
"hash": "9b454a63526db0eb56a27269fd38a86ac3d35dba85338ae89c91ea9895d467cb"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
2025-06-25T20:37:18.651Z info: [Optional] Redis not initialized.
|
||||
2025-06-25T20:37:20.168Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-25T20:39:50.405Z info: [Optional] Redis not initialized.
|
||||
2025-06-25T20:39:51.070Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-25T20:40:07.114Z info: [Optional] Redis not initialized.
|
||||
2025-06-25T20:40:07.770Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-25T20:40:53.234Z info: [Optional] Redis not initialized.
|
||||
2025-06-25T20:40:53.905Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
@@ -1,115 +0,0 @@
|
||||
2025-06-26T15:10:34.209Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T15:10:35.166Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T16:21:35.171Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T16:21:36.574Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T16:22:06.626Z error: connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T16:23:03.561Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T16:23:04.255Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T16:23:04.300Z info: Connected to MongoDB
|
||||
2025-06-26T16:23:04.301Z info: [indexSync] Starting index synchronization check...
|
||||
2025-06-26T16:23:04.420Z error: Invalid custom config file at /Users/z/work/hanzo/chat/chat.yaml:
|
||||
{
|
||||
"issues": [
|
||||
{
|
||||
"code": "unrecognized_keys",
|
||||
"keys": [
|
||||
"ag... [truncated]
|
||||
2025-06-26T16:23:04.420Z debug: Web search serperApiKey: Using environment variable SERPER_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:23:04.420Z debug: Web search firecrawlApiKey: Using environment variable FIRECRAWL_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:23:04.420Z debug: Web search firecrawlApiUrl: Using environment variable FIRECRAWL_API_URL (not set in environment, user provided value)
|
||||
2025-06-26T16:23:04.421Z debug: Web search jinaApiKey: Using environment variable JINA_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:23:04.421Z debug: Web search cohereApiKey: Using environment variable COHERE_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:23:04.421Z warn: Default value for CREDS_KEY is being used.
|
||||
2025-06-26T16:23:04.421Z warn: Default value for CREDS_IV is being used.
|
||||
2025-06-26T16:23:04.421Z warn: Default value for JWT_SECRET is being used.
|
||||
2025-06-26T16:23:04.421Z warn: Default value for JWT_REFRESH_SECRET is being used.
|
||||
2025-06-26T16:23:04.421Z info: Please replace any default secret values.
|
||||
2025-06-26T16:23:04.421Z info:
|
||||
|
||||
For your convenience, use this tool to generate your own secret values:
|
||||
https://www.hanzo.ai/toolkit/creds_generator
|
||||
|
||||
|
||||
2025-06-26T16:23:04.421Z warn: RAG API is either not running or not reachable at undefined, you may experience errors with file uploads.
|
||||
2025-06-26T16:23:04.429Z info: No changes needed for 'USER' role permissions
|
||||
2025-06-26T16:23:04.431Z info: No changes needed for 'ADMIN' role permissions
|
||||
2025-06-26T16:23:04.431Z info: Turnstile is DISABLED (no siteKey provided).
|
||||
2025-06-26T16:24:16.068Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T16:24:17.688Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T16:24:17.756Z info: Connected to MongoDB
|
||||
2025-06-26T16:24:17.757Z info: [indexSync] Starting index synchronization check...
|
||||
2025-06-26T16:24:17.811Z error: Invalid custom config file at /Users/z/work/hanzo/chat/chat.yaml:
|
||||
{
|
||||
"issues": [
|
||||
{
|
||||
"code": "unrecognized_keys",
|
||||
"keys": [
|
||||
"ag... [truncated]
|
||||
2025-06-26T16:24:17.812Z debug: Web search serperApiKey: Using environment variable SERPER_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:24:17.812Z debug: Web search firecrawlApiKey: Using environment variable FIRECRAWL_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:24:17.812Z debug: Web search firecrawlApiUrl: Using environment variable FIRECRAWL_API_URL (not set in environment, user provided value)
|
||||
2025-06-26T16:24:17.812Z debug: Web search jinaApiKey: Using environment variable JINA_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:24:17.812Z debug: Web search cohereApiKey: Using environment variable COHERE_API_KEY (not set in environment, user provided value)
|
||||
2025-06-26T16:24:17.812Z warn: Default value for CREDS_KEY is being used.
|
||||
2025-06-26T16:24:17.812Z warn: Default value for CREDS_IV is being used.
|
||||
2025-06-26T16:24:17.812Z warn: Default value for JWT_SECRET is being used.
|
||||
2025-06-26T16:24:17.812Z warn: Default value for JWT_REFRESH_SECRET is being used.
|
||||
2025-06-26T16:24:17.812Z info: Please replace any default secret values.
|
||||
2025-06-26T16:24:17.812Z info:
|
||||
|
||||
For your convenience, use this tool to generate your own secret values:
|
||||
https://www.hanzo.ai/toolkit/creds_generator
|
||||
|
||||
|
||||
2025-06-26T16:24:17.812Z warn: RAG API is either not running or not reachable at undefined, you may experience errors with file uploads.
|
||||
2025-06-26T16:24:17.823Z info: No changes needed for 'USER' role permissions
|
||||
2025-06-26T16:24:17.825Z info: No changes needed for 'ADMIN' role permissions
|
||||
2025-06-26T16:24:17.825Z info: Turnstile is DISABLED (no siteKey provided).
|
||||
2025-06-26T16:24:17.832Z info: Server listening at http://localhost:3080
|
||||
2025-06-26T16:24:18.010Z debug: [MEILI_SYNC:meili-index-sync] Creating initial flow state
|
||||
2025-06-26T16:24:18.020Z info: [indexSync] Messages are fully synced: 0/0
|
||||
2025-06-26T16:24:18.023Z info: [indexSync] Conversations are fully synced: 0/0
|
||||
2025-06-26T16:24:18.024Z debug: [indexSync] No sync was needed
|
||||
2025-06-26T16:24:37.105Z error: invalid signature
|
||||
2025-06-26T17:16:21.631Z info: Cleaning up FlowStateManager intervals...
|
||||
2025-06-26T17:28:31.428Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T19:16:07.812Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T19:16:09.408Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T19:16:39.465Z error: connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T19:28:57.619Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T19:28:58.798Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:18:29.425Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:18:30.678Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:19:00.734Z error: getaddrinfo ENOTFOUND mongodb
|
||||
2025-06-26T21:20:04.085Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:20:04.788Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:20:34.832Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T21:35:21.338Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:35:22.746Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:35:52.803Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T21:39:16.497Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:39:17.655Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:39:47.696Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T21:43:04.982Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:06.587Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:07.208Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:07.990Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:09.790Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:10.572Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:12.993Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:14.084Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:16.627Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:17.383Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:19.449Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:20.212Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:34.812Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:43:36.179Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-26T21:43:50.258Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
|
||||
2025-06-26T21:44:17.565Z info: [Optional] Redis not initialized.
|
||||
2025-06-26T21:44:18.322Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-27T03:16:07.463Z info: [Optional] Redis not initialized.
|
||||
2025-06-27T03:16:08.973Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-27T03:16:17.650Z debug: [indexSync] Clearing sync timeouts before exiting...
|
||||
2025-06-27T03:19:09.423Z info: [Optional] Redis not initialized.
|
||||
2025-06-27T03:19:10.741Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-27T03:19:15.742Z debug: [indexSync] Clearing sync timeouts before exiting...
|
||||
@@ -1,3 +0,0 @@
|
||||
2025-06-27T18:46:02.321Z info: [Optional] Redis not initialized.
|
||||
2025-06-27T18:46:03.818Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-27T18:46:23.237Z debug: [indexSync] Clearing sync timeouts before exiting...
|
||||
@@ -1,3 +0,0 @@
|
||||
2025-06-28T15:35:37.951Z info: [Optional] Redis not initialized.
|
||||
2025-06-28T15:35:39.502Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-06-28T15:36:09.559Z error: connect ECONNREFUSED ::1:27017, connect ECONNREFUSED 127.0.0.1:27017
|
||||
@@ -1,3 +0,0 @@
|
||||
2025-07-04T03:32:21.282Z info: [Optional] Redis not initialized.
|
||||
2025-07-04T03:32:22.739Z info: [Optional] IoRedis not initialized for rate limiters.
|
||||
2025-07-04T03:32:40.543Z debug: [indexSync] Clearing sync timeouts before exiting...
|
||||
@@ -1,4 +0,0 @@
|
||||
{"level":"error","message":"[mongoMeili] Error checking index convos: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
|
||||
{"level":"error","message":"[mongoMeili] Error checking index messages: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
|
||||
{"level":"error","message":"[mongoMeili] Error checking index convos: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
|
||||
{"level":"error","message":"[mongoMeili] Error checking index messages: fetch failed","name":"MeiliSearchCommunicationError","stack":"MeiliSearchCommunicationError: fetch failed\n at node:internal/deps/undici/undici:13510:13\n at process.processTicksAndRejections (node:internal/process/task_queues:105:5)"}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index messages already exists"}
|
||||
{"level":"debug","message":"[mongoMeili] Index convos already exists"}
|
||||
@@ -2189,13 +2189,13 @@ describe('models/Agent', () => {
|
||||
const actions = [
|
||||
{
|
||||
action_id: '123',
|
||||
metadata: { version: '1.0', endpoints: ['GET /api/test'], schema: { type: 'object' } },
|
||||
metadata: { version: '1.0', endpoints: ['GET /v1/chat/test'], schema: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
action_id: '456',
|
||||
metadata: {
|
||||
version: '2.0',
|
||||
endpoints: ['POST /api/example'],
|
||||
endpoints: ['POST /v1/chat/example'],
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
},
|
||||
@@ -2212,10 +2212,10 @@ describe('models/Agent', () => {
|
||||
test('should generate different hashes for different action metadata', async () => {
|
||||
const actionIds = ['test.com_action_123'];
|
||||
const actions1 = [
|
||||
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /api/test'] } },
|
||||
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /v1/chat/test'] } },
|
||||
];
|
||||
const actions2 = [
|
||||
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /api/test'] } },
|
||||
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /v1/chat/test'] } },
|
||||
];
|
||||
|
||||
const hash1 = await generateActionMetadataHash(actionIds, actions1);
|
||||
@@ -2284,8 +2284,8 @@ describe('models/Agent', () => {
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{ path: '/api/test', method: 'GET', params: ['id'] },
|
||||
{ path: '/api/create', method: 'POST', body: true },
|
||||
{ path: '/v1/chat/test', method: 'GET', params: ['id'] },
|
||||
{ path: '/v1/chat/create', method: 'POST', body: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createTempChatExpirationDate } = require('@hanzochat/api');
|
||||
const { getMessages, deleteMessages } = require('./Message');
|
||||
const { Conversation } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
|
||||
* @param {string} conversationId - The conversation's ID.
|
||||
* @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
|
||||
*/
|
||||
const searchConversation = async (conversationId) => {
|
||||
try {
|
||||
return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
|
||||
} catch (error) {
|
||||
logger.error('[searchConversation] Error searching conversation', error);
|
||||
throw new Error('Error searching conversation');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a single conversation for a given user and conversation ID.
|
||||
* @param {string} user - The user's ID.
|
||||
* @param {string} conversationId - The conversation's ID.
|
||||
* @returns {Promise<TConversation>} The conversation object.
|
||||
*/
|
||||
const getConvo = async (user, conversationId) => {
|
||||
try {
|
||||
return await Conversation.findOne({ user, conversationId }).lean();
|
||||
} catch (error) {
|
||||
logger.error('[getConvo] Error getting single conversation', error);
|
||||
throw new Error('Error getting single conversation');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNullOrEmptyConversations = async () => {
|
||||
try {
|
||||
const filter = {
|
||||
$or: [
|
||||
{ conversationId: null },
|
||||
{ conversationId: '' },
|
||||
{ conversationId: { $exists: false } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await Conversation.deleteMany(filter);
|
||||
|
||||
// Delete associated messages
|
||||
const messageDeleteResult = await deleteMessages(filter);
|
||||
|
||||
logger.info(
|
||||
`[deleteNullOrEmptyConversations] Deleted ${result.deletedCount} conversations and ${messageDeleteResult.deletedCount} messages`,
|
||||
);
|
||||
|
||||
return {
|
||||
conversations: result,
|
||||
messages: messageDeleteResult,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[deleteNullOrEmptyConversations] Error deleting conversations', error);
|
||||
throw new Error('Error deleting conversations with null or empty conversationId');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a conversation by conversationId and returns associated file ids.
|
||||
* @param {string} conversationId - The conversation's ID.
|
||||
* @returns {Promise<string[] | null>}
|
||||
*/
|
||||
const getConvoFiles = async (conversationId) => {
|
||||
try {
|
||||
return (await Conversation.findOne({ conversationId }, 'files').lean())?.files ?? [];
|
||||
} catch (error) {
|
||||
logger.error('[getConvoFiles] Error getting conversation files', error);
|
||||
throw new Error('Error getting conversation files');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getConvoFiles,
|
||||
searchConversation,
|
||||
deleteNullOrEmptyConversations,
|
||||
/**
|
||||
* Saves a conversation to the database.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {string} conversationId - The conversation's ID.
|
||||
* @param {Object} metadata - Additional metadata to log for operation.
|
||||
* @returns {Promise<TConversation>} The conversation object.
|
||||
*/
|
||||
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
|
||||
try {
|
||||
if (metadata?.context) {
|
||||
logger.debug(`[saveConvo] ${metadata.context}`);
|
||||
}
|
||||
|
||||
const messages = await getMessages({ conversationId }, '_id');
|
||||
const update = { ...convo, messages, user: req.user.id };
|
||||
|
||||
if (newConversationId) {
|
||||
update.conversationId = newConversationId;
|
||||
}
|
||||
|
||||
if (req?.body?.isTemporary) {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
|
||||
} catch (err) {
|
||||
logger.error('Error creating temporary chat expiration date:', err);
|
||||
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
|
||||
update.expiredAt = null;
|
||||
}
|
||||
} else {
|
||||
update.expiredAt = null;
|
||||
}
|
||||
|
||||
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
|
||||
const updateOperation = { $set: update };
|
||||
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
|
||||
updateOperation.$unset = metadata.unsetFields;
|
||||
}
|
||||
|
||||
/** Note: the resulting Model object is necessary for Meilisearch operations */
|
||||
const conversation = await Conversation.findOneAndUpdate(
|
||||
{ conversationId, user: req.user.id },
|
||||
updateOperation,
|
||||
{
|
||||
new: true,
|
||||
upsert: metadata?.noUpsert !== true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
logger.debug('[saveConvo] Conversation not found, skipping update');
|
||||
return null;
|
||||
}
|
||||
|
||||
return conversation.toObject();
|
||||
} catch (error) {
|
||||
logger.error('[saveConvo] Error saving conversation', error);
|
||||
if (metadata && metadata?.context) {
|
||||
logger.info(`[saveConvo] ${metadata.context}`);
|
||||
}
|
||||
return { message: 'Error saving conversation' };
|
||||
}
|
||||
},
|
||||
bulkSaveConvos: async (conversations) => {
|
||||
try {
|
||||
const bulkOps = conversations.map((convo) => ({
|
||||
updateOne: {
|
||||
filter: { conversationId: convo.conversationId, user: convo.user },
|
||||
update: convo,
|
||||
upsert: true,
|
||||
timestamps: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await Conversation.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
throw new Error('Failed to save conversations in bulk.');
|
||||
}
|
||||
},
|
||||
getConvosByCursor: async (
|
||||
user,
|
||||
{
|
||||
cursor,
|
||||
limit = 25,
|
||||
isArchived = false,
|
||||
tags,
|
||||
search,
|
||||
sortBy = 'updatedAt',
|
||||
sortDirection = 'desc',
|
||||
} = {},
|
||||
) => {
|
||||
const filters = [{ user }];
|
||||
if (isArchived) {
|
||||
filters.push({ isArchived: true });
|
||||
} else {
|
||||
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
|
||||
}
|
||||
|
||||
if (Array.isArray(tags) && tags.length > 0) {
|
||||
filters.push({ tags: { $in: tags } });
|
||||
}
|
||||
|
||||
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });
|
||||
|
||||
if (search) {
|
||||
try {
|
||||
const meiliResults = await Conversation.meiliSearch(search, { filter: `user = "${user}"` });
|
||||
const matchingIds = Array.isArray(meiliResults.hits)
|
||||
? meiliResults.hits.map((result) => result.conversationId)
|
||||
: [];
|
||||
if (!matchingIds.length) {
|
||||
return { conversations: [], nextCursor: null };
|
||||
}
|
||||
filters.push({ conversationId: { $in: matchingIds } });
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||
throw new Error('Error during meiliSearch');
|
||||
}
|
||||
}
|
||||
|
||||
const validSortFields = ['title', 'createdAt', 'updatedAt'];
|
||||
if (!validSortFields.includes(sortBy)) {
|
||||
throw new Error(
|
||||
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const finalSortBy = sortBy;
|
||||
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
let cursorFilter = null;
|
||||
if (cursor) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const { primary, secondary } = decoded;
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
const secondaryValue = new Date(secondary);
|
||||
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||
|
||||
cursorFilter = {
|
||||
$or: [
|
||||
{ [finalSortBy]: { [op]: primaryValue } },
|
||||
{
|
||||
[finalSortBy]: primaryValue,
|
||||
updatedAt: { [op]: secondaryValue },
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (_err) {
|
||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||
}
|
||||
if (cursorFilter) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||
|
||||
try {
|
||||
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||
const sortObj = { [finalSortBy]: sortOrder };
|
||||
|
||||
if (finalSortBy !== 'updatedAt') {
|
||||
sortObj.updatedAt = sortOrder;
|
||||
}
|
||||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||
)
|
||||
.sort(sortObj)
|
||||
.limit(limit + 1)
|
||||
.lean();
|
||||
|
||||
let nextCursor = null;
|
||||
if (convos.length > limit) {
|
||||
convos.pop(); // Remove extra item used to detect next page
|
||||
// Create cursor from the last RETURNED item (not the popped one)
|
||||
const lastReturned = convos[convos.length - 1];
|
||||
const primaryValue = lastReturned[finalSortBy];
|
||||
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
|
||||
const secondaryStr = lastReturned.updatedAt.toISOString();
|
||||
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
throw new Error('Error getting conversations');
|
||||
}
|
||||
},
|
||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||
try {
|
||||
if (!convoIds?.length) {
|
||||
return { conversations: [], nextCursor: null, convoMap: {} };
|
||||
}
|
||||
|
||||
const conversationIds = convoIds.map((convo) => convo.conversationId);
|
||||
|
||||
const results = await Conversation.find({
|
||||
user,
|
||||
conversationId: { $in: conversationIds },
|
||||
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
|
||||
}).lean();
|
||||
|
||||
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
let filtered = results;
|
||||
if (cursor && cursor !== 'start') {
|
||||
const cursorDate = new Date(cursor);
|
||||
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
|
||||
}
|
||||
|
||||
const limited = filtered.slice(0, limit + 1);
|
||||
let nextCursor = null;
|
||||
if (limited.length > limit) {
|
||||
limited.pop(); // Remove extra item used to detect next page
|
||||
// Create cursor from the last RETURNED item (not the popped one)
|
||||
nextCursor = limited[limited.length - 1].updatedAt.toISOString();
|
||||
}
|
||||
|
||||
const convoMap = {};
|
||||
limited.forEach((convo) => {
|
||||
convoMap[convo.conversationId] = convo;
|
||||
});
|
||||
|
||||
return { conversations: limited, nextCursor, convoMap };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||
throw new Error('Error fetching conversations');
|
||||
}
|
||||
},
|
||||
getConvo,
|
||||
/* chore: this method is not properly error handled */
|
||||
getConvoTitle: async (user, conversationId) => {
|
||||
try {
|
||||
const convo = await getConvo(user, conversationId);
|
||||
/* ChatGPT Browser was triggering error here due to convo being saved later */
|
||||
if (convo && !convo.title) {
|
||||
return null;
|
||||
} else {
|
||||
// TypeError: Cannot read properties of null (reading 'title')
|
||||
return convo?.title || 'New Chat';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||
throw new Error('Error getting conversation title');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Asynchronously deletes conversations and associated messages for a given user and filter.
|
||||
*
|
||||
* @async
|
||||
* @function
|
||||
* @param {string|ObjectId} user - The user's ID.
|
||||
* @param {Object} filter - Additional filter criteria for the conversations to be deleted.
|
||||
* @returns {Promise<{ n: number, ok: number, deletedCount: number, messages: { n: number, ok: number, deletedCount: number } }>}
|
||||
* An object containing the count of deleted conversations and associated messages.
|
||||
* @throws {Error} Throws an error if there's an issue with the database operations.
|
||||
*
|
||||
* @example
|
||||
* const user = 'someUserId';
|
||||
* const filter = { someField: 'someValue' };
|
||||
* const result = await deleteConvos(user, filter);
|
||||
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
|
||||
*/
|
||||
deleteConvos: async (user, filter) => {
|
||||
try {
|
||||
const userFilter = { ...filter, user };
|
||||
const conversations = await Conversation.find(userFilter).select('conversationId');
|
||||
const conversationIds = conversations.map((c) => c.conversationId);
|
||||
|
||||
if (!conversationIds.length) {
|
||||
throw new Error('Conversation not found or already deleted.');
|
||||
}
|
||||
|
||||
const deleteConvoResult = await Conversation.deleteMany(userFilter);
|
||||
|
||||
const deleteMessagesResult = await deleteMessages({
|
||||
conversationId: { $in: conversationIds },
|
||||
user,
|
||||
});
|
||||
|
||||
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
||||
} catch (error) {
|
||||
logger.error('[deleteConvos] Error deleting conversations and messages', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
const { z } = require('zod');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createTempChatExpirationDate } = require('@hanzochat/api');
|
||||
const { Message } = require('~/db/models');
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
/**
|
||||
* Saves a message in the database.
|
||||
*
|
||||
* @async
|
||||
* @function saveMessage
|
||||
* @param {ServerRequest} req - The request object containing user information.
|
||||
* @param {Object} params - The message data object.
|
||||
* @param {string} params.endpoint - The endpoint where the message originated.
|
||||
* @param {string} params.iconURL - The URL of the sender's icon.
|
||||
* @param {string} params.messageId - The unique identifier for the message.
|
||||
* @param {string} params.newMessageId - The new unique identifier for the message (if applicable).
|
||||
* @param {string} params.conversationId - The identifier of the conversation.
|
||||
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
|
||||
* @param {string} params.sender - The identifier of the sender.
|
||||
* @param {string} params.text - The text content of the message.
|
||||
* @param {boolean} params.isCreatedByUser - Indicates if the message was created by the user.
|
||||
* @param {string} [params.error] - Any error associated with the message.
|
||||
* @param {boolean} [params.unfinished] - Indicates if the message is unfinished.
|
||||
* @param {Object[]} [params.files] - An array of files associated with the message.
|
||||
* @param {string} [params.finish_reason] - Reason for finishing the message.
|
||||
* @param {number} [params.tokenCount] - The number of tokens in the message.
|
||||
* @param {string} [params.plugin] - Plugin associated with the message.
|
||||
* @param {string[]} [params.plugins] - An array of plugins associated with the message.
|
||||
* @param {string} [params.model] - The model used to generate the message.
|
||||
* @param {Object} [metadata] - Additional metadata for this operation
|
||||
* @param {string} [metadata.context] - The context of the operation
|
||||
* @returns {Promise<TMessage>} The updated or newly inserted message document.
|
||||
* @throws {Error} If there is an error in saving the message.
|
||||
*/
|
||||
async function saveMessage(req, params, metadata) {
|
||||
if (!req?.user?.id) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const validConvoId = idSchema.safeParse(params.conversationId);
|
||||
if (!validConvoId.success) {
|
||||
logger.warn(`Invalid conversation ID: ${params.conversationId}`);
|
||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
logger.info(`---Invalid conversation ID Params: ${JSON.stringify(params, null, 2)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const update = {
|
||||
...params,
|
||||
user: req.user.id,
|
||||
messageId: params.newMessageId || params.messageId,
|
||||
};
|
||||
|
||||
if (req?.body?.isTemporary) {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
|
||||
} catch (err) {
|
||||
logger.error('Error creating temporary chat expiration date:', err);
|
||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
update.expiredAt = null;
|
||||
}
|
||||
} else {
|
||||
update.expiredAt = null;
|
||||
}
|
||||
|
||||
if (update.tokenCount != null && isNaN(update.tokenCount)) {
|
||||
logger.warn(
|
||||
`Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`,
|
||||
);
|
||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
update.tokenCount = 0;
|
||||
}
|
||||
const message = await Message.findOneAndUpdate(
|
||||
{ messageId: params.messageId, user: req.user.id },
|
||||
update,
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
|
||||
return message.toObject();
|
||||
} catch (err) {
|
||||
logger.error('Error saving message:', err);
|
||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
|
||||
// Check if this is a duplicate key error (MongoDB error code 11000)
|
||||
if (err.code === 11000 && err.message.includes('duplicate key error')) {
|
||||
// Log the duplicate key error but don't crash the application
|
||||
logger.warn(`Duplicate messageId detected: ${params.messageId}. Continuing execution.`);
|
||||
|
||||
try {
|
||||
// Try to find the existing message with this ID
|
||||
const existingMessage = await Message.findOne({
|
||||
messageId: params.messageId,
|
||||
user: req.user.id,
|
||||
});
|
||||
|
||||
// If we found it, return it
|
||||
if (existingMessage) {
|
||||
return existingMessage.toObject();
|
||||
}
|
||||
|
||||
// If we can't find it (unlikely but possible in race conditions)
|
||||
return {
|
||||
...params,
|
||||
messageId: params.messageId,
|
||||
user: req.user.id,
|
||||
};
|
||||
} catch (findError) {
|
||||
// If the findOne also fails, log it but don't crash
|
||||
logger.warn(
|
||||
`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`,
|
||||
);
|
||||
return {
|
||||
...params,
|
||||
messageId: params.messageId,
|
||||
user: req.user.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw err; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves multiple messages in the database in bulk.
|
||||
*
|
||||
* @async
|
||||
* @function bulkSaveMessages
|
||||
* @param {Object[]} messages - An array of message objects to save.
|
||||
* @param {boolean} [overrideTimestamp=false] - Indicates whether to override the timestamps of the messages. Defaults to false.
|
||||
* @returns {Promise<Object>} The result of the bulk write operation.
|
||||
* @throws {Error} If there is an error in saving messages in bulk.
|
||||
*/
|
||||
async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
||||
try {
|
||||
const bulkOps = messages.map((message) => ({
|
||||
updateOne: {
|
||||
filter: { messageId: message.messageId },
|
||||
update: message,
|
||||
timestamps: !overrideTimestamp,
|
||||
upsert: true,
|
||||
},
|
||||
}));
|
||||
const result = await Message.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error('Error saving messages in bulk:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a message in the database.
|
||||
*
|
||||
* @async
|
||||
* @function recordMessage
|
||||
* @param {Object} params - The message data object.
|
||||
* @param {string} params.user - The identifier of the user.
|
||||
* @param {string} params.endpoint - The endpoint where the message originated.
|
||||
* @param {string} params.messageId - The unique identifier for the message.
|
||||
* @param {string} params.conversationId - The identifier of the conversation.
|
||||
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
|
||||
* @param {Partial<TMessage>} rest - Any additional properties from the TMessage typedef not explicitly listed.
|
||||
* @returns {Promise<Object>} The updated or newly inserted message document.
|
||||
* @throws {Error} If there is an error in saving the message.
|
||||
*/
|
||||
async function recordMessage({
|
||||
user,
|
||||
endpoint,
|
||||
messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
...rest
|
||||
}) {
|
||||
try {
|
||||
// No parsing of convoId as may use threadId
|
||||
const message = {
|
||||
user,
|
||||
endpoint,
|
||||
messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return await Message.findOneAndUpdate({ user, messageId }, message, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error recording message:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the text of a message.
|
||||
*
|
||||
* @async
|
||||
* @function updateMessageText
|
||||
* @param {Object} params - The update data object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {string} params.messageId - The unique identifier for the message.
|
||||
* @param {string} params.text - The new text content of the message.
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If there is an error in updating the message text.
|
||||
*/
|
||||
async function updateMessageText(req, { messageId, text }) {
|
||||
try {
|
||||
await Message.updateOne({ messageId, user: req.user.id }, { text });
|
||||
} catch (err) {
|
||||
logger.error('Error updating message text:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message.
|
||||
*
|
||||
* @async
|
||||
* @function updateMessage
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} message - The message object containing update data.
|
||||
* @param {string} message.messageId - The unique identifier for the message.
|
||||
* @param {string} [message.text] - The new text content of the message.
|
||||
* @param {Object[]} [message.files] - The files associated with the message.
|
||||
* @param {boolean} [message.isCreatedByUser] - Indicates if the message was created by the user.
|
||||
* @param {string} [message.sender] - The identifier of the sender.
|
||||
* @param {number} [message.tokenCount] - The number of tokens in the message.
|
||||
* @param {Object} [metadata] - The operation metadata
|
||||
* @param {string} [metadata.context] - The operation metadata
|
||||
* @returns {Promise<TMessage>} The updated message document.
|
||||
* @throws {Error} If there is an error in updating the message or if the message is not found.
|
||||
*/
|
||||
async function updateMessage(req, message, metadata) {
|
||||
try {
|
||||
const { messageId, ...update } = message;
|
||||
const updatedMessage = await Message.findOneAndUpdate(
|
||||
{ messageId, user: req.user.id },
|
||||
update,
|
||||
{
|
||||
new: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!updatedMessage) {
|
||||
throw new Error('Message not found or user not authorized.');
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: updatedMessage.messageId,
|
||||
conversationId: updatedMessage.conversationId,
|
||||
parentMessageId: updatedMessage.parentMessageId,
|
||||
sender: updatedMessage.sender,
|
||||
text: updatedMessage.text,
|
||||
isCreatedByUser: updatedMessage.isCreatedByUser,
|
||||
tokenCount: updatedMessage.tokenCount,
|
||||
feedback: updatedMessage.feedback,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('Error updating message:', err);
|
||||
if (metadata && metadata?.context) {
|
||||
logger.info(`---\`updateMessage\` context: ${metadata.context}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes messages in a conversation since a specific message.
|
||||
*
|
||||
* @async
|
||||
* @function deleteMessagesSince
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {string} params.messageId - The unique identifier for the message.
|
||||
* @param {string} params.conversationId - The identifier of the conversation.
|
||||
* @returns {Promise<Number>} The number of deleted messages.
|
||||
* @throws {Error} If there is an error in deleting messages.
|
||||
*/
|
||||
async function deleteMessagesSince(req, { messageId, conversationId }) {
|
||||
try {
|
||||
const message = await Message.findOne({ messageId, user: req.user.id }).lean();
|
||||
|
||||
if (message) {
|
||||
const query = Message.find({ conversationId, user: req.user.id });
|
||||
return await query.deleteMany({
|
||||
createdAt: { $gt: message.createdAt },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
logger.error('Error deleting messages:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves messages from the database.
|
||||
* @async
|
||||
* @function getMessages
|
||||
* @param {Record<string, unknown>} filter - The filter criteria.
|
||||
* @param {string | undefined} [select] - The fields to select.
|
||||
* @returns {Promise<TMessage[]>} The messages that match the filter criteria.
|
||||
* @throws {Error} If there is an error in retrieving messages.
|
||||
*/
|
||||
async function getMessages(filter, select) {
|
||||
try {
|
||||
if (select) {
|
||||
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
|
||||
}
|
||||
|
||||
return await Message.find(filter).sort({ createdAt: 1 }).lean();
|
||||
} catch (err) {
|
||||
logger.error('Error getting messages:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single message from the database.
|
||||
* @async
|
||||
* @function getMessage
|
||||
* @param {{ user: string, messageId: string }} params - The search parameters
|
||||
* @returns {Promise<TMessage | null>} The message that matches the criteria or null if not found
|
||||
* @throws {Error} If there is an error in retrieving the message
|
||||
*/
|
||||
async function getMessage({ user, messageId }) {
|
||||
try {
|
||||
return await Message.findOne({
|
||||
user,
|
||||
messageId,
|
||||
}).lean();
|
||||
} catch (err) {
|
||||
logger.error('Error getting message:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes messages from the database.
|
||||
*
|
||||
* @async
|
||||
* @function deleteMessages
|
||||
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
|
||||
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
|
||||
* @throws {Error} If there is an error in deleting messages.
|
||||
*/
|
||||
async function deleteMessages(filter) {
|
||||
try {
|
||||
return await Message.deleteMany(filter);
|
||||
} catch (err) {
|
||||
logger.error('Error deleting messages:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveMessage,
|
||||
bulkSaveMessages,
|
||||
recordMessage,
|
||||
updateMessageText,
|
||||
updateMessage,
|
||||
deleteMessagesSince,
|
||||
getMessages,
|
||||
getMessage,
|
||||
deleteMessages,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Preset } = require('~/db/models');
|
||||
|
||||
const getPreset = async (user, presetId) => {
|
||||
try {
|
||||
return await Preset.findOne({ user, presetId }).lean();
|
||||
} catch (error) {
|
||||
logger.error('[getPreset] Error getting single preset', error);
|
||||
return { message: 'Error getting single preset' };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getPreset,
|
||||
getPresets: async (user, filter) => {
|
||||
try {
|
||||
const presets = await Preset.find({ ...filter, user }).lean();
|
||||
const defaultValue = 10000;
|
||||
|
||||
presets.sort((a, b) => {
|
||||
let orderA = a.order !== undefined ? a.order : defaultValue;
|
||||
let orderB = b.order !== undefined ? b.order : defaultValue;
|
||||
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
|
||||
return presets;
|
||||
} catch (error) {
|
||||
logger.error('[getPresets] Error getting presets', error);
|
||||
return { message: 'Error retrieving presets' };
|
||||
}
|
||||
},
|
||||
savePreset: async (user, { presetId, newPresetId, defaultPreset, ...preset }) => {
|
||||
try {
|
||||
const setter = { $set: {} };
|
||||
const { user: _, ...cleanPreset } = preset;
|
||||
const update = { presetId, ...cleanPreset };
|
||||
if (preset.tools && Array.isArray(preset.tools)) {
|
||||
update.tools =
|
||||
preset.tools
|
||||
.map((tool) => tool?.pluginKey ?? tool)
|
||||
.filter((toolName) => typeof toolName === 'string') ?? [];
|
||||
}
|
||||
if (newPresetId) {
|
||||
update.presetId = newPresetId;
|
||||
}
|
||||
|
||||
if (defaultPreset) {
|
||||
update.defaultPreset = defaultPreset;
|
||||
update.order = 0;
|
||||
|
||||
const currentDefault = await Preset.findOne({ defaultPreset: true, user });
|
||||
|
||||
if (currentDefault && currentDefault.presetId !== presetId) {
|
||||
await Preset.findByIdAndUpdate(currentDefault._id, {
|
||||
$unset: { defaultPreset: '', order: '' },
|
||||
});
|
||||
}
|
||||
} else if (defaultPreset === false) {
|
||||
update.defaultPreset = undefined;
|
||||
update.order = undefined;
|
||||
setter['$unset'] = { defaultPreset: '', order: '' };
|
||||
}
|
||||
|
||||
setter.$set = update;
|
||||
return await Preset.findOneAndUpdate({ presetId, user }, setter, { new: true, upsert: true });
|
||||
} catch (error) {
|
||||
logger.error('[savePreset] Error saving preset', error);
|
||||
return { message: 'Error saving preset' };
|
||||
}
|
||||
},
|
||||
deletePresets: async (user, filter) => {
|
||||
// let toRemove = await Preset.find({ ...filter, user }).select('presetId');
|
||||
// const ids = toRemove.map((instance) => instance.presetId);
|
||||
let deleteCount = await Preset.deleteMany({ ...filter, user });
|
||||
return deleteCount;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,588 @@
|
||||
const { ObjectId } = require('mongodb');
|
||||
const { escapeRegExp } = require('@hanzochat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles, ResourceType, SystemCategories } = require('librechat-data-provider');
|
||||
const {
|
||||
getSoleOwnedResourceIds,
|
||||
removeAllPermissions,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Batch-fetches production prompts for an array of prompt groups
|
||||
* and attaches them as `productionPrompt` field.
|
||||
* Replaces $lookup aggregation for FerretDB compatibility.
|
||||
*/
|
||||
const attachProductionPrompts = async (groups) => {
|
||||
const uniqueIds = [...new Set(groups.map((g) => g.productionId?.toString()).filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
return groups.map((g) => ({ ...g, productionPrompt: null }));
|
||||
}
|
||||
|
||||
const prompts = await Prompt.find({ _id: { $in: uniqueIds } })
|
||||
.select('prompt')
|
||||
.lean();
|
||||
const promptMap = new Map(prompts.map((p) => [p._id.toString(), p]));
|
||||
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
productionPrompt: g.productionId ? (promptMap.get(g.productionId.toString()) ?? null) : null,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all prompt groups with filters
|
||||
* @param {ServerRequest} req
|
||||
* @param {TPromptGroupsWithFilterRequest} filter
|
||||
* @returns {Promise<PromptGroupListResponse>}
|
||||
*/
|
||||
const getAllPromptGroups = async (req, filter) => {
|
||||
try {
|
||||
const { name, ...query } = filter;
|
||||
|
||||
if (name) {
|
||||
query.name = new RegExp(escapeRegExp(name), 'i');
|
||||
}
|
||||
if (!query.category) {
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.MY_PROMPTS) {
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.NO_CATEGORY) {
|
||||
query.category = '';
|
||||
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
|
||||
delete query.category;
|
||||
}
|
||||
|
||||
let combinedQuery = query;
|
||||
|
||||
const groups = await PromptGroup.find(combinedQuery)
|
||||
.sort({ createdAt: -1 })
|
||||
.select('name oneliner category author authorName createdAt updatedAt command productionId')
|
||||
.lean();
|
||||
return await attachProductionPrompts(groups);
|
||||
} catch (error) {
|
||||
console.error('Error getting all prompt groups', error);
|
||||
return { message: 'Error getting all prompt groups' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get prompt groups with filters
|
||||
* @param {ServerRequest} req
|
||||
* @param {TPromptGroupsWithFilterRequest} filter
|
||||
* @returns {Promise<PromptGroupListResponse>}
|
||||
*/
|
||||
const getPromptGroups = async (req, filter) => {
|
||||
try {
|
||||
const { pageNumber = 1, pageSize = 10, name, ...query } = filter;
|
||||
|
||||
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
|
||||
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
|
||||
|
||||
if (name) {
|
||||
query.name = new RegExp(escapeRegExp(name), 'i');
|
||||
}
|
||||
if (!query.category) {
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.MY_PROMPTS) {
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.NO_CATEGORY) {
|
||||
query.category = '';
|
||||
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
|
||||
delete query.category;
|
||||
}
|
||||
|
||||
let combinedQuery = query;
|
||||
|
||||
const skip = (validatedPageNumber - 1) * validatedPageSize;
|
||||
const limit = validatedPageSize;
|
||||
|
||||
const [groups, totalPromptGroups] = await Promise.all([
|
||||
PromptGroup.find(combinedQuery)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select(
|
||||
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
|
||||
)
|
||||
.lean(),
|
||||
PromptGroup.countDocuments(combinedQuery),
|
||||
]);
|
||||
|
||||
const promptGroups = await attachProductionPrompts(groups);
|
||||
|
||||
return {
|
||||
promptGroups,
|
||||
pageNumber: validatedPageNumber.toString(),
|
||||
pageSize: validatedPageSize.toString(),
|
||||
pages: Math.ceil(totalPromptGroups / validatedPageSize).toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} fields
|
||||
* @param {string} fields._id
|
||||
* @param {string} fields.author
|
||||
* @param {string} fields.role
|
||||
* @returns {Promise<TDeletePromptGroupResponse>}
|
||||
*/
|
||||
const deletePromptGroup = async ({ _id, author, role }) => {
|
||||
// Build query - with ACL, author is optional
|
||||
const query = { _id };
|
||||
const groupQuery = { groupId: new ObjectId(_id) };
|
||||
|
||||
// Legacy: Add author filter if provided (backward compatibility)
|
||||
if (author && role !== SystemRoles.ADMIN) {
|
||||
query.author = author;
|
||||
groupQuery.author = author;
|
||||
}
|
||||
|
||||
const response = await PromptGroup.deleteOne(query);
|
||||
|
||||
if (!response || response.deletedCount === 0) {
|
||||
throw new Error('Prompt group not found');
|
||||
}
|
||||
|
||||
await Prompt.deleteMany(groupQuery);
|
||||
|
||||
try {
|
||||
await removeAllPermissions({ resourceType: ResourceType.PROMPTGROUP, resourceId: _id });
|
||||
} catch (error) {
|
||||
logger.error('Error removing promptGroup permissions:', error);
|
||||
}
|
||||
|
||||
return { message: 'Prompt group deleted successfully' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get prompt groups by accessible IDs with optional cursor-based pagination.
|
||||
* @param {Object} params - The parameters for getting accessible prompt groups.
|
||||
* @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
|
||||
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
|
||||
* @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
|
||||
* @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the prompt groups data and pagination info.
|
||||
*/
|
||||
async function getListPromptGroupsByAccess({
|
||||
accessibleIds = [],
|
||||
otherParams = {},
|
||||
limit = null,
|
||||
after = null,
|
||||
}) {
|
||||
const isPaginated = limit !== null && limit !== undefined;
|
||||
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
|
||||
|
||||
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
|
||||
|
||||
if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||
const { updatedAt, _id } = cursor;
|
||||
|
||||
const cursorCondition = {
|
||||
$or: [
|
||||
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||
{ updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
|
||||
],
|
||||
};
|
||||
|
||||
if (Object.keys(baseQuery).length > 0) {
|
||||
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||
Object.keys(baseQuery).forEach((key) => {
|
||||
if (key !== '$and') delete baseQuery[key];
|
||||
});
|
||||
} else {
|
||||
Object.assign(baseQuery, cursorCondition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Invalid cursor:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const findQuery = PromptGroup.find(baseQuery)
|
||||
.sort({ updatedAt: -1, _id: 1 })
|
||||
.select(
|
||||
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
|
||||
);
|
||||
|
||||
if (isPaginated) {
|
||||
findQuery.limit(normalizedLimit + 1);
|
||||
}
|
||||
|
||||
const groups = await findQuery.lean();
|
||||
const promptGroups = await attachProductionPrompts(groups);
|
||||
|
||||
const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
|
||||
const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
|
||||
(group) => {
|
||||
if (group.author) {
|
||||
group.author = group.author.toString();
|
||||
}
|
||||
return group;
|
||||
},
|
||||
);
|
||||
|
||||
let nextCursor = null;
|
||||
if (isPaginated && hasMore && data.length > 0) {
|
||||
const lastGroup = promptGroups[normalizedLimit - 1];
|
||||
nextCursor = Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastGroup.updatedAt.toISOString(),
|
||||
_id: lastGroup._id.toString(),
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data,
|
||||
first_id: data.length > 0 ? data[0]._id.toString() : null,
|
||||
last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
|
||||
has_more: hasMore,
|
||||
after: nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPromptGroups,
|
||||
deletePromptGroup,
|
||||
getAllPromptGroups,
|
||||
getListPromptGroupsByAccess,
|
||||
/**
|
||||
* Create a prompt and its respective group
|
||||
* @param {TCreatePromptRecord} saveData
|
||||
* @returns {Promise<TCreatePromptResponse>}
|
||||
*/
|
||||
createPromptGroup: async (saveData) => {
|
||||
try {
|
||||
const { prompt, group, author, authorName } = saveData;
|
||||
|
||||
let newPromptGroup = await PromptGroup.findOneAndUpdate(
|
||||
{ ...group, author, authorName, productionId: null },
|
||||
{ $setOnInsert: { ...group, author, authorName, productionId: null } },
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
const newPrompt = await Prompt.findOneAndUpdate(
|
||||
{ ...prompt, author, groupId: newPromptGroup._id },
|
||||
{ $setOnInsert: { ...prompt, author, groupId: newPromptGroup._id } },
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
newPromptGroup = await PromptGroup.findByIdAndUpdate(
|
||||
newPromptGroup._id,
|
||||
{ productionId: newPrompt._id },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
return {
|
||||
prompt: newPrompt,
|
||||
group: {
|
||||
...newPromptGroup,
|
||||
productionPrompt: { prompt: newPrompt.prompt },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error saving prompt group', error);
|
||||
throw new Error('Error saving prompt group');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Save a prompt
|
||||
* @param {TCreatePromptRecord} saveData
|
||||
* @returns {Promise<TCreatePromptResponse>}
|
||||
*/
|
||||
savePrompt: async (saveData) => {
|
||||
try {
|
||||
const { prompt, author } = saveData;
|
||||
const newPromptData = {
|
||||
...prompt,
|
||||
author,
|
||||
};
|
||||
|
||||
/** @type {TPrompt} */
|
||||
let newPrompt;
|
||||
try {
|
||||
newPrompt = await Prompt.create(newPromptData);
|
||||
} catch (error) {
|
||||
if (error?.message?.includes('groupId_1_version_1')) {
|
||||
await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
newPrompt = await Prompt.create(newPromptData);
|
||||
}
|
||||
|
||||
return { prompt: newPrompt };
|
||||
} catch (error) {
|
||||
logger.error('Error saving prompt', error);
|
||||
return { message: 'Error saving prompt' };
|
||||
}
|
||||
},
|
||||
getPrompts: async (filter) => {
|
||||
try {
|
||||
return await Prompt.find(filter).sort({ createdAt: -1 }).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompts', error);
|
||||
return { message: 'Error getting prompts' };
|
||||
}
|
||||
},
|
||||
getPrompt: async (filter) => {
|
||||
try {
|
||||
if (filter.groupId) {
|
||||
filter.groupId = new ObjectId(filter.groupId);
|
||||
}
|
||||
return await Prompt.findOne(filter).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt', error);
|
||||
return { message: 'Error getting prompt' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get prompt groups with filters
|
||||
* @param {TGetRandomPromptsRequest} filter
|
||||
* @returns {Promise<TGetRandomPromptsResponse>}
|
||||
*/
|
||||
getRandomPromptGroups: async (filter) => {
|
||||
try {
|
||||
const categories = await PromptGroup.distinct('category', { category: { $ne: '' } });
|
||||
|
||||
for (let i = categories.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[categories[i], categories[j]] = [categories[j], categories[i]];
|
||||
}
|
||||
|
||||
const skip = +filter.skip;
|
||||
const limit = +filter.limit;
|
||||
const selectedCategories = categories.slice(skip, skip + limit);
|
||||
|
||||
if (selectedCategories.length === 0) {
|
||||
return { prompts: [] };
|
||||
}
|
||||
|
||||
const groups = await PromptGroup.find({ category: { $in: selectedCategories } }).lean();
|
||||
|
||||
const groupByCategory = new Map();
|
||||
for (const group of groups) {
|
||||
if (!groupByCategory.has(group.category)) {
|
||||
groupByCategory.set(group.category, group);
|
||||
}
|
||||
}
|
||||
|
||||
const prompts = selectedCategories.map((cat) => groupByCategory.get(cat)).filter(Boolean);
|
||||
|
||||
return { prompts };
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
},
|
||||
getPromptGroupsWithPrompts: async (filter) => {
|
||||
try {
|
||||
return await PromptGroup.findOne(filter)
|
||||
.populate({
|
||||
path: 'prompts',
|
||||
select: '-_id -__v -user',
|
||||
})
|
||||
.select('-_id -__v -user')
|
||||
.lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
},
|
||||
getPromptGroup: async (filter) => {
|
||||
try {
|
||||
return await PromptGroup.findOne(filter).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt group', error);
|
||||
return { message: 'Error getting prompt group' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes a prompt and its corresponding prompt group if it is the last prompt in the group.
|
||||
*
|
||||
* @param {Object} options - The options for deleting the prompt.
|
||||
* @param {ObjectId|string} options.promptId - The ID of the prompt to delete.
|
||||
* @param {ObjectId|string} options.groupId - The ID of the prompt's group.
|
||||
* @param {ObjectId|string} options.author - The ID of the prompt's author.
|
||||
* @param {string} options.role - The role of the prompt's author.
|
||||
* @return {Promise<TDeletePromptResponse>} An object containing the result of the deletion.
|
||||
* If the prompt was deleted successfully, the object will have a property 'prompt' with the value 'Prompt deleted successfully'.
|
||||
* If the prompt group was deleted successfully, the object will have a property 'promptGroup' with the message 'Prompt group deleted successfully' and id of the deleted group.
|
||||
* If there was an error deleting the prompt, the object will have a property 'message' with the value 'Error deleting prompt'.
|
||||
*/
|
||||
deletePrompt: async ({ promptId, groupId, author, role }) => {
|
||||
const query = { _id: promptId, groupId, author };
|
||||
if (role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
}
|
||||
const { deletedCount } = await Prompt.deleteOne(query);
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('Failed to delete the prompt');
|
||||
}
|
||||
|
||||
const remainingPrompts = await Prompt.find({ groupId })
|
||||
.select('_id')
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
if (remainingPrompts.length === 0) {
|
||||
// Remove all ACL entries for the promptGroup when deleting the last prompt
|
||||
try {
|
||||
await removeAllPermissions({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
resourceId: groupId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error removing promptGroup permissions:', error);
|
||||
}
|
||||
|
||||
await PromptGroup.deleteOne({ _id: groupId });
|
||||
|
||||
return {
|
||||
prompt: 'Prompt deleted successfully',
|
||||
promptGroup: {
|
||||
message: 'Prompt group deleted successfully',
|
||||
id: groupId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const promptGroup = await PromptGroup.findById(groupId).lean();
|
||||
if (promptGroup.productionId.toString() === promptId.toString()) {
|
||||
await PromptGroup.updateOne(
|
||||
{ _id: groupId },
|
||||
{ productionId: remainingPrompts[remainingPrompts.length - 1]._id },
|
||||
);
|
||||
}
|
||||
|
||||
return { prompt: 'Prompt deleted successfully' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Delete prompt groups solely owned by the user and clean up their prompts/ACLs.
|
||||
* Groups with other owners are left intact; the caller is responsible for
|
||||
* removing the user's own ACL principal entries separately.
|
||||
*
|
||||
* Also handles legacy (pre-ACL) prompt groups that only have the author field set,
|
||||
* ensuring they are not orphaned if the permission migration has not been run.
|
||||
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
|
||||
*/
|
||||
deleteUserPrompts: async (userId) => {
|
||||
try {
|
||||
const userObjectId = new ObjectId(userId);
|
||||
const soleOwnedIds = await getSoleOwnedResourceIds(userObjectId, ResourceType.PROMPTGROUP);
|
||||
|
||||
const authoredGroups = await PromptGroup.find({ author: userObjectId }).select('_id').lean();
|
||||
const authoredGroupIds = authoredGroups.map((g) => g._id);
|
||||
|
||||
const migratedEntries =
|
||||
authoredGroupIds.length > 0
|
||||
? await AclEntry.find({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
resourceId: { $in: authoredGroupIds },
|
||||
})
|
||||
.select('resourceId')
|
||||
.lean()
|
||||
: [];
|
||||
const migratedIds = new Set(migratedEntries.map((e) => e.resourceId.toString()));
|
||||
const legacyGroupIds = authoredGroupIds.filter((id) => !migratedIds.has(id.toString()));
|
||||
|
||||
const allGroupIdsToDelete = [...soleOwnedIds, ...legacyGroupIds];
|
||||
|
||||
if (allGroupIdsToDelete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await AclEntry.deleteMany({
|
||||
resourceType: ResourceType.PROMPTGROUP,
|
||||
resourceId: { $in: allGroupIdsToDelete },
|
||||
});
|
||||
|
||||
await PromptGroup.deleteMany({ _id: { $in: allGroupIdsToDelete } });
|
||||
await Prompt.deleteMany({ groupId: { $in: allGroupIdsToDelete } });
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserPrompts] General error:', error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update prompt group
|
||||
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
||||
* @param {Partial<MongoPromptGroup>} data - Data to update
|
||||
* @returns {Promise<TUpdatePromptGroupResponse>}
|
||||
*/
|
||||
updatePromptGroup: async (filter, data) => {
|
||||
try {
|
||||
const updateOps = {};
|
||||
|
||||
const updateData = { ...data, ...updateOps };
|
||||
const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, {
|
||||
new: true,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (!updatedDoc) {
|
||||
throw new Error('Prompt group not found');
|
||||
}
|
||||
|
||||
return updatedDoc;
|
||||
} catch (error) {
|
||||
logger.error('Error updating prompt group', error);
|
||||
return { message: 'Error updating prompt group' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Function to make a prompt production based on its ID.
|
||||
* @param {String} promptId - The ID of the prompt to make production.
|
||||
* @returns {Object} The result of the production operation.
|
||||
*/
|
||||
makePromptProduction: async (promptId) => {
|
||||
try {
|
||||
const prompt = await Prompt.findById(promptId).lean();
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('Prompt not found');
|
||||
}
|
||||
|
||||
await PromptGroup.findByIdAndUpdate(
|
||||
prompt.groupId,
|
||||
{ productionId: prompt._id },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return {
|
||||
message: 'Prompt production made successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error making prompt production', error);
|
||||
return { message: 'Error making prompt production' };
|
||||
}
|
||||
},
|
||||
updatePromptLabels: async (_id, labels) => {
|
||||
try {
|
||||
const response = await Prompt.updateOne({ _id }, { $set: { labels } });
|
||||
if (response.matchedCount === 0) {
|
||||
return { message: 'Prompt not found' };
|
||||
}
|
||||
return { message: 'Prompt labels updated successfully' };
|
||||
} catch (error) {
|
||||
logger.error('Error updating prompt labels', error);
|
||||
return { message: 'Error updating prompt labels' };
|
||||
}
|
||||
},
|
||||
};
|
||||
+106
-44
@@ -1,5 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { billingSubject } = require('@hanzochat/api');
|
||||
const { createAutoRefillTransaction } = require('./Transaction');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { getMultiplier } = require('./tx');
|
||||
@@ -22,6 +23,21 @@ function getMinBalance() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum balance in COMMERCE cents (commerce balances are in cents).
|
||||
* Derived from HANZO_MIN_BALANCE (USD). Used by the commerce-first money gate.
|
||||
*/
|
||||
function getMinBalanceCents() {
|
||||
const envVal = process.env.HANZO_MIN_BALANCE;
|
||||
if (envVal != null && envVal !== '') {
|
||||
const usd = parseFloat(envVal);
|
||||
if (!isNaN(usd) && usd > 0) {
|
||||
return Math.round(usd * 100);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isInvalidDate(date) {
|
||||
return isNaN(date);
|
||||
}
|
||||
@@ -50,10 +66,24 @@ const checkModelAccess = async function (userId, model) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple check method that calculates token cost and returns balance info.
|
||||
* Integrates with Commerce balance gate when configured (fail-open).
|
||||
* Calculates token cost and decides whether the request may proceed.
|
||||
*
|
||||
* MONEY GATE — Commerce-first and FAIL CLOSED (this is the money path; better to
|
||||
* block a user than bleed). When Commerce is configured and we can identify the
|
||||
* user's billing org (their IAM `organization`), Commerce is AUTHORITATIVE:
|
||||
* - sufficient org balance (>= HANZO_MIN_BALANCE) → allowed (decisive; we do
|
||||
* NOT fall through to the local tokenCredits gate, so startBalance:0 never
|
||||
* false-blocks a Commerce-funded user);
|
||||
* - insufficient / unreachable / no billing identity → BLOCKED.
|
||||
* The cloud gateway separately debits the user's own hk- key against this same
|
||||
* org, so this check is the pre-flight that yields a clean "claim credit" UX
|
||||
* instead of a raw gateway 402.
|
||||
*
|
||||
* When Commerce is not configured (or no billing identity), falls back to the
|
||||
* legacy local tokenCredits gate.
|
||||
*/
|
||||
const checkBalanceRecord = async function ({
|
||||
req,
|
||||
user,
|
||||
model,
|
||||
endpoint,
|
||||
@@ -65,7 +95,79 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Retrieve the balance record
|
||||
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
|
||||
// two ways, neither of which is an authed user's org balance: (1) the per-IP
|
||||
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
|
||||
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
|
||||
// balance the cloud gateway debits and 402s when empty. Running them through the
|
||||
// Commerce/local gate (startBalance:0) would block the free tier entirely.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
const billingOrg = (req?.user?.organization ?? '').toString().trim();
|
||||
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
|
||||
// the authoritative IAM identity; otherwise derive it from organization + email
|
||||
// (name == email for individual signups). This keys the gate on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
|
||||
// the shared org balance.
|
||||
const subject =
|
||||
(req?.user?.billingSubject ?? '').toString().trim() ||
|
||||
billingSubject(billingOrg, req?.user?.email);
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
let commerceBalance;
|
||||
try {
|
||||
commerceBalance = await commerceClient.checkBalance(subject);
|
||||
} catch (err) {
|
||||
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
|
||||
user,
|
||||
subject,
|
||||
error: err.message,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
|
||||
}
|
||||
|
||||
const available = commerceBalance.available || 0;
|
||||
const minCents = Math.max(getMinBalanceCents(), 1);
|
||||
if (available < minCents) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
subject,
|
||||
available,
|
||||
minCents,
|
||||
});
|
||||
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
|
||||
if (model) {
|
||||
try {
|
||||
const modelAccess = await commerceClient.isModelAllowed(subject, model);
|
||||
if (!modelAccess.allowed) {
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: available,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decisive: Commerce says funded → allow. Do not consult local credits.
|
||||
return { canSpend: true, balance: available, tokenCost };
|
||||
}
|
||||
|
||||
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
|
||||
let record = await Balance.findOne({ user }).lean();
|
||||
if (!record) {
|
||||
logger.debug('[Balance.check] No balance record found for user', { user });
|
||||
@@ -76,46 +178,6 @@ const checkBalanceRecord = async function ({
|
||||
};
|
||||
}
|
||||
|
||||
// Commerce balance gate: if configured, check Commerce first (fail-open)
|
||||
const commerceClient = getCommerceClient();
|
||||
if (commerceClient && record.commerceUserId) {
|
||||
try {
|
||||
const commerceBalance = await commerceClient.checkBalance(record.commerceUserId);
|
||||
if (!commerceBalance.sufficient) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
commerceUserId: record.commerceUserId,
|
||||
available: commerceBalance.available,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Check model access via Commerce tier
|
||||
if (model) {
|
||||
const modelAccess = await commerceClient.isModelAllowed(record.commerceUserId, model);
|
||||
if (!modelAccess.allowed) {
|
||||
logger.debug('[Balance.check] Model not allowed for tier', {
|
||||
user,
|
||||
model,
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
});
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: record.tokenCredits,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail-open: Commerce error → fall through to local check
|
||||
logger.warn('[Balance.check] Commerce check failed, falling back to local', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let balance = record.tokenCredits;
|
||||
|
||||
// Check if credits have expired
|
||||
@@ -224,7 +286,7 @@ const addIntervalToDate = (date, value, unit) => {
|
||||
* @throws {Error} Throws an error if there's an issue with the balance check.
|
||||
*/
|
||||
const checkBalance = async ({ req, res, txData }) => {
|
||||
const result = await checkBalanceRecord(txData);
|
||||
const result = await checkBalanceRecord({ ...txData, req });
|
||||
if (result.canSpend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
|
||||
// (per-org via service token + X-Hanzo-Org; the cloud gateway debits the user's
|
||||
// own hk- key against the SAME org). Proves both directions: unmetered AI never
|
||||
// leaks (block on any ambiguity), and funded users + capped guests are never
|
||||
// wrongly blocked.
|
||||
|
||||
const mockClient = {
|
||||
checkBalance: jest.fn(),
|
||||
isModelAllowed: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/CommerceClient', () => ({
|
||||
getCommerceClient: jest.fn(() => mockClient),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
|
||||
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
|
||||
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
|
||||
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
|
||||
}));
|
||||
|
||||
const { checkBalance } = require('./balanceMethods');
|
||||
const { getCommerceClient } = require('~/server/services/CommerceClient');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const argsFor = (user) => ({
|
||||
req: { user },
|
||||
res: {},
|
||||
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
|
||||
});
|
||||
|
||||
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
|
||||
// `reason`) when blocked.
|
||||
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
|
||||
try {
|
||||
const ok = await checkBalance(argsFor(user));
|
||||
return { allowed: ok, reason: null };
|
||||
} catch (err) {
|
||||
let reason = null;
|
||||
try {
|
||||
reason = JSON.parse(err.message).reason;
|
||||
} catch (_) {
|
||||
reason = err.message;
|
||||
}
|
||||
return { allowed: false, reason };
|
||||
}
|
||||
};
|
||||
|
||||
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCommerceClient.mockReturnValue(mockClient);
|
||||
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
|
||||
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
|
||||
});
|
||||
|
||||
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
|
||||
const { allowed } = await decide({ id: 'g1', guest: true });
|
||||
expect(allowed).toBe(true);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
expect(Balance.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org (available >= min)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBeNull();
|
||||
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
|
||||
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_unavailable');
|
||||
});
|
||||
|
||||
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockResolvedValue({
|
||||
allowed: false,
|
||||
tier: 'free',
|
||||
allowedModels: ['zen3-nano'],
|
||||
});
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('model_not_allowed');
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
|
||||
const { allowed } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
|
||||
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
|
||||
const { allowed } = await decide({ id: 'u2', organization: '' });
|
||||
expect(allowed).toBe(false);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,9 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -45,7 +45,7 @@ const validateResourceType = (resourceType) => {
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, remove)
|
||||
* @route PUT /api/{resourceType}/{resourceId}/permissions
|
||||
* @route PUT /v1/chat/{resourceType}/{resourceId}/permissions
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} req.params - Route parameters
|
||||
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
|
||||
@@ -178,7 +178,7 @@ const updateResourcePermissions = async (req, res) => {
|
||||
/**
|
||||
* Get principals with their permission roles for a resource (UI-friendly format)
|
||||
* Uses efficient aggregation pipeline to join User/Group data in single query
|
||||
* @route GET /api/permissions/{resourceType}/{resourceId}
|
||||
* @route GET /v1/chat/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
const getResourcePermissions = async (req, res) => {
|
||||
try {
|
||||
@@ -311,7 +311,7 @@ const getResourcePermissions = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Get available roles for a resource type
|
||||
* @route GET /api/{resourceType}/roles
|
||||
* @route GET /v1/chat/{resourceType}/roles
|
||||
*/
|
||||
const getResourceRoles = async (req, res) => {
|
||||
try {
|
||||
@@ -339,7 +339,7 @@ const getResourceRoles = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Get user's effective permission bitmask for a resource
|
||||
* @route GET /api/{resourceType}/{resourceId}/effective
|
||||
* @route GET /v1/chat/{resourceType}/{resourceId}/effective
|
||||
*/
|
||||
const getUserEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
@@ -370,7 +370,7 @@ const getUserEffectivePermissions = async (req, res) => {
|
||||
/**
|
||||
* Search for users and groups to grant permissions
|
||||
* Supports hybrid local database + Entra ID search when configured
|
||||
* @route GET /api/permissions/search-principals
|
||||
* @route GET /v1/chat/permissions/search-principals
|
||||
*/
|
||||
const searchPrincipals = async (req, res) => {
|
||||
try {
|
||||
@@ -487,7 +487,7 @@ const searchPrincipals = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Get user's effective permissions for all accessible resources of a type
|
||||
* @route GET /api/permissions/{resourceType}/effective/all
|
||||
* @route GET /v1/chat/permissions/{resourceType}/effective/all
|
||||
*/
|
||||
const getAllEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -40,12 +40,16 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Guest-scoped bootstrap controllers: verify that a guest principal receives
|
||||
* safe, scoped data (and never triggers a DB lookup) on the read-only endpoints
|
||||
* the chat composer hard-requires.
|
||||
*/
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
CacheKeys: { CONFIG_STORE: 'CONFIG_STORE', MODELS_CONFIG: 'MODELS_CONFIG' },
|
||||
Tools: {},
|
||||
Constants: {},
|
||||
FileSources: { local: 'local', s3: 's3' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
// --- EndpointController deps ---
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getEndpointsConfig: jest.fn(),
|
||||
loadDefaultModels: jest.fn(),
|
||||
loadConfigModels: jest.fn(),
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }));
|
||||
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
|
||||
const guestReq = (overrides = {}) => ({
|
||||
user: { id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('guest bootstrap controllers', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('GET /v1/chat/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 /v1/chat/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'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -891,9 +891,20 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture before running the graph. `run.processStream` (via
|
||||
* `@librechat/agents`) treats the passed `config` as owned and, during its
|
||||
* post-stream cleanup, sets `config.configurable = undefined` to break the
|
||||
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
|
||||
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
|
||||
* therefore throws `Cannot read properties of undefined`, which surfaces as
|
||||
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
|
||||
* Chain filter working without depending on post-run config state.
|
||||
*/
|
||||
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
if (hideSequentialOutputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
@@ -929,7 +940,7 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
|
||||
/** Skip token spending if aborted - the abort handler (abortMiddleware.js) handles it
|
||||
This prevents double-spending when user aborts via `/api/agents/chat/abort` */
|
||||
This prevents double-spending when user aborts via `/v1/chat/agents/chat/abort` */
|
||||
const wasAborted = abortController?.signal?.aborted;
|
||||
if (!wasAborted) {
|
||||
await this.recordCollectedUsage({
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('@hanzochat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
initializeAgent: jest.fn(),
|
||||
createMemoryProcessor: jest.fn(),
|
||||
createRun: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({
|
||||
@@ -2257,3 +2258,122 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - chatCompletion post-reply banner regression', () => {
|
||||
/**
|
||||
* Regression for the post-reply error banner:
|
||||
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
|
||||
*
|
||||
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
|
||||
* `config` as owned and, in its post-stream cleanup, sets
|
||||
* `config.configurable = undefined` to break the AsyncLocalStorage reference
|
||||
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
|
||||
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
|
||||
* AFTER the stream completes, or it throws and pushes an ERROR content part
|
||||
* (the red banner) which also aborts the post-stream finalize (auto-titling).
|
||||
* This test reproduces that mutation and asserts no ERROR part is produced.
|
||||
*/
|
||||
const { createRun } = require('@hanzochat/api');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
let processStreamMock;
|
||||
|
||||
const makeClient = (agentOverrides = {}) => {
|
||||
const agent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
...agentOverrides,
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {},
|
||||
config: { endpoints: {} },
|
||||
};
|
||||
const client = new AgentClient({
|
||||
req,
|
||||
res: {},
|
||||
agent,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
contentParts: [],
|
||||
collectedUsage: [],
|
||||
artifactPromises: [],
|
||||
endpointTokenConfig: {},
|
||||
});
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
client.user = 'user-123';
|
||||
// Avoid DB / memory side effects unrelated to this regression.
|
||||
client.recordCollectedUsage = jest.fn().mockResolvedValue();
|
||||
client.runMemory = jest.fn().mockResolvedValue(undefined);
|
||||
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
|
||||
return client;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
|
||||
// the caller's `config.configurable` after the stream finishes.
|
||||
processStreamMock = jest.fn(async (_inputs, config) => {
|
||||
config.configurable = undefined;
|
||||
return [];
|
||||
});
|
||||
createRun.mockResolvedValue({
|
||||
Graph: {},
|
||||
processStream: processStreamMock,
|
||||
});
|
||||
});
|
||||
|
||||
const errorParts = (client) =>
|
||||
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
|
||||
|
||||
it('does not push an error content part when processStream nulls config.configurable', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
await expect(
|
||||
client.sendCompletion('hello', { abortController: new AbortController() }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(processStreamMock).toHaveBeenCalledTimes(1);
|
||||
// The config passed to processStream had configurable nulled (reproduction sanity check).
|
||||
const passedConfig = processStreamMock.mock.calls[0][1];
|
||||
expect(passedConfig.configurable).toBeUndefined();
|
||||
// The fix: no banner-producing ERROR part despite the nulled configurable.
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: true });
|
||||
// Seed multiple content parts; the filter keeps only the last part plus
|
||||
// tool_call parts. With the value captured before the stream, the filter
|
||||
// must run even though config.configurable is undefined afterwards.
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'intermediate-1' },
|
||||
{ type: 'text', text: 'intermediate-2' },
|
||||
{ type: 'text', text: 'final' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
// hide_sequential_outputs collapses to the final part only (no tool calls present).
|
||||
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
|
||||
});
|
||||
|
||||
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: false });
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
expect(client.contentParts).toEqual([
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -7,7 +7,11 @@ const {
|
||||
generateAdminExchangeCode,
|
||||
} = require('@hanzochat/api');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const {
|
||||
setAuthTokens,
|
||||
setOpenIDAuthTokens,
|
||||
persistOpenIDTokensToSession,
|
||||
} = require('~/server/services/AuthService');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { generateToken } = require('~/models');
|
||||
@@ -56,13 +60,32 @@ function createOAuthHandler(redirectUri = domains.client) {
|
||||
}
|
||||
|
||||
/** Standard OAuth flow - set cookies and redirect */
|
||||
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());
|
||||
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 (`/v1/chat/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 /v1/chat/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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ 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() };
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
|
||||
@@ -33,6 +34,7 @@ jest.mock('~/server/services/PermissionService', () => ({
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
||||
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -148,4 +150,87 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ const getMCPTools = async (req, res) => {
|
||||
};
|
||||
/**
|
||||
* Get all MCP servers with permissions
|
||||
* @route GET /api/mcp/servers
|
||||
* @route GET /v1/chat/mcp/servers
|
||||
*/
|
||||
const getMCPServersList = async (req, res) => {
|
||||
try {
|
||||
@@ -193,7 +193,7 @@ const getMCPServersList = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Create MCP server
|
||||
* @route POST /api/mcp/servers
|
||||
* @route POST /v1/chat/mcp/servers
|
||||
*/
|
||||
const createMCPServerController = async (req, res) => {
|
||||
try {
|
||||
@@ -252,7 +252,7 @@ const getMCPServerById = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Update MCP server
|
||||
* @route PATCH /api/mcp/servers/:serverName
|
||||
* @route PATCH /v1/chat/mcp/servers/:serverName
|
||||
*/
|
||||
const updateMCPServerController = async (req, res) => {
|
||||
try {
|
||||
@@ -287,7 +287,7 @@ const updateMCPServerController = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Delete MCP server
|
||||
* @route DELETE /api/mcp/servers/:serverName
|
||||
* @route DELETE /v1/chat/mcp/servers/:serverName
|
||||
*/
|
||||
const deleteMCPServerController = async (req, res) => {
|
||||
try {
|
||||
|
||||
+26
-26
@@ -296,33 +296,33 @@ if (cluster.isMaster) {
|
||||
|
||||
/** Routes */
|
||||
app.use('/oauth', routes.oauth);
|
||||
app.use('/api/auth', routes.auth);
|
||||
app.use('/api/actions', routes.actions);
|
||||
app.use('/api/keys', routes.keys);
|
||||
app.use('/api/api-keys', routes.apiKeys);
|
||||
app.use('/api/user', routes.user);
|
||||
app.use('/api/search', routes.search);
|
||||
app.use('/api/messages', routes.messages);
|
||||
app.use('/api/convos', routes.convos);
|
||||
app.use('/api/presets', routes.presets);
|
||||
app.use('/api/prompts', routes.prompts);
|
||||
app.use('/api/categories', routes.categories);
|
||||
app.use('/api/endpoints', routes.endpoints);
|
||||
app.use('/api/balance', routes.balance);
|
||||
app.use('/api/models', routes.models);
|
||||
app.use('/api/plugins', routes.plugins);
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/v1/chat/auth', routes.auth);
|
||||
app.use('/v1/chat/actions', routes.actions);
|
||||
app.use('/v1/chat/keys', routes.keys);
|
||||
app.use('/v1/chat/api-keys', routes.apiKeys);
|
||||
app.use('/v1/chat/user', routes.user);
|
||||
app.use('/v1/chat/search', routes.search);
|
||||
app.use('/v1/chat/messages', routes.messages);
|
||||
app.use('/v1/chat/convos', routes.convos);
|
||||
app.use('/v1/chat/presets', routes.presets);
|
||||
app.use('/v1/chat/prompts', routes.prompts);
|
||||
app.use('/v1/chat/categories', routes.categories);
|
||||
app.use('/v1/chat/endpoints', routes.endpoints);
|
||||
app.use('/v1/chat/balance', routes.balance);
|
||||
app.use('/v1/chat/models', routes.models);
|
||||
app.use('/v1/chat/plugins', routes.plugins);
|
||||
app.use('/v1/chat/config', routes.config);
|
||||
app.use('/v1/chat/assistants', routes.assistants);
|
||||
app.use('/v1/chat/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use('/api/share', routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents', routes.agents);
|
||||
app.use('/api/banner', routes.banner);
|
||||
app.use('/api/memories', routes.memories);
|
||||
app.use('/api/permissions', routes.accessPermissions);
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
app.use('/v1/chat/share', routes.share);
|
||||
app.use('/v1/chat/roles', routes.roles);
|
||||
app.use('/v1/chat/agents', routes.agents);
|
||||
app.use('/v1/chat/banner', routes.banner);
|
||||
app.use('/v1/chat/memories', routes.memories);
|
||||
app.use('/v1/chat/permissions', routes.accessPermissions);
|
||||
app.use('/v1/chat/tags', routes.tags);
|
||||
app.use('/v1/chat/mcp', routes.mcp);
|
||||
|
||||
/** Error handler */
|
||||
app.use(ErrorController);
|
||||
|
||||
+27
-27
@@ -136,7 +136,7 @@ const startServer = async () => {
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat; frame-ancestors 'none';",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
);
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
@@ -174,34 +174,34 @@ const startServer = async () => {
|
||||
|
||||
app.use('/oauth', routes.oauth);
|
||||
/* API Endpoints */
|
||||
app.use('/api/auth', routes.auth);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/actions', routes.actions);
|
||||
app.use('/api/keys', routes.keys);
|
||||
app.use('/api/api-keys', routes.apiKeys);
|
||||
app.use('/api/user', routes.user);
|
||||
app.use('/api/search', routes.search);
|
||||
app.use('/api/messages', routes.messages);
|
||||
app.use('/api/convos', routes.convos);
|
||||
app.use('/api/presets', routes.presets);
|
||||
app.use('/api/prompts', routes.prompts);
|
||||
app.use('/api/categories', routes.categories);
|
||||
app.use('/api/endpoints', routes.endpoints);
|
||||
app.use('/api/balance', routes.balance);
|
||||
app.use('/api/models', routes.models);
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/v1/chat/auth', routes.auth);
|
||||
app.use('/v1/chat/admin', routes.adminAuth);
|
||||
app.use('/v1/chat/actions', routes.actions);
|
||||
app.use('/v1/chat/keys', routes.keys);
|
||||
app.use('/v1/chat/api-keys', routes.apiKeys);
|
||||
app.use('/v1/chat/user', routes.user);
|
||||
app.use('/v1/chat/search', routes.search);
|
||||
app.use('/v1/chat/messages', routes.messages);
|
||||
app.use('/v1/chat/convos', routes.convos);
|
||||
app.use('/v1/chat/presets', routes.presets);
|
||||
app.use('/v1/chat/prompts', routes.prompts);
|
||||
app.use('/v1/chat/categories', routes.categories);
|
||||
app.use('/v1/chat/endpoints', routes.endpoints);
|
||||
app.use('/v1/chat/balance', routes.balance);
|
||||
app.use('/v1/chat/models', routes.models);
|
||||
app.use('/v1/chat/config', routes.config);
|
||||
app.use('/v1/chat/assistants', routes.assistants);
|
||||
app.use('/v1/chat/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use('/api/share', routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents', routes.agents);
|
||||
app.use('/api/banner', routes.banner);
|
||||
app.use('/api/memories', routes.memories);
|
||||
app.use('/api/permissions', routes.accessPermissions);
|
||||
app.use('/v1/chat/share', routes.share);
|
||||
app.use('/v1/chat/roles', routes.roles);
|
||||
app.use('/v1/chat/agents', routes.agents);
|
||||
app.use('/v1/chat/banner', routes.banner);
|
||||
app.use('/v1/chat/memories', routes.memories);
|
||||
app.use('/v1/chat/permissions', routes.accessPermissions);
|
||||
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
app.use('/v1/chat/tags', routes.tags);
|
||||
app.use('/v1/chat/mcp', routes.mcp);
|
||||
|
||||
app.use(ErrorController);
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('Server Configuration', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app).post('/api/auth/login').send({
|
||||
const response = await request(app).post('/v1/chat/auth/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/agents/chat/abort')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.3')
|
||||
.send({ streamId: 'x' });
|
||||
// requireJwtAuth mock forces a real USER; guest never reaches abort.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ aborted: true, role: 'USER' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Regression: a logged-in user in "My Agents" with no agent created/selected
|
||||
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
|
||||
*
|
||||
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
|
||||
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
|
||||
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
|
||||
* The downstream build-options fix point is covered by
|
||||
* ../../services/Endpoints/agents/build.spec.js.
|
||||
*/
|
||||
|
||||
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
ResourceType: { AGENT: 'agent' },
|
||||
// Real predicate semantics (mirrors packages/data-provider/src):
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
|
||||
}));
|
||||
|
||||
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
|
||||
req.__aclChecked = true;
|
||||
return next();
|
||||
});
|
||||
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
|
||||
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
const makeReqRes = (body) => {
|
||||
const req = { body, params: {} };
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const next = jest.fn();
|
||||
return { req, res, next };
|
||||
};
|
||||
|
||||
describe('agent_id missing → ephemeral fallback (no 400)', () => {
|
||||
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
|
||||
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
|
||||
});
|
||||
|
||||
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('explicit ephemeral id proceeds', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a real agent_id still runs the per-agent ACL check', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
|
||||
await mw(req, res, next);
|
||||
|
||||
// The middleware builds the ACL check with the real agent id as resourceIdParam,
|
||||
// then runs it (which here calls next via the mock).
|
||||
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -60,15 +60,11 @@ const canAccessAgentFromBody = (options) => {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
|
||||
// not an error: "type a message and get a reply" must work before the user
|
||||
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
|
||||
// so skip the permission check and fall through to plain chat. A missing id
|
||||
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
|
||||
if (isEphemeralAgentId(agentId)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -104,13 +104,15 @@ describe('canAccessAgentFromBody middleware', () => {
|
||||
});
|
||||
|
||||
describe('primary agent checks', () => {
|
||||
test('returns 400 when agent_id is missing on agents endpoint', async () => {
|
||||
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
|
||||
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
|
||||
// must work out of the box; a missing id is ephemeral, not a 400.
|
||||
req.body.agent_id = undefined;
|
||||
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('proceeds for ephemeral primary agent without addedConvo', async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ const buildEndpointOption = require('./buildEndpointOption');
|
||||
const createReq = (body, config = {}) => ({
|
||||
body,
|
||||
config,
|
||||
baseUrl: '/api/chat',
|
||||
baseUrl: '/v1/chat/chat',
|
||||
});
|
||||
|
||||
const createRes = () => ({
|
||||
|
||||
@@ -26,7 +26,7 @@ const banResponse = async (req, res) => {
|
||||
const { baseUrl, originalUrl } = req;
|
||||
if (!ua.browser.name) {
|
||||
return res.status(403).json({ message });
|
||||
} else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
|
||||
} else if (baseUrl === '/v1/chat/agents' && originalUrl.startsWith('/v1/chat/agents/chat')) {
|
||||
return await denyRequest(req, res, { type: ViolationTypes.BAN });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -10,6 +10,8 @@ const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -36,6 +38,8 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 (`/v1/chat/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;
|
||||
@@ -0,0 +1,41 @@
|
||||
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 };
|
||||
@@ -0,0 +1,36 @@
|
||||
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 };
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@ 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');
|
||||
@@ -17,8 +20,11 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
verifyEmailLimiter,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -36,7 +36,7 @@ function createApp(user) {
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/config', configRoute);
|
||||
app.use('/v1/chat/config', configRoute);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
||||
delete process.env.RUM_ENVIRONMENT;
|
||||
});
|
||||
|
||||
describe('GET /api/config RUM config', () => {
|
||||
describe('GET /v1/chat/config RUM config', () => {
|
||||
it('includes public-token RUM config when enabled with valid env', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
@@ -82,7 +82,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_ENVIRONMENT = 'test';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
@@ -107,7 +107,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -119,12 +119,12 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: '/api/rum',
|
||||
url: '/v1/chat/rum',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'proxy',
|
||||
consoleCapture: false,
|
||||
@@ -139,7 +139,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'proxy';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body.rum?.url).toBe('http://[::1]:4318');
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('GET /api/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const configRoute = require('../config');
|
||||
// file deepcode ignore UseCsurfForExpress/test: test
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use('/api/config', configRoute);
|
||||
app.use('/v1/chat/config', configRoute);
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.APP_TITLE;
|
||||
|
||||
@@ -39,7 +39,7 @@ jest.mock('multer', () => require(MOCKS).multerLib());
|
||||
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
|
||||
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
|
||||
|
||||
describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||
describe('POST /v1/chat/convos/duplicate - Rate Limiting', () => {
|
||||
let app;
|
||||
let duplicateConversation;
|
||||
const savedEnv = {};
|
||||
@@ -73,7 +73,7 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||
req.user = { id: 'rate-limit-test-user' };
|
||||
next();
|
||||
});
|
||||
app.use('/api/convos', convosRouter);
|
||||
app.use('/v1/chat/convos', convosRouter);
|
||||
});
|
||||
|
||||
duplicateConversation.mockResolvedValue({
|
||||
@@ -95,13 +95,13 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||
|
||||
for (let i = 0; i < userMax; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/convos/duplicate')
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/convos/duplicate')
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.message).toMatch(/too many/i);
|
||||
@@ -122,13 +122,13 @@ describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||
|
||||
for (let i = 0; i < ipMax; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/convos/duplicate')
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/convos/duplicate')
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.message).toMatch(/too many/i);
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Convos Routes', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/convos', convosRouter);
|
||||
app.use('/v1/chat/convos', convosRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -145,7 +145,7 @@ describe('Convos Routes', () => {
|
||||
deletedCount: 3,
|
||||
});
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockDbResponse);
|
||||
@@ -176,7 +176,7 @@ describe('Convos Routes', () => {
|
||||
deletedCount: 0,
|
||||
});
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
@@ -186,7 +186,7 @@ describe('Convos Routes', () => {
|
||||
const errorMessage = 'Database connection error';
|
||||
deleteConvos.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
@@ -200,7 +200,7 @@ describe('Convos Routes', () => {
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 5 });
|
||||
deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
@@ -211,7 +211,7 @@ describe('Convos Routes', () => {
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
|
||||
deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed'));
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.text).toBe('Error clearing conversations');
|
||||
@@ -223,7 +223,7 @@ describe('Convos Routes', () => {
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
|
||||
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 });
|
||||
|
||||
let response = await request(app).delete('/api/convos/all');
|
||||
let response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
|
||||
@@ -237,13 +237,13 @@ describe('Convos Routes', () => {
|
||||
req.user = { id: 'test-user-456' };
|
||||
next();
|
||||
});
|
||||
app2.use('/api/convos', require('../convos'));
|
||||
app2.use('/v1/chat/convos', require('../convos'));
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 7 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
|
||||
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
|
||||
|
||||
response = await request(app2).delete('/api/convos/all');
|
||||
response = await request(app2).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456');
|
||||
@@ -267,7 +267,7 @@ describe('Convos Routes', () => {
|
||||
return Promise.resolve({ deletedCount: 3 });
|
||||
});
|
||||
|
||||
await request(app).delete('/api/convos/all');
|
||||
await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
/** Verify all three functions were called */
|
||||
expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']);
|
||||
@@ -286,7 +286,7 @@ describe('Convos Routes', () => {
|
||||
deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
|
||||
deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted);
|
||||
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -341,7 +341,7 @@ describe('Convos Routes', () => {
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
source: 'button',
|
||||
@@ -363,7 +363,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -375,7 +375,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when no parameters provided', async () => {
|
||||
const response = await request(app).delete('/api/convos').send({
|
||||
const response = await request(app).delete('/v1/chat/convos').send({
|
||||
arg: {},
|
||||
});
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when request body is empty (DoS prevention)', async () => {
|
||||
const response = await request(app).delete('/api/convos').send({});
|
||||
const response = await request(app).delete('/v1/chat/convos').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'no parameters provided' });
|
||||
@@ -394,7 +394,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when arg is null (DoS prevention)', async () => {
|
||||
const response = await request(app).delete('/api/convos').send({ arg: null });
|
||||
const response = await request(app).delete('/v1/chat/convos').send({ arg: null });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'no parameters provided' });
|
||||
@@ -402,7 +402,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when arg is undefined (DoS prevention)', async () => {
|
||||
const response = await request(app).delete('/api/convos').send({ arg: undefined });
|
||||
const response = await request(app).delete('/v1/chat/convos').send({ arg: undefined });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'no parameters provided' });
|
||||
@@ -411,7 +411,7 @@ describe('Convos Routes', () => {
|
||||
|
||||
it('should return 400 when request body is null (DoS prevention)', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('null');
|
||||
|
||||
@@ -427,7 +427,7 @@ describe('Convos Routes', () => {
|
||||
deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -458,7 +458,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -479,7 +479,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/convos')
|
||||
.delete('/v1/chat/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -509,7 +509,7 @@ describe('Convos Routes', () => {
|
||||
saveConvo.mockResolvedValue(mockArchivedConvo);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -522,7 +522,7 @@ describe('Convos Routes', () => {
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: { id: 'test-user-123' } }),
|
||||
{ conversationId: mockConversationId, isArchived: true },
|
||||
{ context: `POST /api/convos/archive ${mockConversationId}` },
|
||||
{ context: `POST /v1/chat/convos/archive ${mockConversationId}` },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('Convos Routes', () => {
|
||||
saveConvo.mockResolvedValue(mockUnarchivedConvo);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -551,13 +551,13 @@ describe('Convos Routes', () => {
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: { id: 'test-user-123' } }),
|
||||
{ conversationId: mockConversationId, isArchived: false },
|
||||
{ context: `POST /api/convos/archive ${mockConversationId}` },
|
||||
{ context: `POST /v1/chat/convos/archive ${mockConversationId}` },
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 when conversationId is missing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
isArchived: true,
|
||||
@@ -571,7 +571,7 @@ describe('Convos Routes', () => {
|
||||
|
||||
it('should return 400 when isArchived is not a boolean', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: 'conv-123',
|
||||
@@ -586,7 +586,7 @@ describe('Convos Routes', () => {
|
||||
|
||||
it('should return 400 when isArchived is undefined', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: 'conv-123',
|
||||
@@ -603,7 +603,7 @@ describe('Convos Routes', () => {
|
||||
saveConvo.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/convos/archive')
|
||||
.post('/v1/chat/convos/archive')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -619,7 +619,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle empty arg object', async () => {
|
||||
const response = await request(app).post('/api/convos/archive').send({
|
||||
const response = await request(app).post('/v1/chat/convos/archive').send({
|
||||
arg: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ function createApp(user) {
|
||||
router.get('/:principalType/:principalId', handlers.getPrincipalGrants);
|
||||
router.post('/', handlers.assignGrant);
|
||||
router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant);
|
||||
app.use('/api/admin/grants', router);
|
||||
app.use('/v1/chat/admin/grants', router);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
|
||||
it('GET / returns seeded admin grants', async () => {
|
||||
const app = createApp(adminUser);
|
||||
const res = await request(app).get('/api/admin/grants').expect(200);
|
||||
const res = await request(app).get('/v1/chat/admin/grants').expect(200);
|
||||
|
||||
expect(res.body).toHaveProperty('grants');
|
||||
expect(res.body).toHaveProperty('total');
|
||||
@@ -107,7 +107,7 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
|
||||
it('GET /effective returns capabilities for admin', async () => {
|
||||
const app = createApp(adminUser);
|
||||
const res = await request(app).get('/api/admin/grants/effective').expect(200);
|
||||
const res = await request(app).get('/v1/chat/admin/grants/effective').expect(200);
|
||||
|
||||
expect(res.body).toHaveProperty('capabilities');
|
||||
expect(res.body.capabilities).toContain('access:admin');
|
||||
@@ -119,7 +119,7 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
|
||||
// Assign
|
||||
const assignRes = await request(app)
|
||||
.post('/api/admin/grants')
|
||||
.post('/v1/chat/admin/grants')
|
||||
.send({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: SystemRoles.USER,
|
||||
@@ -135,19 +135,19 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
|
||||
// Verify via GET
|
||||
const getRes = await request(app)
|
||||
.get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.get(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.expect(200);
|
||||
|
||||
expect(getRes.body.grants.some((g) => g.capability === 'read:users')).toBe(true);
|
||||
|
||||
// Revoke
|
||||
await request(app)
|
||||
.delete(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
|
||||
.delete(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
|
||||
.expect(200);
|
||||
|
||||
// Verify revoked
|
||||
const afterRes = await request(app)
|
||||
.get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.get(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterRes.body.grants.some((g) => g.capability === 'read:users')).toBe(false);
|
||||
@@ -157,7 +157,7 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
const app = createApp(adminUser);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/grants')
|
||||
.post('/v1/chat/admin/grants')
|
||||
.send({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: 'nonexistent-role',
|
||||
@@ -172,7 +172,7 @@ describe('Admin Grants Routes — Integration', () => {
|
||||
const app = createApp(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/grants')
|
||||
.post('/v1/chat/admin/grants')
|
||||
.send({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: SystemRoles.USER,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* Proves:
|
||||
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
|
||||
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/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('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
|
||||
});
|
||||
app.get('/v1/chat/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('/v1/chat/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('/v1/chat/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 /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.get('/v1/chat/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 /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
|
||||
const res = await request(app).get('/v1/chat/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 /v1/chat/convos → 200 empty list for a guest', async () => {
|
||||
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
|
||||
const res = await request(app).get('/v1/chat/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 /v1/chat/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('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
|
||||
const res = await request(app).get('/v1/chat/prompts');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe('Keys Routes', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/keys', keysRouter);
|
||||
app.use('/v1/chat/keys', keysRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,7 +40,7 @@ describe('Keys Routes', () => {
|
||||
updateUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/keys')
|
||||
.put('/v1/chat/keys')
|
||||
.send({ name: 'openAI', value: 'sk-test-key-123', expiresAt: '2026-12-31' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -56,7 +56,7 @@ describe('Keys Routes', () => {
|
||||
it('should not allow userId override via request body (IDOR prevention)', async () => {
|
||||
updateUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app).put('/api/keys').send({
|
||||
const response = await request(app).put('/v1/chat/keys').send({
|
||||
userId: 'attacker-injected-id',
|
||||
name: 'openAI',
|
||||
value: 'sk-attacker-key',
|
||||
@@ -74,7 +74,7 @@ describe('Keys Routes', () => {
|
||||
it('should ignore extraneous fields from request body', async () => {
|
||||
updateUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app).put('/api/keys').send({
|
||||
const response = await request(app).put('/v1/chat/keys').send({
|
||||
name: 'openAI',
|
||||
value: 'sk-test-key',
|
||||
expiresAt: '2026-12-31',
|
||||
@@ -96,7 +96,7 @@ describe('Keys Routes', () => {
|
||||
updateUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/keys')
|
||||
.put('/v1/chat/keys')
|
||||
.send({ name: 'anthropic', value: 'sk-ant-key' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -110,7 +110,7 @@ describe('Keys Routes', () => {
|
||||
|
||||
it('should return 400 when request body is null', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/keys')
|
||||
.put('/v1/chat/keys')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('null');
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('Keys Routes', () => {
|
||||
it('should delete a user key by name', async () => {
|
||||
deleteUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app).delete('/api/keys/openAI');
|
||||
const response = await request(app).delete('/v1/chat/keys/openAI');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(deleteUserKey).toHaveBeenCalledWith({
|
||||
@@ -138,7 +138,7 @@ describe('Keys Routes', () => {
|
||||
it('should delete all keys when all=true', async () => {
|
||||
deleteUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app).delete('/api/keys?all=true');
|
||||
const response = await request(app).delete('/v1/chat/keys?all=true');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(deleteUserKey).toHaveBeenCalledWith({
|
||||
@@ -148,7 +148,7 @@ describe('Keys Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 400 when all query param is not true', async () => {
|
||||
const response = await request(app).delete('/api/keys');
|
||||
const response = await request(app).delete('/v1/chat/keys');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Specify either all=true to delete.' });
|
||||
@@ -161,7 +161,7 @@ describe('Keys Routes', () => {
|
||||
const mockExpiry = { expiresAt: '2026-12-31' };
|
||||
getUserKeyExpiry.mockResolvedValue(mockExpiry);
|
||||
|
||||
const response = await request(app).get('/api/keys?name=openAI');
|
||||
const response = await request(app).get('/v1/chat/keys?name=openAI');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockExpiry);
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('@hanzochat/api', () => ({
|
||||
const app = express();
|
||||
|
||||
// Mock the route handler
|
||||
app.get('/api/config', (req, res) => {
|
||||
app.get('/v1/chat/config', (req, res) => {
|
||||
const ldapConfig = getLdapConfig();
|
||||
res.json({ ldap: ldapConfig });
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe('LDAP Config Tests', () => {
|
||||
getLdapConfig.mockReturnValue({ enabled: true, username: true });
|
||||
isEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.ldap).toEqual({
|
||||
@@ -39,7 +39,7 @@ describe('LDAP Config Tests', () => {
|
||||
getLdapConfig.mockReturnValue({ enabled: true });
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.ldap).toEqual({
|
||||
@@ -50,7 +50,7 @@ describe('LDAP Config Tests', () => {
|
||||
it('should not return LDAP config when LDAP is not enabled', async () => {
|
||||
getLdapConfig.mockReturnValue(undefined);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.ldap).toBeUndefined();
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('MCP Routes', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/mcp', mcpRouter);
|
||||
app.use('/v1/chat/mcp', mcpRouter);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -182,7 +182,7 @@ describe('MCP Routes', () => {
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'test-user-id',
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -199,7 +199,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 403 when userId does not match authenticated user', async () => {
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'different-user-id',
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'test-user-id',
|
||||
flowId: 'non-existent-flow-id',
|
||||
});
|
||||
@@ -237,7 +237,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'test-user-id',
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -254,7 +254,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'test-user-id',
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -274,7 +274,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
userId: 'test-user-id',
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -289,7 +289,7 @@ describe('MCP Routes', () => {
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
it('should redirect to error page when OAuth error is received', async () => {
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
error: 'access_denied',
|
||||
state: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -300,7 +300,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should redirect to error page when code is missing', async () => {
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
state: 'test-user-id:test-server',
|
||||
});
|
||||
const basePath = getBasePath();
|
||||
@@ -310,7 +310,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should redirect to error page when state is missing', async () => {
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
});
|
||||
const basePath = getBasePath();
|
||||
@@ -320,7 +320,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should redirect to error page when CSRF cookie is missing', async () => {
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-user-id:test-server',
|
||||
});
|
||||
@@ -335,7 +335,7 @@ describe('MCP Routes', () => {
|
||||
it('should redirect to error page when CSRF cookie does not match state', async () => {
|
||||
const csrfToken = generateTestCsrfToken('different-flow-id');
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -355,7 +355,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -419,7 +419,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -464,7 +464,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -506,7 +506,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -558,7 +558,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -606,7 +606,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -671,7 +671,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -718,7 +718,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/mcp/test-server/oauth/callback')
|
||||
.get('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.query({
|
||||
code: 'test-auth-code',
|
||||
@@ -751,7 +751,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:flow-123');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:flow-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -769,16 +769,16 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/api/mcp/oauth/tokens/test-flow-id');
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/oauth/tokens/test-flow-id');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
});
|
||||
|
||||
it('should return 403 when user tries to access flow they do not own', async () => {
|
||||
const response = await request(app).get('/api/mcp/oauth/tokens/other-user-id:flow-123');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/other-user-id:flow-123');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Access denied' });
|
||||
@@ -793,7 +793,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get(
|
||||
'/api/mcp/oauth/tokens/test-user-id:non-existent-flow',
|
||||
'/v1/chat/mcp/oauth/tokens/test-user-id:non-existent-flow',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -811,7 +811,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:pending-flow');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:pending-flow');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Flow not completed' });
|
||||
@@ -822,7 +822,7 @@ describe('MCP Routes', () => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/tokens/test-user-id:error-flow');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to get tokens' });
|
||||
@@ -843,7 +843,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:test-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:test-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -855,7 +855,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 403 when flowId does not match authenticated user', async () => {
|
||||
const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/status/other-user-id:test-server');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Access denied' });
|
||||
@@ -869,7 +869,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:non-existent');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:non-existent');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Flow not found' });
|
||||
@@ -883,7 +883,7 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:error-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/oauth/status/test-user-id:error-server');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to get flow status' });
|
||||
@@ -906,7 +906,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
||||
|
||||
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -930,7 +930,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
||||
|
||||
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -949,7 +949,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
||||
|
||||
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to cancel OAuth flow' });
|
||||
@@ -962,9 +962,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).post('/api/mcp/oauth/cancel/test-server');
|
||||
const response = await request(unauthApp).post('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
@@ -984,7 +984,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
||||
const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/non-existent-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1018,7 +1018,7 @@ describe('MCP Routes', () => {
|
||||
oauthUrl: 'https://oauth.example.com/auth',
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/oauth-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1043,7 +1043,7 @@ describe('MCP Routes', () => {
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).post('/api/mcp/error-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/error-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1061,7 +1061,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Internal server error' });
|
||||
@@ -1074,9 +1074,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).post('/api/mcp/test-server/reinitialize');
|
||||
const response = await request(unauthApp).post('/v1/chat/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
@@ -1116,7 +1116,7 @@ describe('MCP Routes', () => {
|
||||
oauthUrl: null,
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1174,7 +1174,7 @@ describe('MCP Routes', () => {
|
||||
oauthUrl: null,
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/v1/chat/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
@@ -1212,7 +1212,7 @@ describe('MCP Routes', () => {
|
||||
requiresOAuth: true,
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1236,7 +1236,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 404 when MCP config is not found', async () => {
|
||||
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'MCP config not found' });
|
||||
@@ -1245,7 +1245,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 500 when connection status check fails', async () => {
|
||||
getMCPSetupData.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to get connection status' });
|
||||
@@ -1258,9 +1258,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/api/mcp/connection/status');
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
@@ -1287,7 +1287,7 @@ describe('MCP Routes', () => {
|
||||
requiresOAuth: true,
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status/oauth-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status/oauth-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1308,7 +1308,7 @@ describe('MCP Routes', () => {
|
||||
oauthServers: [],
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status/non-existent-server');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1319,7 +1319,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 404 when MCP config is not found', async () => {
|
||||
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status/test-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status/test-server');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'MCP config not found' });
|
||||
@@ -1328,7 +1328,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 500 when connection status check fails', async () => {
|
||||
getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/connection/status/test-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/connection/status/test-server');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to get connection status' });
|
||||
@@ -1341,9 +1341,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/api/mcp/connection/status/test-server');
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status/test-server');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
@@ -1366,7 +1366,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1387,7 +1387,7 @@ describe('MCP Routes', () => {
|
||||
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
|
||||
const response = await request(app).get('/v1/chat/mcp/non-existent-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1406,7 +1406,7 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1426,7 +1426,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to check auth value flags' });
|
||||
@@ -1440,7 +1440,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1453,9 +1453,9 @@ describe('MCP Routes', () => {
|
||||
it('should return 401 when user is not authenticated in auth-values endpoint', async () => {
|
||||
const appWithoutAuth = express();
|
||||
appWithoutAuth.use(express.json());
|
||||
appWithoutAuth.use('/api/mcp', mcpRouter);
|
||||
appWithoutAuth.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(appWithoutAuth).get('/api/mcp/test-server/auth-values');
|
||||
const response = await request(appWithoutAuth).get('/v1/chat/mcp/test-server/auth-values');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
@@ -1502,7 +1502,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.get(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.expect(302);
|
||||
|
||||
@@ -1556,7 +1556,7 @@ describe('MCP Routes', () => {
|
||||
const csrfToken = generateTestCsrfToken(flowId);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.get(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
||||
.expect(302);
|
||||
|
||||
@@ -1583,7 +1583,7 @@ describe('MCP Routes', () => {
|
||||
|
||||
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockServerConfigs);
|
||||
@@ -1593,7 +1593,7 @@ describe('MCP Routes', () => {
|
||||
it('should return empty object when no servers are configured', async () => {
|
||||
mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({});
|
||||
@@ -1606,9 +1606,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/api/mcp/servers');
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/servers');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ message: 'Unauthorized' });
|
||||
@@ -1617,7 +1617,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 500 when server config retrieval fails', async () => {
|
||||
mockRegistryInstance.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Database error' });
|
||||
@@ -1638,7 +1638,7 @@ describe('MCP Routes', () => {
|
||||
config: validConfig,
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: validConfig });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual({
|
||||
@@ -1664,7 +1664,7 @@ describe('MCP Routes', () => {
|
||||
title: 'Test Stdio Server',
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: stdioConfig });
|
||||
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: stdioConfig });
|
||||
|
||||
// Stdio transport is not allowed via API - only admins can configure it via YAML
|
||||
expect(response.status).toBe(400);
|
||||
@@ -1678,7 +1678,7 @@ describe('MCP Routes', () => {
|
||||
title: 'Invalid Server',
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
|
||||
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: invalidConfig });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid configuration');
|
||||
@@ -1692,7 +1692,7 @@ describe('MCP Routes', () => {
|
||||
title: 'Invalid Protocol Server',
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
|
||||
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: invalidConfig });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid configuration');
|
||||
@@ -1707,7 +1707,7 @@ describe('MCP Routes', () => {
|
||||
|
||||
mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||
const response = await request(app).post('/v1/chat/mcp/servers').send({ config: validConfig });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Database connection failed' });
|
||||
@@ -1724,7 +1724,7 @@ describe('MCP Routes', () => {
|
||||
|
||||
mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers/test-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers/test-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockConfig);
|
||||
@@ -1737,7 +1737,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 404 when server not found', async () => {
|
||||
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers/non-existent-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers/non-existent-server');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'MCP server not found' });
|
||||
@@ -1746,7 +1746,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 500 when registry throws error', async () => {
|
||||
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/servers/error-server');
|
||||
const response = await request(app).get('/v1/chat/mcp/servers/error-server');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Database error' });
|
||||
@@ -1765,7 +1765,7 @@ describe('MCP Routes', () => {
|
||||
mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/api/mcp/servers/test-server')
|
||||
.patch('/v1/chat/mcp/servers/test-server')
|
||||
.send({ config: updatedConfig });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -1789,7 +1789,7 @@ describe('MCP Routes', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/api/mcp/servers/test-server')
|
||||
.patch('/v1/chat/mcp/servers/test-server')
|
||||
.send({ config: invalidConfig });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
@@ -1807,7 +1807,7 @@ describe('MCP Routes', () => {
|
||||
mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/api/mcp/servers/test-server')
|
||||
.patch('/v1/chat/mcp/servers/test-server')
|
||||
.send({ config: validConfig });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
@@ -1819,7 +1819,7 @@ describe('MCP Routes', () => {
|
||||
it('should delete server successfully', async () => {
|
||||
mockRegistryInstance.removeServer.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app).delete('/api/mcp/servers/test-server');
|
||||
const response = await request(app).delete('/v1/chat/mcp/servers/test-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
|
||||
@@ -1833,7 +1833,7 @@ describe('MCP Routes', () => {
|
||||
it('should return 500 when registry throws error', async () => {
|
||||
mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
|
||||
|
||||
const response = await request(app).delete('/api/mcp/servers/error-server');
|
||||
const response = await request(app).delete('/v1/chat/mcp/servers/error-server');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Deletion failed' });
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||
req.user = { id: authenticatedUserId };
|
||||
next();
|
||||
});
|
||||
app.use('/api/messages', messagesRouter);
|
||||
app.use('/v1/chat/messages', messagesRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -165,7 +165,7 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||
it('should pass user and conversationId in the deleteMessages filter', async () => {
|
||||
deleteMessages.mockResolvedValue({ deletedCount: 1 });
|
||||
|
||||
await request(app).delete('/api/messages/convo-1/msg-1');
|
||||
await request(app).delete('/v1/chat/messages/convo-1/msg-1');
|
||||
|
||||
expect(deleteMessages).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessages).toHaveBeenCalledWith({
|
||||
@@ -178,7 +178,7 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||
it('should return 204 on successful deletion', async () => {
|
||||
deleteMessages.mockResolvedValue({ deletedCount: 1 });
|
||||
|
||||
const response = await request(app).delete('/api/messages/convo-1/msg-owned');
|
||||
const response = await request(app).delete('/v1/chat/messages/convo-1/msg-owned');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(deleteMessages).toHaveBeenCalledWith({
|
||||
@@ -191,7 +191,7 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||
it('should return 500 when deleteMessages throws', async () => {
|
||||
deleteMessages.mockRejectedValue(new Error('DB failure'));
|
||||
|
||||
const response = await request(app).delete('/api/messages/convo-1/msg-1');
|
||||
const response = await request(app).delete('/v1/chat/messages/convo-1/msg-1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Internal server error' });
|
||||
@@ -213,7 +213,7 @@ describe('message route conversation ownership filters', () => {
|
||||
req.user = { id: authenticatedUserId };
|
||||
next();
|
||||
});
|
||||
app.use('/api/messages', messagesRouter);
|
||||
app.use('/v1/chat/messages', messagesRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -233,7 +233,7 @@ describe('message route conversation ownership filters', () => {
|
||||
saveMessage.mockResolvedValue(savedMessage);
|
||||
saveConvo.mockResolvedValue({ conversationId: urlConversationId });
|
||||
|
||||
const response = await request(app).post(`/api/messages/${urlConversationId}`).send({
|
||||
const response = await request(app).post(`/v1/chat/messages/${urlConversationId}`).send({
|
||||
messageId: savedMessage.messageId,
|
||||
conversationId: bodyConversationId,
|
||||
text: savedMessage.text,
|
||||
@@ -248,20 +248,20 @@ describe('message route conversation ownership filters', () => {
|
||||
text: savedMessage.text,
|
||||
user: authenticatedUserId,
|
||||
}),
|
||||
{ context: 'POST /api/messages/:conversationId' },
|
||||
{ context: 'POST /v1/chat/messages/:conversationId' },
|
||||
);
|
||||
expect(saveMessage.mock.calls[0][1].conversationId).not.toBe(bodyConversationId);
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: authenticatedUserId }),
|
||||
savedMessage,
|
||||
{ context: 'POST /api/messages/:conversationId' },
|
||||
{ context: 'POST /v1/chat/messages/:conversationId' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter conversation message reads by authenticated user', async () => {
|
||||
getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
|
||||
|
||||
const response = await request(app).get('/api/messages/convo-1');
|
||||
const response = await request(app).get('/v1/chat/messages/convo-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getMessages).toHaveBeenCalledWith(
|
||||
@@ -273,7 +273,7 @@ describe('message route conversation ownership filters', () => {
|
||||
it('should filter single message reads by authenticated user', async () => {
|
||||
getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]);
|
||||
|
||||
const response = await request(app).get('/api/messages/convo-1/message-1');
|
||||
const response = await request(app).get('/v1/chat/messages/convo-1/message-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getMessages).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Regression test for the OAuth login rate-limiter scoping fix.
|
||||
*
|
||||
* Background: `loginLimiter` (LOGIN_MAX per 5 min, keyed by IP) used to be
|
||||
* applied as blanket router middleware (`router.use(loginLimiter)`), which also
|
||||
* covered the OIDC/social callback routes. Behind a shared LB/CDN IP, with
|
||||
* OPENID_AUTO_REDIRECT firing `/oauth/openid` on every page load, the per-IP
|
||||
* budget was exhausted instantly and the IdP code-exchange callback got 429 -
|
||||
* aborting the token exchange and breaking login.
|
||||
*
|
||||
* The fix scopes `loginLimiter` to the human-initiated entry points (the GETs
|
||||
* that REDIRECT to the IdP) and leaves the machine-driven callback routes
|
||||
* (the IdP returning a one-time code) un-limited.
|
||||
*
|
||||
* This test mounts the REAL oauth router with the REAL `loginLimiter` and only
|
||||
* stubs the heavy leaf deps (passport, openid-client, controllers, config, db),
|
||||
* mirroring server/routes/__tests__/convos-duplicate-ratelimit.spec.js.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// passport.authenticate(...) -> a middleware that signals "reached the handler"
|
||||
// (in prod this would redirect to the IdP for initiation, or exchange the code
|
||||
// for callbacks). Returning 200 lets us detect whether the limiter intercepted
|
||||
// the request with 429 BEFORE control reached the auth handler.
|
||||
jest.mock('passport', () => ({
|
||||
authenticate: jest.fn(() => (req, res) => res.status(200).json({ reached: true })),
|
||||
}));
|
||||
|
||||
jest.mock('openid-client', () => ({
|
||||
randomState: jest.fn(() => 'test-state'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ErrorTypes: { AUTH_FAILED: 'AUTH_FAILED' },
|
||||
ViolationTypes: { LOGINS: 'logins' },
|
||||
}));
|
||||
|
||||
// `limiterCache: () => undefined` makes express-rate-limit fall back to its
|
||||
// built-in in-process MemoryStore (no Redis), same as the convos limiter test.
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
|
||||
limiterCache: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// loginLimiter.js does `require('~/cache')` (the index, which also pulls
|
||||
// getLogStores) and `require('~/server/utils')` (index, which pulls
|
||||
// sendEmail/files/queue). Mock both indexes to the minimal real/inert surface
|
||||
// the limiter actually uses: a no-op violation logger and the dependency-free
|
||||
// `removePorts` IP key generator.
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: jest.requireActual('~/server/utils/removePorts'),
|
||||
}));
|
||||
|
||||
// oauth.js imports { checkDomainAllowed, loginLimiter, logHeaders } from the
|
||||
// middleware index, whose full require graph is too heavy for a hermetic unit
|
||||
// test. Mock the index but expose the REAL `loginLimiter` (the unit under test)
|
||||
// via requireActual of the leaf file — mirroring how
|
||||
// convos-duplicate-ratelimit.spec.js pulls the real forkLimiters. The other two
|
||||
// are inert passthroughs (not under test here).
|
||||
jest.mock('~/server/middleware', () => {
|
||||
const loginLimiter = jest.requireActual('~/server/middleware/limiters/loginLimiter');
|
||||
return {
|
||||
loginLimiter,
|
||||
logHeaders: (req, res, next) => next(),
|
||||
checkDomainAllowed: (req, res, next) => next(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/server/controllers/auth/oauth', () => ({
|
||||
createOAuthHandler: jest.fn(() => (req, res) => res.status(200).json({ handler: true })),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/db/models', () => ({
|
||||
Balance: {},
|
||||
}));
|
||||
|
||||
const LOGIN_INITIATION = '/oauth/openid';
|
||||
const LOGIN_CALLBACK = '/oauth/openid/callback';
|
||||
|
||||
describe('OAuth router — loginLimiter scoping', () => {
|
||||
const savedEnv = {};
|
||||
|
||||
beforeAll(() => {
|
||||
savedEnv.LOGIN_MAX = process.env.LOGIN_MAX;
|
||||
savedEnv.LOGIN_WINDOW = process.env.LOGIN_WINDOW;
|
||||
savedEnv.DOMAIN_CLIENT = process.env.DOMAIN_CLIENT;
|
||||
savedEnv.DOMAIN_SERVER = process.env.DOMAIN_SERVER;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of Object.keys(savedEnv)) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The limiter reads LOGIN_MAX/LOGIN_WINDOW at module-load time, so build the
|
||||
// app inside isolateModules AFTER setting env to get a fresh limiter+store.
|
||||
const buildApp = () => {
|
||||
let oauthRouter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/oauth', oauthRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.LOGIN_MAX = '1';
|
||||
process.env.LOGIN_WINDOW = '5';
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080';
|
||||
process.env.DOMAIN_SERVER = 'http://localhost:3080';
|
||||
});
|
||||
|
||||
describe('initiation route (redirect-to-IdP) IS rate-limited', () => {
|
||||
it('returns 429 once an initiation route exceeds LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const max = parseInt(process.env.LOGIN_MAX, 10);
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.reached).toBe(true);
|
||||
}
|
||||
|
||||
const limited = await request(app).get(LOGIN_INITIATION);
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.body.message).toMatch(/too many/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback route (IdP-returns-code) is NOT rate-limited', () => {
|
||||
it('never returns 429 even when hammered well past LOGIN_MAX', async () => {
|
||||
const app = buildApp();
|
||||
const hits = parseInt(process.env.LOGIN_MAX, 10) + 5;
|
||||
|
||||
for (let i = 0; i < hits; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
// The callback may legitimately 200/redirect/fail auth — it must just
|
||||
// never be throttled with 429.
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not consume the initiation budget (callback hits never trip the limiter)', async () => {
|
||||
const app = buildApp();
|
||||
|
||||
// Hammer the callback far past the limit first.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).get(LOGIN_CALLBACK);
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
|
||||
// The single allowed initiation request must still succeed: callbacks and
|
||||
// initiations do not share a (broken) blanket budget.
|
||||
const ok = await request(app).get(LOGIN_INITIATION);
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('router wiring (defense-in-depth structural assertion)', () => {
|
||||
// Even independent of runtime behavior, assert the limiter is present in the
|
||||
// initiation route's middleware stack and absent from every callback stack.
|
||||
const limiterStackByPath = () => {
|
||||
let oauthRouter;
|
||||
let loginLimiter;
|
||||
jest.isolateModules(() => {
|
||||
oauthRouter = require('../oauth');
|
||||
// Same instance the router received (the mocked index re-exports the
|
||||
// real leaf limiter), so reference-equality in the stack is meaningful.
|
||||
({ loginLimiter } = require('~/server/middleware'));
|
||||
});
|
||||
const byPath = {};
|
||||
for (const layer of oauthRouter.stack) {
|
||||
if (!layer.route) continue; // skip router.use(logHeaders) etc.
|
||||
const handles = layer.route.stack.map((s) => s.handle);
|
||||
byPath[layer.route.path] = handles.includes(loginLimiter);
|
||||
}
|
||||
return byPath;
|
||||
};
|
||||
|
||||
it('applies loginLimiter to every initiation route and to NO callback route', () => {
|
||||
const limited = limiterStackByPath();
|
||||
|
||||
const initiationRoutes = [
|
||||
'/google',
|
||||
'/facebook',
|
||||
'/openid',
|
||||
'/github',
|
||||
'/discord',
|
||||
'/apple',
|
||||
'/saml',
|
||||
];
|
||||
const callbackRoutes = [
|
||||
'/google/callback',
|
||||
'/facebook/callback',
|
||||
'/openid/callback',
|
||||
'/github/callback',
|
||||
'/discord/callback',
|
||||
'/apple/callback',
|
||||
'/saml/callback',
|
||||
];
|
||||
|
||||
for (const path of initiationRoutes) {
|
||||
expect(limited[path]).toBe(true);
|
||||
}
|
||||
for (const path of callbackRoutes) {
|
||||
expect(limited[path]).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ function createApp(user) {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use('/api/roles', rolesRouter);
|
||||
app.use('/v1/chat/roles', rolesRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@ beforeEach(() => {
|
||||
mockGetRoleByName.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
describe('GET /v1/chat/roles/:roleName — isOwnRole authorization', () => {
|
||||
it('allows a custom role user to fetch their own role', async () => {
|
||||
mockGetRoleByName.mockResolvedValue(staffRole);
|
||||
const app = createApp({ id: 'u1', role: 'STAFF' });
|
||||
|
||||
const res = await request(app).get('/api/roles/STAFF');
|
||||
const res = await request(app).get('/v1/chat/roles/STAFF');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('STAFF');
|
||||
@@ -63,7 +63,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
it('returns 403 when a custom role user requests a different custom role', async () => {
|
||||
const app = createApp({ id: 'u1', role: 'STAFF' });
|
||||
|
||||
const res = await request(app).get('/api/roles/MANAGER');
|
||||
const res = await request(app).get('/v1/chat/roles/MANAGER');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockGetRoleByName).not.toHaveBeenCalled();
|
||||
@@ -72,7 +72,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
it('returns 403 when a custom role user requests ADMIN', async () => {
|
||||
const app = createApp({ id: 'u1', role: 'STAFF' });
|
||||
|
||||
const res = await request(app).get('/api/roles/ADMIN');
|
||||
const res = await request(app).get('/v1/chat/roles/ADMIN');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockGetRoleByName).not.toHaveBeenCalled();
|
||||
@@ -82,7 +82,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
mockGetRoleByName.mockResolvedValue(userRole);
|
||||
const app = createApp({ id: 'u1', role: SystemRoles.USER });
|
||||
|
||||
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
|
||||
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.USER}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
it('returns 403 when USER requests the ADMIN role', async () => {
|
||||
const app = createApp({ id: 'u1', role: SystemRoles.USER });
|
||||
|
||||
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
|
||||
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.ADMIN}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
mockGetRoleByName.mockResolvedValue(adminRole);
|
||||
const app = createApp({ id: 'u1', role: SystemRoles.ADMIN });
|
||||
|
||||
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
|
||||
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.ADMIN}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
mockGetRoleByName.mockResolvedValue(staffRole);
|
||||
const app = createApp({ id: 'u1', role: SystemRoles.USER });
|
||||
|
||||
const res = await request(app).get('/api/roles/STAFF');
|
||||
const res = await request(app).get('/v1/chat/roles/STAFF');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('STAFF');
|
||||
@@ -120,7 +120,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
mockGetRoleByName.mockResolvedValue(null);
|
||||
const app = createApp({ id: 'u1', role: 'GHOST' });
|
||||
|
||||
const res = await request(app).get('/api/roles/GHOST');
|
||||
const res = await request(app).get('/v1/chat/roles/GHOST');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
mockGetRoleByName.mockRejectedValue(new Error('db error'));
|
||||
const app = createApp({ id: 'u1', role: SystemRoles.USER });
|
||||
|
||||
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
|
||||
const res = await request(app).get(`/v1/chat/roles/${SystemRoles.USER}`);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
it('returns 403 for prototype property names like constructor (no prototype pollution)', async () => {
|
||||
const app = createApp({ id: 'u1', role: 'STAFF' });
|
||||
|
||||
const res = await request(app).get('/api/roles/constructor');
|
||||
const res = await request(app).get('/v1/chat/roles/constructor');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockGetRoleByName).not.toHaveBeenCalled();
|
||||
@@ -148,7 +148,7 @@ describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
|
||||
const app = createApp({ id: 'u1', role: 'STAFF' });
|
||||
mockGetRoleByName.mockResolvedValue(staffRole);
|
||||
|
||||
const res = await request(app).get('/api/roles/STAFF');
|
||||
const res = await request(app).get('/v1/chat/roles/STAFF');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
|
||||
req.config = { interfaceConfig: { retentionMode } };
|
||||
next();
|
||||
});
|
||||
app.use('/api/share', shareRouter);
|
||||
app.use('/v1/chat/share', shareRouter);
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('share routes retention', () => {
|
||||
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
|
||||
|
||||
const response = await request(buildApp())
|
||||
.post('/api/share/convo-123')
|
||||
.post('/v1/chat/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -113,7 +113,7 @@ describe('share routes retention', () => {
|
||||
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
|
||||
|
||||
const response = await request(buildApp())
|
||||
.post('/api/share/convo-123')
|
||||
.post('/v1/chat/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -125,7 +125,7 @@ describe('share routes retention', () => {
|
||||
createSharedLink.mockResolvedValue({ shareId: 'share-123' });
|
||||
|
||||
const response = await request(buildApp({ retentionMode: RetentionMode.ALL }))
|
||||
.post('/api/share/convo-123')
|
||||
.post('/v1/chat/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -137,7 +137,7 @@ describe('share routes retention', () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
|
||||
@@ -169,7 +169,7 @@ describe('share routes retention', () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(updateSharedLink).not.toHaveBeenCalled();
|
||||
@@ -181,7 +181,7 @@ describe('share routes retention', () => {
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp({ retentionMode: RetentionMode.ALL })).patch(
|
||||
'/api/share/share-123',
|
||||
'/v1/chat/share/share-123',
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -197,7 +197,7 @@ describe('share routes retention', () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(null);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
|
||||
@@ -208,7 +208,7 @@ describe('share routes retention', () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(undefined);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined);
|
||||
@@ -223,7 +223,7 @@ describe('share routes retention', () => {
|
||||
});
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
const response = await request(buildApp()).patch('/v1/chat/share/share-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
@@ -239,7 +239,7 @@ describe('share routes retention', () => {
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
|
||||
|
||||
const response = await request(buildApp())
|
||||
.patch('/api/share/share-123')
|
||||
.patch('/v1/chat/share/share-123')
|
||||
.send({ targetMessageId: 'msg-456' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -253,7 +253,7 @@ describe('share routes retention', () => {
|
||||
|
||||
it('rejects non-string target message updates', async () => {
|
||||
const response = await request(buildApp())
|
||||
.patch('/api/share/share-123')
|
||||
.patch('/v1/chat/share/share-123')
|
||||
.send({ targetMessageId: 123 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -22,17 +22,17 @@ router.use(uaParser);
|
||||
|
||||
/**
|
||||
* Generic routes for resource permissions
|
||||
* Pattern: /api/permissions/{resourceType}/{resourceId}
|
||||
* Pattern: /v1/chat/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/permissions/search-principals
|
||||
* GET /v1/chat/permissions/search-principals
|
||||
* Search for users and groups to grant permissions
|
||||
*/
|
||||
router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/roles
|
||||
* GET /v1/chat/permissions/{resourceType}/roles
|
||||
* Get available roles for a resource type
|
||||
*/
|
||||
router.get('/:resourceType/roles', getResourceRoles);
|
||||
@@ -84,7 +84,7 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}
|
||||
* GET /v1/chat/permissions/{resourceType}/{resourceId}
|
||||
* Get all permissions for a specific resource
|
||||
* SECURITY: Requires SHARE permission to view resource permissions
|
||||
*/
|
||||
@@ -95,7 +95,7 @@ router.get(
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/permissions/{resourceType}/{resourceId}
|
||||
* PUT /v1/chat/permissions/{resourceType}/{resourceId}
|
||||
* Bulk update permissions for a specific resource
|
||||
* SECURITY: Requires SHARE permission to modify resource permissions
|
||||
* SECURITY: Requires SHARE_PUBLIC permission to enable public sharing
|
||||
@@ -108,13 +108,13 @@ router.put(
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/effective/all
|
||||
* GET /v1/chat/permissions/{resourceType}/effective/all
|
||||
* Get user's effective permissions for all accessible resources of a type
|
||||
*/
|
||||
router.get('/:resourceType/effective/all', getAllEffectivePermissions);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}/effective
|
||||
* GET /v1/chat/permissions/{resourceType}/{resourceId}/effective
|
||||
* Get user's effective permissions for a specific resource
|
||||
*/
|
||||
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('Access permissions share policy', () => {
|
||||
req.user = { id: 'skill-owner', role: SystemRoles.USER };
|
||||
next();
|
||||
});
|
||||
app.use('/api/permissions', accessPermissionsRouter);
|
||||
app.use('/v1/chat/permissions', accessPermissionsRouter);
|
||||
});
|
||||
|
||||
it.each(sharePolicyCases)(
|
||||
@@ -142,7 +142,7 @@ describe('Access permissions share policy', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/permissions/${resourceType}/${resourceId}`)
|
||||
.put(`/v1/chat/permissions/${resourceType}/${resourceId}`)
|
||||
.send({ updated: [createUpdatedPrincipal(accessRoleId)], public: false });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -166,7 +166,7 @@ describe('Access permissions share policy', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -178,7 +178,7 @@ describe('Access permissions share policy', () => {
|
||||
hasCapability.mockResolvedValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.send({ updated: [createUpdatedPrincipal(AccessRoleIds.SKILL_VIEWER)], public: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -198,7 +198,7 @@ describe('Access permissions share policy', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.put(`/v1/chat/permissions/${ResourceType.SKILL}/${resourceId}`)
|
||||
.send({ public: true, publicAccessRoleId: AccessRoleIds.SKILL_VIEWER });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
@@ -19,7 +19,7 @@ const { getLogStores } = require('~/cache');
|
||||
|
||||
const router = express.Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const OAUTH_CSRF_COOKIE_PATH = '/api/actions';
|
||||
const OAUTH_CSRF_COOKIE_PATH = '/v1/chat/actions';
|
||||
|
||||
/**
|
||||
* Sets a CSRF cookie binding the action OAuth flow to the current browser session.
|
||||
|
||||
@@ -80,7 +80,7 @@ const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
* This endpoint is called server-to-server by the admin panel.
|
||||
* The code is one-time-use and expires in 30 seconds.
|
||||
*
|
||||
* POST /api/admin/oauth/exchange
|
||||
* POST /v1/chat/admin/oauth/exchange
|
||||
* Body: { code: string }
|
||||
* Response: { token: string, refreshToken: string, user: object }
|
||||
*/
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('admin auth OpenID refresh route', () => {
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin', adminAuthRouter);
|
||||
app.use('/v1/chat/admin', adminAuthRouter);
|
||||
|
||||
isEnabled.mockReturnValue(true);
|
||||
getOpenIdConfig.mockReturnValue(openIdConfig);
|
||||
@@ -187,7 +187,7 @@ describe('admin auth OpenID refresh route', () => {
|
||||
Object.assign(process.env, env);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/admin/oauth/refresh')
|
||||
.post('/v1/chat/admin/oauth/refresh')
|
||||
.send({ refresh_token: 'incoming-refresh-token' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -211,7 +211,7 @@ describe('admin auth OpenID refresh route', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/admin/oauth/refresh')
|
||||
.post('/v1/chat/admin/oauth/refresh')
|
||||
.send({ refresh_token: 'incoming-refresh-token' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -227,7 +227,7 @@ describe('admin auth OpenID refresh route', () => {
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await request(app)
|
||||
.post('/api/admin/oauth/refresh')
|
||||
.post('/v1/chat/admin/oauth/refresh')
|
||||
.send({ refresh_token: 'incoming-refresh-token' });
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh params', {
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
beforeAll(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/agents', agentRoutes);
|
||||
app.use('/v1/chat/agents', agentRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -86,7 +86,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -112,7 +112,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -135,7 +135,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -163,7 +163,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -189,7 +189,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -224,7 +224,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
mockSaveMessage.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -271,7 +271,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
mockSaveMessage.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: jobStreamId });
|
||||
|
||||
// Should still return success even if save fails
|
||||
@@ -289,7 +289,7 @@ describe('Agent Abort Endpoint', () => {
|
||||
mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/abort')
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.send({ conversationId: 'non-existent-job' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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('/v1/chat/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(`/v1/chat/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('/v1/chat/agents/cloud/..%2Fetc');
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -411,7 +411,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
// Mount the responses routes
|
||||
const responsesRoutes = require('~/server/routes/agents/responses');
|
||||
app.use('/api/agents/v1/responses', responsesRoutes);
|
||||
app.use('/v1/chat/agents/v1/responses', responsesRoutes);
|
||||
|
||||
// Create test user
|
||||
testUser = await User.create({
|
||||
@@ -531,7 +531,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
describe('basic-response', () => {
|
||||
it('should return a valid ResponseResource for a simple text request', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -570,7 +570,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
describe('streaming-response', () => {
|
||||
it('should return valid SSE streaming events', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -642,7 +642,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
it('should emit valid event types per Open Responses spec', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -679,7 +679,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
it('should include logprobs array in output_text events', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -736,7 +736,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
// gets merged into the system prompt, or we test with a simple user message
|
||||
// that instructs the behavior.
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -762,7 +762,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
describe('multi-turn', () => {
|
||||
it('should handle multi-turn conversation history', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: testAgent.id,
|
||||
input: [
|
||||
@@ -802,7 +802,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
describe('string-input', () => {
|
||||
it('should accept simple string input', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: testAgent.id,
|
||||
input: 'Hello!',
|
||||
});
|
||||
@@ -822,7 +822,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
describe('Extended Thinking', () => {
|
||||
it('should return reasoning output when thinking is enabled', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: thinkingAgent.id,
|
||||
input: [
|
||||
@@ -863,7 +863,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
it('should stream reasoning events when thinking is enabled', async () => {
|
||||
const response = await authRequest()
|
||||
.post('/api/agents/v1/responses')
|
||||
.post('/v1/chat/agents/v1/responses')
|
||||
.send({
|
||||
model: thinkingAgent.id,
|
||||
input: [
|
||||
@@ -938,7 +938,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
describe('Schema Validation', () => {
|
||||
it('should include all required fields in response', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: testAgent.id,
|
||||
input: 'Test',
|
||||
});
|
||||
@@ -984,7 +984,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should have valid message item structure', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: testAgent.id,
|
||||
input: 'Hello',
|
||||
});
|
||||
@@ -1034,7 +1034,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
describe('Response Storage', () => {
|
||||
it('should store response when store: true and retrieve it', async () => {
|
||||
// Create a stored response
|
||||
const createResponse = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const createResponse = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: testAgent.id,
|
||||
input: 'Remember this: The answer is 42.',
|
||||
store: true,
|
||||
@@ -1050,7 +1050,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Retrieve the stored response
|
||||
const getResponseResult = await authRequest().get(`/api/agents/v1/responses/${responseId}`);
|
||||
const getResponseResult = await authRequest().get(`/v1/chat/agents/v1/responses/${responseId}`);
|
||||
|
||||
// Note: The response might be stored under conversationId, not responseId
|
||||
// If we get 404, that's expected behavior for now since we store by conversationId
|
||||
@@ -1062,7 +1062,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent response', async () => {
|
||||
const response = await authRequest().get('/api/agents/v1/responses/resp_nonexistent123');
|
||||
const response = await authRequest().get('/v1/chat/agents/v1/responses/resp_nonexistent123');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBeDefined();
|
||||
@@ -1075,7 +1075,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should return error for missing model', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
input: 'Hello',
|
||||
});
|
||||
|
||||
@@ -1084,7 +1084,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return error for missing input', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: testAgent.id,
|
||||
});
|
||||
|
||||
@@ -1093,7 +1093,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return error for non-existent agent', async () => {
|
||||
const response = await authRequest().post('/api/agents/v1/responses').send({
|
||||
const response = await authRequest().post('/v1/chat/agents/v1/responses').send({
|
||||
model: 'agent_nonexistent123456789',
|
||||
input: 'Hello',
|
||||
});
|
||||
@@ -1109,7 +1109,7 @@ describeWithApiKey('Open Responses API Integration Tests', () => {
|
||||
|
||||
describe('GET /v1/responses/models', () => {
|
||||
it('should list available agents as models', async () => {
|
||||
const response = await authRequest().get('/api/agents/v1/responses/models');
|
||||
const response = await authRequest().get('/v1/chat/agents/v1/responses/models');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.object).toBe('list');
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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 `/v1/chat/agents/cloud`.
|
||||
*
|
||||
* GET /v1/chat/agents/cloud list the caller's cloud agents
|
||||
* GET /v1/chat/agents/cloud/:name one agent's detail + recent runs
|
||||
* POST /v1/chat/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 /v1/chat/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 /v1/chat/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 /v1/chat/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;
|
||||
@@ -8,11 +8,15 @@ 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 ?? {};
|
||||
@@ -21,7 +25,7 @@ const router = express.Router();
|
||||
|
||||
/**
|
||||
* Open Responses API routes (API key authentication handled in route file)
|
||||
* Mounted at /agents/v1/responses (full path: /api/agents/v1/responses)
|
||||
* Mounted at /agents/v1/responses (full path: /v1/chat/agents/v1/responses)
|
||||
* NOTE: Must be mounted BEFORE /v1 to avoid being caught by the less specific route
|
||||
* @see https://openresponses.org/specification
|
||||
*/
|
||||
@@ -29,14 +33,80 @@ router.use('/v1/responses', responses);
|
||||
|
||||
/**
|
||||
* OpenAI-compatible API routes (API key authentication handled in route file)
|
||||
* Mounted at /agents/v1 (full path: /api/agents/v1/chat/completions)
|
||||
* Mounted at /agents/v1 (full path: /v1/chat/agents/v1/chat/completions)
|
||||
*/
|
||||
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 `/v1/chat/agents` CRUD stays untouched.
|
||||
*/
|
||||
router.use('/cloud', cloud);
|
||||
|
||||
router.use('/', v1);
|
||||
|
||||
/**
|
||||
@@ -269,18 +339,4 @@ router.post('/chat/abort', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Job not found', streamId: jobStreamId });
|
||||
});
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use(configMiddleware);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
}
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
chatRouter.use('/', chat);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -79,18 +79,18 @@ jest.mock('~/server/middleware', () => {
|
||||
|
||||
const authRouter = require('./auth');
|
||||
|
||||
describe('POST /api/auth/cloudfront/refresh', () => {
|
||||
describe('POST /v1/chat/auth/cloudfront/refresh', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/v1/chat/auth', authRouter);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
await request(app).post('/api/auth/cloudfront/refresh').expect(401);
|
||||
await request(app).post('/v1/chat/auth/cloudfront/refresh').expect(401);
|
||||
|
||||
expect(mockForceRefreshCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/cloudfront/refresh')
|
||||
.post('/v1/chat/auth/cloudfront/refresh')
|
||||
.set('Authorization', 'Bearer ok')
|
||||
.expect(404);
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/cloudfront/refresh')
|
||||
.post('/v1/chat/auth/cloudfront/refresh')
|
||||
.set('Authorization', 'Bearer ok')
|
||||
.expect(200);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('POST /api/auth/cloudfront/refresh', () => {
|
||||
|
||||
it('reuses the auth middleware refresh result instead of minting cookies twice', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/cloudfront/refresh')
|
||||
.post('/v1/chat/auth/cloudfront/refresh')
|
||||
.set('Authorization', 'Bearer ok')
|
||||
.set('x-cloudfront-warmed', 'true')
|
||||
.expect(200);
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const middleware = require('~/server/middleware');
|
||||
const { Balance } = require('~/db/models');
|
||||
@@ -41,6 +42,7 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
|
||||
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -59,6 +60,8 @@ router.get('/', async function (req, res) {
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
const guestConfig = getGuestConfig();
|
||||
|
||||
// Strip server-internal fields from balance config before sending to client.
|
||||
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
|
||||
const clientBalanceConfig = balanceConfig
|
||||
@@ -121,6 +124,8 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
allowGuestChat: guestConfig.enabled,
|
||||
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -25,9 +26,17 @@ const assistantClients = {
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
/**
|
||||
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
||||
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
||||
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
|
||||
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
|
||||
*/
|
||||
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
@@ -57,6 +66,9 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Every route below reads or writes persisted user data — JWT-only, no guests. */
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.get('/:conversationId', async (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const convo = await getConvo(req.user.id, conversationId);
|
||||
@@ -174,7 +186,7 @@ router.post('/archive', validateConvoAccess, async (req, res) => {
|
||||
const dbResponse = await saveConvo(
|
||||
req,
|
||||
{ conversationId, isArchived },
|
||||
{ context: `POST /api/convos/archive ${conversationId}` },
|
||||
{ context: `POST /v1/chat/convos/archive ${conversationId}` },
|
||||
);
|
||||
res.status(200).json(dbResponse);
|
||||
} catch (error) {
|
||||
@@ -214,7 +226,7 @@ router.post('/update', validateConvoAccess, async (req, res) => {
|
||||
const dbResponse = await saveConvo(
|
||||
req,
|
||||
{ conversationId, title: sanitizedTitle },
|
||||
{ context: `POST /api/convos/update ${conversationId}` },
|
||||
{ context: `POST /v1/chat/convos/update ${conversationId}` },
|
||||
);
|
||||
res.status(201).json(dbResponse);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('file upload routes restore strict isolation context after multer', ()
|
||||
beforeAll(async () => {
|
||||
const { initialize } = require('./index');
|
||||
app = express();
|
||||
app.use('/api/files', await initialize());
|
||||
app.use('/v1/chat/files', await initialize());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -123,12 +123,12 @@ describe('file upload routes restore strict isolation context after multer', ()
|
||||
});
|
||||
|
||||
it.each([
|
||||
['files', '/api/files'],
|
||||
['images', '/api/files/images'],
|
||||
['avatar', '/api/files/images/avatar'],
|
||||
['agent-avatar', '/api/files/images/agents/agent-1/avatar'],
|
||||
['assistant-avatar', '/api/files/images/assistants/asst-1/avatar'],
|
||||
['speech-stt', '/api/files/speech/stt'],
|
||||
['files', '/v1/chat/files'],
|
||||
['images', '/v1/chat/files/images'],
|
||||
['avatar', '/v1/chat/files/images/avatar'],
|
||||
['agent-avatar', '/v1/chat/files/images/agents/agent-1/avatar'],
|
||||
['assistant-avatar', '/v1/chat/files/images/assistants/asst-1/avatar'],
|
||||
['speech-stt', '/v1/chat/files/speech/stt'],
|
||||
])('restores context for %s upload', async (route, url) => {
|
||||
const res = await request(app).post(url);
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('file upload routes restore strict isolation context after multer', ()
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
const res = await request(app).post('/api/files');
|
||||
const res = await request(app).post('/v1/chat/files');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/Tenant context required/);
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Multer Configuration', () => {
|
||||
mockReq = {
|
||||
user: { id: 'test-user-123' },
|
||||
body: {},
|
||||
originalUrl: '/api/files/upload',
|
||||
originalUrl: '/v1/chat/files/upload',
|
||||
config: {
|
||||
paths: {
|
||||
uploads: tempDir,
|
||||
@@ -256,7 +256,7 @@ describe('Multer Configuration', () => {
|
||||
});
|
||||
|
||||
it('should handle audio files for speech-to-text endpoint with real config', async () => {
|
||||
mockReq.originalUrl = '/api/speech/stt';
|
||||
mockReq.originalUrl = '/v1/chat/speech/stt';
|
||||
|
||||
const multerInstance = await createMulterInstance();
|
||||
expect(multerInstance).toBeDefined();
|
||||
|
||||
@@ -47,7 +47,7 @@ const { getLogStores } = require('~/cache');
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
|
||||
const OAUTH_CSRF_COOKIE_PATH = '/v1/chat/mcp';
|
||||
|
||||
/**
|
||||
* Get all MCP tools available to the user
|
||||
@@ -677,7 +677,7 @@ const checkMCPCreate = generateCheckAccess({
|
||||
|
||||
/**
|
||||
* Get list of accessible MCP servers
|
||||
* @route GET /api/mcp/servers
|
||||
* @route GET /v1/chat/mcp/servers
|
||||
* @param {Object} req.query - Query parameters for pagination and search
|
||||
* @param {number} [req.query.limit] - Number of results per page
|
||||
* @param {string} [req.query.after] - Pagination cursor
|
||||
@@ -688,7 +688,7 @@ router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList
|
||||
|
||||
/**
|
||||
* Create a new MCP server
|
||||
* @route POST /api/mcp/servers
|
||||
* @route POST /v1/chat/mcp/servers
|
||||
* @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
|
||||
* @returns {MCPServer} 201 - Success response - application/json
|
||||
*/
|
||||
@@ -696,7 +696,7 @@ router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerControlle
|
||||
|
||||
/**
|
||||
* Get single MCP server by ID
|
||||
* @route GET /api/mcp/servers/:serverName
|
||||
* @route GET /v1/chat/mcp/servers/:serverName
|
||||
* @param {string} req.params.serverName - MCP server identifier.
|
||||
* @returns {MCPServer} 200 - Success response - application/json
|
||||
*/
|
||||
@@ -713,7 +713,7 @@ router.get(
|
||||
|
||||
/**
|
||||
* Update MCP server
|
||||
* @route PATCH /api/mcp/servers/:serverName
|
||||
* @route PATCH /v1/chat/mcp/servers/:serverName
|
||||
* @param {string} req.params.serverName - MCP server identifier.
|
||||
* @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
|
||||
* @returns {MCPServer} 200 - Success response - application/json
|
||||
@@ -731,7 +731,7 @@ router.patch(
|
||||
|
||||
/**
|
||||
* Delete MCP server
|
||||
* @route DELETE /api/mcp/servers/:serverName
|
||||
* @route DELETE /v1/chat/mcp/servers/:serverName
|
||||
* @param {string} req.params.serverName - MCP server identifier.
|
||||
* @returns {Object} 200 - Success response - application/json
|
||||
*/
|
||||
|
||||
@@ -188,7 +188,7 @@ router.post('/branch', async (req, res) => {
|
||||
};
|
||||
|
||||
const savedMessage = await saveMessage(req, newMessage, {
|
||||
context: 'POST /api/messages/branch',
|
||||
context: 'POST /v1/chat/messages/branch',
|
||||
});
|
||||
|
||||
if (!savedMessage) {
|
||||
@@ -265,7 +265,7 @@ router.post('/artifact/:messageId', async (req, res) => {
|
||||
content: message.content,
|
||||
user: req.user.id,
|
||||
},
|
||||
{ context: 'POST /api/messages/artifact/:messageId' },
|
||||
{ context: 'POST /v1/chat/messages/artifact/:messageId' },
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
@@ -297,12 +297,12 @@ router.post('/:conversationId', validateMessageReq, async (req, res) => {
|
||||
const savedMessage = await saveMessage(
|
||||
req,
|
||||
{ ...message, user: req.user.id },
|
||||
{ context: 'POST /api/messages/:conversationId' },
|
||||
{ context: 'POST /v1/chat/messages/:conversationId' },
|
||||
);
|
||||
if (!savedMessage) {
|
||||
return res.status(400).json({ error: 'Message not saved' });
|
||||
}
|
||||
await saveConvo(req, savedMessage, { context: 'POST /api/messages/:conversationId' });
|
||||
await saveConvo(req, savedMessage, { context: 'POST /v1/chat/messages/:conversationId' });
|
||||
res.status(201).json(savedMessage);
|
||||
} catch (error) {
|
||||
logger.error('Error saving message:', error);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
// file deepcode ignore NoRateLimitingForLogin: `loginLimiter` is applied per-route to the IdP-initiation GETs (not the machine-driven /callback routes, which would break the OIDC code exchange)
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { randomState } = require('openid-client');
|
||||
@@ -23,7 +23,6 @@ const domains = {
|
||||
};
|
||||
|
||||
router.use(logHeaders);
|
||||
router.use(loginLimiter);
|
||||
|
||||
const oauthHandler = createOAuthHandler();
|
||||
|
||||
@@ -42,6 +41,7 @@ router.get('/error', (req, res) => {
|
||||
*/
|
||||
router.get(
|
||||
'/google',
|
||||
loginLimiter,
|
||||
passport.authenticate('google', {
|
||||
scope: ['openid', 'profile', 'email'],
|
||||
session: false,
|
||||
@@ -66,6 +66,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/facebook',
|
||||
loginLimiter,
|
||||
passport.authenticate('facebook', {
|
||||
scope: ['public_profile'],
|
||||
profileFields: ['id', 'email', 'name'],
|
||||
@@ -90,7 +91,7 @@ router.get(
|
||||
/**
|
||||
* OpenID Routes
|
||||
*/
|
||||
router.get('/openid', (req, res, next) => {
|
||||
router.get('/openid', loginLimiter, (req, res, next) => {
|
||||
return passport.authenticate('openid', {
|
||||
session: false,
|
||||
state: randomState(),
|
||||
@@ -114,6 +115,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/github',
|
||||
loginLimiter,
|
||||
passport.authenticate('github', {
|
||||
scope: ['user:email', 'read:user'],
|
||||
session: false,
|
||||
@@ -138,6 +140,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/discord',
|
||||
loginLimiter,
|
||||
passport.authenticate('discord', {
|
||||
scope: ['identify', 'email'],
|
||||
session: false,
|
||||
@@ -162,6 +165,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/apple',
|
||||
loginLimiter,
|
||||
passport.authenticate('apple', {
|
||||
session: false,
|
||||
}),
|
||||
@@ -184,6 +188,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/saml',
|
||||
loginLimiter,
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
}),
|
||||
|
||||
@@ -83,7 +83,7 @@ beforeAll(async () => {
|
||||
|
||||
// Import routes after middleware is set up
|
||||
promptRoutes = require('./prompts');
|
||||
app.use('/api/prompts', promptRoutes);
|
||||
app.use('/v1/chat/prompts', promptRoutes);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -180,13 +180,13 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
// Simple test to verify route is loaded
|
||||
it('should have routes loaded', async () => {
|
||||
// This should at least not crash
|
||||
const response = await request(app).get('/api/prompts/test-404');
|
||||
const response = await request(app).get('/v1/chat/prompts/test-404');
|
||||
|
||||
// We expect a 401 or 404, not 500
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
|
||||
describe('POST /api/prompts - Create Prompt', () => {
|
||||
describe('POST /v1/chat/prompts - Create Prompt', () => {
|
||||
afterEach(async () => {
|
||||
await Prompt.deleteMany({});
|
||||
await PromptGroup.deleteMany({});
|
||||
@@ -204,7 +204,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/prompts').send(promptData);
|
||||
const response = await request(app).post('/v1/chat/prompts').send(promptData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.prompt).toBeDefined();
|
||||
@@ -234,7 +234,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/prompts').send(promptData).expect(200);
|
||||
const response = await request(app).post('/v1/chat/prompts').send(promptData).expect(200);
|
||||
|
||||
expect(response.body.prompt).toBeDefined();
|
||||
expect(response.body.group).toBeDefined();
|
||||
@@ -252,7 +252,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/prompts/:promptId - Get Prompt', () => {
|
||||
describe('GET /v1/chat/prompts/:promptId - Get Prompt', () => {
|
||||
let testPrompt;
|
||||
let testGroup;
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
const response = await request(app).get(`/api/prompts/${testPrompt._id}`);
|
||||
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body._id).toBe(testPrompt._id.toString());
|
||||
expect(response.body.prompt).toBe(testPrompt.prompt);
|
||||
@@ -303,7 +303,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
// Change the user to one without access
|
||||
setTestUser(app, testUsers.noAccess);
|
||||
|
||||
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403);
|
||||
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`).expect(403);
|
||||
|
||||
// Verify error response
|
||||
expect(response.body.error).toBe('Forbidden');
|
||||
@@ -314,13 +314,13 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
// Set admin user
|
||||
setTestUser(app, testUsers.admin);
|
||||
|
||||
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
|
||||
const response = await request(app).get(`/v1/chat/prompts/${testPrompt._id}`).expect(200);
|
||||
|
||||
expect(response.body._id).toBe(testPrompt._id.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/prompts/:promptId - Delete Prompt', () => {
|
||||
describe('DELETE /v1/chat/prompts/:promptId - Delete Prompt', () => {
|
||||
let testPrompt;
|
||||
let testGroup;
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
it('should delete prompt when user has delete permissions', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/prompts/${testPrompt._id}`)
|
||||
.delete(`/v1/chat/prompts/${testPrompt._id}`)
|
||||
.query({ groupId: testGroup._id.toString() })
|
||||
.expect(200);
|
||||
|
||||
@@ -408,7 +408,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
setTestUser(app, testUsers.viewer);
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/prompts/${authorPrompt._id}`)
|
||||
.delete(`/v1/chat/prompts/${authorPrompt._id}`)
|
||||
.query({ groupId: testGroup._id.toString() })
|
||||
.expect(403);
|
||||
|
||||
@@ -418,7 +418,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => {
|
||||
describe('PATCH /v1/chat/prompts/:promptId/tags/production - Make Production', () => {
|
||||
let testPrompt;
|
||||
let testGroup;
|
||||
|
||||
@@ -462,7 +462,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
setTestUser(app, testUsers.owner);
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/${testPrompt._id}/tags/production`)
|
||||
.patch(`/v1/chat/prompts/${testPrompt._id}/tags/production`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Prompt production made successfully');
|
||||
@@ -486,7 +486,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
// Set viewer user
|
||||
setTestUser(app, testUsers.viewer);
|
||||
|
||||
await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
|
||||
await request(app).patch(`/v1/chat/prompts/${testPrompt._id}/tags/production`).expect(403);
|
||||
|
||||
// Verify prompt hasn't changed
|
||||
const unchangedGroup = await PromptGroup.findById(testGroup._id);
|
||||
@@ -538,13 +538,13 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
// Change user to someone without explicit permissions
|
||||
setTestUser(app, testUsers.noAccess);
|
||||
|
||||
const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200);
|
||||
const response = await request(app).get(`/v1/chat/prompts/${publicPrompt._id}`).expect(200);
|
||||
|
||||
expect(response.body._id).toBe(publicPrompt._id.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
|
||||
describe('PATCH /v1/chat/prompts/groups/:groupId - Update Prompt Group Security', () => {
|
||||
let testGroup;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -581,7 +581,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(updateData)
|
||||
.expect(200);
|
||||
|
||||
@@ -597,7 +597,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -613,7 +613,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -645,7 +645,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -661,7 +661,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -676,7 +676,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -696,7 +696,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/prompts/groups/${testGroup._id}`)
|
||||
.patch(`/v1/chat/prompts/groups/${testGroup._id}`)
|
||||
.send(maliciousUpdate)
|
||||
.expect(400);
|
||||
|
||||
@@ -741,7 +741,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
it('should correctly indicate hasMore when there are more pages', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '10' })
|
||||
.expect(200);
|
||||
|
||||
@@ -755,7 +755,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
it('should correctly indicate no more pages on the last page', async () => {
|
||||
// First get the cursor for page 2
|
||||
const firstPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '10' })
|
||||
.expect(200);
|
||||
|
||||
@@ -764,7 +764,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Now fetch the second page using the cursor
|
||||
const response = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '10', cursor: firstPage.body.after })
|
||||
.expect(200);
|
||||
|
||||
@@ -775,7 +775,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
it('should support cursor-based pagination', async () => {
|
||||
// First page
|
||||
const firstPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5' })
|
||||
.expect(200);
|
||||
|
||||
@@ -785,7 +785,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Second page using cursor
|
||||
const secondPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5', cursor: firstPage.body.after })
|
||||
.expect(200);
|
||||
|
||||
@@ -848,7 +848,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Test pagination with category filter
|
||||
const firstPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5', category: 'test-cat-1' })
|
||||
.expect(200);
|
||||
|
||||
@@ -858,7 +858,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
expect(firstPage.body.after).toBeTruthy();
|
||||
|
||||
const secondPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
|
||||
.expect(200);
|
||||
|
||||
@@ -916,7 +916,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Test pagination with name filter
|
||||
const firstPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '10', name: 'Search' })
|
||||
.expect(200);
|
||||
|
||||
@@ -926,7 +926,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
expect(firstPage.body.after).toBeTruthy();
|
||||
|
||||
const secondPage = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
|
||||
.expect(200);
|
||||
|
||||
@@ -984,7 +984,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Test pagination with both filters
|
||||
const response = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5', name: 'API', category: 'api-category' })
|
||||
.expect(200);
|
||||
|
||||
@@ -999,7 +999,7 @@ describe('Prompt Routes - ACL Permissions', () => {
|
||||
|
||||
// Page 2
|
||||
const page2 = await request(app)
|
||||
.get('/api/prompts/groups')
|
||||
.get('/v1/chat/prompts/groups')
|
||||
.query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
|
||||
.expect(200);
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ const createPermissionUpdateHandler = (permissionKey) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/roles/:roleName
|
||||
* GET /v1/chat/roles/:roleName
|
||||
* Get a specific role by name
|
||||
*/
|
||||
router.get('/:roleName', async (req, res) => {
|
||||
@@ -131,43 +131,43 @@ router.get('/:roleName', async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/prompts
|
||||
* PUT /v1/chat/roles/:roleName/prompts
|
||||
* Update prompt permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/agents
|
||||
* PUT /v1/chat/roles/:roleName/agents
|
||||
* Update agent permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/memories
|
||||
* PUT /v1/chat/roles/:roleName/memories
|
||||
* Update memory permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/people-picker
|
||||
* PUT /v1/chat/roles/:roleName/people-picker
|
||||
* Update people picker permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/mcp-servers
|
||||
* PUT /v1/chat/roles/:roleName/mcp-servers
|
||||
* Update MCP servers permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/mcp-servers', checkAdmin, createPermissionUpdateHandler('mcp-servers'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/marketplace
|
||||
* PUT /v1/chat/roles/:roleName/marketplace
|
||||
* Update marketplace permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace'));
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/remote-agents
|
||||
* PUT /v1/chat/roles/:roleName/remote-agents
|
||||
* Update remote agents (API) permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/remote-agents', checkAdmin, createPermissionUpdateHandler('remote-agents'));
|
||||
|
||||
@@ -3,11 +3,12 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -141,7 +141,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
currentTestUser = testUsers.owner;
|
||||
app.use('/api/skills', require('./skills'));
|
||||
app.use('/v1/chat/skills', require('./skills'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -202,7 +202,7 @@ async function setupTestData() {
|
||||
|
||||
async function createSkillAsOwner(overrides = {}) {
|
||||
return request(app)
|
||||
.post('/api/skills')
|
||||
.post('/v1/chat/skills')
|
||||
.send({
|
||||
name: 'strict-file-skill',
|
||||
description: 'A strict tenant skill used in multipart route tests.',
|
||||
@@ -227,7 +227,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
|
||||
zip.file('scripts/run.sh', 'echo strict');
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
|
||||
const res = await request(app).post('/api/skills/import').attach('file', buffer, {
|
||||
const res = await request(app).post('/v1/chat/skills/import').attach('file', buffer, {
|
||||
filename: 'strict-import.skill',
|
||||
contentType: 'application/zip',
|
||||
});
|
||||
@@ -274,7 +274,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/skills/import')
|
||||
.post('/v1/chat/skills/import')
|
||||
.attach('file', Buffer.from('# Request Tenant Markdown'), {
|
||||
filename: 'request-tenant.md',
|
||||
contentType: 'text/markdown',
|
||||
@@ -301,7 +301,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/skills/import')
|
||||
.post('/v1/chat/skills/import')
|
||||
.attach('file', Buffer.from('# No Tenant'), {
|
||||
filename: 'no-tenant.md',
|
||||
contentType: 'text/markdown',
|
||||
@@ -316,7 +316,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/skills/${created.body._id}/files`)
|
||||
.post(`/v1/chat/skills/${created.body._id}/files`)
|
||||
.field('relativePath', 'scripts/manual.sh')
|
||||
.attach('file', Buffer.from('echo manual'), {
|
||||
filename: 'manual.sh',
|
||||
@@ -351,7 +351,7 @@ describe('Skill multipart routes under strict tenant isolation', () => {
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/skills/${created.body._id}/files`)
|
||||
.post(`/v1/chat/skills/${created.body._id}/files`)
|
||||
.field('relativePath', 'scripts/request-tenant.sh')
|
||||
.attach('file', Buffer.from('echo request tenant'), {
|
||||
filename: 'request-tenant.sh',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user