Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61cb192134 |
@@ -1,16 +0,0 @@
|
||||
# depcheck configuration.
|
||||
#
|
||||
# ignores: dependencies that are present on purpose but never imported directly,
|
||||
# so depcheck's static scan reports them as a false positive.
|
||||
#
|
||||
# The three @opentelemetry/* entries below are all transitive dependencies of
|
||||
# @opentelemetry/sdk-node, which we DO import and drive (NodeSDK in
|
||||
# packages/api/src/telemetry/sdk.ts). They are pinned at the top level to keep
|
||||
# every otel package on one aligned version (mismatched otel versions break at
|
||||
# runtime). None is imported by name, so depcheck flags them — expected, not
|
||||
# dead. Do NOT remove them (that would unpin the otel version set). Only
|
||||
# @opentelemetry/api is imported directly and stays checked.
|
||||
ignores:
|
||||
- "@opentelemetry/core"
|
||||
- "@opentelemetry/exporter-trace-otlp-http"
|
||||
- "@opentelemetry/sdk-trace-base"
|
||||
+19
-40
@@ -117,17 +117,6 @@ PROXY=
|
||||
# Endpoint: https://api.hanzo.ai/v1
|
||||
HANZO_API_KEY=
|
||||
|
||||
# Canonical Hanzo Cloud agents (/v1/agents). Lets signed-in users run their own
|
||||
# cloud agents from chat via `/agent <name>` or the @mention picker. The chat
|
||||
# backend proxies /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 #
|
||||
#===================================#
|
||||
@@ -474,21 +463,6 @@ ALLOW_REGISTRATION=true
|
||||
ALLOW_SOCIAL_LOGIN=false
|
||||
ALLOW_SOCIAL_REGISTRATION=false
|
||||
ALLOW_PASSWORD_RESET=false
|
||||
|
||||
#=====================================================#
|
||||
# Guest Chat (anonymous preview) #
|
||||
#=====================================================#
|
||||
# Off by default. When enabled, unauthenticated visitors get a small free
|
||||
# quota on the free Zen model routed through api.hanzo.ai. After the quota is
|
||||
# spent the UI surfaces the existing OpenID/hanzo.id login. Guests are scoped
|
||||
# server-side to the free model only and rejected from every other route.
|
||||
ALLOW_GUEST_CHAT=false
|
||||
# GUEST_MESSAGE_MAX=3 # free messages per IP before login is required
|
||||
# GUEST_ENDPOINT=Hanzo # custom endpoint name from librechat.yaml (api.hanzo.ai)
|
||||
# GUEST_MODEL=zen3-nano # free Zen model id guests are pinned to
|
||||
# GUEST_TOKEN_EXPIRY=3600000 # guest JWT lifetime in ms (default 1h)
|
||||
# GUEST_TOKEN_MAX=20 # max guest sessions issued per IP per window
|
||||
# GUEST_TOKEN_WINDOW=60 # guest-session issuance window in minutes
|
||||
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
|
||||
ALLOW_UNVERIFIED_EMAIL_LOGIN=true
|
||||
|
||||
@@ -836,26 +810,31 @@ OPENWEATHER_API_KEY=
|
||||
# Hanzo Chat Code Interpreter API #
|
||||
#====================================#
|
||||
|
||||
# "Run Code" routes through the Hanzo unified backend (api.hanzo.ai/v1/exec),
|
||||
# which executes in a Hanzo sandbox. execute_code POSTs {BASEURL}/exec with
|
||||
# X-API-Key. https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
# LIBRECHAT_CODE_API_KEY=your-key # prod: KMS chat-secrets/LIBRECHAT_CODE_API_KEY
|
||||
# https://hanzo.ai/docs/chat/code-interpreter
|
||||
# LIBRECHAT_CODE_API_KEY=your-key
|
||||
|
||||
#======================#
|
||||
# Web Search #
|
||||
#======================#
|
||||
|
||||
# Web Search routes through the Hanzo unified backend ONLY — no external SaaS.
|
||||
# The searxng provider + firecrawl scraper both point at api.hanzo.ai/v1/websearch
|
||||
# (Hanzo metasearch + Hanzo Crawl). See librechat.yaml `webSearch`.
|
||||
# Note: All of the following variable names can be customized.
|
||||
# Omit values to allow user to provide them.
|
||||
|
||||
# For more information on configuration values, see:
|
||||
# https://hanzo.ai/docs/chat/features/web_search
|
||||
# SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
# FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
# WEBSEARCH_API_KEY=your-key # prod: KMS chat-secrets/WEBSEARCH_API_KEY
|
||||
#
|
||||
# Do NOT set SERPER_API_KEY / TAVILY_API_KEY / JINA_API_KEY / COHERE_API_KEY:
|
||||
# external providers are intentionally disabled (one way, unified backend).
|
||||
|
||||
# Search Provider (Required)
|
||||
# SERPER_API_KEY=your_serper_api_key
|
||||
|
||||
# Scraper (Required)
|
||||
# FIRECRAWL_API_KEY=your_firecrawl_api_key
|
||||
# Optional: Custom Firecrawl API URL
|
||||
# FIRECRAWL_API_URL=your_firecrawl_api_url
|
||||
|
||||
# Reranker (Required)
|
||||
# JINA_API_KEY=your_jina_api_key
|
||||
# or
|
||||
# COHERE_API_KEY=your_cohere_api_key
|
||||
|
||||
#======================#
|
||||
# MCP Configuration #
|
||||
|
||||
+3
-13
@@ -5,19 +5,9 @@
|
||||
OPENAI_API_KEY=sk-hanzo-your-api-key-here
|
||||
OPENAI_BASE_URL=https://api.hanzo.ai/v1
|
||||
|
||||
# Code Interpreter ("Run Code") — Hanzo unified backend.
|
||||
# LibreChat's execute_code tool POSTs {LIBRECHAT_CODE_BASEURL}/exec with header
|
||||
# X-API-Key. cloud-api serves /v1/exec (-> sandboxed executor). The key is
|
||||
# KMS-sourced (chat-secrets/LIBRECHAT_CODE_API_KEY). These are the ONLY vars
|
||||
# LibreChat reads — the old CODE_EXECUTION_ENDPOINT/RUNTIME_API_KEY were dead.
|
||||
LIBRECHAT_CODE_BASEURL=https://api.hanzo.ai/v1
|
||||
LIBRECHAT_CODE_API_KEY=set-in-KMS-chat-secrets
|
||||
|
||||
# Web Search — Hanzo unified backend (searxng + firecrawl compat over Hanzo
|
||||
# search + crawl). See librechat.yaml `webSearch`. One shared service key.
|
||||
SEARXNG_INSTANCE_URL=https://api.hanzo.ai/v1/websearch
|
||||
FIRECRAWL_API_URL=https://api.hanzo.ai/v1/websearch
|
||||
WEBSEARCH_API_KEY=set-in-KMS-chat-secrets
|
||||
# Runtime Execution (Code Interpreter)
|
||||
CODE_EXECUTION_ENDPOINT=https://api.hanzo.ai/v1/execute
|
||||
RUNTIME_API_KEY=${OPENAI_API_KEY}
|
||||
|
||||
# ====== BRANDING ======
|
||||
APP_TITLE=Hanzo Chat
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="chat">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">chat</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">AI chat with MCP integration and multi-provider support</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -21,9 +21,6 @@ jobs:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
CI: true
|
||||
# pnpm run frontend builds packages/api (rollup, ~5 GiB peak) + the client;
|
||||
# the default ~2 GiB heap OOMs. Match the review workflows' heap knob.
|
||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||
NODE_ENV: CI
|
||||
SEARCH: false
|
||||
MONGO_URI: mongodb://localhost:27017/librechat-test
|
||||
|
||||
@@ -119,31 +119,30 @@ jobs:
|
||||
echo "unused_keys=[]" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Post via github-script (Octokit + the built-in token) rather than the gh
|
||||
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
|
||||
# exits 127. The job already grants `pull-requests: write`.
|
||||
- name: Post verified comment on PR
|
||||
if: env.unused_keys != '[]'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const keys = JSON.parse(process.env.unused_keys || '[]').filter(Boolean);
|
||||
const list = keys.map((k) => `- [ ] \`${k}\``).join('\n');
|
||||
const body = [
|
||||
'### 🚨 Unused i18next Keys Detected',
|
||||
'',
|
||||
'The following translation keys are defined in `translation.json` but are **not used** in the codebase:',
|
||||
'',
|
||||
list,
|
||||
'',
|
||||
'⚠️ **Please remove these unused keys to keep the translation files clean.**',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
run: |
|
||||
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
# Format the unused keys list as checkboxes for easy manual checking.
|
||||
FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
### 🚨 Unused i18next Keys Detected
|
||||
|
||||
The following translation keys are defined in \`translation.json\` but are **not used** in the codebase:
|
||||
|
||||
$FILTERED_KEYS
|
||||
|
||||
⚠️ **Please remove these unused keys to keep the translation files clean.**
|
||||
EOF
|
||||
)
|
||||
|
||||
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
-f body="$COMMENT_BODY" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fail workflow if unused keys found
|
||||
if: env.unused_keys != '[]'
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
service: chat
|
||||
image: ghcr.io/hanzoai/chat
|
||||
port: "3080"
|
||||
health-path: /health
|
||||
health-path: /api/health
|
||||
e2e-dir: e2e
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: LiteLLM Linting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install openai==1.81.0
|
||||
poetry install --with dev
|
||||
pip install openai==1.81.0
|
||||
|
||||
|
||||
|
||||
- name: Run Black formatting
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run black .
|
||||
cd ..
|
||||
|
||||
- name: Run Ruff linting
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run ruff check .
|
||||
cd ..
|
||||
|
||||
- name: Run MyPy type checking
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run mypy . --ignore-missing-imports
|
||||
cd ..
|
||||
|
||||
- name: Check for circular imports
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run python ../tests/documentation_tests/test_circular_imports.py
|
||||
cd ..
|
||||
|
||||
- name: Check import safety
|
||||
run: |
|
||||
poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
|
||||
@@ -10,12 +10,6 @@ jobs:
|
||||
test:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
# The packages/api rollup build peaks ~5 GiB RSS and OOMs at Node's default
|
||||
# ~2 GiB old-space heap. Match the heap the review workflows already use
|
||||
# (well under the 8 GiB runner limit). One knob, same expression everywhere.
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
@@ -72,15 +66,5 @@ jobs:
|
||||
MEILI_HOST: http://localhost:7700
|
||||
MEILI_MASTER_KEY: test_master_key
|
||||
|
||||
# Gate against tests-without-implementation drift in the MCP connection/
|
||||
# transport layer (proxy, response-size caps, WS SSRF, allowedAddresses).
|
||||
# These suites live in packages/api and were never run by this workflow —
|
||||
# which is how the connection.ts port drifted from its byte-identical tests.
|
||||
# A future half-port now fails here loudly.
|
||||
- name: Run MCP connection/transport tests
|
||||
run: cd packages/api && pnpm run test:transport
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Run client tests
|
||||
run: pnpm run test:client
|
||||
@@ -248,35 +248,35 @@ jobs:
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Post via github-script (Octokit + the built-in token) rather than the gh
|
||||
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
|
||||
# exits 127. The job already grants `pull-requests: write`.
|
||||
- name: Post comment on PR if unused dependencies are found
|
||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const section = (title, val) => {
|
||||
const items = (val || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
if (!items.length) return '';
|
||||
return `#### 📂 ${title}\n\n${items.map((i) => `- \`${i}\``).join('\n')}\n`;
|
||||
};
|
||||
const body = [
|
||||
'### 🚨 Unused NPM Packages Detected',
|
||||
'',
|
||||
'The following **unused dependencies** were found:',
|
||||
'',
|
||||
section('Root `package.json`', process.env.ROOT_UNUSED),
|
||||
section('Client `client/package.json`', process.env.CLIENT_UNUSED),
|
||||
section('API `api/package.json`', process.env.API_UNUSED),
|
||||
'⚠️ **Please remove these unused dependencies to keep your project clean.**',
|
||||
].filter(Boolean).join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
run: |
|
||||
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
### 🚨 Unused NPM Packages Detected
|
||||
|
||||
The following **unused dependencies** were found:
|
||||
|
||||
$(if [[ ! -z "$ROOT_UNUSED" ]]; then echo "#### 📂 Root \`package.json\`"; echo ""; echo "$ROOT_LIST"; echo ""; fi)
|
||||
|
||||
$(if [[ ! -z "$CLIENT_UNUSED" ]]; then echo "#### 📂 Client \`client/package.json\`"; echo ""; echo "$CLIENT_LIST"; echo ""; fi)
|
||||
|
||||
$(if [[ ! -z "$API_UNUSED" ]]; then echo "#### 📂 API \`api/package.json\`"; echo ""; echo "$API_LIST"; echo ""; fi)
|
||||
|
||||
⚠️ **Please remove these unused dependencies to keep your project clean.**
|
||||
EOF
|
||||
)
|
||||
|
||||
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
-f body="$COMMENT_BODY" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fail workflow if unused dependencies found
|
||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||
|
||||
+2
-14
@@ -1,10 +1,7 @@
|
||||
# v0.8.3-rc1
|
||||
|
||||
# Base node image — Node 22 (parity with Dockerfile.static). Node >= 22.5 ships
|
||||
# `node:sqlite` (DatabaseSync), required by the SQLite document store once
|
||||
# CHAT_STORE_SQLITE / CHAT_STORE_DUALWRITE is enabled. The store lazy-requires it
|
||||
# (see stores/sqlite/index.ts) so the runtime still boots with the flag unset.
|
||||
FROM node:22-alpine AS node
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
# Install jemalloc
|
||||
RUN apk add --no-cache jemalloc
|
||||
@@ -45,15 +42,6 @@ RUN \
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# Bake the Umami analytics website id into the Vite build so the index.html
|
||||
# %VITE_ANALYTICS_SITE_ID% placeholder is substituted at build time. Vite's HTML
|
||||
# env replacement (loadEnv, envPrefix includes VITE_) picks this up from process.env.
|
||||
# Without it the tracker POSTs a literal "%VITE_ANALYTICS_SITE_ID%" as the website
|
||||
# id and analytics.hanzo.ai/api/send answers 400. Public site identifier (it appears
|
||||
# in the served HTML), safe to default here; override with --build-arg if needed.
|
||||
ARG VITE_ANALYTICS_SITE_ID=2f72b944-f1f8-4d2d-8f6c-26063bde0d1a
|
||||
ENV VITE_ANALYTICS_SITE_ID=$VITE_ANALYTICS_SITE_ID
|
||||
|
||||
RUN \
|
||||
# React client build with configurable memory
|
||||
NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" pnpm run frontend; \
|
||||
|
||||
@@ -5,7 +5,6 @@ Portions of this software are licensed as follows:
|
||||
---
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
Copyright (c) 2023 Hanzo AI Inc
|
||||
Originally based on LibreChat by Berri AI.
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ npm run format # Prettier
|
||||
```
|
||||
api/ # Express backend (port 3080)
|
||||
server/ # Entry point, routes, controllers, middleware
|
||||
db/base/ # Hanzo Base data-layer adapter (replaces MongoDB) — see below
|
||||
models/ # Model method wrappers (persist to Base via the adapter)
|
||||
models/ # Mongoose models (MongoDB)
|
||||
client/ # React frontend (Vite)
|
||||
src/components/ # UI components
|
||||
src/routes/ # Client-side routing
|
||||
@@ -61,121 +60,6 @@ packages/
|
||||
mcp/ # MCP server integration
|
||||
```
|
||||
|
||||
## Data Layer — Hanzo Base (MongoDB dropped)
|
||||
|
||||
Hanzo Chat does **not** use MongoDB. Persistence runs on **Hanzo Base** (the Go
|
||||
PocketBase-lineage server; SQLite embedded for dev, Postgres for prod). The
|
||||
"drop mongo completely" directive (#48) is realised by a thin adapter under
|
||||
`api/db/base/` that presents a Mongoose-Model-compatible surface backed by the
|
||||
`@hanzo/base` JS client. There is **one** adapter; every model comes along.
|
||||
|
||||
### How it works (one adapter, all models)
|
||||
`@librechat/data-schemas` builds its schemas with real `mongoose` purely as a
|
||||
schema DSL, then asks a mongoose instance to turn them into models
|
||||
(`createModels`/`createMethods`). We hand it a **facade** instead of real
|
||||
mongoose:
|
||||
|
||||
- `api/db/base/mongoose.js` — a `mongoose`-compatible facade. Delegates
|
||||
schema/ObjectId/type concerns to real mongoose (never connected) but
|
||||
overrides `model()`/`models`/`connect`/`connection`. `model(name, schema)`
|
||||
returns a Mongoose-style **constructor** (statics + `new Model(data)` docs).
|
||||
- `api/db/base/model.js` — `BaseModel`: the Mongoose Model static surface
|
||||
(`find/findOne/findOneAndUpdate/create/insertMany/updateOne/updateMany/
|
||||
deleteOne/deleteMany/countDocuments/bulkWrite/aggregate/distinct/exists`),
|
||||
a chainable/thenable `Query` (`select/sort/limit/skip/lean/populate` +
|
||||
`find(A).deleteMany(B)`), and lightweight documents (`toObject/save`).
|
||||
- `api/db/base/query.js` — a correct in-JS Mongo query/update engine
|
||||
(`$or/$and/$in/$nin/$gt../$exists/$regex/$elemMatch`, update ops
|
||||
`$set/$unset/$inc/$push/$addToSet/$pull/$setOnInsert`, projection, sort).
|
||||
This is the correctness backstop — every predicate is evaluated here.
|
||||
- `api/db/base/store.js` — the Base backend via `@hanzo/base` `BaseClient`.
|
||||
Each model → one Base collection whose records hold the **full document as a
|
||||
JSON `data` field** plus promoted scalar columns (from indexed/unique schema
|
||||
paths) used only for **filter pushdown** (a guaranteed superset; the JS
|
||||
engine then narrows). `@hanzo/base` is ESM-only → loaded via dynamic
|
||||
`import()` from this CommonJS code.
|
||||
- `api/db/base/schema.js` — introspects a Mongoose schema for defaults,
|
||||
timestamps, and promotable columns.
|
||||
- `api/db/base/index.js` — `connectDb()` inits the Base client, health-checks,
|
||||
and provisions every registered model's collection (idempotent).
|
||||
|
||||
### Wiring (all app code off mongoose)
|
||||
`api/db/{connect,models,index}.js`, `api/models/index.js`, and every remaining
|
||||
app file (`Agent`, `inviteUser`, `PermissionService`, `PermissionsController`,
|
||||
`initializeMCPs`, `migration`) import the facade (`~/db/base`) — not `mongoose`.
|
||||
The facade itself carries **no `mongoose` runtime dependency** (own ObjectId,
|
||||
Schema.Types shim, no-op session/connection). `grep "require('mongoose')"` over
|
||||
non-test `api/` is **zero**; **nothing calls `mongoose.connect`**. `_id` is a
|
||||
real 24-hex ObjectId string stored inside the document; conversations/messages
|
||||
remain keyed by their `conversationId`/`messageId` UUIDs.
|
||||
|
||||
### Search (FTS on Base/SQLite)
|
||||
MeiliSearch is dropped. `BaseModel.meiliSearch(query, {filter})` runs a
|
||||
user-scoped, case-insensitive substring search over content fields flagged
|
||||
`meiliIndex` (title, text), returning Meili-shaped `{ hits }`. `indexSync.js`
|
||||
is a no-op (no external index); `/search/enable` reports availability from Base
|
||||
(set `SEARCH=false` to disable). A future optimisation is a SQLite FTS5 index
|
||||
instead of the per-user scan.
|
||||
|
||||
### Security invariants (do not regress — from blue+red review)
|
||||
- **Prototype pollution:** `query.js` path helpers reject `__proto__` /
|
||||
`constructor` / `prototype` segments (attacker update keys flow in via
|
||||
`saveConvo` / conversation import). Keep the guard.
|
||||
- **`select:false`:** secrets (password, totpSecret, backupCodes, keyHash) are
|
||||
excluded from reads by default; only `+field` returns them. `save()` merges
|
||||
onto the stored record so a projected load never erases them.
|
||||
- **Pushdown = SUPERSET:** `store.js buildFilter` must only ever broaden; the JS
|
||||
matcher narrows. Never push a predicate that could wrongly exclude — Date
|
||||
columns and non-finite numbers are intentionally NOT pushed (Base date-column
|
||||
normalization vs ISO would silently drop records).
|
||||
- **DSL injection:** `quote()` escapes `\` and `'`; verified a crafted
|
||||
`conversationId` cannot inject `||`/operators.
|
||||
|
||||
### Uniqueness / concurrency
|
||||
Collections carry a Base **UNIQUE** index on always-present unique keys (`_id`,
|
||||
`conversationId`, `messageId`, `email`); optional/sparse unique fields
|
||||
(`googleId`, `openidId`, …) stay plain-indexed to avoid null conflicts, with
|
||||
logical uniqueness enforced by the adapter upsert. The unique index is the
|
||||
race-safety net (verified: a duplicate `conversationId` insert is rejected).
|
||||
|
||||
### Env / running
|
||||
```
|
||||
HANZO_BASE_URL=http://… # Hanzo Base instance (replaces MONGO_URI)
|
||||
HANZO_BASE_TOKEN=… # Base superuser/service token (IAM in prod)
|
||||
```
|
||||
Local Base for dev: build a pure-SQLite launcher from `~/work/hanzo/base`
|
||||
(`base.New()` without the IAM platform plugin), `serve --http=127.0.0.1:8090`;
|
||||
the first serve prints a 30-min superuser installer token. API prefix is `/v1`.
|
||||
|
||||
### Tests
|
||||
- `api/db/base/query.spec.js`, `api/db/base/model.spec.js` — jest, in-memory
|
||||
store, no Base/Mongo needed (CI). `cd api && npx jest db/base`.
|
||||
- `api/db/base/scripts/live-check.js` — real data-schemas factories vs a live
|
||||
Base: createUser + balance grant + findUser + bcrypt + saveConvo + saveMessage,
|
||||
verified via raw Base REST. Env-gated (`HANZO_BASE_URL`/`HANZO_BASE_TOKEN`).
|
||||
- `api/db/base/scripts/boot-smoke.js` — drives the wired `~/db` + `~/models`
|
||||
boot path (`connectDb` + `seedDatabase`) against live Base.
|
||||
|
||||
### Phased status
|
||||
- **Phase 1 (done):** adapter + core hot path verified on Base (User, Balance,
|
||||
Conversation, Message, Agent, plus Role/Session/Token/Category via the same
|
||||
adapter — 29 collections provisioned). Nothing connects to Mongo.
|
||||
- **Phase 2 (done):** Base/SQLite full-text search replaces MeiliSearch;
|
||||
`indexSync` no-op; dead Meili helpers removed. Live-verified real hits.
|
||||
- **Phase 3 (done):** all app code off `mongoose` (self-contained facade);
|
||||
Base UNIQUE indexes on natural keys (race-safety, live-verified);
|
||||
`select:false` secrets honored; all Date fields hydrated.
|
||||
- **Remaining tail (test migration):** ~28 legacy `*.spec.js` still build models
|
||||
on real `mongoose` + `mongodb-memory-server` (e.g. `api/models/*.spec.js`).
|
||||
They're incompatible with the port (they expect mongoose-backed models) and
|
||||
must be migrated to the adapter (in-memory store harness) — that is what gates
|
||||
dropping the `mongoose`/`mongodb-memory-server` devDeps and a literal
|
||||
`grep -riE 'mongoose|mongodb' api/` = 0. The adapter's own suites
|
||||
(`api/db/base/*.spec.js`) are the migration template.
|
||||
- **Deploy:** build image via CI → `registry.hanzo.ai`, deploy to DOKS with a
|
||||
Base instance + IAM service token (`HANZO_BASE_URL`/`HANZO_BASE_TOKEN`), then
|
||||
Playwright-verify login+chat+agent+search in the browser. Gated on cluster access.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `librechat.yaml` (or ConfigMap `chat-config` -> `/app/librechat.yaml`)
|
||||
@@ -206,212 +90,6 @@ CREDS_KEY= CREDS_IV= # Credential encryption
|
||||
- `uv` bundled for MCP server support
|
||||
- `dompurify` must be in `client/package.json` (externalized by bundler)
|
||||
|
||||
## Guest Chat (anonymous preview)
|
||||
|
||||
Off by default (`ALLOW_GUEST_CHAT=false`). When enabled, the landing IS the chat
|
||||
composer (ChatGPT-style): an unauthenticated visitor renders the real chat view —
|
||||
composer, starter cards, model picker — WITHOUT logging in, scoped to the free
|
||||
Zen model (`GUEST_MODEL`, default `zen3-nano`) via the `Hanzo` custom endpoint
|
||||
(`api.hanzo.ai`). Prod uses `GUEST_MESSAGE_MAX=2`. Exhausting the quota returns
|
||||
`402 {type:'GUEST_LIMIT'}` and the client opens the existing OpenID/hanzo.id login.
|
||||
|
||||
Client render path (guest === chat, not a marketing gate):
|
||||
- `AuthContext` auto-acquires a guest token when `startupConfig.allowGuestChat`
|
||||
is true (`silentRefresh` fallback + a dedicated effect closing the config race);
|
||||
sets `isGuest=true`, `isAuthenticated=false`. `useAuthRedirect` keeps guests on
|
||||
the chat surface (only truly anonymous, non-guest, non-guest-enabled users go to
|
||||
`/login`). `Root` shows the chat shell for `isAuthenticated || isGuest`.
|
||||
- `ChatRoute` renders `ChatView` for `canChat = isAuthenticated || isGuest`; the
|
||||
`/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):
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
chat
|
||||
Copyright (c) 2023 Hanzo AI Inc.
|
||||
|
||||
This product includes software from LibreChat (https://github.com/danny-avila/LibreChat), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 LibreChat (Danny Avila)
|
||||
@@ -1,5 +1,3 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="chat" width="880"></p>
|
||||
|
||||
# Hanzo AI Chat
|
||||
|
||||
AI-powered chat platform with enterprise features, using Hanzo's cloud API or local deployment.
|
||||
|
||||
@@ -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="/v1/chat/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="/api/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="/v1/chat/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="/api/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="/v1/chat/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="/api/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="/v1/chat/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="/api/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="/v1/chat/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="/api/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="/v1/chat/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="/api/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,26 +143,16 @@ 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(genParams);
|
||||
resp = await this.openai.images.generate({
|
||||
model: 'dall-e-3',
|
||||
quality,
|
||||
style,
|
||||
size,
|
||||
prompt: this.replaceUnwantedChars(prompt),
|
||||
n: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[DALL-E-3] Problem generating the image:', error);
|
||||
return this
|
||||
|
||||
@@ -12,8 +12,6 @@ const {
|
||||
loadWebSearchAuth,
|
||||
buildImageToolContext,
|
||||
buildWebSearchContext,
|
||||
resolveHanzoCloudKey,
|
||||
isHanzoPerUserKeyEnabled,
|
||||
} = require('@hanzochat/api');
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
const {
|
||||
@@ -234,30 +232,7 @@ const loadTools = async ({
|
||||
const requestedTools = {};
|
||||
|
||||
if (functions === true) {
|
||||
// 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 });
|
||||
};
|
||||
toolConstructors.dalle = DALLE3;
|
||||
}
|
||||
|
||||
/** @type {ImageGenOptions} */
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Hanzo Base data-layer adapter — entry point.
|
||||
*
|
||||
* Exposes:
|
||||
* - `mongoose` : the mongoose-compatible facade (feed to createModels/createMethods)
|
||||
* - `connectDb()`: initialise the Base client and provision collections
|
||||
* - `store` : the underlying DocumentStore (for tests/introspection)
|
||||
*/
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoose = require('./mongoose');
|
||||
const store = require('./store');
|
||||
|
||||
function resolveConfig() {
|
||||
const url = process.env.HANZO_BASE_URL || process.env.BASE_URL || null;
|
||||
const token = process.env.HANZO_BASE_TOKEN || process.env.BASE_TOKEN || null;
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
let ready = null;
|
||||
|
||||
/**
|
||||
* Connect to Hanzo Base and ensure every registered model's collection exists.
|
||||
* Idempotent: repeated calls return the same in-flight/settled promise.
|
||||
*/
|
||||
async function connectDb() {
|
||||
if (ready) {
|
||||
return ready;
|
||||
}
|
||||
ready = (async () => {
|
||||
const { url, token } = resolveConfig();
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'[BaseAdapter] Please define HANZO_BASE_URL (Hanzo Base instance URL) for the data layer',
|
||||
);
|
||||
}
|
||||
await store.init({ url, token });
|
||||
try {
|
||||
await store.health();
|
||||
logger.info(`[BaseAdapter] Connected to Hanzo Base at ${url}`);
|
||||
} catch (err) {
|
||||
logger.error('[BaseAdapter] Base health check failed', err);
|
||||
throw err;
|
||||
}
|
||||
await ensureCollections();
|
||||
return mongoose;
|
||||
})();
|
||||
return ready;
|
||||
}
|
||||
|
||||
/** Provision the Base collection for every model registered on the facade. */
|
||||
async function ensureCollections() {
|
||||
const registered = mongoose.models;
|
||||
const names = Object.keys(registered);
|
||||
for (const name of names) {
|
||||
await registered[name].ensure();
|
||||
}
|
||||
logger.info(`[BaseAdapter] Provisioned ${names.length} Base collections`);
|
||||
}
|
||||
|
||||
module.exports = { mongoose, store, connectDb, ensureCollections };
|
||||
@@ -1,828 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* BaseModel — a Mongoose-Model-compatible facade backed by Hanzo Base.
|
||||
*
|
||||
* One instance per collection. It presents the subset of the Mongoose Model
|
||||
* static API that the LibreChat data layer actually uses (find / findOne /
|
||||
* findOneAndUpdate / create / updateOne / deleteMany / countDocuments /
|
||||
* bulkWrite / aggregate …) and executes it against a document `store`
|
||||
* (see store.js) whose records hold the full Mongo document as JSON.
|
||||
*
|
||||
* Correctness comes from query.js: candidate records are fetched from Base
|
||||
* (with best-effort filter pushdown) and every predicate, update operator,
|
||||
* projection and sort is then evaluated in JS.
|
||||
*/
|
||||
|
||||
const {
|
||||
matches,
|
||||
applyUpdate,
|
||||
parseProjection,
|
||||
applyProjection,
|
||||
sortDocs,
|
||||
getPath,
|
||||
setPath,
|
||||
idString,
|
||||
} = require('./query');
|
||||
const { describeSchema, resolveDefault } = require('./schema');
|
||||
const { generateObjectId } = require('./objectId');
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a MeiliSearch-style filter string (e.g. `user = "abc"`) into a Mongo
|
||||
* equality filter for scoping. Supports the `field = "value"` / `field = 'value'`
|
||||
* clauses the chat app emits; unknown syntax is ignored (no scoping).
|
||||
*/
|
||||
function parseMeiliFilter(filter) {
|
||||
const scope = {};
|
||||
if (typeof filter !== 'string') {
|
||||
return scope;
|
||||
}
|
||||
const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
let m;
|
||||
while ((m = re.exec(filter)) !== null) {
|
||||
scope[m[1]] = m[2] !== undefined ? m[2] : m[3];
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** Extract top-level equality conditions from a filter (for upsert seeding). */
|
||||
function equalitySeed(filter) {
|
||||
const seed = {};
|
||||
if (!filter || typeof filter !== 'object') {
|
||||
return seed;
|
||||
}
|
||||
for (const [k, v] of Object.entries(filter)) {
|
||||
if (k.startsWith('$')) {
|
||||
continue;
|
||||
}
|
||||
if (v === null || typeof v !== 'object' || v instanceof Date || Array.isArray(v)) {
|
||||
seed[k] = v;
|
||||
}
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
class BaseModel {
|
||||
/**
|
||||
* @param {string} name - Mongoose model name (e.g. "Conversation")
|
||||
* @param {import('mongoose').Schema} schema
|
||||
* @param {import('./store').DocumentStore} store
|
||||
*/
|
||||
constructor(name, schema, store) {
|
||||
this.modelName = name;
|
||||
this.schema = schema;
|
||||
this.store = store;
|
||||
this.collection = { collectionName: name.toLowerCase() };
|
||||
this.collectionName = name.toLowerCase();
|
||||
|
||||
const desc = describeSchema(schema);
|
||||
this._defaults = desc.defaults;
|
||||
this._timestamps = desc.timestamps;
|
||||
this._promoted = desc.promoted;
|
||||
this._promotedNames = desc.promoted.map((p) => p.name);
|
||||
this._deselected = desc.deselected;
|
||||
this._dateFields = desc.dateFields;
|
||||
this._searchable = desc.searchable;
|
||||
}
|
||||
|
||||
/** Register/verify this collection's schema in Base. Idempotent. */
|
||||
async ensure() {
|
||||
await this.store.ensureCollection(this.collectionName, this._promoted);
|
||||
}
|
||||
|
||||
// ---- internal helpers -------------------------------------------------
|
||||
|
||||
/** Build the persisted record { promoted columns…, data } from a Mongo doc. */
|
||||
_toRecord(doc) {
|
||||
const record = { data: doc };
|
||||
for (const col of this._promoted) {
|
||||
let v = idString(getPath(doc, col.name));
|
||||
if (v === undefined) {
|
||||
v = null;
|
||||
}
|
||||
if (v instanceof Date) {
|
||||
v = v.toISOString();
|
||||
}
|
||||
record[col.name] = v;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Materialize a stored Mongo doc: all Date-typed fields become Date objects. */
|
||||
_hydrate(doc) {
|
||||
for (const f of this._dateFields) {
|
||||
const v = getPath(doc, f);
|
||||
// Cast ISO strings and epoch numbers (e.g. `default: Date.now`) to Date.
|
||||
if (typeof v === 'string' || typeof v === 'number') {
|
||||
setPath(doc, f, new Date(v));
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Apply schema defaults for keys absent from doc. */
|
||||
_applyDefaults(doc) {
|
||||
for (const [name, def] of this._defaults) {
|
||||
if (doc[name] === undefined) {
|
||||
const value = resolveDefault(def);
|
||||
if (value !== undefined) {
|
||||
doc[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch candidate {baseId, doc} records for a filter (pushdown + always safe). */
|
||||
async _candidates(filter) {
|
||||
const raw = await this.store.list(this.collectionName, filter, this._promotedNames);
|
||||
return raw;
|
||||
}
|
||||
|
||||
_newDoc(seed) {
|
||||
const doc = { ...seed };
|
||||
if (!doc._id) {
|
||||
doc._id = generateObjectId();
|
||||
}
|
||||
this._applyDefaults(doc);
|
||||
if (this._timestamps) {
|
||||
const ts = now();
|
||||
if (doc[this._timestamps.createdAt] === undefined) {
|
||||
doc[this._timestamps.createdAt] = ts;
|
||||
}
|
||||
doc[this._timestamps.updatedAt] = ts;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async _insert(doc) {
|
||||
const stored = { ...doc };
|
||||
// Persist Dates as ISO strings inside the JSON blob.
|
||||
if (this._timestamps) {
|
||||
for (const f of [this._timestamps.createdAt, this._timestamps.updatedAt]) {
|
||||
if (stored[f] instanceof Date) {
|
||||
stored[f] = stored[f].toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
const rec = await this.store.create(this.collectionName, this._toRecord(stored));
|
||||
return rec.baseId;
|
||||
}
|
||||
|
||||
async _replace(baseId, doc, { touch = true } = {}) {
|
||||
if (this._timestamps && touch) {
|
||||
doc[this._timestamps.updatedAt] = now(); // reflect fresh updatedAt on the caller's doc
|
||||
}
|
||||
const stored = { ...doc };
|
||||
if (this._timestamps) {
|
||||
for (const f of [this._timestamps.createdAt, this._timestamps.updatedAt]) {
|
||||
if (stored[f] instanceof Date) {
|
||||
stored[f] = stored[f].toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.store.update(this.collectionName, baseId, this._toRecord(stored));
|
||||
}
|
||||
|
||||
// ---- write operations -------------------------------------------------
|
||||
|
||||
async _create(input) {
|
||||
const doc = this._newDoc(input);
|
||||
await this._insert(doc);
|
||||
return this._hydrate({ ...doc });
|
||||
}
|
||||
|
||||
/** `new Model(data)` — build an unsaved document (defaults + _id) with .save(). */
|
||||
_newDocument(data = {}) {
|
||||
return makeDocument(this, this._hydrate(this._newDoc(data)));
|
||||
}
|
||||
|
||||
async create(input) {
|
||||
if (Array.isArray(input)) {
|
||||
const out = [];
|
||||
for (const item of input) {
|
||||
out.push(makeDocument(this, await this._create(item)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return makeDocument(this, await this._create(input));
|
||||
}
|
||||
|
||||
async insertMany(docs, options = {}) {
|
||||
const created = [];
|
||||
for (const d of docs) {
|
||||
created.push(await this._create(d));
|
||||
}
|
||||
if (options.lean) {
|
||||
return created;
|
||||
}
|
||||
return created.map((d) => makeDocument(this, d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Core upsert/update primitive shared by findOneAndUpdate/updateOne/updateMany.
|
||||
* @returns {{ doc: object|null, matched: number, modified: number, upserted: boolean }}
|
||||
*/
|
||||
async _updateOneInternal(filter, update, options = {}) {
|
||||
const candidates = await this._candidates(filter);
|
||||
const hit = candidates.find((c) => matches(c.doc, filter));
|
||||
if (hit) {
|
||||
const before = this._hydrate({ ...hit.doc });
|
||||
const next = { ...hit.doc };
|
||||
applyUpdate(next, update, { isInsert: false });
|
||||
await this._replace(hit.baseId, next, { touch: options.timestamps !== false });
|
||||
return { doc: this._hydrate(next), before, matched: 1, modified: 1, upserted: false };
|
||||
}
|
||||
if (options.upsert) {
|
||||
const seed = equalitySeed(filter);
|
||||
const doc = { ...seed };
|
||||
applyUpdate(doc, update, { isInsert: true });
|
||||
const full = this._newDoc(doc);
|
||||
await this._insert(full);
|
||||
return { doc: this._hydrate(full), before: null, matched: 0, modified: 0, upserted: true };
|
||||
}
|
||||
return { doc: null, before: null, matched: 0, modified: 0, upserted: false };
|
||||
}
|
||||
|
||||
findOneAndUpdate(filter, update, options = {}) {
|
||||
return new Query(this, 'findOneAndUpdate', { filter, update, options });
|
||||
}
|
||||
|
||||
findByIdAndUpdate(id, update, options = {}) {
|
||||
return this.findOneAndUpdate({ _id: id }, update, options);
|
||||
}
|
||||
|
||||
findOneAndDelete(filter) {
|
||||
return new Query(this, 'findOneAndDelete', { filter });
|
||||
}
|
||||
|
||||
async updateOne(filter, update, options = {}) {
|
||||
const r = await this._updateOneInternalPublic(filter, update, options);
|
||||
return r;
|
||||
}
|
||||
|
||||
async _updateOneInternalPublic(filter, update, options) {
|
||||
const r = await this._updateOneInternal(filter, update, options);
|
||||
return {
|
||||
acknowledged: true,
|
||||
matchedCount: r.matched,
|
||||
modifiedCount: r.modified,
|
||||
upsertedCount: r.upserted ? 1 : 0,
|
||||
upsertedId: r.upserted ? r.doc._id : null,
|
||||
};
|
||||
}
|
||||
|
||||
async updateMany(filter, update, options = {}) {
|
||||
const candidates = await this._candidates(filter);
|
||||
const hits = candidates.filter((c) => matches(c.doc, filter));
|
||||
for (const hit of hits) {
|
||||
const next = { ...hit.doc };
|
||||
applyUpdate(next, update, { isInsert: false });
|
||||
await this._replace(hit.baseId, next, { touch: options.timestamps !== false });
|
||||
}
|
||||
if (!hits.length && options.upsert) {
|
||||
const seed = equalitySeed(filter);
|
||||
const doc = { ...seed };
|
||||
applyUpdate(doc, update, { isInsert: true });
|
||||
await this._insert(this._newDoc(doc));
|
||||
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedCount: 1 };
|
||||
}
|
||||
return {
|
||||
acknowledged: true,
|
||||
matchedCount: hits.length,
|
||||
modifiedCount: hits.length,
|
||||
upsertedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async _deleteWhere(filter, limit) {
|
||||
const candidates = await this._candidates(filter);
|
||||
const hits = candidates.filter((c) => matches(c.doc, filter));
|
||||
const toDelete = limit ? hits.slice(0, limit) : hits;
|
||||
for (const hit of toDelete) {
|
||||
await this.store.delete(this.collectionName, hit.baseId);
|
||||
}
|
||||
return { acknowledged: true, deletedCount: toDelete.length };
|
||||
}
|
||||
|
||||
deleteOne(filter) {
|
||||
return this._deleteWhere(filter, 1);
|
||||
}
|
||||
|
||||
deleteMany(filter) {
|
||||
return this._deleteWhere(filter || {}, 0);
|
||||
}
|
||||
|
||||
// ---- read operations --------------------------------------------------
|
||||
|
||||
find(filter = {}, projection, options) {
|
||||
return new Query(this, 'find', { filter, projection, options });
|
||||
}
|
||||
|
||||
findOne(filter = {}, projection, options) {
|
||||
return new Query(this, 'findOne', { filter, projection, options });
|
||||
}
|
||||
|
||||
findById(id, projection, options) {
|
||||
return new Query(this, 'findOne', { filter: { _id: id }, projection, options });
|
||||
}
|
||||
|
||||
async countDocuments(filter = {}) {
|
||||
const candidates = await this._candidates(filter);
|
||||
return candidates.filter((c) => matches(c.doc, filter)).length;
|
||||
}
|
||||
|
||||
async estimatedDocumentCount() {
|
||||
const candidates = await this._candidates({});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
async distinct(field, filter = {}) {
|
||||
const candidates = await this._candidates(filter);
|
||||
const set = new Set();
|
||||
for (const c of candidates) {
|
||||
if (!matches(c.doc, filter)) {
|
||||
continue;
|
||||
}
|
||||
const v = getPath(c.doc, field);
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((x) => set.add(x));
|
||||
} else if (v !== undefined) {
|
||||
set.add(v);
|
||||
}
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
async exists(filter) {
|
||||
const candidates = await this._candidates(filter);
|
||||
const hit = candidates.find((c) => matches(c.doc, filter));
|
||||
return hit ? { _id: hit.doc._id } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search over Base/SQLite (replaces MeiliSearch).
|
||||
* Case-insensitive substring match across the model's searchable content
|
||||
* fields (title, text, …), scoped by the Meili-style `opts.filter` string
|
||||
* (e.g. `user = "abc"`). Returns MeiliSearch-shaped `{ hits }`.
|
||||
*/
|
||||
async meiliSearch(query, opts = {}) {
|
||||
const terms = (query || '').trim().toLowerCase();
|
||||
if (!terms || !this._searchable.size) {
|
||||
return { hits: [] };
|
||||
}
|
||||
const scope = parseMeiliFilter(opts.filter);
|
||||
const candidates = await this._candidates(scope);
|
||||
const hits = [];
|
||||
for (const c of candidates) {
|
||||
if (!matches(c.doc, scope)) {
|
||||
continue;
|
||||
}
|
||||
for (const field of this._searchable) {
|
||||
const value = getPath(c.doc, field);
|
||||
if (typeof value === 'string' && value.toLowerCase().includes(terms)) {
|
||||
hits.push(applyProjection(this._hydrate({ ...c.doc }), null, this._deselected));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hits };
|
||||
}
|
||||
|
||||
async syncWithMeili() {
|
||||
/* no-op: MeiliSearch sync disabled under Base adapter (Phase 2 FTS). */
|
||||
}
|
||||
|
||||
async bulkWrite(operations = []) {
|
||||
const result = {
|
||||
insertedCount: 0,
|
||||
matchedCount: 0,
|
||||
modifiedCount: 0,
|
||||
deletedCount: 0,
|
||||
upsertedCount: 0,
|
||||
upsertedIds: {},
|
||||
insertedIds: {},
|
||||
};
|
||||
for (const op of operations) {
|
||||
if (op.insertOne) {
|
||||
const doc = await this._create(op.insertOne.document);
|
||||
result.insertedIds[result.insertedCount] = doc._id;
|
||||
result.insertedCount += 1;
|
||||
} else if (op.updateOne) {
|
||||
const { filter, update, upsert, timestamps } = op.updateOne;
|
||||
const r = await this._updateOneInternal(filter, update, { upsert, timestamps });
|
||||
result.matchedCount += r.matched;
|
||||
result.modifiedCount += r.modified;
|
||||
if (r.upserted) {
|
||||
result.upsertedIds[result.upsertedCount] = r.doc._id;
|
||||
result.upsertedCount += 1;
|
||||
}
|
||||
} else if (op.updateMany) {
|
||||
const { filter, update, upsert, timestamps } = op.updateMany;
|
||||
const r = await this.updateMany(filter, update, { upsert, timestamps });
|
||||
result.matchedCount += r.matchedCount;
|
||||
result.modifiedCount += r.modifiedCount;
|
||||
result.upsertedCount += r.upsertedCount || 0;
|
||||
} else if (op.deleteOne) {
|
||||
const r = await this._deleteWhere(op.deleteOne.filter, 1);
|
||||
result.deletedCount += r.deletedCount;
|
||||
} else if (op.deleteMany) {
|
||||
const r = await this._deleteWhere(op.deleteMany.filter, 0);
|
||||
result.deletedCount += r.deletedCount;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Minimal aggregation engine (used by Prompt / agentCategory, not the hot path). */
|
||||
async aggregate(pipeline = []) {
|
||||
const candidates = await this._candidates({});
|
||||
let docs = candidates.map((c) => this._hydrate({ ...c.doc }));
|
||||
for (const stage of pipeline) {
|
||||
const op = Object.keys(stage)[0];
|
||||
const arg = stage[op];
|
||||
switch (op) {
|
||||
case '$match':
|
||||
docs = docs.filter((d) => matches(d, arg));
|
||||
break;
|
||||
case '$sort':
|
||||
docs = sortDocs(docs, arg);
|
||||
break;
|
||||
case '$limit':
|
||||
docs = docs.slice(0, arg);
|
||||
break;
|
||||
case '$skip':
|
||||
docs = docs.slice(arg);
|
||||
break;
|
||||
case '$count':
|
||||
docs = [{ [arg]: docs.length }];
|
||||
break;
|
||||
case '$project':
|
||||
docs = docs.map((d) => applyProjection(d, parseProjection(arg)));
|
||||
break;
|
||||
case '$unwind': {
|
||||
const field = (typeof arg === 'string' ? arg : arg.path).replace(/^\$/, '');
|
||||
const next = [];
|
||||
for (const d of docs) {
|
||||
const v = getPath(d, field);
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) {
|
||||
next.push({ ...d, [field]: item });
|
||||
}
|
||||
} else if (v !== undefined) {
|
||||
next.push(d);
|
||||
}
|
||||
}
|
||||
docs = next;
|
||||
break;
|
||||
}
|
||||
case '$group':
|
||||
docs = groupStage(docs, arg);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`[BaseModel.aggregate] Unsupported stage: ${op}`);
|
||||
}
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
}
|
||||
|
||||
/** $group implementation for the aggregation engine. */
|
||||
function groupStage(docs, spec) {
|
||||
const { _id: idSpec, ...accs } = spec;
|
||||
const groups = new Map();
|
||||
const keyOf = (d) => {
|
||||
if (idSpec === null) {
|
||||
return '__null__';
|
||||
}
|
||||
if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
|
||||
return JSON.stringify(getPath(d, idSpec.slice(1)) ?? null);
|
||||
}
|
||||
return JSON.stringify(idSpec);
|
||||
};
|
||||
const idValue = (d) => {
|
||||
if (idSpec === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
|
||||
return getPath(d, idSpec.slice(1)) ?? null;
|
||||
}
|
||||
return idSpec;
|
||||
};
|
||||
for (const d of docs) {
|
||||
const k = keyOf(d);
|
||||
if (!groups.has(k)) {
|
||||
groups.set(k, { _id: idValue(d), _docs: [] });
|
||||
}
|
||||
groups.get(k)._docs.push(d);
|
||||
}
|
||||
const out = [];
|
||||
for (const g of groups.values()) {
|
||||
const row = { _id: g._id };
|
||||
for (const [field, acc] of Object.entries(accs)) {
|
||||
const accOp = Object.keys(acc)[0];
|
||||
const expr = acc[accOp];
|
||||
const values = g._docs.map((d) =>
|
||||
typeof expr === 'string' && expr.startsWith('$') ? getPath(d, expr.slice(1)) : expr,
|
||||
);
|
||||
switch (accOp) {
|
||||
case '$sum':
|
||||
row[field] = values.reduce((s, v) => s + (Number(v) || (expr === 1 ? 1 : 0)), 0);
|
||||
break;
|
||||
case '$count':
|
||||
row[field] = g._docs.length;
|
||||
break;
|
||||
case '$avg':
|
||||
row[field] = values.reduce((s, v) => s + (Number(v) || 0), 0) / (values.length || 1);
|
||||
break;
|
||||
case '$min':
|
||||
row[field] = values.reduce((m, v) => (m == null || v < m ? v : m), null);
|
||||
break;
|
||||
case '$max':
|
||||
row[field] = values.reduce((m, v) => (m == null || v > m ? v : m), null);
|
||||
break;
|
||||
case '$first':
|
||||
row[field] = values[0];
|
||||
break;
|
||||
case '$last':
|
||||
row[field] = values[values.length - 1];
|
||||
break;
|
||||
case '$push':
|
||||
row[field] = values;
|
||||
break;
|
||||
case '$addToSet':
|
||||
row[field] = [...new Set(values)];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`[BaseModel.aggregate] Unsupported accumulator: ${accOp}`);
|
||||
}
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query — a lazy, chainable, thenable wrapper mirroring the Mongoose Query API
|
||||
* subset in use: select / sort / limit / skip / lean / populate / collation,
|
||||
* plus terminal execution when awaited (or via .then/.exec).
|
||||
*/
|
||||
class Query {
|
||||
constructor(model, op, params) {
|
||||
this.model = model;
|
||||
this.op = op;
|
||||
this.params = params;
|
||||
this._projection = params.projection;
|
||||
this._sort = null;
|
||||
this._limit = null;
|
||||
this._skip = null;
|
||||
this._lean = false;
|
||||
}
|
||||
|
||||
select(projection) {
|
||||
this._projection = projection;
|
||||
return this;
|
||||
}
|
||||
|
||||
sort(spec) {
|
||||
this._sort = spec;
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(n) {
|
||||
this._limit = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
skip(n) {
|
||||
this._skip = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
lean() {
|
||||
this._lean = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
populate() {
|
||||
return this; // refs are stored inline as ids; no server-side population
|
||||
}
|
||||
|
||||
collation() {
|
||||
return this;
|
||||
}
|
||||
|
||||
session() {
|
||||
return this; // Base has no transactions; sessions are inert
|
||||
}
|
||||
|
||||
hint() {
|
||||
return this;
|
||||
}
|
||||
|
||||
read() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Merge an additional filter (Mongoose `Model.find(A).find(B)` / `.deleteMany(B)`). */
|
||||
find(extra) {
|
||||
this.params.filter = mergeFilter(this.params.filter, extra);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `Model.find(A).deleteMany(B)` — delete with merged conditions. */
|
||||
deleteMany(extra) {
|
||||
const filter = mergeFilter(this.params.filter, extra);
|
||||
return this.model._deleteWhere(filter, 0);
|
||||
}
|
||||
|
||||
deleteOne(extra) {
|
||||
const filter = mergeFilter(this.params.filter, extra);
|
||||
return this.model._deleteWhere(filter, 1);
|
||||
}
|
||||
|
||||
countDocuments() {
|
||||
return this.model.countDocuments(this.params.filter);
|
||||
}
|
||||
|
||||
async _run() {
|
||||
const model = this.model;
|
||||
if (this.op === 'findOneAndUpdate') {
|
||||
const r = await model._updateOneInternal(
|
||||
this.params.filter,
|
||||
this.params.update,
|
||||
this.params.options,
|
||||
);
|
||||
// Mongoose returns the pre-update doc when `new` is not truthy (default),
|
||||
// and the post-update doc when `{ new: true }`. Upserts with `new:false`
|
||||
// return null (there was no prior document).
|
||||
const wantNew = !!(this.params.options && this.params.options.new);
|
||||
let doc = wantNew ? r.doc : r.upserted ? null : r.before;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
doc = applyProjection(doc, parseProjection(this._projection), model._deselected);
|
||||
return this._lean ? doc : makeDocument(model, doc);
|
||||
}
|
||||
if (this.op === 'findOneAndDelete') {
|
||||
const candidates = await model._candidates(this.params.filter);
|
||||
const hit = candidates.find((c) => matches(c.doc, this.params.filter));
|
||||
if (!hit) {
|
||||
return null;
|
||||
}
|
||||
await model.store.delete(model.collectionName, hit.baseId);
|
||||
const doc = applyProjection(
|
||||
model._hydrate({ ...hit.doc }),
|
||||
parseProjection(this._projection),
|
||||
model._deselected,
|
||||
);
|
||||
return this._lean ? doc : makeDocument(model, doc);
|
||||
}
|
||||
|
||||
// read path (find / findOne)
|
||||
const candidates = await model._candidates(this.params.filter);
|
||||
let docs = candidates.filter((c) => matches(c.doc, this.params.filter)).map((c) => c.doc);
|
||||
if (this._sort) {
|
||||
docs = sortDocs(docs, this._sort);
|
||||
}
|
||||
if (this._skip) {
|
||||
docs = docs.slice(this._skip);
|
||||
}
|
||||
if (this.op === 'findOne') {
|
||||
const doc = docs[0];
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const out = applyProjection(
|
||||
model._hydrate({ ...doc }),
|
||||
parseProjection(this._projection),
|
||||
model._deselected,
|
||||
);
|
||||
return this._lean ? out : makeDocument(model, out);
|
||||
}
|
||||
if (this._limit != null) {
|
||||
docs = docs.slice(0, this._limit);
|
||||
}
|
||||
const projection = parseProjection(this._projection);
|
||||
const out = docs.map((d) =>
|
||||
applyProjection(model._hydrate({ ...d }), projection, model._deselected),
|
||||
);
|
||||
return this._lean ? out : out.map((d) => makeDocument(model, d));
|
||||
}
|
||||
|
||||
exec() {
|
||||
return this._run();
|
||||
}
|
||||
|
||||
then(onFulfilled, onRejected) {
|
||||
return this._run().then(onFulfilled, onRejected);
|
||||
}
|
||||
|
||||
catch(onRejected) {
|
||||
return this._run().catch(onRejected);
|
||||
}
|
||||
|
||||
finally(onFinally) {
|
||||
return this._run().finally(onFinally);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeFilter(a, b) {
|
||||
if (!b || !Object.keys(b).length) {
|
||||
return a;
|
||||
}
|
||||
if (!a || !Object.keys(a).length) {
|
||||
return b;
|
||||
}
|
||||
return { $and: [a, b] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plain doc as a lightweight "document": all data fields are enumerable
|
||||
* (so spreads, `.map`, JSON all behave), while toObject/save/etc. are hidden.
|
||||
*/
|
||||
function makeDocument(model, doc) {
|
||||
if (doc == null) {
|
||||
return doc;
|
||||
}
|
||||
const defineHidden = (name, value) =>
|
||||
Object.defineProperty(doc, name, { value, enumerable: false, writable: true, configurable: true });
|
||||
|
||||
defineHidden('toObject', function toObject() {
|
||||
const plain = {};
|
||||
for (const k of Object.keys(this)) {
|
||||
plain[k] = this[k];
|
||||
}
|
||||
return plain;
|
||||
});
|
||||
defineHidden('toJSON', doc.toObject);
|
||||
defineHidden('save', async function save() {
|
||||
const candidates = await model._candidates({ _id: this._id });
|
||||
const plain = this.toObject();
|
||||
const hit = candidates.find((c) => c.doc._id === this._id);
|
||||
if (hit) {
|
||||
// Merge over the stored record so a doc loaded with a narrowed projection
|
||||
// (e.g. without select:false secrets) never erases the unloaded fields.
|
||||
await model._replace(hit.baseId, { ...hit.doc, ...plain }, { touch: true });
|
||||
} else {
|
||||
await model._insert(model._newDoc(plain));
|
||||
}
|
||||
return this;
|
||||
});
|
||||
defineHidden('deleteOne', async function deleteOne() {
|
||||
return model._deleteWhere({ _id: this._id }, 1);
|
||||
});
|
||||
if (doc._id !== undefined) {
|
||||
defineHidden('id', String(doc._id));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Static methods exposed on the constructor returned by `mongoose.model()`. */
|
||||
const MODEL_STATICS = [
|
||||
'find',
|
||||
'findOne',
|
||||
'findById',
|
||||
'findOneAndUpdate',
|
||||
'findByIdAndUpdate',
|
||||
'findOneAndDelete',
|
||||
'create',
|
||||
'insertMany',
|
||||
'updateOne',
|
||||
'updateMany',
|
||||
'deleteOne',
|
||||
'deleteMany',
|
||||
'countDocuments',
|
||||
'estimatedDocumentCount',
|
||||
'distinct',
|
||||
'exists',
|
||||
'bulkWrite',
|
||||
'aggregate',
|
||||
'meiliSearch',
|
||||
'syncWithMeili',
|
||||
'ensure',
|
||||
];
|
||||
|
||||
/**
|
||||
* Wrap a BaseModel as a Mongoose-style model constructor:
|
||||
* - `new Model(data)` creates an unsaved document (with `.save()`)
|
||||
* - `Model.find(...)`, `Model.findOneAndUpdate(...)`, … are the statics
|
||||
*/
|
||||
function makeModelCtor(baseModel) {
|
||||
function ModelCtor(data) {
|
||||
return baseModel._newDocument(data);
|
||||
}
|
||||
for (const name of MODEL_STATICS) {
|
||||
ModelCtor[name] = baseModel[name].bind(baseModel);
|
||||
}
|
||||
ModelCtor.modelName = baseModel.modelName;
|
||||
ModelCtor.schema = baseModel.schema;
|
||||
ModelCtor.collection = baseModel.collection;
|
||||
ModelCtor.base = baseModel;
|
||||
return ModelCtor;
|
||||
}
|
||||
|
||||
module.exports = { BaseModel, Query, makeDocument, makeModelCtor };
|
||||
@@ -1,284 +0,0 @@
|
||||
'use strict';
|
||||
/*
|
||||
* BaseModel behaviour over an in-memory store (no Base/Mongo required).
|
||||
* Exercises the exact Mongoose call patterns the chat hot path relies on.
|
||||
*/
|
||||
const { Schema } = require('mongoose');
|
||||
const { BaseModel } = require('./model');
|
||||
|
||||
/** In-memory backend mirroring DocumentStore's interface. */
|
||||
function memStore() {
|
||||
const cols = {};
|
||||
let seq = 0;
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||
return {
|
||||
async ensureCollection(name) {
|
||||
cols[name] = cols[name] || new Map();
|
||||
},
|
||||
async list(name) {
|
||||
cols[name] = cols[name] || new Map();
|
||||
return [...cols[name].entries()].map(([baseId, doc]) => ({ baseId, doc: clone(doc) }));
|
||||
},
|
||||
async create(name, record) {
|
||||
cols[name] = cols[name] || new Map();
|
||||
const baseId = 'b' + ++seq;
|
||||
cols[name].set(baseId, clone(record.data));
|
||||
return { baseId, doc: clone(record.data) };
|
||||
},
|
||||
async update(name, baseId, record) {
|
||||
cols[name].set(baseId, clone(record.data));
|
||||
return { baseId, doc: clone(record.data) };
|
||||
},
|
||||
async delete(name, baseId) {
|
||||
cols[name].delete(baseId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const convoSchema = new Schema(
|
||||
{
|
||||
conversationId: { type: String, unique: true, required: true, index: true },
|
||||
title: { type: String, default: 'New Chat', meiliIndex: true },
|
||||
user: { type: String, index: true },
|
||||
tags: { type: [String], default: [] },
|
||||
expiredAt: { type: Date },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
const messageSchema = new Schema(
|
||||
{
|
||||
messageId: { type: String, unique: true, required: true, index: true },
|
||||
conversationId: { type: String, index: true, required: true },
|
||||
user: { type: String, index: true, required: true, default: null },
|
||||
text: { type: String, meiliIndex: true },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
const userSchema = new Schema(
|
||||
{
|
||||
email: { type: String, required: true, unique: true },
|
||||
role: { type: String, default: 'USER' },
|
||||
password: { type: String, select: false },
|
||||
totpSecret: { type: String, select: false },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
const balanceSchema = new Schema({
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User', index: true, required: true },
|
||||
tokenCredits: { type: Number, default: 0 },
|
||||
expiresAt: { type: Date, default: null },
|
||||
lastRefill: { type: Date, default: Date.now },
|
||||
});
|
||||
const sessionSchema = new Schema({
|
||||
refreshTokenHash: { type: String, index: true },
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
|
||||
expiration: { type: Date, required: true, index: true },
|
||||
});
|
||||
|
||||
let store, Conversation, Message, User, Balance, Session;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = memStore();
|
||||
Conversation = new BaseModel('Conversation', convoSchema, store);
|
||||
Message = new BaseModel('Message', messageSchema, store);
|
||||
User = new BaseModel('User', userSchema, store);
|
||||
Balance = new BaseModel('Balance', balanceSchema, store);
|
||||
Session = new BaseModel('Session', sessionSchema, store);
|
||||
for (const m of [Conversation, Message, User, Balance, Session]) await m.ensure();
|
||||
});
|
||||
|
||||
describe('schema introspection', () => {
|
||||
test('promotes indexed/unique/timestamp fields and _id', () => {
|
||||
expect(Conversation._promotedNames.sort()).toEqual(
|
||||
['_id', 'conversationId', 'createdAt', 'updatedAt', 'user'].sort(),
|
||||
);
|
||||
});
|
||||
test('timestamps detected only when configured', () => {
|
||||
expect(Conversation._timestamps.createdAt).toBe('createdAt');
|
||||
expect(Balance._timestamps).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create + defaults + ids', () => {
|
||||
test('User.create applies defaults, ObjectId _id, Date timestamps', async () => {
|
||||
const u = await User.create({ email: 'z@zoo.ngo', password: 'hashed' });
|
||||
expect(u._id).toMatch(/^[0-9a-f]{24}$/);
|
||||
expect(u.role).toBe('USER');
|
||||
expect(u.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login-path reads', () => {
|
||||
test('findOne + select projection + lean', async () => {
|
||||
await User.create({ email: 'z@zoo.ngo', password: 'hashed' });
|
||||
const found = await User.findOne({ email: 'z@zoo.ngo' }).select('email password').lean();
|
||||
expect(found.password).toBe('hashed');
|
||||
expect(found.role).toBeUndefined();
|
||||
expect(found._id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('select:false secrets (password/totpSecret) hidden by default', async () => {
|
||||
await User.create({ email: 'z@zoo.ngo', password: 'hashed', totpSecret: 'seed' });
|
||||
const def = await User.findOne({ email: 'z@zoo.ngo' }).lean();
|
||||
expect(def.password).toBeUndefined();
|
||||
expect(def.totpSecret).toBeUndefined();
|
||||
expect(def.email).toBe('z@zoo.ngo');
|
||||
// login path explicitly requests +password
|
||||
const withPw = await User.findOne({ email: 'z@zoo.ngo' }, '+password').lean();
|
||||
expect(withPw.password).toBe('hashed');
|
||||
expect(withPw.totpSecret).toBeUndefined(); // still hidden
|
||||
});
|
||||
});
|
||||
|
||||
describe('date-equality reads (H1 — no broken date pushdown)', () => {
|
||||
test('find by an exact Date value returns the record', async () => {
|
||||
const exp = new Date('2026-07-04T12:34:56.789Z');
|
||||
await Conversation.findOneAndUpdate(
|
||||
{ conversationId: 'd1', user: '507f1f77bcf86cd799439011' },
|
||||
{ $set: { expiredAt: exp } },
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
const byDate = await Conversation.find({ expiredAt: exp }).lean();
|
||||
expect(byDate.map((c) => c.conversationId)).toEqual(['d1']);
|
||||
const gt = await Conversation.find({ expiredAt: { $gt: new Date('2026-07-01T00:00:00Z') } }).lean();
|
||||
expect(gt.some((c) => c.conversationId === 'd1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save() preserves select:false fields after a projected load (M2)', () => {
|
||||
test('mutate a default-loaded user + save keeps password/totpSecret', async () => {
|
||||
await User.create({ email: 'z@zoo.ngo', password: 'hashed', totpSecret: 'seed' });
|
||||
const u = await User.findOne({ email: 'z@zoo.ngo' }); // default view: secrets hidden
|
||||
expect(u.password).toBeUndefined();
|
||||
u.name = 'Renamed';
|
||||
await u.save();
|
||||
const reloaded = await User.findOne({ email: 'z@zoo.ngo' }, '+password').lean();
|
||||
expect(reloaded.password).toBe('hashed'); // not erased by save()
|
||||
expect(reloaded.name).toBe('Renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date hydration (non-timestamp Date fields)', () => {
|
||||
test('session.expiration and balance dates hydrate to Date', async () => {
|
||||
const exp = new Date(Date.now() + 3600_000);
|
||||
await Session.create({ user: '507f1f77bcf86cd799439011', expiration: exp });
|
||||
const s = await Session.findOne({ user: '507f1f77bcf86cd799439011' }).lean();
|
||||
expect(s.expiration).toBeInstanceOf(Date);
|
||||
expect(typeof s.expiration.getTime()).toBe('number'); // would throw on a string
|
||||
const bal = await Balance.findOneAndUpdate(
|
||||
{ user: '507f1f77bcf86cd799439011' },
|
||||
{ $inc: { tokenCredits: 1 } },
|
||||
{ upsert: true, new: true },
|
||||
).lean();
|
||||
expect(bal.lastRefill).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balance upsert', () => {
|
||||
test('$inc + $set upsert then accumulate', async () => {
|
||||
const uid = '507f1f77bcf86cd799439011';
|
||||
const bal = await Balance.findOneAndUpdate(
|
||||
{ user: uid },
|
||||
{ $inc: { tokenCredits: 1000 } },
|
||||
{ upsert: true, new: true },
|
||||
).lean();
|
||||
expect(bal.tokenCredits).toBe(1000);
|
||||
expect(String(bal.user)).toBe(uid);
|
||||
const bal2 = await Balance.findOneAndUpdate(
|
||||
{ user: uid },
|
||||
{ $inc: { tokenCredits: 500 } },
|
||||
{ new: true },
|
||||
).lean();
|
||||
expect(bal2.tokenCredits).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat hot path', () => {
|
||||
const uid = '507f1f77bcf86cd799439011';
|
||||
|
||||
test('saveConvo upsert + toObject', async () => {
|
||||
const c = await Conversation.findOneAndUpdate(
|
||||
{ conversationId: 'c1', user: uid },
|
||||
{ $set: { title: 'Hello', endpoint: 'openAI' } },
|
||||
{ new: true, upsert: true },
|
||||
);
|
||||
expect(c.conversationId).toBe('c1');
|
||||
expect(c.toObject().title).toBe('Hello');
|
||||
});
|
||||
|
||||
test('saveMessage upsert + partial merge, sorted read', async () => {
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await Message.findOneAndUpdate(
|
||||
{ messageId: 'm' + i, user: uid },
|
||||
{ conversationId: 'c1', text: 'msg' + i, createdAt: new Date(base + i * 1000) },
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
}
|
||||
await Message.findOneAndUpdate({ messageId: 'm1', user: uid }, { text: 'edited' }, { new: true });
|
||||
const msgs = await Message.find({ conversationId: 'c1', user: uid }).sort({ createdAt: 1 }).lean();
|
||||
expect(msgs.map((m) => m.messageId)).toEqual(['m0', 'm1', 'm2']);
|
||||
expect(msgs[1].text).toBe('edited');
|
||||
expect(msgs[1].conversationId).toBe('c1'); // merge preserved
|
||||
});
|
||||
|
||||
test('find(A).deleteMany(B) merges conditions', async () => {
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await Message.create({ messageId: 'm' + i, conversationId: 'c1', user: uid, createdAt: new Date(base + i * 1000) });
|
||||
}
|
||||
const res = await Message.find({ conversationId: 'c1', user: uid }).deleteMany({
|
||||
createdAt: { $gt: new Date(base + 500) },
|
||||
});
|
||||
expect(res.deletedCount).toBe(2);
|
||||
const left = await Message.find({ conversationId: 'c1' }).lean();
|
||||
expect(left).toHaveLength(1);
|
||||
expect(left[0].messageId).toBe('m0');
|
||||
});
|
||||
|
||||
test('deleteMany $or/$exists', async () => {
|
||||
await Message.create({ messageId: 'mx', conversationId: '', user: uid });
|
||||
const res = await Message.deleteMany({
|
||||
$or: [{ conversationId: '' }, { conversationId: { $exists: false } }],
|
||||
});
|
||||
expect(res.deletedCount).toBe(1);
|
||||
});
|
||||
|
||||
test('bulkWrite updateOne upsert', async () => {
|
||||
const res = await Conversation.bulkWrite([
|
||||
{ updateOne: { filter: { conversationId: 'c1', user: uid }, update: { title: 'A' }, upsert: true } },
|
||||
{ updateOne: { filter: { conversationId: 'c2', user: uid }, update: { title: 'B' }, upsert: true } },
|
||||
]);
|
||||
expect(res.upsertedCount).toBe(2);
|
||||
expect(await Conversation.countDocuments({ conversationId: { $in: ['c1', 'c2'] } })).toBe(2);
|
||||
});
|
||||
|
||||
test('meiliSearch (Base/SQLite FTS) — title + text, user-scoped', async () => {
|
||||
const other = '507f1f77bcf86cd799439099';
|
||||
await Conversation.findOneAndUpdate({ conversationId: 'c1', user: uid }, { $set: { title: 'Quantum physics notes' } }, { upsert: true, new: true });
|
||||
await Conversation.findOneAndUpdate({ conversationId: 'c2', user: uid }, { $set: { title: 'Grocery list' } }, { upsert: true, new: true });
|
||||
await Conversation.findOneAndUpdate({ conversationId: 'c3', user: other }, { $set: { title: 'Quantum leaps' } }, { upsert: true, new: true });
|
||||
|
||||
const conv = await Conversation.meiliSearch('quantum', { filter: `user = "${uid}"` });
|
||||
expect(conv.hits.map((h) => h.conversationId)).toEqual(['c1']); // case-insensitive, user-scoped
|
||||
|
||||
await Message.create({ messageId: 'm1', conversationId: 'c1', user: uid, text: 'the WAVEFUNCTION collapses' });
|
||||
await Message.create({ messageId: 'm2', conversationId: 'c1', user: uid, text: 'unrelated chatter' });
|
||||
const msg = await Message.meiliSearch('wavefunction', { filter: `user = "${uid}"` });
|
||||
expect(msg.hits.map((h) => h.messageId)).toEqual(['m1']);
|
||||
|
||||
expect((await Conversation.meiliSearch('', { filter: `user = "${uid}"` })).hits).toEqual([]);
|
||||
});
|
||||
|
||||
test('findOneAndUpdate new:false returns pre-update doc', async () => {
|
||||
await Conversation.findOneAndUpdate({ conversationId: 'c1', user: uid }, { title: 'first' }, { upsert: true, new: true });
|
||||
const before = await Conversation.findOneAndUpdate(
|
||||
{ conversationId: 'c1', user: uid },
|
||||
{ title: 'second' },
|
||||
{ new: false },
|
||||
);
|
||||
expect(before.title).toBe('first');
|
||||
const now = await Conversation.findOne({ conversationId: 'c1' }).lean();
|
||||
expect(now.title).toBe('second');
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A self-contained, `mongoose`-shaped facade backed by Hanzo Base.
|
||||
*
|
||||
* `@librechat/data-schemas` builds its schemas with real `mongoose` (a pure
|
||||
* schema DSL inside that package) and then asks a mongoose instance to turn
|
||||
* those schemas into models (`createModels`/`createMethods`). We hand it THIS
|
||||
* facade instead: it registers every model as a BaseModel persisting to Base —
|
||||
* never MongoDB — and provides just the mongoose surface the data layer uses at
|
||||
* runtime (`model`, `models`, `Types.ObjectId`, `Schema.Types`, a no-op session
|
||||
* / connection). It carries **no `mongoose` runtime dependency**; schema objects
|
||||
* are passed in already-built, so this module never imports the driver.
|
||||
*
|
||||
* Result: one adapter, and the entire data-schemas model+method surface
|
||||
* (User, Session, Token, Role, Balance, Conversation, Message, Agent, …) runs
|
||||
* on Base with zero per-model porting.
|
||||
*/
|
||||
|
||||
const { BaseModel, makeModelCtor } = require('./model');
|
||||
const { ObjectId, isValidObjectId } = require('./objectId');
|
||||
const store = require('./store');
|
||||
|
||||
/** Shared model registry (mirrors `mongoose.models`) — values are model ctors. */
|
||||
const models = {};
|
||||
/** BaseModel instances keyed by name (for connectDb collection provisioning). */
|
||||
const baseModels = {};
|
||||
|
||||
function model(name, schema) {
|
||||
if (models[name]) {
|
||||
return models[name];
|
||||
}
|
||||
if (!schema) {
|
||||
return undefined;
|
||||
}
|
||||
const base = new BaseModel(name, schema, store);
|
||||
const ctor = makeModelCtor(base);
|
||||
baseModels[name] = base;
|
||||
models[name] = ctor;
|
||||
return ctor;
|
||||
}
|
||||
|
||||
/** No-op session: Base has no multi-document transactions (see adapter notes). */
|
||||
function makeSession() {
|
||||
return {
|
||||
startTransaction() {},
|
||||
async commitTransaction() {},
|
||||
async abortTransaction() {},
|
||||
async endSession() {},
|
||||
async withTransaction(fn) {
|
||||
return fn();
|
||||
},
|
||||
inTransaction() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Connection stub — the real Base connection lives in ./index.js connectDb(). */
|
||||
const connection = {
|
||||
readyState: 1,
|
||||
_readyState: 1,
|
||||
on() {},
|
||||
once() {},
|
||||
model,
|
||||
models,
|
||||
collections: {},
|
||||
async dropDatabase() {},
|
||||
async close() {},
|
||||
db: {
|
||||
async dropDatabase() {},
|
||||
collection() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Minimal Schema.Types surface for the few `mongoose.Schema.Types.*` runtime uses. */
|
||||
class Mixed {}
|
||||
const SchemaTypes = {
|
||||
ObjectId,
|
||||
Mixed,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Date,
|
||||
Array,
|
||||
Buffer,
|
||||
Map,
|
||||
};
|
||||
|
||||
const facade = {
|
||||
__isBaseFacade: true,
|
||||
model,
|
||||
models,
|
||||
connection,
|
||||
connections: [connection],
|
||||
Types: { ObjectId, Decimal128: Number, Mixed },
|
||||
Schema: { Types: SchemaTypes },
|
||||
Model: BaseModel,
|
||||
isValidObjectId,
|
||||
async connect() {
|
||||
return facade;
|
||||
},
|
||||
createConnection() {
|
||||
return connection;
|
||||
},
|
||||
async disconnect() {},
|
||||
startSession() {
|
||||
return makeSession();
|
||||
},
|
||||
set() {
|
||||
return facade;
|
||||
},
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
/** Access the underlying BaseModel instances (used by connectDb). */
|
||||
baseModels,
|
||||
};
|
||||
|
||||
module.exports = facade;
|
||||
@@ -1,93 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Mongo-compatible ObjectId generation.
|
||||
*
|
||||
* Hanzo Base stores documents keyed by its own 15-char id, but LibreChat code
|
||||
* treats `_id` as a 24-hex Mongo ObjectId string (equality, `.toString()`,
|
||||
* refs). We generate real ObjectId-shaped values so that behaviour is
|
||||
* preserved without a live MongoDB.
|
||||
*
|
||||
* Layout (12 bytes -> 24 hex): 4-byte timestamp | 5-byte process random | 3-byte counter.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PROCESS_RANDOM = crypto.randomBytes(5);
|
||||
let COUNTER = crypto.randomBytes(3).readUIntBE(0, 3);
|
||||
|
||||
/** @returns {string} 24-char lowercase hex ObjectId string. */
|
||||
function generateObjectId() {
|
||||
const buf = Buffer.allocUnsafe(12);
|
||||
buf.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
||||
PROCESS_RANDOM.copy(buf, 4);
|
||||
COUNTER = (COUNTER + 1) % 0xffffff;
|
||||
buf.writeUIntBE(COUNTER, 9, 3);
|
||||
return buf.toString('hex');
|
||||
}
|
||||
|
||||
const HEX_24 = /^[0-9a-fA-F]{24}$/;
|
||||
|
||||
/** @param {unknown} value @returns {boolean} */
|
||||
function isValidObjectId(value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return HEX_24.test(value);
|
||||
}
|
||||
if (typeof value === 'object' && typeof value.toString === 'function') {
|
||||
return HEX_24.test(value.toString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal, dependency-free ObjectId compatible with the surface the chat code
|
||||
* uses from `mongoose.Types.ObjectId`: `new ObjectId()`, `new ObjectId(hex)`,
|
||||
* `.isValid()`, `.createFromHexString()`, and instances that stringify / JSON /
|
||||
* compare as their 24-hex value. Lets us drop the `mongoose` runtime dependency.
|
||||
*/
|
||||
class ObjectId {
|
||||
constructor(id) {
|
||||
if (id == null) {
|
||||
this._id = generateObjectId();
|
||||
} else if (typeof id === 'string') {
|
||||
this._id = id;
|
||||
} else if (id instanceof ObjectId) {
|
||||
this._id = id._id;
|
||||
} else if (typeof id.toHexString === 'function') {
|
||||
this._id = id.toHexString();
|
||||
} else {
|
||||
this._id = String(id);
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this._id;
|
||||
}
|
||||
toHexString() {
|
||||
return this._id;
|
||||
}
|
||||
toJSON() {
|
||||
return this._id;
|
||||
}
|
||||
valueOf() {
|
||||
return this._id;
|
||||
}
|
||||
equals(other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
return String(typeof other.toHexString === 'function' ? other.toHexString() : other) === this._id;
|
||||
}
|
||||
|
||||
static isValid(value) {
|
||||
return isValidObjectId(value);
|
||||
}
|
||||
static createFromHexString(hex) {
|
||||
return new ObjectId(hex);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateObjectId, isValidObjectId, HEX_24, ObjectId };
|
||||
@@ -1,537 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A small, correct MongoDB query/update engine used by the Hanzo Base adapter.
|
||||
*
|
||||
* The Base document store keeps each document as a JSON blob; filtering,
|
||||
* update-operator application, projection and sorting all run here in JS.
|
||||
* This is the correctness backstop for the whole adapter: any Mongo filter the
|
||||
* LibreChat data layer produces is evaluated here, so translation to Base's
|
||||
* filter DSL (see store.js) is only ever a best-effort pushdown optimisation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path segments that must never be traversed or written — guards against
|
||||
* prototype pollution via attacker-controlled dotted keys (e.g. an update or
|
||||
* imported document containing `__proto__.x`). Mongoose sanitized these; the
|
||||
* adapter must too.
|
||||
*/
|
||||
const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/** Resolve a possibly-dotted path against a document. */
|
||||
function getPath(doc, path) {
|
||||
if (doc == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!path.includes('.')) {
|
||||
return FORBIDDEN_SEGMENTS.has(path) ? undefined : doc[path];
|
||||
}
|
||||
let cur = doc;
|
||||
for (const part of path.split('.')) {
|
||||
if (cur == null || FORBIDDEN_SEGMENTS.has(part)) {
|
||||
return undefined;
|
||||
}
|
||||
cur = cur[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Set a possibly-dotted path on a document (mutates). No-op on forbidden segments. */
|
||||
function setPath(doc, path, value) {
|
||||
if (!path.includes('.')) {
|
||||
if (!FORBIDDEN_SEGMENTS.has(path)) {
|
||||
doc[path] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const parts = path.split('.');
|
||||
if (parts.some((p) => FORBIDDEN_SEGMENTS.has(p))) {
|
||||
return;
|
||||
}
|
||||
let cur = doc;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
/** Delete a possibly-dotted path (mutates). No-op on forbidden segments. */
|
||||
function unsetPath(doc, path) {
|
||||
if (!path.includes('.')) {
|
||||
if (!FORBIDDEN_SEGMENTS.has(path)) {
|
||||
delete doc[path];
|
||||
}
|
||||
return;
|
||||
}
|
||||
const parts = path.split('.');
|
||||
if (parts.some((p) => FORBIDDEN_SEGMENTS.has(p))) {
|
||||
return;
|
||||
}
|
||||
let cur = doc;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] == null) {
|
||||
return;
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
delete cur[parts[parts.length - 1]];
|
||||
}
|
||||
|
||||
/** Coerce ObjectId-like objects (Mongo ObjectId, wrappers) to their hex string. */
|
||||
function idString(value) {
|
||||
if (value && typeof value === 'object' && typeof value.toHexString === 'function') {
|
||||
return value.toHexString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Coerce a value to a comparable primitive (Date -> ms, ObjectId -> hex). */
|
||||
function comparable(value) {
|
||||
value = idString(value);
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// ISO date strings sort correctly lexicographically, but to compare against
|
||||
// Date values we normalise anything that parses as a date to ms.
|
||||
const t = Date.parse(value);
|
||||
if (!Number.isNaN(t) && /\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Deep-ish equality sufficient for query matching (primitives, dates, arrays, plain objects). */
|
||||
function valueEquals(a, b) {
|
||||
a = idString(a);
|
||||
b = idString(b);
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a instanceof Date || b instanceof Date) {
|
||||
return comparable(a) === comparable(b);
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return a == null && b == null;
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((x, i) => valueEquals(x, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
const ca = comparable(a);
|
||||
const cb = comparable(b);
|
||||
if (ca == null && cb == null) {
|
||||
return 0;
|
||||
}
|
||||
if (ca == null) {
|
||||
return -1;
|
||||
}
|
||||
if (cb == null) {
|
||||
return 1;
|
||||
}
|
||||
if (ca < cb) {
|
||||
return -1;
|
||||
}
|
||||
if (ca > cb) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function toRegExp(spec, options) {
|
||||
if (spec instanceof RegExp) {
|
||||
return spec;
|
||||
}
|
||||
return new RegExp(spec, options || '');
|
||||
}
|
||||
|
||||
/** Evaluate a single field's operator expression against a document value. */
|
||||
function matchOperators(fieldValue, expr) {
|
||||
// Array-aware equality helper: a field matches a scalar if it equals it, or
|
||||
// (when the field is an array) if the array contains it — Mongo semantics.
|
||||
const eq = (target) => {
|
||||
if (Array.isArray(fieldValue) && !Array.isArray(target)) {
|
||||
return fieldValue.some((v) => valueEquals(v, target));
|
||||
}
|
||||
return valueEquals(fieldValue, target);
|
||||
};
|
||||
|
||||
if (expr instanceof RegExp) {
|
||||
return typeof fieldValue === 'string' && expr.test(fieldValue);
|
||||
}
|
||||
if (expr === null || typeof expr !== 'object' || expr instanceof Date || Array.isArray(expr)) {
|
||||
return eq(expr);
|
||||
}
|
||||
|
||||
const keys = Object.keys(expr);
|
||||
const isOperatorExpr = keys.some((k) => k.startsWith('$'));
|
||||
if (!isOperatorExpr) {
|
||||
return eq(expr);
|
||||
}
|
||||
|
||||
for (const op of keys) {
|
||||
const operand = expr[op];
|
||||
switch (op) {
|
||||
case '$eq':
|
||||
if (!eq(operand)) return false;
|
||||
break;
|
||||
case '$ne':
|
||||
if (eq(operand)) return false;
|
||||
break;
|
||||
case '$in': {
|
||||
const arr = operand || [];
|
||||
const hit = Array.isArray(fieldValue)
|
||||
? fieldValue.some((v) => arr.some((o) => valueEquals(v, o)))
|
||||
: arr.some((o) => valueEquals(fieldValue, o));
|
||||
if (!hit) return false;
|
||||
break;
|
||||
}
|
||||
case '$nin': {
|
||||
const arr = operand || [];
|
||||
const hit = Array.isArray(fieldValue)
|
||||
? fieldValue.some((v) => arr.some((o) => valueEquals(v, o)))
|
||||
: arr.some((o) => valueEquals(fieldValue, o));
|
||||
if (hit) return false;
|
||||
break;
|
||||
}
|
||||
case '$gt':
|
||||
if (fieldValue === undefined || compare(fieldValue, operand) <= 0) return false;
|
||||
break;
|
||||
case '$gte':
|
||||
if (fieldValue === undefined || compare(fieldValue, operand) < 0) return false;
|
||||
break;
|
||||
case '$lt':
|
||||
if (fieldValue === undefined || compare(fieldValue, operand) >= 0) return false;
|
||||
break;
|
||||
case '$lte':
|
||||
if (fieldValue === undefined || compare(fieldValue, operand) > 0) return false;
|
||||
break;
|
||||
case '$exists':
|
||||
if ((fieldValue !== undefined) !== !!operand) return false;
|
||||
break;
|
||||
case '$regex': {
|
||||
const re = toRegExp(operand, expr.$options);
|
||||
if (typeof fieldValue !== 'string' || !re.test(fieldValue)) return false;
|
||||
break;
|
||||
}
|
||||
case '$options':
|
||||
break; // handled with $regex
|
||||
case '$not':
|
||||
if (matchOperators(fieldValue, operand)) return false;
|
||||
break;
|
||||
case '$size':
|
||||
if (!Array.isArray(fieldValue) || fieldValue.length !== operand) return false;
|
||||
break;
|
||||
case '$all': {
|
||||
if (!Array.isArray(fieldValue)) return false;
|
||||
const ok = (operand || []).every((o) => fieldValue.some((v) => valueEquals(v, o)));
|
||||
if (!ok) return false;
|
||||
break;
|
||||
}
|
||||
case '$elemMatch': {
|
||||
if (!Array.isArray(fieldValue)) return false;
|
||||
if (!fieldValue.some((v) => matches(v, operand))) return false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown operator: treat the whole expression as an equality target.
|
||||
return eq(expr);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `doc` match Mongo filter `query`?
|
||||
* @param {Record<string, unknown>} doc
|
||||
* @param {Record<string, unknown>} query
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matches(doc, query) {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return true;
|
||||
}
|
||||
for (const key of Object.keys(query)) {
|
||||
const val = query[key];
|
||||
if (key === '$and') {
|
||||
if (!(val || []).every((sub) => matches(doc, sub))) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === '$or') {
|
||||
if (!(val || []).some((sub) => matches(doc, sub))) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === '$nor') {
|
||||
if ((val || []).some((sub) => matches(doc, sub))) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === '$not') {
|
||||
if (matches(doc, val)) return false;
|
||||
continue;
|
||||
}
|
||||
if (key.startsWith('$')) {
|
||||
continue; // unsupported top-level operator -> ignore (permissive)
|
||||
}
|
||||
if (!matchOperators(getPath(doc, key), val)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const UPDATE_OPERATORS = new Set([
|
||||
'$set',
|
||||
'$unset',
|
||||
'$inc',
|
||||
'$push',
|
||||
'$addToSet',
|
||||
'$pull',
|
||||
'$setOnInsert',
|
||||
'$min',
|
||||
'$max',
|
||||
'$mul',
|
||||
'$rename',
|
||||
]);
|
||||
|
||||
/** Split a Mongoose-style update into operator groups, wrapping bare fields as $set. */
|
||||
function normalizeUpdate(update) {
|
||||
if (!update || typeof update !== 'object') {
|
||||
return { $set: {} };
|
||||
}
|
||||
const keys = Object.keys(update);
|
||||
const hasOperator = keys.some((k) => UPDATE_OPERATORS.has(k));
|
||||
if (!hasOperator) {
|
||||
return { $set: { ...update } };
|
||||
}
|
||||
const out = {};
|
||||
const bareSet = {};
|
||||
for (const k of keys) {
|
||||
if (UPDATE_OPERATORS.has(k)) {
|
||||
out[k] = update[k];
|
||||
} else {
|
||||
bareSet[k] = update[k];
|
||||
}
|
||||
}
|
||||
if (Object.keys(bareSet).length) {
|
||||
out.$set = { ...bareSet, ...(out.$set || {}) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a normalized/raw update to a document in place and return it.
|
||||
* @param {Record<string, unknown>} doc - target document (mutated)
|
||||
* @param {Record<string, unknown>} update
|
||||
* @param {{ isInsert?: boolean }} [opts]
|
||||
*/
|
||||
function applyUpdate(doc, update, opts = {}) {
|
||||
const ops = normalizeUpdate(update);
|
||||
|
||||
if (ops.$set) {
|
||||
for (const [k, v] of Object.entries(ops.$set)) {
|
||||
setPath(doc, k, v);
|
||||
}
|
||||
}
|
||||
if (ops.$setOnInsert && opts.isInsert) {
|
||||
for (const [k, v] of Object.entries(ops.$setOnInsert)) {
|
||||
setPath(doc, k, v);
|
||||
}
|
||||
}
|
||||
if (ops.$unset) {
|
||||
for (const k of Object.keys(ops.$unset)) {
|
||||
unsetPath(doc, k);
|
||||
}
|
||||
}
|
||||
if (ops.$inc) {
|
||||
for (const [k, v] of Object.entries(ops.$inc)) {
|
||||
setPath(doc, k, (Number(getPath(doc, k)) || 0) + Number(v));
|
||||
}
|
||||
}
|
||||
if (ops.$mul) {
|
||||
for (const [k, v] of Object.entries(ops.$mul)) {
|
||||
setPath(doc, k, (Number(getPath(doc, k)) || 0) * Number(v));
|
||||
}
|
||||
}
|
||||
if (ops.$min) {
|
||||
for (const [k, v] of Object.entries(ops.$min)) {
|
||||
const cur = getPath(doc, k);
|
||||
if (cur === undefined || compare(v, cur) < 0) setPath(doc, k, v);
|
||||
}
|
||||
}
|
||||
if (ops.$max) {
|
||||
for (const [k, v] of Object.entries(ops.$max)) {
|
||||
const cur = getPath(doc, k);
|
||||
if (cur === undefined || compare(v, cur) > 0) setPath(doc, k, v);
|
||||
}
|
||||
}
|
||||
if (ops.$push) {
|
||||
for (const [k, v] of Object.entries(ops.$push)) {
|
||||
const arr = Array.isArray(getPath(doc, k)) ? getPath(doc, k) : [];
|
||||
if (v && typeof v === 'object' && Array.isArray(v.$each)) {
|
||||
arr.push(...v.$each);
|
||||
} else {
|
||||
arr.push(v);
|
||||
}
|
||||
setPath(doc, k, arr);
|
||||
}
|
||||
}
|
||||
if (ops.$addToSet) {
|
||||
for (const [k, v] of Object.entries(ops.$addToSet)) {
|
||||
const arr = Array.isArray(getPath(doc, k)) ? getPath(doc, k) : [];
|
||||
const items = v && typeof v === 'object' && Array.isArray(v.$each) ? v.$each : [v];
|
||||
for (const item of items) {
|
||||
if (!arr.some((x) => valueEquals(x, item))) arr.push(item);
|
||||
}
|
||||
setPath(doc, k, arr);
|
||||
}
|
||||
}
|
||||
if (ops.$pull) {
|
||||
for (const [k, cond] of Object.entries(ops.$pull)) {
|
||||
const arr = getPath(doc, k);
|
||||
if (Array.isArray(arr)) {
|
||||
setPath(
|
||||
doc,
|
||||
k,
|
||||
arr.filter((x) =>
|
||||
cond && typeof cond === 'object' && !Array.isArray(cond)
|
||||
? !matchOperators(x, cond)
|
||||
: !valueEquals(x, cond),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ops.$rename) {
|
||||
for (const [from, to] of Object.entries(ops.$rename)) {
|
||||
const v = getPath(doc, from);
|
||||
unsetPath(doc, from);
|
||||
setPath(doc, to, v);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a projection spec (space string or object) into
|
||||
* { plain:Set, plus:Set, exclude:Set } or null for no projection.
|
||||
* - `field` -> plain inclusion
|
||||
* - `+field` -> force-include a `select:false` field on top of the default set
|
||||
* - `-field` -> exclusion
|
||||
*/
|
||||
function parseProjection(spec) {
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
const plain = new Set();
|
||||
const plus = new Set();
|
||||
const exclude = new Set();
|
||||
if (typeof spec === 'string') {
|
||||
for (const token of spec.split(/\s+/).filter(Boolean)) {
|
||||
if (token.startsWith('-')) {
|
||||
exclude.add(token.slice(1));
|
||||
} else if (token.startsWith('+')) {
|
||||
plus.add(token.slice(1));
|
||||
} else {
|
||||
plain.add(token);
|
||||
}
|
||||
}
|
||||
} else if (typeof spec === 'object') {
|
||||
for (const [k, v] of Object.entries(spec)) {
|
||||
if (v) {
|
||||
plain.add(k);
|
||||
} else {
|
||||
exclude.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!plain.size && !plus.size && !exclude.size) {
|
||||
return null;
|
||||
}
|
||||
return { plain, plus, exclude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed projection with Mongoose `select:false` semantics, returning a
|
||||
* new object. `deselected` are fields excluded from reads by default (secrets).
|
||||
*/
|
||||
function applyProjection(doc, projection, deselected) {
|
||||
const hidden = deselected || EMPTY_SET;
|
||||
// No projection: default view = everything minus deselected (secrets).
|
||||
if (!projection) {
|
||||
if (!hidden.size) {
|
||||
return doc;
|
||||
}
|
||||
const out = { ...doc };
|
||||
for (const f of hidden) {
|
||||
unsetPath(out, f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Inclusion mode: return only the named fields (+plus), _id unless -_id.
|
||||
if (projection.plain.size) {
|
||||
const fields = new Set([...projection.plain, ...projection.plus]);
|
||||
if (!projection.exclude.has('_id')) {
|
||||
fields.add('_id');
|
||||
}
|
||||
const out = {};
|
||||
for (const f of fields) {
|
||||
const v = getPath(doc, f);
|
||||
if (v !== undefined) {
|
||||
setPath(out, f, v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Default + adjustments: base minus deselected (unless +included) minus -excluded.
|
||||
const out = { ...doc };
|
||||
for (const f of hidden) {
|
||||
if (!projection.plus.has(f)) {
|
||||
unsetPath(out, f);
|
||||
}
|
||||
}
|
||||
for (const f of projection.exclude) {
|
||||
unsetPath(out, f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EMPTY_SET = new Set();
|
||||
|
||||
/** Sort documents by a Mongo sort spec ({ field: 1|-1 }). Returns a new array. */
|
||||
function sortDocs(docs, sortSpec) {
|
||||
if (!sortSpec || !Object.keys(sortSpec).length) {
|
||||
return docs;
|
||||
}
|
||||
const keys = Object.entries(sortSpec).map(([k, v]) => [k, v === -1 || v === 'desc' ? -1 : 1]);
|
||||
return [...docs].sort((a, b) => {
|
||||
for (const [k, dir] of keys) {
|
||||
const c = compare(getPath(a, k), getPath(b, k));
|
||||
if (c !== 0) {
|
||||
return c * dir;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
matches,
|
||||
applyUpdate,
|
||||
normalizeUpdate,
|
||||
parseProjection,
|
||||
applyProjection,
|
||||
sortDocs,
|
||||
getPath,
|
||||
setPath,
|
||||
unsetPath,
|
||||
valueEquals,
|
||||
compare,
|
||||
idString,
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
'use strict';
|
||||
const { matches, applyUpdate, parseProjection, applyProjection, sortDocs } = require('./query');
|
||||
|
||||
describe('base/query matcher', () => {
|
||||
const doc = {
|
||||
_id: 'a1',
|
||||
user: 'u1',
|
||||
conversationId: 'c1',
|
||||
tags: ['x', 'y'],
|
||||
tokenCount: 10,
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('equality + implicit array-contains', () => {
|
||||
expect(matches(doc, { user: 'u1' })).toBe(true);
|
||||
expect(matches(doc, { user: 'u2' })).toBe(false);
|
||||
expect(matches(doc, { tags: 'x' })).toBe(true); // array contains
|
||||
expect(matches(doc, { tags: 'z' })).toBe(false);
|
||||
});
|
||||
|
||||
test('operators $in/$nin/$ne/$exists', () => {
|
||||
expect(matches(doc, { conversationId: { $in: ['c1', 'c2'] } })).toBe(true);
|
||||
expect(matches(doc, { conversationId: { $nin: ['c2'] } })).toBe(true);
|
||||
expect(matches(doc, { user: { $ne: 'u2' } })).toBe(true);
|
||||
expect(matches(doc, { missing: { $exists: false } })).toBe(true);
|
||||
expect(matches(doc, { user: { $exists: true } })).toBe(true);
|
||||
});
|
||||
|
||||
test('comparison operators with numbers and dates', () => {
|
||||
expect(matches(doc, { tokenCount: { $gt: 5, $lte: 10 } })).toBe(true);
|
||||
expect(matches(doc, { tokenCount: { $gte: 11 } })).toBe(false);
|
||||
expect(matches(doc, { createdAt: { $gt: new Date('2026-07-03T00:00:00Z') } })).toBe(true);
|
||||
expect(matches(doc, { createdAt: { $lt: new Date('2026-07-03T00:00:00Z') } })).toBe(false);
|
||||
});
|
||||
|
||||
test('logical $or / $and / $nor', () => {
|
||||
expect(matches(doc, { $or: [{ user: 'nope' }, { conversationId: 'c1' }] })).toBe(true);
|
||||
expect(matches(doc, { $and: [{ user: 'u1' }, { conversationId: 'c1' }] })).toBe(true);
|
||||
expect(matches(doc, { $nor: [{ user: 'u1' }] })).toBe(false);
|
||||
});
|
||||
|
||||
test('$exists false / null equivalence for absent fields', () => {
|
||||
expect(matches({ a: 1 }, { $or: [{ b: null }, { b: { $exists: false } }] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base/query applyUpdate', () => {
|
||||
test('$set / implicit set / $unset', () => {
|
||||
expect(applyUpdate({ a: 1 }, { $set: { b: 2 } })).toEqual({ a: 1, b: 2 });
|
||||
expect(applyUpdate({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); // implicit $set (merge)
|
||||
expect(applyUpdate({ a: 1, b: 2 }, { $unset: { b: 1 } })).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test('$inc accumulates', () => {
|
||||
expect(applyUpdate({ n: 5 }, { $inc: { n: 3 } })).toEqual({ n: 8 });
|
||||
expect(applyUpdate({}, { $inc: { n: 2 } })).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
test('$push / $addToSet / $pull', () => {
|
||||
expect(applyUpdate({ a: [1] }, { $push: { a: 2 } })).toEqual({ a: [1, 2] });
|
||||
expect(applyUpdate({ a: [1] }, { $addToSet: { a: 1 } })).toEqual({ a: [1] });
|
||||
expect(applyUpdate({ a: [1, 2, 3] }, { $pull: { a: 2 } })).toEqual({ a: [1, 3] });
|
||||
});
|
||||
|
||||
test('$setOnInsert only applies on insert', () => {
|
||||
expect(applyUpdate({}, { $setOnInsert: { a: 1 } }, { isInsert: true })).toEqual({ a: 1 });
|
||||
expect(applyUpdate({}, { $setOnInsert: { a: 1 } }, { isInsert: false })).toEqual({});
|
||||
});
|
||||
|
||||
test('prototype pollution is blocked (dotted + bare keys, every operator)', () => {
|
||||
applyUpdate({}, { $set: { '__proto__.polluted': 'x' } });
|
||||
applyUpdate({}, { '__proto__.polluted2': 'y' }); // implicit $set
|
||||
applyUpdate({}, { $inc: { '__proto__.count': 5 } });
|
||||
applyUpdate({}, { $set: { 'constructor.prototype.polluted3': 'z' } });
|
||||
applyUpdate({}, { $set: { __proto__: { polluted4: 'w' } } });
|
||||
applyUpdate({}, { $rename: { a: '__proto__.x' } });
|
||||
expect({}.polluted).toBeUndefined();
|
||||
expect({}.polluted2).toBeUndefined();
|
||||
expect({}.count).toBeUndefined();
|
||||
expect({}.polluted3).toBeUndefined();
|
||||
expect({}.polluted4).toBeUndefined();
|
||||
expect({}.x).toBeUndefined();
|
||||
expect(Object.prototype.polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('base/query projection + sort', () => {
|
||||
const d = { _id: '1', a: 1, b: 2, c: 3, password: 'secret' };
|
||||
const hidden = new Set(['password']);
|
||||
test('inclusion projection keeps _id', () => {
|
||||
expect(applyProjection(d, parseProjection('a b'), hidden)).toEqual({ _id: '1', a: 1, b: 2 });
|
||||
});
|
||||
test('exclusion projection', () => {
|
||||
expect(applyProjection(d, parseProjection('-b -c -password'), hidden)).toEqual({ _id: '1', a: 1 });
|
||||
});
|
||||
test('select:false field hidden by default', () => {
|
||||
expect(applyProjection(d, null, hidden)).toEqual({ _id: '1', a: 1, b: 2, c: 3 });
|
||||
});
|
||||
test('+field re-includes a select:false field on top of default', () => {
|
||||
expect(applyProjection(d, parseProjection('+password'), hidden)).toEqual({
|
||||
_id: '1',
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
password: 'secret',
|
||||
});
|
||||
});
|
||||
test('explicit inclusion can return a select:false field', () => {
|
||||
expect(applyProjection(d, parseProjection('a password'), hidden)).toEqual({
|
||||
_id: '1',
|
||||
a: 1,
|
||||
password: 'secret',
|
||||
});
|
||||
});
|
||||
test('sort by multiple keys / directions', () => {
|
||||
const rows = [
|
||||
{ k: 2, t: 'b' },
|
||||
{ k: 1, t: 'a' },
|
||||
{ k: 2, t: 'a' },
|
||||
];
|
||||
expect(sortDocs(rows, { k: 1, t: 1 }).map((r) => `${r.k}${r.t}`)).toEqual(['1a', '2a', '2b']);
|
||||
expect(sortDocs(rows, { k: -1 }).map((r) => r.k)).toEqual([2, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Introspects a real Mongoose Schema (built by @librechat/data-schemas as a
|
||||
* pure schema DSL — never connected to Mongo) and distills exactly what the
|
||||
* Base adapter needs:
|
||||
* - default values to apply on insert
|
||||
* - whether timestamps (createdAt/updatedAt) are managed
|
||||
* - which primitive fields to promote to real Base columns for filter/sort
|
||||
*
|
||||
* Everything else about the Mongoose schema (validators, casting, refs) is
|
||||
* intentionally ignored: the adapter trusts the application layer and stores
|
||||
* the full document as JSON.
|
||||
*/
|
||||
|
||||
const PRIMITIVE_INSTANCES = new Set(['String', 'Number', 'Date', 'Boolean', 'ObjectID', 'ObjectId']);
|
||||
|
||||
/** Identifier / scoping fields that are `meiliIndex` but not useful free-text search targets. */
|
||||
const SEARCH_STOPLIST = new Set([
|
||||
'_id',
|
||||
'_meiliIndex',
|
||||
'conversationId',
|
||||
'messageId',
|
||||
'user',
|
||||
'organization',
|
||||
'endpoint',
|
||||
'model',
|
||||
'sender',
|
||||
]);
|
||||
|
||||
/** Map a Mongoose SchemaType instance to a Base column type. */
|
||||
function baseColumnType(instance) {
|
||||
switch (instance) {
|
||||
case 'Number':
|
||||
return 'number';
|
||||
case 'Boolean':
|
||||
return 'bool';
|
||||
case 'Date':
|
||||
return 'date';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('mongoose').Schema} schema
|
||||
* @returns {{
|
||||
* defaults: Array<[string, unknown]>,
|
||||
* timestamps: { createdAt: string, updatedAt: string } | null,
|
||||
* promoted: Array<{ name: string, type: string, unique: boolean }>,
|
||||
* deselected: Set<string>,
|
||||
* dateFields: Set<string>,
|
||||
* }}
|
||||
*/
|
||||
function describeSchema(schema) {
|
||||
const defaults = [];
|
||||
const promoted = new Map();
|
||||
const deselected = new Set();
|
||||
const dateFields = new Set();
|
||||
const searchable = new Set();
|
||||
|
||||
// Always promote _id so document lookup by id is a real, unique Base column.
|
||||
promoted.set('_id', { name: '_id', type: 'text', unique: true });
|
||||
|
||||
const paths = schema.paths || {};
|
||||
for (const name of Object.keys(paths)) {
|
||||
if (name === '_id' || name === '__v') {
|
||||
continue;
|
||||
}
|
||||
const type = paths[name];
|
||||
const options = type.options || {};
|
||||
|
||||
// Fields marked `select: false` are excluded from reads by default
|
||||
// (Mongoose semantics) — critical for secrets: password, totpSecret,
|
||||
// backupCodes, keyHash. Only returned when explicitly `+selected`.
|
||||
if (options.select === false) {
|
||||
deselected.add(name);
|
||||
}
|
||||
|
||||
// All Date-typed paths must be re-hydrated to Date objects on read so
|
||||
// callers can safely call `.getTime()` / date methods (e.g. session.expiration).
|
||||
if (type.instance === 'Date') {
|
||||
dateFields.add(name);
|
||||
}
|
||||
|
||||
// Free-text search targets: fields flagged for MeiliSearch that are actual
|
||||
// content (title, text, …), not identifiers. Used by Base/SQLite FTS.
|
||||
if (options.meiliIndex && type.instance === 'String' && !SEARCH_STOPLIST.has(name)) {
|
||||
searchable.add(name);
|
||||
}
|
||||
|
||||
// Collect declared defaults for primitive top-level paths (no dots).
|
||||
if (!name.includes('.') && Object.prototype.hasOwnProperty.call(options, 'default')) {
|
||||
defaults.push([name, options.default]);
|
||||
}
|
||||
|
||||
// Promote indexed / unique primitive fields to Base columns for filtering.
|
||||
// A DB-level UNIQUE index is only safe for always-present unique keys
|
||||
// (unique && required) — e.g. conversationId, messageId — to avoid the
|
||||
// sparse-null conflicts of optional unique fields (googleId, openidId, …),
|
||||
// whose logical uniqueness stays enforced by the adapter-level upsert.
|
||||
if (!name.includes('.') && PRIMITIVE_INSTANCES.has(type.instance)) {
|
||||
if ((options.index || options.unique) && !promoted.has(name)) {
|
||||
const columnType = baseColumnType(type.instance);
|
||||
promoted.set(name, {
|
||||
name,
|
||||
type: columnType,
|
||||
// Full UNIQUE for always-present keys (conversationId, messageId, email).
|
||||
unique: !!(options.unique && options.required),
|
||||
// Partial UNIQUE (WHERE != '') for optional unique text keys
|
||||
// (googleId, openidId, username, …): DB-level race safety without the
|
||||
// sparse-null conflict.
|
||||
sparseUnique: !!(options.unique && !options.required && columnType === 'text'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-field indexes declared via schema.index({ field: 1 }).
|
||||
try {
|
||||
for (const [spec] of schema.indexes()) {
|
||||
const fields = Object.keys(spec || {});
|
||||
if (fields.length === 1) {
|
||||
const f = fields[0];
|
||||
if (f !== '_id' && !f.includes('.') && paths[f] && PRIMITIVE_INSTANCES.has(paths[f].instance) && !promoted.has(f)) {
|
||||
promoted.set(f, { name: f, type: baseColumnType(paths[f].instance), unique: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* schema.indexes() unavailable — ignore */
|
||||
}
|
||||
|
||||
let timestamps = null;
|
||||
const tsOpt = schema.options && schema.options.timestamps;
|
||||
if (tsOpt) {
|
||||
timestamps =
|
||||
tsOpt === true
|
||||
? { createdAt: 'createdAt', updatedAt: 'updatedAt' }
|
||||
: {
|
||||
createdAt: tsOpt.createdAt || 'createdAt',
|
||||
updatedAt: tsOpt.updatedAt || 'updatedAt',
|
||||
};
|
||||
for (const f of [timestamps.createdAt, timestamps.updatedAt]) {
|
||||
dateFields.add(f);
|
||||
if (!promoted.has(f)) {
|
||||
promoted.set(f, { name: f, type: 'date', unique: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaults,
|
||||
timestamps,
|
||||
promoted: [...promoted.values()],
|
||||
deselected,
|
||||
dateFields,
|
||||
searchable,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a schema default (calling it if it is a function). */
|
||||
function resolveDefault(def) {
|
||||
return typeof def === 'function' ? def() : def;
|
||||
}
|
||||
|
||||
module.exports = { describeSchema, resolveDefault, baseColumnType };
|
||||
@@ -1,44 +0,0 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Boot smoke: drive the REAL wired boot entry points against Hanzo Base, the
|
||||
* same way api/server/index.js does:
|
||||
* require('~/db').connectDb() -> Base facade + collection provisioning
|
||||
* require('~/models').seedDatabase() -> roles + default categories
|
||||
*
|
||||
* Proves the wired data layer boots on Base and that non-core models
|
||||
* (Role, AgentCategory) come along through the same adapter.
|
||||
*
|
||||
* Run: HANZO_BASE_URL=... HANZO_BASE_TOKEN=... node api/db/base/__tests__/boot-smoke.js
|
||||
*/
|
||||
const path = require('path');
|
||||
require('module-alias')({ base: path.resolve(__dirname, '../../..') }); // base = api/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
async function main() {
|
||||
const { connectDb } = require('~/db');
|
||||
const { seedDatabase } = require('~/models');
|
||||
const { Role } = require('~/db/models');
|
||||
|
||||
await connectDb();
|
||||
console.log('[boot] connectDb() OK — Base connected + collections provisioned');
|
||||
|
||||
await seedDatabase();
|
||||
console.log('[boot] seedDatabase() OK — roles + categories seeded');
|
||||
|
||||
const adminRole = await Role.findOne({ name: 'ADMIN' }).lean();
|
||||
assert(adminRole && adminRole.name === 'ADMIN', 'ADMIN role seeded to Base');
|
||||
console.log(' ok - ADMIN role present on Base:', adminRole.name);
|
||||
|
||||
const userRole = await Role.findOne({ name: 'USER' }).lean();
|
||||
assert(userRole && userRole.name === 'USER', 'USER role seeded to Base');
|
||||
console.log(' ok - USER role present on Base:', userRole.name);
|
||||
|
||||
console.log('\nBOOT SMOKE PASSED — wired data layer boots on Hanzo Base.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\nBOOT SMOKE FAILED:', e && e.stack ? e.stack : e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LIVE proof: the REAL @librechat/data-schemas model+method factories, driven
|
||||
* through the Hanzo Base facade, persisting to a running Base instance.
|
||||
*
|
||||
* Exercises the login path (createUser + balance grant + findUser +
|
||||
* bcrypt.compare) and the chat hot path (saveConvo + saveMessage shapes),
|
||||
* then reads the documents straight back out of Base to prove they landed
|
||||
* there — not in MongoDB.
|
||||
*
|
||||
* Run: HANZO_BASE_URL=... HANZO_BASE_TOKEN=... node api/db/base/__tests__/live.js
|
||||
*/
|
||||
const assert = require('assert');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { createModels, createMethods } = require('@librechat/data-schemas');
|
||||
const { mongoose, connectDb, store } = require('..');
|
||||
|
||||
async function main() {
|
||||
const models = createModels(mongoose);
|
||||
const methods = createMethods(mongoose);
|
||||
await connectDb();
|
||||
console.log('[live] connected + collections provisioned');
|
||||
|
||||
const ok = (m) => console.log(' ok -', m);
|
||||
const email = `z+${Date.now()}@zoo.ngo`;
|
||||
const plain = 'IloveChat2026!!';
|
||||
const password = await bcrypt.hash(plain, 10);
|
||||
|
||||
// ---- LOGIN PATH ----------------------------------------------------------
|
||||
const userId = await methods.createUser(
|
||||
{ email, password, name: 'Z', username: 'z', provider: 'local' },
|
||||
{ enabled: true, startBalance: 100000 },
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert(userId, 'createUser returned an id');
|
||||
ok('createUser (User + Balance grant)');
|
||||
|
||||
// Default read hides select:false secrets (password/totpSecret) — regression guard.
|
||||
const safeUser = await methods.findUser({ email: email.toUpperCase() }); // email normalized
|
||||
assert(safeUser && safeUser.email === email, 'findUser by (normalized) email');
|
||||
assert.strictEqual(safeUser.password, undefined, 'password NOT returned by default (select:false)');
|
||||
ok('findUser default view hides the password hash (select:false honored)');
|
||||
|
||||
// Real login path selects +password explicitly (see api/strategies/localStrategy.js).
|
||||
const user = await methods.findUser({ email }, '+password');
|
||||
const match = await bcrypt.compare(plain, user.password);
|
||||
assert.strictEqual(match, true, 'bcrypt.compare succeeds — password stored hashed');
|
||||
assert.notStrictEqual(user.password, plain, 'password is NOT plaintext');
|
||||
ok('findUser(+password) + bcrypt verification (hashed, never plaintext)');
|
||||
|
||||
const balance = await models.Balance.findOne({ user: user._id }).lean();
|
||||
assert.strictEqual(balance.tokenCredits, 100000, 'balance granted via $inc upsert');
|
||||
ok('Balance record created on Base with $inc grant');
|
||||
|
||||
// ---- CHAT HOT PATH -------------------------------------------------------
|
||||
const uid = String(user._id);
|
||||
const convo = await models.Conversation.findOneAndUpdate(
|
||||
{ conversationId: 'conv-live-1', user: uid },
|
||||
{ $set: { title: 'Hello Base', endpoint: 'openAI', model: 'gpt-4o', messages: [] } },
|
||||
{ new: true, upsert: true },
|
||||
);
|
||||
assert.strictEqual(convo.conversationId, 'conv-live-1');
|
||||
assert.strictEqual(convo.title, 'Hello Base');
|
||||
ok('saveConvo — conversation upserted to Base');
|
||||
|
||||
const msg = await models.Message.findOneAndUpdate(
|
||||
{ messageId: 'msg-live-1', user: uid },
|
||||
{
|
||||
conversationId: 'conv-live-1',
|
||||
text: 'Persisted to Hanzo Base (SQLite), not Mongo.',
|
||||
sender: 'User',
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
{ new: true, upsert: true },
|
||||
);
|
||||
assert.strictEqual(msg.messageId, 'msg-live-1');
|
||||
ok('saveMessage — message upserted to Base');
|
||||
|
||||
const msgs = await models.Message.find({ conversationId: 'conv-live-1', user: uid })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
assert.strictEqual(msgs.length, 1);
|
||||
assert.ok(msgs[0].text.includes('Hanzo Base'));
|
||||
ok('getMessages — read back from Base, sorted');
|
||||
|
||||
// ---- VERIFY THE BYTES ARE IN BASE (raw REST, bypassing the adapter) ------
|
||||
const raw = await store.client.send(
|
||||
`/v1/collections/message/records?filter=${encodeURIComponent(`messageId='msg-live-1' && user='${uid}'`)}`,
|
||||
);
|
||||
assert.ok(raw.items && raw.items.length === 1, 'message present in Base via raw REST');
|
||||
const storedDoc = raw.items[0].data;
|
||||
const parsed = typeof storedDoc === 'string' ? JSON.parse(storedDoc) : storedDoc;
|
||||
assert.strictEqual(parsed.messageId, 'msg-live-1', 'Base record.data holds the full document');
|
||||
assert.strictEqual(raw.items[0].conversationId, 'conv-live-1', 'promoted column mirrored for filter');
|
||||
ok('RAW Base REST confirms the document physically persisted to Base/SQLite');
|
||||
|
||||
// ---- FTS (Base/SQLite search replaces MeiliSearch) -----------------------
|
||||
const convoHits = await models.Conversation.meiliSearch('hello', { filter: `user = "${uid}"` });
|
||||
assert.ok(
|
||||
convoHits.hits.some((h) => h.conversationId === 'conv-live-1'),
|
||||
'conversation search returns the matching convo by title',
|
||||
);
|
||||
ok('Conversation.meiliSearch — real title hit from Base/SQLite');
|
||||
|
||||
const msgHits = await models.Message.meiliSearch('hanzo base', { filter: `user = "${uid}"` });
|
||||
assert.ok(
|
||||
msgHits.hits.some((h) => h.messageId === 'msg-live-1'),
|
||||
'message search returns the matching message by text',
|
||||
);
|
||||
assert.strictEqual(
|
||||
(await models.Conversation.meiliSearch('zzz-no-such-term', { filter: `user = "${uid}"` })).hits.length,
|
||||
0,
|
||||
'no false positives',
|
||||
);
|
||||
ok('Message.meiliSearch — real text hit from Base/SQLite (Meili dropped)');
|
||||
|
||||
// ---- Base UNIQUE index on natural key rejects a duplicate (race-safety) ----
|
||||
let rejected = false;
|
||||
try {
|
||||
await store.create('conversation', {
|
||||
_id: '0'.repeat(24),
|
||||
conversationId: 'conv-live-1', // duplicate of the one saved above
|
||||
user: uid,
|
||||
data: { _id: '0'.repeat(24), conversationId: 'conv-live-1', user: uid },
|
||||
});
|
||||
} catch (err) {
|
||||
rejected = true;
|
||||
}
|
||||
assert.ok(rejected, 'Base UNIQUE index on conversationId rejects a duplicate insert');
|
||||
ok('Base UNIQUE index enforces conversationId uniqueness (race-safety)');
|
||||
|
||||
console.log('\nLIVE proof PASSED — login + conversation + message + SEARCH on Hanzo Base.');
|
||||
console.log(` user collection id space, email=${email}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\nLIVE proof FAILED:', e && e.stack ? e.stack : e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DocumentStore — the Hanzo Base backend for the Mongoose adapter.
|
||||
*
|
||||
* Each Mongo model maps to one Base collection whose records carry the full
|
||||
* document as a JSON `data` field plus a handful of promoted scalar columns
|
||||
* (derived from indexed/unique schema paths) used only for filter pushdown.
|
||||
*
|
||||
* Filter pushdown builds a Base filter-DSL string that is guaranteed to be a
|
||||
* SUPERSET of the true result; query.js then narrows it in JS, so pushdown can
|
||||
* be as conservative as needed without ever being wrong.
|
||||
*/
|
||||
|
||||
const { idString } = require('./query');
|
||||
|
||||
// `@hanzo/base` ships as an ESM-only package (exports expose only the `import`
|
||||
// condition), so it must be loaded from this CommonJS module via dynamic import.
|
||||
let _baseModulePromise = null;
|
||||
function loadBase() {
|
||||
if (!_baseModulePromise) {
|
||||
_baseModulePromise = import('@hanzo/base');
|
||||
}
|
||||
return _baseModulePromise;
|
||||
}
|
||||
|
||||
const DATA_FIELD_MAX_SIZE = 8 * 1024 * 1024; // 8 MiB per document
|
||||
|
||||
/** Escape a string value for the Base filter DSL (single-quoted). */
|
||||
function quote(value) {
|
||||
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
/** Format a scalar for the filter DSL, or return undefined if not pushable. */
|
||||
function literal(value) {
|
||||
const v = idString(value);
|
||||
if (v === null || v === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? String(v) : undefined; // never emit `= NaN/Infinity`
|
||||
}
|
||||
if (typeof v === 'boolean') {
|
||||
return String(v);
|
||||
}
|
||||
// Dates are intentionally NOT pushed down: Base normalizes its date columns to
|
||||
// a `YYYY-MM-DD HH:MM:SS.sssZ` (space) form that never string-matches an ISO
|
||||
// `T` literal, which would wrongly EXCLUDE the record (superset invariant
|
||||
// violation). The JS matcher (query.js) handles all date predicates.
|
||||
if (v instanceof Date) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return quote(v);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect conjunctive (AND-ed) constraints on promoted fields into a Base DSL
|
||||
* filter. Disjunctions and non-promoted / operator predicates are skipped —
|
||||
* the JS matcher enforces them — keeping the result a safe superset.
|
||||
*/
|
||||
function buildFilter(query, promoted) {
|
||||
const promotedSet = new Set(promoted);
|
||||
const clauses = [];
|
||||
|
||||
const walk = (q) => {
|
||||
if (!q || typeof q !== 'object') {
|
||||
return;
|
||||
}
|
||||
for (const [key, val] of Object.entries(q)) {
|
||||
if (key === '$and' && Array.isArray(val)) {
|
||||
val.forEach(walk);
|
||||
continue;
|
||||
}
|
||||
if (key.startsWith('$')) {
|
||||
continue; // $or/$nor/etc — not safe to push
|
||||
}
|
||||
if (!promotedSet.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (val === null || typeof val !== 'object' || val instanceof Date) {
|
||||
const lit = literal(val);
|
||||
if (lit !== undefined) {
|
||||
clauses.push(`${key} = ${lit}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
continue;
|
||||
}
|
||||
// operator expression on a promoted field
|
||||
if (Array.isArray(val.$in) && val.$in.length) {
|
||||
const parts = val.$in.map((v) => literal(v)).filter((l) => l !== undefined);
|
||||
if (parts.length === val.$in.length) {
|
||||
clauses.push(`(${parts.map((l) => `${key} = ${l}`).join(' || ')})`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (val.$eq !== undefined) {
|
||||
const lit = literal(val.$eq);
|
||||
if (lit !== undefined) {
|
||||
clauses.push(`${key} = ${lit}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(query);
|
||||
return clauses.length ? clauses.join(' && ') : undefined;
|
||||
}
|
||||
|
||||
class DocumentStore {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.url = null;
|
||||
this._ensured = new Set();
|
||||
}
|
||||
|
||||
/** @param {{ url: string, token?: string }} config */
|
||||
async init({ url, token }) {
|
||||
if (!url) {
|
||||
throw new Error('[BaseStore] Base URL is required (HANZO_BASE_URL / BASE_URL)');
|
||||
}
|
||||
const { BaseClient } = await loadBase();
|
||||
this.url = url.replace(/\/$/, '');
|
||||
this.client = new BaseClient(this.url);
|
||||
if (token) {
|
||||
this.client.authStore.save(token, null);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async health() {
|
||||
return this.client.send('/v1/health');
|
||||
}
|
||||
|
||||
/** Idempotently ensure the Base collection exists with the required schema. */
|
||||
async ensureCollection(name, promoted) {
|
||||
if (this._ensured.has(name)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.send(`/v1/collections/${name}`, { method: 'GET' });
|
||||
this._ensured.add(name);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const fields = [{ name: 'data', type: 'json', maxSize: DATA_FIELD_MAX_SIZE }];
|
||||
const indexes = [];
|
||||
for (const col of promoted) {
|
||||
fields.push({ name: col.name, type: col.type });
|
||||
const safe = col.name.replace(/[^A-Za-z0-9_]/g, '_');
|
||||
const idx = `\`idx_${name}_${safe}\` ON \`${name}\` (\`${col.name}\`)`;
|
||||
if (col.unique) {
|
||||
indexes.push(`CREATE UNIQUE INDEX ${idx}`);
|
||||
} else if (col.sparseUnique) {
|
||||
// Partial unique: allow many empty values, enforce uniqueness on real ones.
|
||||
indexes.push(`CREATE UNIQUE INDEX ${idx} WHERE \`${col.name}\` != ''`);
|
||||
} else {
|
||||
indexes.push(`CREATE INDEX ${idx}`);
|
||||
}
|
||||
}
|
||||
const body = { name, type: 'base', fields, indexes };
|
||||
try {
|
||||
await this.client.send('/v1/collections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
// Tolerate a concurrent creator (unique-name violation).
|
||||
if (!isConflict(err) && !isNotFound(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
this._ensured.add(name);
|
||||
}
|
||||
|
||||
_parse(record) {
|
||||
let doc = record.data;
|
||||
if (typeof doc === 'string') {
|
||||
try {
|
||||
doc = JSON.parse(doc);
|
||||
} catch {
|
||||
doc = {};
|
||||
}
|
||||
}
|
||||
return { baseId: record.id, doc: doc || {} };
|
||||
}
|
||||
|
||||
/** List candidate docs for a Mongo filter (superset via pushdown). */
|
||||
async list(collectionName, mongoFilter, promotedNames) {
|
||||
const filter = buildFilter(mongoFilter, promotedNames || []);
|
||||
const items = await this.client
|
||||
.collection(collectionName)
|
||||
.getFullList(filter ? { filter } : {});
|
||||
return items.map((r) => this._parse(r));
|
||||
}
|
||||
|
||||
async create(collectionName, record) {
|
||||
const created = await this.client.collection(collectionName).create(record);
|
||||
return this._parse(created);
|
||||
}
|
||||
|
||||
async update(collectionName, baseId, record) {
|
||||
const updated = await this.client.collection(collectionName).update(baseId, record);
|
||||
return this._parse(updated);
|
||||
}
|
||||
|
||||
async delete(collectionName, baseId) {
|
||||
await this.client.collection(collectionName).delete(baseId);
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(err) {
|
||||
return (err && (err.status || (err.data && err.data.status))) || 0;
|
||||
}
|
||||
function isNotFound(err) {
|
||||
return statusOf(err) === 404;
|
||||
}
|
||||
function isConflict(err) {
|
||||
return statusOf(err) === 400 || statusOf(err) === 409;
|
||||
}
|
||||
|
||||
module.exports = new DocumentStore();
|
||||
module.exports.DocumentStore = DocumentStore;
|
||||
module.exports.buildFilter = buildFilter;
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
/* buildFilter — the Base filter-DSL pushdown. Must only ever emit a SUPERSET. */
|
||||
const { buildFilter } = require('./store');
|
||||
|
||||
const promoted = ['_id', 'conversationId', 'user', 'expiredAt', 'tokenCount'];
|
||||
|
||||
describe('store/buildFilter pushdown', () => {
|
||||
test('promoted equality + $in', () => {
|
||||
expect(buildFilter({ conversationId: 'c1', user: 'u1' }, promoted)).toBe(
|
||||
"conversationId = 'c1' && user = 'u1'",
|
||||
);
|
||||
expect(buildFilter({ conversationId: { $in: ['a', 'b'] } }, promoted)).toBe(
|
||||
"(conversationId = 'a' || conversationId = 'b')",
|
||||
);
|
||||
});
|
||||
|
||||
test('DSL injection cannot break out of the quoted literal', () => {
|
||||
const f = buildFilter({ conversationId: "x' || user='victim" }, promoted);
|
||||
expect(f).toBe("conversationId = 'x\\' || user=\\'victim'");
|
||||
expect(f).not.toMatch(/ \|\| user='victim/); // no un-escaped injected clause
|
||||
});
|
||||
|
||||
test('Date predicates are NOT pushed down (superset invariant — H1)', () => {
|
||||
// Base date columns normalize to a space separator; pushing an ISO literal
|
||||
// would wrongly EXCLUDE the record. Dates must fall through to the JS matcher.
|
||||
expect(buildFilter({ expiredAt: new Date('2026-07-04T12:00:00Z') }, promoted)).toBeUndefined();
|
||||
expect(buildFilter({ expiredAt: { $eq: new Date() } }, promoted)).toBeUndefined();
|
||||
// a date predicate must never partially push while dropping the date clause
|
||||
expect(buildFilter({ user: 'u1', expiredAt: new Date() }, promoted)).toBe("user = 'u1'");
|
||||
});
|
||||
|
||||
test('non-finite numbers are not pushed (invalid DSL guard — L1)', () => {
|
||||
expect(buildFilter({ tokenCount: NaN }, promoted)).toBeUndefined();
|
||||
expect(buildFilter({ tokenCount: Infinity }, promoted)).toBeUndefined();
|
||||
expect(buildFilter({ tokenCount: 5 }, promoted)).toBe('tokenCount = 5');
|
||||
});
|
||||
|
||||
test('non-promoted / $or / empty-$in are not pushed (JS matcher handles them)', () => {
|
||||
expect(buildFilter({ title: 'x' }, promoted)).toBeUndefined();
|
||||
expect(buildFilter({ $or: [{ user: 'a' }, { user: 'b' }] }, promoted)).toBeUndefined();
|
||||
expect(buildFilter({ conversationId: { $in: [] } }, promoted)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+82
-9
@@ -1,10 +1,83 @@
|
||||
/**
|
||||
* Database connection.
|
||||
*
|
||||
* MongoDB has been dropped from Hanzo Chat — the data layer runs on Hanzo Base
|
||||
* (SQLite embedded / Postgres for prod multi-instance). `connectDb` initialises
|
||||
* the Base client and provisions every registered model's collection. See ./base.
|
||||
*/
|
||||
const { connectDb } = require('./base');
|
||||
require('dotenv').config();
|
||||
const { isEnabled } = require('@hanzochat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
module.exports = { connectDb };
|
||||
const mongoose = require('mongoose');
|
||||
const MONGO_URI = process.env.MONGO_URI;
|
||||
|
||||
if (!MONGO_URI) {
|
||||
throw new Error('Please define the MONGO_URI environment variable');
|
||||
}
|
||||
/** The maximum number of connections in the connection pool. */
|
||||
const maxPoolSize = parseInt(process.env.MONGO_MAX_POOL_SIZE) || undefined;
|
||||
/** The minimum number of connections in the connection pool. */
|
||||
const minPoolSize = parseInt(process.env.MONGO_MIN_POOL_SIZE) || undefined;
|
||||
/** The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
|
||||
const maxConnecting = parseInt(process.env.MONGO_MAX_CONNECTING) || undefined;
|
||||
/** The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
|
||||
const maxIdleTimeMS = parseInt(process.env.MONGO_MAX_IDLE_TIME_MS) || undefined;
|
||||
/** The maximum time in milliseconds that a thread can wait for a connection to become available. */
|
||||
const waitQueueTimeoutMS = parseInt(process.env.MONGO_WAIT_QUEUE_TIMEOUT_MS) || undefined;
|
||||
/** Set to false to disable automatic index creation for all models associated with this connection. */
|
||||
const autoIndex =
|
||||
process.env.MONGO_AUTO_INDEX != undefined
|
||||
? isEnabled(process.env.MONGO_AUTO_INDEX) || false
|
||||
: undefined;
|
||||
|
||||
/** Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
|
||||
const autoCreate =
|
||||
process.env.MONGO_AUTO_CREATE != undefined
|
||||
? isEnabled(process.env.MONGO_AUTO_CREATE) || false
|
||||
: undefined;
|
||||
/**
|
||||
* Global is used here to maintain a cached connection across hot reloads
|
||||
* in development. This prevents connections growing exponentially
|
||||
* during API Route usage.
|
||||
*/
|
||||
let cached = global.mongoose;
|
||||
|
||||
if (!cached) {
|
||||
cached = global.mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
mongoose.connection.on('error', (err) => {
|
||||
logger.error('[connectDb] MongoDB connection error:', err);
|
||||
});
|
||||
|
||||
async function connectDb() {
|
||||
if (cached.conn && cached.conn?._readyState === 1) {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
const disconnected = cached.conn && cached.conn?._readyState !== 1;
|
||||
if (!cached.promise || disconnected) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
...(maxPoolSize ? { maxPoolSize } : {}),
|
||||
...(minPoolSize ? { minPoolSize } : {}),
|
||||
...(maxConnecting ? { maxConnecting } : {}),
|
||||
...(maxIdleTimeMS ? { maxIdleTimeMS } : {}),
|
||||
...(waitQueueTimeoutMS ? { waitQueueTimeoutMS } : {}),
|
||||
...(autoIndex != undefined ? { autoIndex } : {}),
|
||||
...(autoCreate != undefined ? { autoCreate } : {}),
|
||||
// useNewUrlParser: true,
|
||||
// useUnifiedTopology: true,
|
||||
// bufferMaxEntries: 0,
|
||||
// useFindAndModify: true,
|
||||
// useCreateIndex: true
|
||||
};
|
||||
logger.info('Mongo Connection options');
|
||||
logger.info(JSON.stringify(opts, null, 2));
|
||||
mongoose.set('strictQuery', true);
|
||||
cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => {
|
||||
return mongoose;
|
||||
});
|
||||
}
|
||||
cached.conn = await cached.promise;
|
||||
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectDb,
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
const { mongoose, connectDb } = require('./base');
|
||||
const mongoose = require('mongoose');
|
||||
const { createModels } = require('@librechat/data-schemas');
|
||||
const { connectDb } = require('./connect');
|
||||
const indexSync = require('./indexSync');
|
||||
|
||||
// Register every model on the Base-backed mongoose facade.
|
||||
createModels(mongoose);
|
||||
|
||||
module.exports = { connectDb, indexSync };
|
||||
|
||||
+358
-11
@@ -1,16 +1,363 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MeiliSearch } = require('meilisearch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { isEnabled, FlowStateManager } = require('@hanzochat/api');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { batchResetMeiliFlags } = require('./utils');
|
||||
|
||||
/**
|
||||
* Search index sync.
|
||||
*
|
||||
* MongoDB + MeiliSearch have been dropped. Full-text search now runs directly
|
||||
* on Hanzo Base/SQLite via the data-layer adapter (the model `meiliSearch()`
|
||||
* method in api/db/base searches content fields on Base). There is no separate
|
||||
* index to sync, so this is a no-op kept for boot-sequence compatibility
|
||||
* (api/server/index.js calls `indexSync()`).
|
||||
*/
|
||||
async function indexSync() {
|
||||
logger.info('[indexSync] Base/SQLite search active — no external index to sync');
|
||||
const Conversation = mongoose.models.Conversation;
|
||||
const Message = mongoose.models.Message;
|
||||
|
||||
const searchEnabled = isEnabled(process.env.SEARCH);
|
||||
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
||||
let currentTimeout = null;
|
||||
|
||||
const defaultSyncThreshold = 1000;
|
||||
const syncThreshold = process.env.MEILI_SYNC_THRESHOLD
|
||||
? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10)
|
||||
: defaultSyncThreshold;
|
||||
|
||||
class MeiliSearchClient {
|
||||
static instance = null;
|
||||
|
||||
static getInstance() {
|
||||
if (!MeiliSearchClient.instance) {
|
||||
if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY) {
|
||||
throw new Error('Meilisearch configuration is missing.');
|
||||
}
|
||||
MeiliSearchClient.instance = new MeiliSearch({
|
||||
host: process.env.MEILI_HOST,
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
});
|
||||
}
|
||||
return MeiliSearchClient.instance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes documents from MeiliSearch index that are missing the user field
|
||||
* @param {import('meilisearch').Index} index - MeiliSearch index instance
|
||||
* @param {string} indexName - Name of the index for logging
|
||||
* @returns {Promise<number>} - Number of documents deleted
|
||||
*/
|
||||
async function deleteDocumentsWithoutUserField(index, indexName) {
|
||||
let deletedCount = 0;
|
||||
let offset = 0;
|
||||
const batchSize = 1000;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const searchResult = await index.search('', {
|
||||
limit: batchSize,
|
||||
offset: offset,
|
||||
});
|
||||
|
||||
if (searchResult.hits.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id);
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
logger.info(
|
||||
`[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`,
|
||||
);
|
||||
await index.deleteDocuments(idsToDelete);
|
||||
deletedCount += idsToDelete.length;
|
||||
}
|
||||
|
||||
if (searchResult.hits.length < batchSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += batchSize;
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures indexes have proper filterable attributes configured and checks if documents have user field
|
||||
* @param {MeiliSearch} client - MeiliSearch client instance
|
||||
* @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done
|
||||
*/
|
||||
async function ensureFilterableAttributes(client) {
|
||||
let settingsUpdated = false;
|
||||
let hasOrphanedDocs = false;
|
||||
|
||||
try {
|
||||
// Check and update messages index
|
||||
try {
|
||||
const messagesIndex = client.index('messages');
|
||||
const settings = await messagesIndex.getSettings();
|
||||
|
||||
if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
|
||||
logger.info('[indexSync] Configuring messages index to filter by user...');
|
||||
await messagesIndex.updateSettings({
|
||||
filterableAttributes: ['user'],
|
||||
});
|
||||
logger.info('[indexSync] Messages index configured for user filtering');
|
||||
settingsUpdated = true;
|
||||
}
|
||||
|
||||
// Check if existing documents have user field indexed
|
||||
try {
|
||||
const searchResult = await messagesIndex.search('', { limit: 1 });
|
||||
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
|
||||
logger.info(
|
||||
'[indexSync] Existing messages missing user field, will clean up orphaned documents...',
|
||||
);
|
||||
hasOrphanedDocs = true;
|
||||
}
|
||||
} catch (searchError) {
|
||||
logger.debug('[indexSync] Could not check message documents:', searchError.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'index_not_found') {
|
||||
logger.warn('[indexSync] Could not check/update messages index settings:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check and update conversations index
|
||||
try {
|
||||
const convosIndex = client.index('convos');
|
||||
const settings = await convosIndex.getSettings();
|
||||
|
||||
if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
|
||||
logger.info('[indexSync] Configuring convos index to filter by user...');
|
||||
await convosIndex.updateSettings({
|
||||
filterableAttributes: ['user'],
|
||||
});
|
||||
logger.info('[indexSync] Convos index configured for user filtering');
|
||||
settingsUpdated = true;
|
||||
}
|
||||
|
||||
// Check if existing documents have user field indexed
|
||||
try {
|
||||
const searchResult = await convosIndex.search('', { limit: 1 });
|
||||
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
|
||||
logger.info(
|
||||
'[indexSync] Existing conversations missing user field, will clean up orphaned documents...',
|
||||
);
|
||||
hasOrphanedDocs = true;
|
||||
}
|
||||
} catch (searchError) {
|
||||
logger.debug('[indexSync] Could not check conversation documents:', searchError.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'index_not_found') {
|
||||
logger.warn('[indexSync] Could not check/update convos index settings:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// If either index has orphaned documents, clean them up (but don't force resync)
|
||||
if (hasOrphanedDocs) {
|
||||
try {
|
||||
const messagesIndex = client.index('messages');
|
||||
await deleteDocumentsWithoutUserField(messagesIndex, 'messages');
|
||||
} catch (error) {
|
||||
logger.debug('[indexSync] Could not clean up messages:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const convosIndex = client.index('convos');
|
||||
await deleteDocumentsWithoutUserField(convosIndex, 'convos');
|
||||
} catch (error) {
|
||||
logger.debug('[indexSync] Could not clean up convos:', error.message);
|
||||
}
|
||||
|
||||
logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.');
|
||||
}
|
||||
|
||||
if (settingsUpdated) {
|
||||
logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[indexSync] Error ensuring filterable attributes:', error);
|
||||
}
|
||||
|
||||
return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual sync operations for messages and conversations
|
||||
* @param {FlowStateManager} flowManager - Flow state manager instance
|
||||
* @param {string} flowId - Flow identifier
|
||||
* @param {string} flowType - Flow type
|
||||
*/
|
||||
async function performSync(flowManager, flowId, flowType) {
|
||||
try {
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||
return { messagesSync: false, convosSync: false };
|
||||
}
|
||||
|
||||
const client = MeiliSearchClient.getInstance();
|
||||
|
||||
const { status } = await client.health();
|
||||
if (status !== 'available') {
|
||||
throw new Error('Meilisearch not available');
|
||||
}
|
||||
|
||||
/** Ensures indexes have proper filterable attributes configured */
|
||||
const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
|
||||
await ensureFilterableAttributes(client);
|
||||
|
||||
let messagesSync = false;
|
||||
let convosSync = false;
|
||||
|
||||
// Only reset flags if settings were actually updated (not just for orphaned doc cleanup)
|
||||
if (settingsUpdated) {
|
||||
logger.info(
|
||||
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||
);
|
||||
|
||||
// Reset sync flags to force full re-sync
|
||||
await batchResetMeiliFlags(Message.collection);
|
||||
await batchResetMeiliFlags(Conversation.collection);
|
||||
}
|
||||
|
||||
// Check if we need to sync messages
|
||||
logger.info('[indexSync] Requesting message sync progress...');
|
||||
const messageProgress = await Message.getSyncProgress();
|
||||
if (!messageProgress.isComplete || settingsUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
const messageCount = messageProgress.totalDocuments;
|
||||
const messagesIndexed = messageProgress.totalProcessed;
|
||||
const unindexedMessages = messageCount - messagesIndexed;
|
||||
|
||||
if (settingsUpdated || unindexedMessages > syncThreshold) {
|
||||
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
|
||||
await Message.syncWithMeili();
|
||||
messagesSync = true;
|
||||
} else if (unindexedMessages > 0) {
|
||||
logger.info(
|
||||
`[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to sync conversations
|
||||
const convoProgress = await Conversation.getSyncProgress();
|
||||
if (!convoProgress.isComplete || settingsUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
const convoCount = convoProgress.totalDocuments;
|
||||
const convosIndexed = convoProgress.totalProcessed;
|
||||
|
||||
const unindexedConvos = convoCount - convosIndexed;
|
||||
if (settingsUpdated || unindexedConvos > syncThreshold) {
|
||||
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
|
||||
await Conversation.syncWithMeili();
|
||||
convosSync = true;
|
||||
} else if (unindexedConvos > 0) {
|
||||
logger.info(
|
||||
`[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { messagesSync, convosSync };
|
||||
} finally {
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping cleanup...');
|
||||
} else if (flowManager && flowId && flowType) {
|
||||
try {
|
||||
await flowManager.deleteFlow(flowId, flowType);
|
||||
logger.debug('[indexSync] Flow state cleaned up');
|
||||
} catch (cleanupErr) {
|
||||
logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main index sync function that uses FlowStateManager to prevent concurrent execution
|
||||
*/
|
||||
async function indexSync() {
|
||||
if (!searchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[indexSync] Starting index synchronization check...');
|
||||
|
||||
// Get or create FlowStateManager instance
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
if (!flowsCache) {
|
||||
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
|
||||
return await performSync(null, null, null);
|
||||
}
|
||||
|
||||
const flowManager = new FlowStateManager(flowsCache, {
|
||||
ttl: 60000 * 10, // 10 minutes TTL for sync operations
|
||||
});
|
||||
|
||||
// Use a unique flow ID for the sync operation
|
||||
const flowId = 'meili-index-sync';
|
||||
const flowType = 'MEILI_SYNC';
|
||||
|
||||
try {
|
||||
// This will only execute the handler if no other instance is running the sync
|
||||
const result = await flowManager.createFlowWithHandler(flowId, flowType, () =>
|
||||
performSync(flowManager, flowId, flowType),
|
||||
);
|
||||
|
||||
if (result.messagesSync || result.convosSync) {
|
||||
logger.info('[indexSync] Sync completed successfully');
|
||||
} else {
|
||||
logger.debug('[indexSync] No sync was needed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err.message.includes('flow already exists')) {
|
||||
logger.info('[indexSync] Sync already running on another instance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.message.includes('not found')) {
|
||||
logger.debug('[indexSync] Creating indices...');
|
||||
currentTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await Message.syncWithMeili();
|
||||
await Conversation.syncWithMeili();
|
||||
} catch (err) {
|
||||
logger.error('[indexSync] Trouble creating indices, try restarting the server.', err);
|
||||
}
|
||||
}, 750);
|
||||
} else if (err.message.includes('Meilisearch not configured')) {
|
||||
logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
|
||||
} else {
|
||||
logger.error('[indexSync] error', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
logger.debug('[indexSync] Clearing sync timeouts before exiting...');
|
||||
clearTimeout(currentTimeout);
|
||||
});
|
||||
|
||||
module.exports = indexSync;
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Unit tests for performSync() function in indexSync.js
|
||||
*
|
||||
* Tests use real mongoose with mocked model methods, only mocking external calls.
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Mock only external dependencies (not internal classes/models)
|
||||
const mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMeiliHealth = jest.fn();
|
||||
const mockMeiliIndex = jest.fn();
|
||||
const mockBatchResetMeiliFlags = jest.fn();
|
||||
const mockIsEnabled = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
|
||||
// Create mock models that will be reused
|
||||
const createMockModel = (collectionName) => ({
|
||||
collection: { name: collectionName },
|
||||
getSyncProgress: jest.fn(),
|
||||
syncWithMeili: jest.fn(),
|
||||
countDocuments: jest.fn(),
|
||||
});
|
||||
|
||||
const originalMessageModel = mongoose.models.Message;
|
||||
const originalConversationModel = mongoose.models.Conversation;
|
||||
|
||||
// Mock external modules
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('meilisearch', () => ({
|
||||
MeiliSearch: jest.fn(() => ({
|
||||
health: mockMeiliHealth,
|
||||
index: mockMeiliIndex,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
batchResetMeiliFlags: mockBatchResetMeiliFlags,
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: mockIsEnabled,
|
||||
FlowStateManager: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: mockGetLogStores,
|
||||
}));
|
||||
|
||||
// Set environment before module load
|
||||
process.env.MEILI_HOST = 'http://localhost:7700';
|
||||
process.env.MEILI_MASTER_KEY = 'test-key';
|
||||
process.env.SEARCH = 'true';
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads
|
||||
|
||||
describe('performSync() - syncThreshold logic', () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
let Message;
|
||||
let Conversation;
|
||||
|
||||
beforeAll(() => {
|
||||
Message = createMockModel('messages');
|
||||
Conversation = createMockModel('conversations');
|
||||
|
||||
mongoose.models.Message = Message;
|
||||
mongoose.models.Conversation = Conversation;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
// Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold)
|
||||
jest.resetModules();
|
||||
|
||||
// Set up environment
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.env.MEILI_HOST = 'http://localhost:7700';
|
||||
process.env.MEILI_MASTER_KEY = 'test-key';
|
||||
process.env.SEARCH = 'true';
|
||||
delete process.env.MEILI_NO_SYNC;
|
||||
|
||||
// Re-ensure models are available in mongoose after resetModules
|
||||
// We must require mongoose again to get the fresh instance that indexSync will use
|
||||
const mongoose = require('mongoose');
|
||||
mongoose.models.Message = Message;
|
||||
mongoose.models.Conversation = Conversation;
|
||||
|
||||
// Mock isEnabled
|
||||
mockIsEnabled.mockImplementation((val) => val === 'true' || val === true);
|
||||
|
||||
// Mock MeiliSearch client responses
|
||||
mockMeiliHealth.mockResolvedValue({ status: 'available' });
|
||||
mockMeiliIndex.mockReturnValue({
|
||||
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }),
|
||||
updateSettings: jest.fn().mockResolvedValue({}),
|
||||
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||
});
|
||||
|
||||
mockBatchResetMeiliFlags.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mongoose.models.Message = originalMessageModel;
|
||||
mongoose.models.Conversation = originalConversationModel;
|
||||
});
|
||||
|
||||
test('triggers sync when unindexed messages exceed syncThreshold', async () => {
|
||||
// Arrange: Set threshold before module load
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Arrange: 1050 unindexed messages > 1000 threshold
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 1150, // 1050 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 50,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Message sync triggered because 1050 > 1000
|
||||
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Messages need syncing: 100/1150 indexed',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Starting message sync (1050 unindexed)',
|
||||
);
|
||||
|
||||
// Assert: Conversation sync NOT triggered (already complete)
|
||||
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips sync when unindexed messages are below syncThreshold', async () => {
|
||||
// Arrange: 50 unindexed messages < 1000 threshold
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 150, // 50 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 50,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Message sync NOT triggered because 50 < 1000
|
||||
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Messages need syncing: 100/150 indexed',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
|
||||
);
|
||||
|
||||
// Assert: Conversation sync NOT triggered (already complete)
|
||||
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('respects syncThreshold at boundary (exactly at threshold)', async () => {
|
||||
// Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than)
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 1100, // 1000 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 0,
|
||||
totalDocuments: 0,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Message sync NOT triggered because 1000 is NOT > 1000
|
||||
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Messages need syncing: 100/1100 indexed',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)',
|
||||
);
|
||||
});
|
||||
|
||||
test('triggers sync when unindexed is threshold + 1', async () => {
|
||||
// Arrange: 1001 unindexed messages > 1000 threshold
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 1101, // 1001 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 0,
|
||||
totalDocuments: 0,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Message sync triggered because 1001 > 1000
|
||||
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Messages need syncing: 100/1101 indexed',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Starting message sync (1001 unindexed)',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses totalDocuments from convoProgress for conversation sync decisions', async () => {
|
||||
// Arrange: Messages complete, conversations need sync
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 100,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 1100, // 1050 unindexed > 1000 threshold
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls (the optimization)
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Only conversation sync triggered
|
||||
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Conversations need syncing: 50/1100 indexed',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Starting convos sync (1050 unindexed)',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips sync when collections are fully synced', async () => {
|
||||
// Arrange: Everything already synced
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 100,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 50,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: No countDocuments calls
|
||||
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: No sync triggered
|
||||
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||
|
||||
// Assert: Correct logs
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100');
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Conversations are fully synced: 50/50',
|
||||
);
|
||||
});
|
||||
|
||||
test('triggers message sync when settingsUpdated even if below syncThreshold', async () => {
|
||||
// Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 150, // 50 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 50,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
// Mock settings update scenario
|
||||
mockMeiliIndex.mockReturnValue({
|
||||
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||
updateSettings: jest.fn().mockResolvedValue({}),
|
||||
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||
});
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: Flags were reset due to settings update
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||
|
||||
// Assert: Message sync triggered despite being below threshold (50 < 1000)
|
||||
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Starting message sync (50 unindexed)',
|
||||
);
|
||||
});
|
||||
|
||||
test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => {
|
||||
// Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 100,
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 100, // 50 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
// Mock settings update scenario
|
||||
mockMeiliIndex.mockReturnValue({
|
||||
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||
updateSettings: jest.fn().mockResolvedValue({}),
|
||||
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||
});
|
||||
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: Flags were reset due to settings update
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||
|
||||
// Assert: Conversation sync triggered despite being below threshold (50 < 1000)
|
||||
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
||||
});
|
||||
|
||||
test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => {
|
||||
// Arrange: Set threshold before module load
|
||||
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||
|
||||
// Arrange: Both have documents below threshold (50 each), but settings were updated
|
||||
Message.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 100,
|
||||
totalDocuments: 150, // 50 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Conversation.getSyncProgress.mockResolvedValue({
|
||||
totalProcessed: 50,
|
||||
totalDocuments: 100, // 50 unindexed
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||
|
||||
// Mock settings update scenario
|
||||
mockMeiliIndex.mockReturnValue({
|
||||
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||
updateSettings: jest.fn().mockResolvedValue({}),
|
||||
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||
});
|
||||
|
||||
// Act
|
||||
const indexSync = require('./indexSync');
|
||||
await indexSync();
|
||||
|
||||
// Assert: Flags were reset due to settings update
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||
|
||||
// Assert: Both syncs triggered despite both being below threshold
|
||||
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[indexSync] Starting message sync (50 unindexed)',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const { mongoose } = require('./base');
|
||||
const mongoose = require('mongoose');
|
||||
const { createModels } = require('@librechat/data-schemas');
|
||||
const models = createModels(mongoose);
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Batch update documents in chunks to avoid timeouts on weak instances
|
||||
* @param {mongoose.Collection} collection - MongoDB collection
|
||||
* @returns {Promise<number>} - Total modified count
|
||||
* @throws {Error} - Throws if database operations fail (e.g., network issues, connection loss, permission problems)
|
||||
*/
|
||||
async function batchResetMeiliFlags(collection) {
|
||||
const DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
let BATCH_SIZE = parseEnvInt('MEILI_SYNC_BATCH_SIZE', DEFAULT_BATCH_SIZE);
|
||||
if (BATCH_SIZE === 0) {
|
||||
logger.warn(
|
||||
`[batchResetMeiliFlags] MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: ${DEFAULT_BATCH_SIZE}`,
|
||||
);
|
||||
BATCH_SIZE = DEFAULT_BATCH_SIZE;
|
||||
}
|
||||
|
||||
const BATCH_DELAY_MS = parseEnvInt('MEILI_SYNC_DELAY_MS', 100);
|
||||
let totalModified = 0;
|
||||
let hasMore = true;
|
||||
|
||||
try {
|
||||
while (hasMore) {
|
||||
const docs = await collection
|
||||
.find({ expiredAt: null, _meiliIndex: { $ne: false } }, { projection: { _id: 1 } })
|
||||
.limit(BATCH_SIZE)
|
||||
.toArray();
|
||||
|
||||
if (docs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const ids = docs.map((doc) => doc._id);
|
||||
const result = await collection.updateMany(
|
||||
{ _id: { $in: ids } },
|
||||
{ $set: { _meiliIndex: false } },
|
||||
);
|
||||
|
||||
totalModified += result.modifiedCount;
|
||||
process.stdout.write(
|
||||
`\r Updating ${collection.collectionName}: ${totalModified} documents...`,
|
||||
);
|
||||
|
||||
if (docs.length < BATCH_SIZE) {
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
if (hasMore && BATCH_DELAY_MS > 0) {
|
||||
await sleep(BATCH_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return totalModified;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to batch reset Meili flags for collection '${collection.collectionName}' after processing ${totalModified} documents: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate an environment variable as a positive integer
|
||||
* @param {string} varName - Environment variable name
|
||||
* @param {number} defaultValue - Default value to use if invalid or missing
|
||||
* @returns {number} - Parsed value or default
|
||||
*/
|
||||
function parseEnvInt(varName, defaultValue) {
|
||||
const value = process.env[varName];
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed < 0) {
|
||||
logger.warn(
|
||||
`[batchResetMeiliFlags] Invalid value for ${varName}="${value}". Expected a positive integer. Using default: ${defaultValue}`,
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
batchResetMeiliFlags,
|
||||
};
|
||||
@@ -0,0 +1,523 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { batchResetMeiliFlags } = require('./utils');
|
||||
|
||||
describe('batchResetMeiliFlags', () => {
|
||||
let mongoServer;
|
||||
let testCollection;
|
||||
const ORIGINAL_BATCH_SIZE = process.env.MEILI_SYNC_BATCH_SIZE;
|
||||
const ORIGINAL_BATCH_DELAY = process.env.MEILI_SYNC_DELAY_MS;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
|
||||
// Restore original env variables
|
||||
if (ORIGINAL_BATCH_SIZE !== undefined) {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = ORIGINAL_BATCH_SIZE;
|
||||
} else {
|
||||
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||
}
|
||||
|
||||
if (ORIGINAL_BATCH_DELAY !== undefined) {
|
||||
process.env.MEILI_SYNC_DELAY_MS = ORIGINAL_BATCH_DELAY;
|
||||
} else {
|
||||
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh collection for each test
|
||||
testCollection = mongoose.connection.db.collection('test_meili_batch');
|
||||
await testCollection.deleteMany({});
|
||||
|
||||
// Reset env variables to defaults
|
||||
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testCollection) {
|
||||
await testCollection.deleteMany({});
|
||||
}
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should reset _meiliIndex flag for documents with expiredAt: null and _meiliIndex: true', async () => {
|
||||
// Insert test documents
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc1' },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc2' },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc3' },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(3);
|
||||
|
||||
const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
|
||||
expect(updatedDocs).toHaveLength(3);
|
||||
|
||||
const notUpdatedDocs = await testCollection.find({ _meiliIndex: true }).toArray();
|
||||
expect(notUpdatedDocs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not modify documents with expiredAt set', async () => {
|
||||
const expiredDate = new Date();
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: expiredDate, _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
|
||||
const expiredDoc = await testCollection.findOne({ expiredAt: expiredDate });
|
||||
expect(expiredDoc._meiliIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('should not modify documents with _meiliIndex: false', async () => {
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 0 when no documents match the criteria', async () => {
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when collection is empty', async () => {
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch processing', () => {
|
||||
it('should process documents in batches according to MEILI_SYNC_BATCH_SIZE', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||
|
||||
const docs = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
docs.push({
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
_meiliIndex: true,
|
||||
name: `doc${i}`,
|
||||
});
|
||||
}
|
||||
await testCollection.insertMany(docs);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(5);
|
||||
|
||||
const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
|
||||
expect(updatedDocs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should handle large datasets with small batch sizes', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '10';
|
||||
|
||||
const docs = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
docs.push({
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
_meiliIndex: true,
|
||||
});
|
||||
}
|
||||
await testCollection.insertMany(docs);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
it('should use default batch size of 1000 when env variable is not set', async () => {
|
||||
// Create exactly 1000 documents to verify default batch behavior
|
||||
const docs = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
docs.push({
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
_meiliIndex: true,
|
||||
});
|
||||
}
|
||||
await testCollection.insertMany(docs);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value', () => {
|
||||
it('should return correct modified count', async () => {
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
await expect(batchResetMeiliFlags(testCollection)).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch delay', () => {
|
||||
it('should respect MEILI_SYNC_DELAY_MS between batches', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||
process.env.MEILI_SYNC_DELAY_MS = '50';
|
||||
|
||||
const docs = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
docs.push({
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
_meiliIndex: true,
|
||||
});
|
||||
}
|
||||
await testCollection.insertMany(docs);
|
||||
|
||||
const startTime = Date.now();
|
||||
await batchResetMeiliFlags(testCollection);
|
||||
const endTime = Date.now();
|
||||
|
||||
// With 5 documents and batch size 2, we need 3 batches
|
||||
// That means 2 delays between batches (not after the last one)
|
||||
// So minimum time should be around 100ms (2 * 50ms)
|
||||
// Using a slightly lower threshold to account for timing variations
|
||||
const elapsed = endTime - startTime;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('should not delay when MEILI_SYNC_DELAY_MS is 0', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||
process.env.MEILI_SYNC_DELAY_MS = '0';
|
||||
|
||||
const docs = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
docs.push({
|
||||
_id: new mongoose.Types.ObjectId(),
|
||||
expiredAt: null,
|
||||
_meiliIndex: true,
|
||||
});
|
||||
}
|
||||
await testCollection.insertMany(docs);
|
||||
|
||||
const startTime = Date.now();
|
||||
await batchResetMeiliFlags(testCollection);
|
||||
const endTime = Date.now();
|
||||
|
||||
const elapsed = endTime - startTime;
|
||||
// Should complete without intentional delays, but database operations still take time
|
||||
// Just verify it completes and returns the correct count
|
||||
expect(elapsed).toBeLessThan(1000); // More reasonable upper bound
|
||||
|
||||
const result = await testCollection.countDocuments({ _meiliIndex: false });
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should not delay after the last batch', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '3';
|
||||
process.env.MEILI_SYNC_DELAY_MS = '100';
|
||||
|
||||
// Exactly 3 documents - should fit in one batch, no delay
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
// Verify all 3 documents were processed in a single batch
|
||||
expect(result).toBe(3);
|
||||
|
||||
const updatedDocs = await testCollection.countDocuments({ _meiliIndex: false });
|
||||
expect(updatedDocs).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle documents without _meiliIndex field', async () => {
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
// both documents should be updated
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle mixed document states correctly', async () => {
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: null },
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(4);
|
||||
|
||||
const flaggedDocs = await testCollection
|
||||
.find({ expiredAt: null, _meiliIndex: false })
|
||||
.toArray();
|
||||
expect(flaggedDocs).toHaveLength(5); // 4 were updated, 1 was already false
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error with context when find operation fails', async () => {
|
||||
const mockCollection = {
|
||||
collectionName: 'test_meili_batch',
|
||||
find: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockReturnValue({
|
||||
toArray: jest.fn().mockRejectedValue(new Error('Network error')),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Network error",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error with context when updateMany operation fails', async () => {
|
||||
const mockCollection = {
|
||||
collectionName: 'test_meili_batch',
|
||||
find: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockReturnValue({
|
||||
toArray: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ _id: new mongoose.Types.ObjectId() },
|
||||
{ _id: new mongoose.Types.ObjectId() },
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
updateMany: jest.fn().mockRejectedValue(new Error('Connection lost')),
|
||||
};
|
||||
|
||||
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Connection lost",
|
||||
);
|
||||
});
|
||||
|
||||
it('should include documents processed count in error when failure occurs mid-batch', async () => {
|
||||
// Set batch size to 2 to force multiple batches
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||
process.env.MEILI_SYNC_DELAY_MS = '0'; // No delay for faster test
|
||||
|
||||
let findCallCount = 0;
|
||||
let updateCallCount = 0;
|
||||
|
||||
const mockCollection = {
|
||||
collectionName: 'test_meili_batch',
|
||||
find: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockReturnValue({
|
||||
toArray: jest.fn().mockImplementation(() => {
|
||||
findCallCount++;
|
||||
// Return 2 documents for first two calls (to keep loop going)
|
||||
// Return 2 documents for third call (to trigger third update which will fail)
|
||||
if (findCallCount <= 3) {
|
||||
return Promise.resolve([
|
||||
{ _id: new mongoose.Types.ObjectId() },
|
||||
{ _id: new mongoose.Types.ObjectId() },
|
||||
]);
|
||||
}
|
||||
// Should not reach here due to error
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
updateMany: jest.fn().mockImplementation(() => {
|
||||
updateCallCount++;
|
||||
if (updateCallCount === 1) {
|
||||
return Promise.resolve({ modifiedCount: 2 });
|
||||
} else if (updateCallCount === 2) {
|
||||
return Promise.resolve({ modifiedCount: 2 });
|
||||
} else {
|
||||
return Promise.reject(new Error('Database timeout'));
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 4 documents: Database timeout",
|
||||
);
|
||||
});
|
||||
|
||||
it('should use collection.collectionName in error messages', async () => {
|
||||
const mockCollection = {
|
||||
collectionName: 'messages',
|
||||
find: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockReturnValue({
|
||||
toArray: jest.fn().mockRejectedValue(new Error('Permission denied')),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||
"Failed to batch reset Meili flags for collection 'messages' after processing 0 documents: Permission denied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable validation', () => {
|
||||
let warnSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock logger.warn to track warning calls
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (warnSpy) {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is not a number', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = 'abc';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="abc"'),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 1000'));
|
||||
});
|
||||
|
||||
it('should log warning and use default when MEILI_SYNC_DELAY_MS is not a number', async () => {
|
||||
process.env.MEILI_SYNC_DELAY_MS = 'xyz';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="xyz"'),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 100'));
|
||||
});
|
||||
|
||||
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is negative', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '-50';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="-50"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log warning and use default when MEILI_SYNC_DELAY_MS is negative', async () => {
|
||||
process.env.MEILI_SYNC_DELAY_MS = '-100';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="-100"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid positive integer values without warnings', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '500';
|
||||
process.env.MEILI_SYNC_DELAY_MS = '50';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is zero', async () => {
|
||||
process.env.MEILI_SYNC_BATCH_SIZE = '0';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: 1000'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept zero as a valid value for MEILI_SYNC_DELAY_MS without warnings', async () => {
|
||||
process.env.MEILI_SYNC_DELAY_MS = '0';
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log warnings when environment variables are not set', async () => {
|
||||
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||
|
||||
await testCollection.insertMany([
|
||||
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||
]);
|
||||
|
||||
const result = await batchResetMeiliFlags(testCollection);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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.
|
||||
@@ -0,0 +1,115 @@
|
||||
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...
|
||||
@@ -0,0 +1,3 @@
|
||||
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...
|
||||
@@ -0,0 +1,3 @@
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
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...
|
||||
@@ -0,0 +1,4 @@
|
||||
{"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)"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"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"}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const { mongoose } = require('~/db/base');
|
||||
const mongoose = require('mongoose');
|
||||
const crypto = require('node:crypto');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCustomEndpointConfig } = require('@hanzochat/api');
|
||||
|
||||
@@ -2189,13 +2189,13 @@ describe('models/Agent', () => {
|
||||
const actions = [
|
||||
{
|
||||
action_id: '123',
|
||||
metadata: { version: '1.0', endpoints: ['GET /v1/chat/test'], schema: { type: 'object' } },
|
||||
metadata: { version: '1.0', endpoints: ['GET /api/test'], schema: { type: 'object' } },
|
||||
},
|
||||
{
|
||||
action_id: '456',
|
||||
metadata: {
|
||||
version: '2.0',
|
||||
endpoints: ['POST /v1/chat/example'],
|
||||
endpoints: ['POST /api/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 /v1/chat/test'] } },
|
||||
{ action_id: '123', metadata: { version: '1.0', endpoints: ['GET /api/test'] } },
|
||||
];
|
||||
const actions2 = [
|
||||
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /v1/chat/test'] } },
|
||||
{ action_id: '123', metadata: { version: '2.0', endpoints: ['GET /api/test'] } },
|
||||
];
|
||||
|
||||
const hash1 = await generateActionMetadataHash(actionIds, actions1);
|
||||
@@ -2284,8 +2284,8 @@ describe('models/Agent', () => {
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{ path: '/v1/chat/test', method: 'GET', params: ['id'] },
|
||||
{ path: '/v1/chat/create', method: 'POST', body: true },
|
||||
{ path: '/api/test', method: 'GET', params: ['id'] },
|
||||
{ path: '/api/create', method: 'POST', body: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+2
-7
@@ -1,5 +1,6 @@
|
||||
const {
|
||||
CacheKeys,
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
permissionsSchema,
|
||||
removeNullishValues,
|
||||
@@ -30,13 +31,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
||||
}
|
||||
let role = await query.lean().exec();
|
||||
|
||||
// Only self-heal a missing system role when we actually hold its canonical
|
||||
// defaults. Keying on SystemRoles[roleName] (enum membership) alone let a
|
||||
// role that exists in the enum but lacks a roleDefaults entry (e.g. GUEST)
|
||||
// fall into `new Role(undefined).save()` -> "name is required" -> every
|
||||
// generation for that role died. roleDefaults[roleName] always carries a
|
||||
// name, so this guard makes the nameless create structurally impossible.
|
||||
if (!role && roleDefaults[roleName]) {
|
||||
if (!role && SystemRoles[roleName]) {
|
||||
role = await new Role(roleDefaults[roleName]).save();
|
||||
await cache.set(roleName, role);
|
||||
return role.toObject();
|
||||
|
||||
@@ -214,14 +214,6 @@ async function createTransaction(_txData) {
|
||||
incrementValue,
|
||||
});
|
||||
|
||||
// NB: chat records NO debit to Commerce here. The single debit for an AI spend
|
||||
// is the cloud gateway's (api.hanzo.ai): every authenticated request forwards
|
||||
// the user's own hk- key and the gateway debits that key's Commerce balance
|
||||
// (per-user, fail-closed, 402 when empty). A second debit from chat would
|
||||
// double-charge — the incident that removed the write in recordCollectedUsage
|
||||
// (packages/api usage.ts). This local Transaction/Balance is the in-app usage
|
||||
// ledger only; in prod the balance gate is off (chat.yaml balance.enabled=false).
|
||||
|
||||
return {
|
||||
rate: transaction.rate,
|
||||
user: transaction.user.toString(),
|
||||
|
||||
+44
-106
@@ -1,6 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { billingSubject } = require('@hanzochat/api');
|
||||
const { createAutoRefillTransaction } = require('./Transaction');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { getMultiplier } = require('./tx');
|
||||
@@ -23,21 +22,6 @@ function getMinBalance() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum balance in COMMERCE cents (commerce balances are in cents).
|
||||
* Derived from HANZO_MIN_BALANCE (USD). Used by the commerce-first money gate.
|
||||
*/
|
||||
function getMinBalanceCents() {
|
||||
const envVal = process.env.HANZO_MIN_BALANCE;
|
||||
if (envVal != null && envVal !== '') {
|
||||
const usd = parseFloat(envVal);
|
||||
if (!isNaN(usd) && usd > 0) {
|
||||
return Math.round(usd * 100);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isInvalidDate(date) {
|
||||
return isNaN(date);
|
||||
}
|
||||
@@ -66,24 +50,10 @@ const checkModelAccess = async function (userId, model) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates token cost and decides whether the request may proceed.
|
||||
*
|
||||
* MONEY GATE — Commerce-first and FAIL CLOSED (this is the money path; better to
|
||||
* block a user than bleed). When Commerce is configured and we can identify the
|
||||
* user's billing org (their IAM `organization`), Commerce is AUTHORITATIVE:
|
||||
* - sufficient org balance (>= HANZO_MIN_BALANCE) → allowed (decisive; we do
|
||||
* NOT fall through to the local tokenCredits gate, so startBalance:0 never
|
||||
* false-blocks a Commerce-funded user);
|
||||
* - insufficient / unreachable / no billing identity → BLOCKED.
|
||||
* The cloud gateway separately debits the user's own hk- key against this same
|
||||
* org, so this check is the pre-flight that yields a clean "claim credit" UX
|
||||
* instead of a raw gateway 402.
|
||||
*
|
||||
* When Commerce is not configured (or no billing identity), falls back to the
|
||||
* legacy local tokenCredits gate.
|
||||
* Simple check method that calculates token cost and returns balance info.
|
||||
* Integrates with Commerce balance gate when configured (fail-open).
|
||||
*/
|
||||
const checkBalanceRecord = async function ({
|
||||
req,
|
||||
user,
|
||||
model,
|
||||
endpoint,
|
||||
@@ -95,79 +65,7 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
|
||||
// two ways, neither of which is an authed user's org balance: (1) the per-IP
|
||||
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
|
||||
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
|
||||
// balance the cloud gateway debits and 402s when empty. Running them through the
|
||||
// Commerce/local gate (startBalance:0) would block the free tier entirely.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
const billingOrg = (req?.user?.organization ?? '').toString().trim();
|
||||
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
|
||||
// the authoritative IAM identity; otherwise derive it from organization + email
|
||||
// (name == email for individual signups). This keys the gate on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
|
||||
// the shared org balance.
|
||||
const subject =
|
||||
(req?.user?.billingSubject ?? '').toString().trim() ||
|
||||
billingSubject(billingOrg, req?.user?.email);
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
let commerceBalance;
|
||||
try {
|
||||
commerceBalance = await commerceClient.checkBalance(subject);
|
||||
} catch (err) {
|
||||
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
|
||||
user,
|
||||
subject,
|
||||
error: err.message,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
|
||||
}
|
||||
|
||||
const available = commerceBalance.available || 0;
|
||||
const minCents = Math.max(getMinBalanceCents(), 1);
|
||||
if (available < minCents) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
subject,
|
||||
available,
|
||||
minCents,
|
||||
});
|
||||
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
|
||||
if (model) {
|
||||
try {
|
||||
const modelAccess = await commerceClient.isModelAllowed(subject, model);
|
||||
if (!modelAccess.allowed) {
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: available,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decisive: Commerce says funded → allow. Do not consult local credits.
|
||||
return { canSpend: true, balance: available, tokenCost };
|
||||
}
|
||||
|
||||
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
|
||||
// Retrieve the balance record
|
||||
let record = await Balance.findOne({ user }).lean();
|
||||
if (!record) {
|
||||
logger.debug('[Balance.check] No balance record found for user', { user });
|
||||
@@ -178,6 +76,46 @@ const checkBalanceRecord = async function ({
|
||||
};
|
||||
}
|
||||
|
||||
// Commerce balance gate: if configured, check Commerce first (fail-open)
|
||||
const commerceClient = getCommerceClient();
|
||||
if (commerceClient && record.commerceUserId) {
|
||||
try {
|
||||
const commerceBalance = await commerceClient.checkBalance(record.commerceUserId);
|
||||
if (!commerceBalance.sufficient) {
|
||||
logger.debug('[Balance.check] Commerce balance insufficient', {
|
||||
user,
|
||||
commerceUserId: record.commerceUserId,
|
||||
available: commerceBalance.available,
|
||||
});
|
||||
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_insufficient' };
|
||||
}
|
||||
|
||||
// Check model access via Commerce tier
|
||||
if (model) {
|
||||
const modelAccess = await commerceClient.isModelAllowed(record.commerceUserId, model);
|
||||
if (!modelAccess.allowed) {
|
||||
logger.debug('[Balance.check] Model not allowed for tier', {
|
||||
user,
|
||||
model,
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
});
|
||||
return {
|
||||
canSpend: false,
|
||||
balance: record.tokenCredits,
|
||||
tokenCost,
|
||||
reason: 'model_not_allowed',
|
||||
tier: modelAccess.tier,
|
||||
allowedModels: modelAccess.allowedModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail-open: Commerce error → fall through to local check
|
||||
logger.warn('[Balance.check] Commerce check failed, falling back to local', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let balance = record.tokenCredits;
|
||||
|
||||
// Check if credits have expired
|
||||
@@ -286,7 +224,7 @@ const addIntervalToDate = (date, value, unit) => {
|
||||
* @throws {Error} Throws an error if there's an issue with the balance check.
|
||||
*/
|
||||
const checkBalance = async ({ req, res, txData }) => {
|
||||
const result = await checkBalanceRecord({ ...txData, req });
|
||||
const result = await checkBalanceRecord(txData);
|
||||
if (result.canSpend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
|
||||
// (per-org via service token + X-Hanzo-Org; the cloud gateway debits the user's
|
||||
// own hk- key against the SAME org). Proves both directions: unmetered AI never
|
||||
// leaks (block on any ambiguity), and funded users + capped guests are never
|
||||
// wrongly blocked.
|
||||
|
||||
const mockClient = {
|
||||
checkBalance: jest.fn(),
|
||||
isModelAllowed: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/CommerceClient', () => ({
|
||||
getCommerceClient: jest.fn(() => mockClient),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
|
||||
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
|
||||
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
|
||||
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
|
||||
}));
|
||||
|
||||
const { checkBalance } = require('./balanceMethods');
|
||||
const { getCommerceClient } = require('~/server/services/CommerceClient');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const argsFor = (user) => ({
|
||||
req: { user },
|
||||
res: {},
|
||||
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
|
||||
});
|
||||
|
||||
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
|
||||
// `reason`) when blocked.
|
||||
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
|
||||
try {
|
||||
const ok = await checkBalance(argsFor(user));
|
||||
return { allowed: ok, reason: null };
|
||||
} catch (err) {
|
||||
let reason = null;
|
||||
try {
|
||||
reason = JSON.parse(err.message).reason;
|
||||
} catch (_) {
|
||||
reason = err.message;
|
||||
}
|
||||
return { allowed: false, reason };
|
||||
}
|
||||
};
|
||||
|
||||
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCommerceClient.mockReturnValue(mockClient);
|
||||
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
|
||||
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
|
||||
});
|
||||
|
||||
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
|
||||
const { allowed } = await decide({ id: 'g1', guest: true });
|
||||
expect(allowed).toBe(true);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
expect(Balance.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org (available >= min)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
expect(reason).toBeNull();
|
||||
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_insufficient');
|
||||
});
|
||||
|
||||
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
|
||||
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('commerce_unavailable');
|
||||
});
|
||||
|
||||
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockResolvedValue({
|
||||
allowed: false,
|
||||
tier: 'free',
|
||||
allowedModels: ['zen3-nano'],
|
||||
});
|
||||
const { allowed, reason } = await decide();
|
||||
expect(allowed).toBe(false);
|
||||
expect(reason).toBe('model_not_allowed');
|
||||
});
|
||||
|
||||
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
|
||||
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
||||
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
|
||||
const { allowed } = await decide();
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
|
||||
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
|
||||
const { allowed } = await decide({ id: 'u2', organization: '' });
|
||||
expect(allowed).toBe(false);
|
||||
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const { mongoose } = require('~/db/base');
|
||||
const mongoose = require('mongoose');
|
||||
const { createMethods } = require('@librechat/data-schemas');
|
||||
const methods = createMethods(mongoose);
|
||||
const { comparePassword } = require('./userMethods');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { mongoose } = require('~/db/base');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger, hashToken, getRandomValues } = require('@librechat/data-schemas');
|
||||
const { createToken, findToken } = require('~/models');
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
"axios": "^1.13.5",
|
||||
"@hanzo/base": "^0.2.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.8.1",
|
||||
"connect-redis": "^8.1.0",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,9 +72,6 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,6 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
const { mongoose } = require('~/db/base');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
|
||||
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@hanzochat/api');
|
||||
@@ -45,7 +45,7 @@ const validateResourceType = (resourceType) => {
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, remove)
|
||||
* @route PUT /v1/chat/{resourceType}/{resourceId}/permissions
|
||||
* @route PUT /api/{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 /v1/chat/permissions/{resourceType}/{resourceId}
|
||||
* @route GET /api/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 /v1/chat/{resourceType}/roles
|
||||
* @route GET /api/{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 /v1/chat/{resourceType}/{resourceId}/effective
|
||||
* @route GET /api/{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 /v1/chat/permissions/search-principals
|
||||
* @route GET /api/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 /v1/chat/permissions/{resourceType}/effective/all
|
||||
* @route GET /api/permissions/{resourceType}/effective/all
|
||||
*/
|
||||
const getAllEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { mongoose } = require('~/db/base');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
MAX_SKILL_STATES,
|
||||
|
||||
@@ -40,16 +40,12 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Guest-scoped bootstrap controllers: verify that a guest principal receives
|
||||
* safe, scoped data (and never triggers a DB lookup) on the read-only endpoints
|
||||
* the chat composer hard-requires.
|
||||
*/
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
CacheKeys: { CONFIG_STORE: 'CONFIG_STORE', MODELS_CONFIG: 'MODELS_CONFIG' },
|
||||
Tools: {},
|
||||
Constants: {},
|
||||
FileSources: { local: 'local', s3: 's3' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
// --- EndpointController deps ---
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getEndpointsConfig: jest.fn(),
|
||||
loadDefaultModels: jest.fn(),
|
||||
loadConfigModels: jest.fn(),
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }));
|
||||
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
|
||||
const guestReq = (overrides = {}) => ({
|
||||
user: { id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('guest bootstrap controllers', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('GET /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'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
getBalanceConfig,
|
||||
getProviderConfig,
|
||||
omitTitleOptions,
|
||||
wrapHanzoGatewayFetch,
|
||||
memoryInstructions,
|
||||
applyContextToAgent,
|
||||
createTokenCounter,
|
||||
@@ -892,20 +891,9 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture before running the graph. `run.processStream` (via
|
||||
* `@librechat/agents`) treats the passed `config` as owned and, during its
|
||||
* post-stream cleanup, sets `config.configurable = undefined` to break the
|
||||
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
|
||||
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
|
||||
* therefore throws `Cannot read properties of undefined`, which surfaces as
|
||||
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
|
||||
* Chain filter working without depending on post-run config state.
|
||||
*/
|
||||
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (hideSequentialOutputs) {
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
@@ -941,7 +929,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 `/v1/chat/agents/chat/abort` */
|
||||
This prevents double-spending when user aborts via `/api/agents/chat/abort` */
|
||||
const wasAborted = abortController?.signal?.aborted;
|
||||
if (!wasAborted) {
|
||||
await this.recordCollectedUsage({
|
||||
@@ -1070,22 +1058,6 @@ class AgentClient extends BaseClient {
|
||||
clientOptions.configuration = options.configOptions;
|
||||
}
|
||||
|
||||
// Envelope-safety for the title path (parity with agent runs). The Hanzo Cloud
|
||||
// gateway answers some failures with HTTP 200 + a JSON error-envelope
|
||||
// ({status:"error", msg}) that carries no `choices`. Left raw, the OpenAI client
|
||||
// parses the 200 to `undefined` and #titleConvo crashes on
|
||||
// `reading 'message'` — so no title is ever written. Wrap the title client's
|
||||
// fetch exactly as `initializeCustom` does for agent runs, so the envelope
|
||||
// becomes a clean, catchable error the outer try/catch skips gracefully.
|
||||
// Response-only + idempotent (a re-wrap sees a 402, not a 200 envelope), so
|
||||
// per-user hk- billing and normal completions are untouched.
|
||||
if (
|
||||
clientOptions.configuration &&
|
||||
/(?:^|\.)hanzo\.ai(?::|\/|$)/i.test(clientOptions.configuration.baseURL ?? '')
|
||||
) {
|
||||
clientOptions.configuration.fetch = wrapHanzoGatewayFetch(clientOptions.configuration.fetch);
|
||||
}
|
||||
|
||||
if (clientOptions.maxTokens != null) {
|
||||
delete clientOptions.maxTokens;
|
||||
}
|
||||
@@ -1102,16 +1074,6 @@ class AgentClient extends BaseClient {
|
||||
),
|
||||
);
|
||||
|
||||
// Force the title generation onto the STREAMING (SSE) path. The Hanzo Cloud
|
||||
// gateway's non-streaming (`stream:false`) chat.completion body — though a
|
||||
// valid `{choices:[{message}]}` — is parsed to ZERO generations by the pinned
|
||||
// langchain ChatOpenAI, so `invoke()` then throws
|
||||
// `Cannot read properties of undefined (reading 'message')` and NO title is
|
||||
// ever written. The streaming path (which every agent RUN already uses, hence
|
||||
// generation works) parses the same gateway correctly. `streaming` is stripped
|
||||
// by `omitTitleOptions` above, so it must be (re)set here, after the filter.
|
||||
clientOptions.streaming = true;
|
||||
|
||||
if (
|
||||
provider === Providers.GOOGLE &&
|
||||
(endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
|
||||
|
||||
@@ -15,7 +15,6 @@ jest.mock('@hanzochat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
initializeAgent: jest.fn(),
|
||||
createMemoryProcessor: jest.fn(),
|
||||
createRun: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({
|
||||
@@ -2258,122 +2257,3 @@ describe('AgentClient - titleConvo', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - chatCompletion post-reply banner regression', () => {
|
||||
/**
|
||||
* Regression for the post-reply error banner:
|
||||
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
|
||||
*
|
||||
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
|
||||
* `config` as owned and, in its post-stream cleanup, sets
|
||||
* `config.configurable = undefined` to break the AsyncLocalStorage reference
|
||||
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
|
||||
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
|
||||
* AFTER the stream completes, or it throws and pushes an ERROR content part
|
||||
* (the red banner) which also aborts the post-stream finalize (auto-titling).
|
||||
* This test reproduces that mutation and asserts no ERROR part is produced.
|
||||
*/
|
||||
const { createRun } = require('@hanzochat/api');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
let processStreamMock;
|
||||
|
||||
const makeClient = (agentOverrides = {}) => {
|
||||
const agent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
...agentOverrides,
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {},
|
||||
config: { endpoints: {} },
|
||||
};
|
||||
const client = new AgentClient({
|
||||
req,
|
||||
res: {},
|
||||
agent,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
contentParts: [],
|
||||
collectedUsage: [],
|
||||
artifactPromises: [],
|
||||
endpointTokenConfig: {},
|
||||
});
|
||||
client.conversationId = 'convo-123';
|
||||
client.responseMessageId = 'response-123';
|
||||
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
client.user = 'user-123';
|
||||
// Avoid DB / memory side effects unrelated to this regression.
|
||||
client.recordCollectedUsage = jest.fn().mockResolvedValue();
|
||||
client.runMemory = jest.fn().mockResolvedValue(undefined);
|
||||
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
|
||||
return client;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
|
||||
// the caller's `config.configurable` after the stream finishes.
|
||||
processStreamMock = jest.fn(async (_inputs, config) => {
|
||||
config.configurable = undefined;
|
||||
return [];
|
||||
});
|
||||
createRun.mockResolvedValue({
|
||||
Graph: {},
|
||||
processStream: processStreamMock,
|
||||
});
|
||||
});
|
||||
|
||||
const errorParts = (client) =>
|
||||
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
|
||||
|
||||
it('does not push an error content part when processStream nulls config.configurable', async () => {
|
||||
const client = makeClient();
|
||||
|
||||
await expect(
|
||||
client.sendCompletion('hello', { abortController: new AbortController() }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(processStreamMock).toHaveBeenCalledTimes(1);
|
||||
// The config passed to processStream had configurable nulled (reproduction sanity check).
|
||||
const passedConfig = processStreamMock.mock.calls[0][1];
|
||||
expect(passedConfig.configurable).toBeUndefined();
|
||||
// The fix: no banner-producing ERROR part despite the nulled configurable.
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: true });
|
||||
// Seed multiple content parts; the filter keeps only the last part plus
|
||||
// tool_call parts. With the value captured before the stream, the filter
|
||||
// must run even though config.configurable is undefined afterwards.
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'intermediate-1' },
|
||||
{ type: 'text', text: 'intermediate-2' },
|
||||
{ type: 'text', text: 'final' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
// hide_sequential_outputs collapses to the final part only (no tool calls present).
|
||||
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
|
||||
});
|
||||
|
||||
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
|
||||
const client = makeClient({ hide_sequential_outputs: false });
|
||||
client.contentParts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
];
|
||||
|
||||
await client.sendCompletion('hello', { abortController: new AbortController() });
|
||||
|
||||
expect(errorParts(client)).toHaveLength(0);
|
||||
expect(client.contentParts).toEqual([
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -7,11 +7,7 @@ const {
|
||||
generateAdminExchangeCode,
|
||||
} = require('@hanzochat/api');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const {
|
||||
setAuthTokens,
|
||||
setOpenIDAuthTokens,
|
||||
persistOpenIDTokensToSession,
|
||||
} = require('~/server/services/AuthService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { generateToken } = require('~/models');
|
||||
@@ -60,32 +56,13 @@ function createOAuthHandler(redirectUri = domains.client) {
|
||||
}
|
||||
|
||||
/** Standard OAuth flow - set cookies and redirect */
|
||||
if (req.user && req.user.provider == 'openid') {
|
||||
if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
|
||||
/**
|
||||
* REUSE path: the OIDC tokenset drives BOTH the app auth token and the
|
||||
* refresh grant (`/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);
|
||||
}
|
||||
}
|
||||
if (
|
||||
req.user &&
|
||||
req.user.provider == 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ const mockGenerateAdminExchangeCode = jest.fn();
|
||||
const mockSyncUserEntraGroupMemberships = jest.fn();
|
||||
const mockSetAuthTokens = jest.fn();
|
||||
const mockSetOpenIDAuthTokens = jest.fn();
|
||||
const mockPersistOpenIDTokensToSession = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockCheckBan = jest.fn();
|
||||
const mockGenerateToken = jest.fn();
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn() };
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
|
||||
@@ -34,7 +33,6 @@ jest.mock('~/server/services/PermissionService', () => ({
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
||||
persistOpenIDTokensToSession: (...args) => mockPersistOpenIDTokensToSession(...args),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -150,87 +148,4 @@ describe('createOAuthHandler', () => {
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The decouple: id_token persistence for downstream on-behalf-of is split from
|
||||
// the refresh strategy. OPENID_REUSE_TOKENS still SOLELY gates the OIDC
|
||||
// refresh-grant; it no longer gates whether the id_token is persisted.
|
||||
describe('standard OpenID flow — id_token persistence decouple', () => {
|
||||
beforeEach(() => {
|
||||
/** Not an admin cross-origin redirect — exercise the cookie/session path. */
|
||||
mockIsAdminPanelRedirect.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('REUSE_TOKENS=false: keeps local-JWT refresh AND persists the id_token to session', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
/** Refresh stays on the local-JWT path — login/refresh cookies unchanged. */
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
|
||||
/** id_token IS persisted for on-behalf-of, regardless of REUSE_TOKENS. */
|
||||
expect(mockPersistOpenIDTokensToSession).toHaveBeenCalledWith(req, req.user.tokenset);
|
||||
/** The OIDC-reuse writer (which flips refresh to the OIDC grant) is NOT used. */
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REUSE_TOKENS=true: uses the OIDC writer (refresh path unchanged); no separate persist call', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockSyncUserEntraGroupMemberships).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
'openid-access-token',
|
||||
);
|
||||
expect(mockSetOpenIDAuthTokens).toHaveBeenCalledWith(req.user.tokenset, req, res, 'user-123');
|
||||
/** setOpenIDAuthTokens already persists the id_token; no separate call. */
|
||||
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('local user: setAuthTokens only, never persists an OpenID token', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq({ user: { _id: 'local-1', provider: 'local', email: 'l@example.com' } });
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith('local-1', res);
|
||||
expect(mockPersistOpenIDTokensToSession).not.toHaveBeenCalled();
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
});
|
||||
|
||||
it('login redirect survives a persistence failure (login is paramount)', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
mockPersistOpenIDTokensToSession.mockImplementation(() => {
|
||||
throw new Error('session store down');
|
||||
});
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
/** The redirect still happens; the error is swallowed (best-effort). */
|
||||
expect(mockSetAuthTokens).toHaveBeenCalledWith(req.user._id, res);
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ const getMCPTools = async (req, res) => {
|
||||
};
|
||||
/**
|
||||
* Get all MCP servers with permissions
|
||||
* @route GET /v1/chat/mcp/servers
|
||||
* @route GET /api/mcp/servers
|
||||
*/
|
||||
const getMCPServersList = async (req, res) => {
|
||||
try {
|
||||
@@ -193,7 +193,7 @@ const getMCPServersList = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Create MCP server
|
||||
* @route POST /v1/chat/mcp/servers
|
||||
* @route POST /api/mcp/servers
|
||||
*/
|
||||
const createMCPServerController = async (req, res) => {
|
||||
try {
|
||||
@@ -252,7 +252,7 @@ const getMCPServerById = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Update MCP server
|
||||
* @route PATCH /v1/chat/mcp/servers/:serverName
|
||||
* @route PATCH /api/mcp/servers/:serverName
|
||||
*/
|
||||
const updateMCPServerController = async (req, res) => {
|
||||
try {
|
||||
@@ -287,7 +287,7 @@ const updateMCPServerController = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Delete MCP server
|
||||
* @route DELETE /v1/chat/mcp/servers/:serverName
|
||||
* @route DELETE /api/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('/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('/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('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
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);
|
||||
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);
|
||||
|
||||
/** 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' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat; frame-ancestors 'none';",
|
||||
);
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
next();
|
||||
@@ -174,34 +174,34 @@ const startServer = async () => {
|
||||
|
||||
app.use('/oauth', routes.oauth);
|
||||
/* API Endpoints */
|
||||
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('/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('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
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/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/tags', routes.tags);
|
||||
app.use('/v1/chat/mcp', routes.mcp);
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
app.use(ErrorController);
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('Server Configuration', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app).post('/v1/chat/auth/login').send({
|
||||
const response = await request(app).post('/api/auth/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Integration test for the guest chat middleware chain composition:
|
||||
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
|
||||
* that keeps management routes (abort/active/...) on the JWT-only parent router.
|
||||
*/
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () =>
|
||||
jest.fn((req, res, next) => {
|
||||
req.user = { id: 'real-user', role: 'USER' };
|
||||
next();
|
||||
}),
|
||||
);
|
||||
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const buildRouter = () => {
|
||||
const router = express.Router();
|
||||
const RESERVED = new Set(['abort', 'active']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
chatRouter.post('/', (req, res) =>
|
||||
res
|
||||
.status(200)
|
||||
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
|
||||
);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// JWT-only management route on the parent (after the guest router)
|
||||
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
|
||||
res.status(200).json({ aborted: true, role: req.user.role }),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/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' });
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Regression: a logged-in user in "My Agents" with no agent created/selected
|
||||
* could type "hi" and get `400 {message:'agent_id is required in request body'}`.
|
||||
*
|
||||
* A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat, not an
|
||||
* error. This hermetic test locks the access-middleware fix point (no MongoDB /
|
||||
* winston): canAccessAgentFromBody skips the per-agent ACL and calls next().
|
||||
* The downstream build-options fix point is covered by
|
||||
* ../../services/Endpoints/agents/build.spec.js.
|
||||
*/
|
||||
|
||||
// Mock externals so neither winston (data-schemas) nor MongoDB (~/models/Agent) load.
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), debug: jest.fn() } }));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Constants: { EPHEMERAL_AGENT_ID: 'ephemeral' },
|
||||
ResourceType: { AGENT: 'agent' },
|
||||
// Real predicate semantics (mirrors packages/data-provider/src):
|
||||
isAgentsEndpoint: (endpoint) => endpoint === 'agents',
|
||||
isEphemeralAgentId: (agentId) => !agentId?.startsWith?.('agent_'),
|
||||
}));
|
||||
|
||||
const mockCanAccessResource = jest.fn(() => (req, res, next) => {
|
||||
req.__aclChecked = true;
|
||||
return next();
|
||||
});
|
||||
jest.mock('./canAccessResource', () => ({ canAccessResource: mockCanAccessResource }));
|
||||
|
||||
jest.mock('~/models/Agent', () => ({ getAgent: jest.fn() }));
|
||||
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
const makeReqRes = (body) => {
|
||||
const req = { body, params: {} };
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const next = jest.fn();
|
||||
return { req, res, next };
|
||||
};
|
||||
|
||||
describe('agent_id missing → ephemeral fallback (no 400)', () => {
|
||||
const mw = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
|
||||
test('agents endpoint with NO agent_id proceeds without a 400', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource).not.toHaveBeenCalled(); // no per-agent ACL for ephemeral
|
||||
});
|
||||
|
||||
test('non-agents endpoint with no agent_id proceeds (ephemeral)', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'openAI', text: 'hi' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('explicit ephemeral id proceeds', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'ephemeral_primary' });
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a real agent_id still runs the per-agent ACL check', async () => {
|
||||
const { req, res, next } = makeReqRes({ endpoint: 'agents', agent_id: 'agent_abc123' });
|
||||
await mw(req, res, next);
|
||||
|
||||
// The middleware builds the ACL check with the real agent id as resourceIdParam,
|
||||
// then runs it (which here calls next via the mock).
|
||||
expect(mockCanAccessResource).toHaveBeenCalledTimes(1);
|
||||
expect(mockCanAccessResource.mock.calls[0][0]).toMatchObject({ resourceType: 'agent' });
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -60,11 +60,15 @@ const canAccessAgentFromBody = (options) => {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
// A missing agent_id on the agents endpoint is an ad-hoc (ephemeral) chat,
|
||||
// not an error: "type a message and get a reply" must work before the user
|
||||
// has created or selected an agent. Ephemeral agents carry no per-agent ACL,
|
||||
// so skip the permission check and fall through to plain chat. A missing id
|
||||
// is ephemeral by the canonical predicate (isEphemeralAgentId(undefined) === true).
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
if (isEphemeralAgentId(agentId)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -104,15 +104,13 @@ describe('canAccessAgentFromBody middleware', () => {
|
||||
});
|
||||
|
||||
describe('primary agent checks', () => {
|
||||
test('proceeds (ephemeral fallback) when agent_id is missing on agents endpoint', async () => {
|
||||
// No agent created/selected yet → ad-hoc chat. "Type hi and get a reply"
|
||||
// must work out of the box; a missing id is ephemeral, not a 400.
|
||||
test('returns 400 when agent_id is missing on agents endpoint', async () => {
|
||||
req.body.agent_id = undefined;
|
||||
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test('proceeds for ephemeral primary agent without addedConvo', async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ const buildEndpointOption = require('./buildEndpointOption');
|
||||
const createReq = (body, config = {}) => ({
|
||||
body,
|
||||
config,
|
||||
baseUrl: '/v1/chat/chat',
|
||||
baseUrl: '/api/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 === '/v1/chat/agents' && originalUrl.startsWith('/v1/chat/agents/chat')) {
|
||||
} else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
|
||||
return await denyRequest(req, res, { type: ViolationTypes.BAN });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -10,8 +10,6 @@ const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -38,8 +36,6 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
/**
|
||||
* Per-user rate limiter for the cloud-agents proxy (`/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;
|
||||
@@ -1,41 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
|
||||
const parsePositiveInt = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
|
||||
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
|
||||
|
||||
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
|
||||
const max = GUEST_TOKEN_MAX;
|
||||
const windowInMinutes = windowMs / 60000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(429).json({
|
||||
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for guest token issuance.
|
||||
*
|
||||
* Caps how many guest tokens a single client IP can mint per window so nobody can
|
||||
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
|
||||
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`) and backed by the shared
|
||||
* Redis `limiterCache` so the cap holds across replicas. Note this is defense in
|
||||
* depth only: even unlimited tokens cannot multiply the message quota, which is
|
||||
* itself keyed per-IP (see `guestMessageLimiter`).
|
||||
*/
|
||||
const guestTokenLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
store: limiterCache('guest_token_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestTokenLimiter };
|
||||
@@ -1,36 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(402).json({
|
||||
type: 'GUEST_LIMIT',
|
||||
message: 'Guest message limit reached. Log in to continue.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP quota limiter for anonymous guest chat completions.
|
||||
*
|
||||
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
|
||||
* message limiters), so the count is shared across replicas — a guest cannot
|
||||
* multiply their quota by round-robining pods. The key is the REAL client IP
|
||||
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`), NOT the guest token, so
|
||||
* clearing cookies, opening incognito, or minting a fresh guest token does not
|
||||
* reset the count. It only counts requests made by an authenticated guest
|
||||
* principal; logged-in users skip the counter entirely. On exhaustion it returns
|
||||
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
|
||||
*/
|
||||
const guestMessageLimiter = rateLimit({
|
||||
windowMs: GUEST_LIMIT_WINDOW_MS,
|
||||
max: () => getGuestConfig().messageMax,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
skip: (req) => req.user?.guest !== true,
|
||||
store: limiterCache('guest_message_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestMessageLimiter };
|
||||
@@ -1,78 +0,0 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
|
||||
const buildApp = (user) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use(guestMessageLimiter);
|
||||
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guestMessageLimiter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
messageMax: 3,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
const ip = '10.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('counts quota per IP independently', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
}
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
expect(blocked.status).toBe(402);
|
||||
|
||||
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
|
||||
expect(fresh.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never counts or blocks authenticated (non-guest) users', async () => {
|
||||
const app = buildApp({ id: 'real-user', role: 'USER' });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,11 @@ const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const { guestTokenLimiter } = require('./guestLimiters');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
const registerLimiter = require('./registerLimiter');
|
||||
const toolCallLimiter = require('./toolCallLimiter');
|
||||
const cloudAgentLimiter = require('./cloudAgentLimiter');
|
||||
const messageLimiters = require('./messageLimiters');
|
||||
const verifyEmailLimiter = require('./verifyEmailLimiter');
|
||||
const resetPasswordLimiter = require('./resetPasswordLimiter');
|
||||
@@ -20,11 +17,8 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
verifyEmailLimiter,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -36,7 +36,7 @@ function createApp(user) {
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/v1/chat/config', configRoute);
|
||||
app.use('/api/config', configRoute);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
||||
delete process.env.RUM_ENVIRONMENT;
|
||||
});
|
||||
|
||||
describe('GET /v1/chat/config RUM config', () => {
|
||||
describe('GET /api/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 /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_ENVIRONMENT = 'test';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
@@ -107,7 +107,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -119,12 +119,12 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: '/v1/chat/rum',
|
||||
url: '/api/rum',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'proxy',
|
||||
consoleCapture: false,
|
||||
@@ -139,7 +139,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'proxy';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum?.url).toBe('http://[::1]:4318');
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('GET /v1/chat/config RUM config', () => {
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/v1/chat/config');
|
||||
const response = await request(app).get('/api/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('/v1/chat/config', configRoute);
|
||||
app.use('/api/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 /v1/chat/convos/duplicate - Rate Limiting', () => {
|
||||
describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||
let app;
|
||||
let duplicateConversation;
|
||||
const savedEnv = {};
|
||||
@@ -73,7 +73,7 @@ describe('POST /v1/chat/convos/duplicate - Rate Limiting', () => {
|
||||
req.user = { id: 'rate-limit-test-user' };
|
||||
next();
|
||||
});
|
||||
app.use('/v1/chat/convos', convosRouter);
|
||||
app.use('/api/convos', convosRouter);
|
||||
});
|
||||
|
||||
duplicateConversation.mockResolvedValue({
|
||||
@@ -95,13 +95,13 @@ describe('POST /v1/chat/convos/duplicate - Rate Limiting', () => {
|
||||
|
||||
for (let i = 0; i < userMax; i++) {
|
||||
const res = await request(app)
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.post('/api/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.post('/api/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 /v1/chat/convos/duplicate - Rate Limiting', () => {
|
||||
|
||||
for (let i = 0; i < ipMax; i++) {
|
||||
const res = await request(app)
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.post('/api/convos/duplicate')
|
||||
.send({ conversationId: 'conv-123' });
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/chat/convos/duplicate')
|
||||
.post('/api/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('/v1/chat/convos', convosRouter);
|
||||
app.use('/api/convos', convosRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -145,7 +145,7 @@ describe('Convos Routes', () => {
|
||||
deletedCount: 3,
|
||||
});
|
||||
|
||||
const response = await request(app).delete('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
let response = await request(app).delete('/api/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('/v1/chat/convos', require('../convos'));
|
||||
app2.use('/api/convos', require('../convos'));
|
||||
|
||||
deleteConvos.mockResolvedValue({ deletedCount: 7 });
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
|
||||
deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
|
||||
|
||||
response = await request(app2).delete('/v1/chat/convos/all');
|
||||
response = await request(app2).delete('/api/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('/v1/chat/convos/all');
|
||||
await request(app).delete('/api/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('/v1/chat/convos/all');
|
||||
const response = await request(app).delete('/api/convos/all');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/v1/chat/convos')
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -341,7 +341,7 @@ describe('Convos Routes', () => {
|
||||
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/v1/chat/convos')
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
source: 'button',
|
||||
@@ -363,7 +363,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/v1/chat/convos')
|
||||
.delete('/api/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('/v1/chat/convos').send({
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos').send({});
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos').send({ arg: null });
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos').send({ arg: undefined });
|
||||
const response = await request(app).delete('/api/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('/v1/chat/convos')
|
||||
.delete('/api/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('/v1/chat/convos')
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -458,7 +458,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete('/v1/chat/convos')
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -479,7 +479,7 @@ describe('Convos Routes', () => {
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/v1/chat/convos')
|
||||
.delete('/api/convos')
|
||||
.send({
|
||||
arg: {
|
||||
conversationId: mockConversationId,
|
||||
@@ -509,7 +509,7 @@ describe('Convos Routes', () => {
|
||||
saveConvo.mockResolvedValue(mockArchivedConvo);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/convos/archive')
|
||||
.post('/api/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 /v1/chat/convos/archive ${mockConversationId}` },
|
||||
{ context: `POST /api/convos/archive ${mockConversationId}` },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('Convos Routes', () => {
|
||||
saveConvo.mockResolvedValue(mockUnarchivedConvo);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/convos/archive')
|
||||
.post('/api/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 /v1/chat/convos/archive ${mockConversationId}` },
|
||||
{ context: `POST /api/convos/archive ${mockConversationId}` },
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 when conversationId is missing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/convos/archive')
|
||||
.post('/api/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('/v1/chat/convos/archive')
|
||||
.post('/api/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('/v1/chat/convos/archive')
|
||||
.post('/api/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('/v1/chat/convos/archive')
|
||||
.post('/api/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('/v1/chat/convos/archive').send({
|
||||
const response = await request(app).post('/api/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('/v1/chat/admin/grants', router);
|
||||
app.use('/api/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('/v1/chat/admin/grants').expect(200);
|
||||
const res = await request(app).get('/api/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('/v1/chat/admin/grants/effective').expect(200);
|
||||
const res = await request(app).get('/api/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('/v1/chat/admin/grants')
|
||||
.post('/api/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(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.get(`/api/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(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
|
||||
.delete(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`)
|
||||
.expect(200);
|
||||
|
||||
// Verify revoked
|
||||
const afterRes = await request(app)
|
||||
.get(`/v1/chat/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`)
|
||||
.get(`/api/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('/v1/chat/admin/grants')
|
||||
.post('/api/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('/v1/chat/admin/grants')
|
||||
.post('/api/admin/grants')
|
||||
.send({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: SystemRoles.USER,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* Proves:
|
||||
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
|
||||
* - /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('/v1/chat/keys', keysRouter);
|
||||
app.use('/api/keys', keysRouter);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,7 +40,7 @@ describe('Keys Routes', () => {
|
||||
updateUserKey.mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
.put('/v1/chat/keys')
|
||||
.put('/api/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('/v1/chat/keys').send({
|
||||
const response = await request(app).put('/api/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('/v1/chat/keys').send({
|
||||
const response = await request(app).put('/api/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('/v1/chat/keys')
|
||||
.put('/api/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('/v1/chat/keys')
|
||||
.put('/api/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('/v1/chat/keys/openAI');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/keys?all=true');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/keys');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/keys?name=openAI');
|
||||
const response = await request(app).get('/api/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('/v1/chat/config', (req, res) => {
|
||||
app.get('/api/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('/v1/chat/config');
|
||||
const response = await request(app).get('/api/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('/v1/chat/config');
|
||||
const response = await request(app).get('/api/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('/v1/chat/config');
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.ldap).toBeUndefined();
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('MCP Routes', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/v1/chat/mcp', mcpRouter);
|
||||
app.use('/api/mcp', mcpRouter);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -182,7 +182,7 @@ describe('MCP Routes', () => {
|
||||
flowId: 'test-user-id:test-server',
|
||||
});
|
||||
|
||||
const response = await request(app).get('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/initiate').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/callback').query({
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/test-server/oauth/callback')
|
||||
.get('/api/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('/v1/chat/mcp/oauth/tokens/test-user-id:flow-123');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/oauth/tokens/test-flow-id');
|
||||
const response = await request(unauthApp).get('/api/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('/v1/chat/mcp/oauth/tokens/other-user-id:flow-123');
|
||||
const response = await request(app).get('/api/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(
|
||||
'/v1/chat/mcp/oauth/tokens/test-user-id:non-existent-flow',
|
||||
'/api/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('/v1/chat/mcp/oauth/tokens/test-user-id:pending-flow');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/tokens/test-user-id:error-flow');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/status/test-user-id:test-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/status/other-user-id:test-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/status/test-user-id:non-existent');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/status/test-user-id:error-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).post('/v1/chat/mcp/oauth/cancel/test-server');
|
||||
const response = await request(unauthApp).post('/api/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('/v1/chat/mcp/non-existent-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/oauth-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/error-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).post('/v1/chat/mcp/test-server/reinitialize');
|
||||
const response = await request(unauthApp).post('/api/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('/v1/chat/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/test-server/reinitialize');
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/connection/status');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/connection/status');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/connection/status');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status');
|
||||
const response = await request(unauthApp).get('/api/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('/v1/chat/mcp/connection/status/oauth-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/connection/status/non-existent-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/connection/status/test-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/connection/status/test-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/connection/status/test-server');
|
||||
const response = await request(unauthApp).get('/api/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('/v1/chat/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/non-existent-server/auth-values');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/test-server/auth-values');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp', mcpRouter);
|
||||
appWithoutAuth.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(appWithoutAuth).get('/v1/chat/mcp/test-server/auth-values');
|
||||
const response = await request(appWithoutAuth).get('/api/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(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.get(`/api/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(`/v1/chat/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
||||
.get(`/api/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('/v1/chat/mcp/servers');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/servers');
|
||||
const response = await request(app).get('/api/mcp/servers');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({});
|
||||
@@ -1606,9 +1606,9 @@ describe('MCP Routes', () => {
|
||||
req.user = null;
|
||||
next();
|
||||
});
|
||||
unauthApp.use('/v1/chat/mcp', mcpRouter);
|
||||
unauthApp.use('/api/mcp', mcpRouter);
|
||||
|
||||
const response = await request(unauthApp).get('/v1/chat/mcp/servers');
|
||||
const response = await request(unauthApp).get('/api/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('/v1/chat/mcp/servers');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/servers').send({ config: validConfig });
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/servers').send({ config: stdioConfig });
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/servers').send({ config: invalidConfig });
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/servers').send({ config: invalidConfig });
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/servers').send({ config: validConfig });
|
||||
const response = await request(app).post('/api/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('/v1/chat/mcp/servers/test-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/servers/non-existent-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/servers/error-server');
|
||||
const response = await request(app).get('/api/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('/v1/chat/mcp/servers/test-server')
|
||||
.patch('/api/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('/v1/chat/mcp/servers/test-server')
|
||||
.patch('/api/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('/v1/chat/mcp/servers/test-server')
|
||||
.patch('/api/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('/v1/chat/mcp/servers/test-server');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/mcp/servers/error-server');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/messages', messagesRouter);
|
||||
app.use('/api/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('/v1/chat/messages/convo-1/msg-1');
|
||||
await request(app).delete('/api/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('/v1/chat/messages/convo-1/msg-owned');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/messages/convo-1/msg-1');
|
||||
const response = await request(app).delete('/api/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('/v1/chat/messages', messagesRouter);
|
||||
app.use('/api/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(`/v1/chat/messages/${urlConversationId}`).send({
|
||||
const response = await request(app).post(`/api/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 /v1/chat/messages/:conversationId' },
|
||||
{ context: 'POST /api/messages/:conversationId' },
|
||||
);
|
||||
expect(saveMessage.mock.calls[0][1].conversationId).not.toBe(bodyConversationId);
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: authenticatedUserId }),
|
||||
savedMessage,
|
||||
{ context: 'POST /v1/chat/messages/:conversationId' },
|
||||
{ context: 'POST /api/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('/v1/chat/messages/convo-1');
|
||||
const response = await request(app).get('/api/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('/v1/chat/messages/convo-1/message-1');
|
||||
const response = await request(app).get('/api/messages/convo-1/message-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getMessages).toHaveBeenCalledWith(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user