Compare commits

..
Author SHA1 Message Date
zandClaude Opus 4.8 6fa2d3e224 feat(image): text-to-image via the Hanzo gateway, metered per-user
Enables 'generate an image of X' to render a real image inline through the
DALLE3 agent tool pointed at the Hanzo gateway (/v1/images/generations):
- DALLE3.js: model is configurable (DALLE3_MODEL, e.g. zen3-image); DALL-E-3-only
  knobs (quality/style) are sent ONLY for a dall-e model so the Hanzo image
  backend never sees a param it would reject. Agent path already fetches the
  result server-side and returns it inline (upstream host never reaches client).
- handleTools.js: dalle is now a custom constructor that injects the signed-in
  user's PER-USER hk- key (resolveHanzoCloudKey) so image generation is metered
  to them — mirroring the chat per-user key path. Guests keep the shared key;
  an authed user whose key can't be resolved FAILS CLOSED (no shared-org spend).

Deploy also sets env DALLE_REVERSE_PROXY=https://api.hanzo.ai/v1/images/generations
and DALLE3_MODEL=zen3-image. Zen-brand model id only; upstream never surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:46:03 -07:00
126 changed files with 1286 additions and 13674 deletions
-16
View File
@@ -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"
-3
View File
@@ -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
+22 -23
View File
@@ -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 != '[]'
+57
View File
@@ -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)
-16
View File
@@ -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
+27 -27
View File
@@ -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 -4
View File
@@ -1,9 +1,7 @@
# v0.8.3-rc1
# Base node image — Hanzo Node 24 (Alpine) base: node:sqlite (DatabaseSync) is
# built in and native better-sqlite3 compiles (build-base + python3 + g++ baked
# in), so the CHAT_STORE_SQLITE document store runs. Node 20 lacked node:sqlite.
FROM ghcr.io/hanzoai/nodejs:v24.18.0 AS node
# Base node image
FROM node:20-alpine AS node
# Install jemalloc
RUN apk add --no-cache jemalloc
+1 -1
View File
@@ -5,7 +5,7 @@
ARG NODE_MAX_OLD_SPACE_SIZE=6144
# Base for all builds
FROM ghcr.io/hanzoai/nodejs:v24.18.0 AS base-min
FROM node:20-alpine AS base-min
# Install jemalloc
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
+1 -1
View File
@@ -6,7 +6,7 @@
# Build: docker build -f Dockerfile.static -t ghcr.io/hanzoai/chat:latest .
# Run: docker run -p 3080:3080 ghcr.io/hanzoai/chat:latest
FROM ghcr.io/hanzoai/nodejs:v24.18.0
FROM node:22-alpine
RUN npm install -g serve@14
+3 -15
View File
@@ -5,11 +5,9 @@ const { logger } = require('@librechat/data-schemas');
const mongoose = require('mongoose');
const MONGO_URI = process.env.MONGO_URI;
// MONGO_URI is intentionally optional: in SQLite-only mode (every collection
// served from CHAT_STORE_SQLITE, chat-docdb deleted) there is no Mongo to
// connect to. connectDb() below handles the unset case by skipping the
// connection rather than failing boot. When MONGO_URI IS set (default and the
// dual-write migration window) the connection is required as before.
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. */
@@ -47,16 +45,6 @@ mongoose.connection.on('error', (err) => {
});
async function connectDb() {
if (!MONGO_URI) {
// SQLite-only mode: skip Mongo entirely. Disable command buffering so any
// stray mongoose query (e.g. Meili indexSync, which still references
// mongoose.models directly) fails fast and is swallowed by its caller
// instead of hanging forever on a pool that will never connect.
mongoose.set('bufferCommands', false);
logger.info('[connectDb] MONGO_URI unset — SQLite-only mode, skipping MongoDB connection');
return null;
}
if (cached.conn && cached.conn?._readyState === 1) {
return cached.conn;
}
+2 -7
View File
@@ -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();
-8
View File
@@ -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(),
-27
View File
@@ -15,7 +15,6 @@ const {
getBalanceConfig,
getProviderConfig,
omitTitleOptions,
wrapHanzoGatewayFetch,
memoryInstructions,
applyContextToAgent,
createTokenCounter,
@@ -1070,22 +1069,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 +1085,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 ||
+39 -8
View File
@@ -214,9 +214,7 @@ const registerUser = async (user, additionalData = {}) => {
//determine if this is the first registered user (not counting anonymous_user)
const isFirstRegisteredUser = (await countUsers()) === 0;
// No local password credential is stored — identity is owned by Hanzo IAM.
// (This local path is gated off in prod via ALLOW_REGISTRATION=false; the
// full local-strategy teardown lands with the identity cutover.)
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: provider ?? 'local',
email,
@@ -224,6 +222,7 @@ const registerUser = async (user, additionalData = {}) => {
name,
avatar: null,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password, salt),
...additionalData,
};
@@ -328,11 +327,43 @@ const requestPasswordReset = async (req) => {
* @param {String} password
* @returns
*/
const resetPassword = async () => {
// Chat stores no local password credential. Password reset is owned by Hanzo
// IAM (hanzo.id); there is nothing to reset here. Prod already disables this
// route (ALLOW_EMAIL_LOGIN=false) — this is the service-level guarantee.
return new Error('Password reset is managed by Hanzo IAM (hanzo.id).');
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await findToken(
{
userId,
},
{ sort: { createdAt: -1 } },
);
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
}
const isValid = bcrypt.compareSync(token, passwordResetToken.token);
if (!isValid) {
return new Error('Invalid or expired password reset token');
}
const hash = bcrypt.hashSync(password, 10);
const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) {
await sendEmail({
email: user.email,
subject: 'Password Reset Successfully',
payload: {
appName: process.env.APP_TITLE || 'Hanzo Chat',
name: user.name || user.username || user.email,
year: new Date().getFullYear(),
},
template: 'passwordReset.handlebars',
});
}
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
/**
+3 -13
View File
@@ -50,25 +50,15 @@ const MAX_RESPONSE = 4 * 1024 * 1024;
*/
const MAX_CONCURRENT = Number(process.env.CLOUD_AGENT_MAX_CONCURRENT) || 50;
/**
* HTTP timeout (ms) for a cloud call. A run is a real chat completion — a
* zen5-mini answer routinely takes ~25-30s and larger models/prompts longer — so
* the old 30s default aborted mid-run, surfacing as an in-UI 502 even though the
* cloud run finished and recorded. 180s gives real runs headroom; override with
* CLOUD_AGENT_TIMEOUT. (List/get are fast; the ceiling only matters for /run.)
*/
const DEFAULT_TIMEOUT = Number(process.env.CLOUD_AGENT_TIMEOUT) || 180000;
class CloudAgentsClient {
/**
* @param {Object} opts
* @param {string} opts.endpoint - Cloud base URL (e.g. https://api.hanzo.ai)
* @param {number} [opts.timeout] - HTTP timeout in ms (default DEFAULT_TIMEOUT,
* 180s; a run is a real chat completion so it needs far more headroom than a
* metadata read — 30s aborted long runs mid-flight)
* @param {number} [opts.timeout] - HTTP timeout in ms (default 30000; a run is
* a real chat completion so it needs more headroom than a metadata read)
* @param {number} [opts.maxConcurrent] - process-wide in-flight ceiling
*/
constructor({ endpoint, timeout = DEFAULT_TIMEOUT, maxConcurrent = MAX_CONCURRENT }) {
constructor({ endpoint, timeout = 30000, maxConcurrent = MAX_CONCURRENT }) {
this.endpoint = endpoint.replace(/\/+$/, '');
this.timeout = timeout;
this.maxConcurrent = maxConcurrent;
@@ -210,36 +210,6 @@ describe('CloudAgentsClient', () => {
});
});
describe('run timeout (headroom for long completions)', () => {
it('defaults to 180s so a long run is not aborted mid-flight (the 30s -> 502 bug)', () => {
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai' });
expect(client.timeout).toBe(180000);
});
it('honors an explicit constructor timeout', () => {
const client = new CloudAgentsClient({ endpoint: 'https://api.hanzo.ai', timeout: 5000 });
expect(client.timeout).toBe(5000);
});
it('is overridable via CLOUD_AGENT_TIMEOUT (mirrors CLOUD_AGENT_MAX_CONCURRENT)', () => {
const saved = process.env.CLOUD_AGENT_TIMEOUT;
process.env.CLOUD_AGENT_TIMEOUT = '90000';
// DEFAULT_TIMEOUT is read at module load, so re-require in isolation.
jest.resetModules();
const { CloudAgentsClient: Fresh } = require('./CloudAgentsClient');
try {
expect(new Fresh({ endpoint: 'https://api.hanzo.ai' }).timeout).toBe(90000);
} finally {
if (saved === undefined) {
delete process.env.CLOUD_AGENT_TIMEOUT;
} else {
process.env.CLOUD_AGENT_TIMEOUT = saved;
}
jest.resetModules();
}
});
});
describe('getCloudAgentsClient (env wiring)', () => {
const saved = {};
beforeEach(() => {
+94 -8
View File
@@ -1,15 +1,11 @@
const { logger } = require('@librechat/data-schemas');
/**
* CommerceClient is chat's READ-ONLY window into Hanzo Commerce (balance, tier,
* credit breakdown). It NEVER writes: the single debit for an AI spend is the
* cloud gateway's (api.hanzo.ai debits the forwarded per-user hk- key), and the
* only credit (the first-chat starter grant) is issued by resolveHanzoCloudKey
* (packages/api) at the request boundary. Two writers to one ledger is the
* double-debit anti-pattern — so chat stays a reader. Pattern follows cloud-api's
* filter_balance.go:
* CommerceClient provides a cached, fail-open interface to Hanzo Commerce
* billing APIs. Pattern follows cloud-api's filter_balance.go:
* - 30s TTL balance/tier cache with async refresh
* - Reads fail CLOSED (the money gate); tier/breakdown fail open
* - Fire-and-forget usage recording queue
* - All errors fail-open (local MongoDB is authoritative fallback)
*
* @example
* const client = new CommerceClient({
@@ -37,6 +33,11 @@ class CommerceClient {
// Tier cache: userId -> { data, fetchedAt, refreshing }
this._tierCache = new Map();
// Usage recording queue
this._usageQueue = [];
this._usageFlushing = false;
this._usageFlushInterval = setInterval(() => this._flushUsageQueue(), 5000);
// Cache cleanup every 5 minutes
this._cleanupInterval = setInterval(() => this._cleanupCaches(), 300000);
}
@@ -133,6 +134,64 @@ class CommerceClient {
return { allowed, tier: tier.name, allowedModels: tier.allowedModels };
}
/**
* Enqueue usage recording (fire-and-forget). Commerce calls BurnCredits
* internally to burn trial grants first, then paid.
*
* @param {Object} usage
* @param {string} usage.userId
* @param {string} usage.model
* @param {number} usage.promptTokens
* @param {number} usage.completionTokens
* @param {number} usage.amountCents - Cost in cents
*/
recordUsage({ userId, model, promptTokens, completionTokens, amountCents }) {
this._usageQueue.push({
user: userId,
model,
promptTokens: promptTokens || 0,
completionTokens: completionTokens || 0,
amount: amountCents || 0,
currency: 'usd',
status: 'completed',
});
// Flush immediately if queue is large
if (this._usageQueue.length >= 50) {
this._flushUsageQueue().catch(() => {});
}
}
/**
* Ensure a subject has the one-time $5 starter credit, idempotently.
*
* This posts to /v1/billing/grant-starter, which creates a real Deposit
* transaction (tag "starter-credit", $5, 30-day expiry) — so it nets into
* GET /v1/billing/balance, the account the gateway gate reads and debits.
* (NOT a credit-grant record: those live in a separate ledger the balance
* endpoint does not read, so they would never unblock the gate.)
*
* Idempotent + race-safe in Commerce (tag-deduped inside a transaction): safe
* to call on every first chat; duplicate/concurrent calls never double-grant.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{granted: boolean}|null>} or null on failure
*/
async grantStarter(subject) {
try {
const resp = await this._request(
'POST',
'/v1/billing/grant-starter',
{ user: subject, trigger: 'chat_first_use' },
this._namespaceOf(subject),
);
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to ensure starter credit', err);
return null;
}
}
/**
* Get credit balance breakdown by tag (trial vs purchased).
*
@@ -209,6 +268,30 @@ class CommerceClient {
}
}
async _flushUsageQueue() {
if (this._usageFlushing || this._usageQueue.length === 0) {
return;
}
this._usageFlushing = true;
const batch = this._usageQueue.splice(0, 100);
for (const usage of batch) {
try {
await this._request('POST', '/v1/billing/usage', usage);
} catch (err) {
logger.warn('[CommerceClient] Usage recording failed', {
user: usage.user,
model: usage.model,
error: err.message,
});
// Don't retry — usage is also tracked locally in MongoDB
}
}
this._usageFlushing = false;
}
/**
* @param {string} method
* @param {string} path
@@ -268,7 +351,10 @@ class CommerceClient {
}
destroy() {
clearInterval(this._usageFlushInterval);
clearInterval(this._cleanupInterval);
// Flush remaining usage
this._flushUsageQueue().catch(() => {});
}
}
+15 -9
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<base href="/" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#171717" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -24,15 +24,21 @@
}
</style>
<script>
// Hard default: true-black dark, unless the user explicitly picked light or system-light.
// Applied on <html> synchronously here to avoid any light flash before React hydrates.
const stored = localStorage.getItem('color-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = stored === 'light' ? false : stored === 'system' ? prefersDark : true;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
const theme = localStorage.getItem('color-theme');
const loadingContainerStyle = document.createElement('style');
const backgroundColor = isDark ? '#000000' : '#ffffff';
let backgroundColor;
if (theme === 'dark') {
backgroundColor = '#070b13';
} else if (theme === 'light') {
backgroundColor = '#ffffff';
} else if (theme === 'system') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
backgroundColor = prefersDarkScheme ? '#070b13' : '#ffffff';
} else {
backgroundColor = '#ffffff';
}
loadingContainerStyle.innerHTML = `
#loading-container {
display: flex;
+20 -29
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useState, useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom';
@@ -23,34 +23,25 @@ const ReactQueryDevtools = import.meta.env.DEV
const App = () => {
const { setError } = useApiErrorBoundary();
// Stabilize the QueryClient for the app's lifetime. Creating it in the render
// body mints a fresh, empty client on every re-render; a guest's expected 401s
// (mcp/servers, files/config, …) fire `setError`, re-rendering App and swapping
// in an empty client — so the lazy chat-form's `getQueryData([endpoints])`
// resolves to a client with no `Hanzo` endpoint and submit throws
// "Unknown endpoint: Hanzo". One client, one cache, every consumer.
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Always attempt network requests, even when navigator.onLine is false
// This is needed because localhost is reachable without WiFi
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {
setError(error);
}
},
}),
}),
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Always attempt network requests, even when navigator.onLine is false
// This is needed because localhost is reachable without WiFi
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {
setError(error);
}
},
}),
});
useEffect(() => {
initializeFontSize();
@@ -1,72 +0,0 @@
import { memo, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { X, ExternalLink, AppWindow } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useChatFormContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { openAppBuilder } from '~/utils';
import store from '~/store';
/**
* SCAFFOLD (Phase 2) for the inline "build an app" experience: the side preview
* pane that will eventually render the generated app. For now it is a placeholder
* whose primary CTA is the "Open in App" handoff to the full hanzo.app builder,
* seeded live from the composer text.
*
* Phase 3 (real inline codegen/preview) replaces the placeholder with a live
* sandbox iframe. It needs: (1) a codegen/session endpoint the chat thread drives
* (hanzo.app `/dev` codegen behind `/v1/`); (2) a sandbox preview URL to load into
* the iframe; (3) a build-state channel (SSE) streaming file/preview updates.
* Until then this hands off to hanzo.app so the experience stays uniform.
*/
function BuildPreviewPane() {
const localize = useLocalize();
const setBuildMode = useSetRecoilState(store.buildMode);
const methods = useChatFormContext();
const text = useWatch({ control: methods.control, name: 'text' });
const openInApp = useCallback(() => openAppBuilder(text), [text]);
const close = useCallback(() => setBuildMode(false), [setBuildMode]);
return (
<aside className="hidden h-full w-full max-w-[45%] flex-col border-l border-border-light bg-surface-primary md:flex">
<div className="flex items-center justify-between border-b border-border-light px-4 py-2.5">
<div className="flex items-center gap-2 text-sm font-medium text-text-primary">
<AppWindow className="icon-md" aria-hidden="true" />
{localize('com_ui_build_app_preview')}
</div>
<button
type="button"
onClick={close}
title={localize('com_ui_close')}
aria-label={localize('com_ui_close')}
className="rounded-lg p-1.5 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
>
<X className="icon-md" aria-hidden="true" />
</button>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-border-medium bg-surface-secondary">
<AppWindow className="h-7 w-7 text-text-secondary" aria-hidden="true" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_build_app_preview_placeholder')}
</p>
<p className="mx-auto max-w-xs text-xs text-text-secondary">
{localize('com_ui_build_app_preview_hint')}
</p>
</div>
<button
type="button"
onClick={openInApp}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-submit px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-surface-submit-hover hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<ExternalLink className="icon-sm" aria-hidden="true" />
{localize('com_ui_build_app_open_in_app')}
</button>
</div>
</aside>
);
}
export default memo(BuildPreviewPane);
+25 -44
View File
@@ -10,7 +10,6 @@ import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } fr
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import BuildPreviewPane from '~/components/BuildApp/BuildPreviewPane';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@@ -34,7 +33,6 @@ function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const buildMode = useRecoilValue(store.buildMode);
const fileMap = useFileMapContext();
@@ -78,53 +76,36 @@ function ChatView({ index = 0 }: { index?: number }) {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}
const chatColumn = (
<div
className={cn(
'relative flex h-full flex-col',
buildMode ? 'min-w-0 flex-1' : 'w-full',
)}
>
{!isLoading && <Header />}
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>
{isLandingPage && <Footer />}
</>
</div>
);
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
{/* Inline "build an app" mode: chat thread + side preview pane (scaffold). */}
{buildMode ? (
<div className="flex h-full w-full flex-row">
{chatColumn}
<BuildPreviewPane />
</div>
) : (
chatColumn
)}
<div className="relative flex h-full w-full flex-col">
{!isLoading && <Header />}
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>
{isLandingPage && <Footer />}
</>
</div>
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
@@ -1,40 +0,0 @@
import { memo, useCallback } from 'react';
import { AppWindow } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/**
* Composer affordance that enters the inline "build an app" mode (split chat +
* side preview). From the preview pane the user hands off to the full hanzo.app
* builder ("Open in App"). Styled to match the composer's rounded badge chrome
* so it reads as one system across chat -> app -> console.
*/
function BuildAppButton() {
const localize = useLocalize();
const [isBuildMode, setBuildMode] = useRecoilState(store.buildMode);
const toggle = useCallback(() => setBuildMode((prev) => !prev), [setBuildMode]);
return (
<button
type="button"
onClick={toggle}
aria-pressed={isBuildMode}
title={localize('com_ui_build_app')}
className={cn(
'group inline-flex h-9 items-center justify-center gap-1.5 whitespace-nowrap',
'rounded-full border border-border-medium px-3 text-sm font-medium',
'bg-transparent text-text-primary shadow-sm transition-all',
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isBuildMode && 'border-border-heavy bg-surface-hover',
)}
>
<AppWindow className="icon-md" aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_build_app')}</span>
</button>
);
}
export default memo(BuildAppButton);
+2 -12
View File
@@ -23,9 +23,8 @@ import { useRunCloudAgent } from '~/hooks/Agents';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { cn, removeFocusRings, parseAgentCommand, parseBuildCommand, openAppBuilder } from '~/utils';
import { cn, removeFocusRings, parseAgentCommand } from '~/utils';
import TextareaHeader from './TextareaHeader';
import BuildAppButton from './BuildAppButton';
import PromptsCommand from './PromptsCommand';
import AgentsCommand from './AgentsCommand';
import AudioRecorder from './AudioRecorder';
@@ -142,15 +141,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
*/
const onSubmit = useCallback(
(data?: { text: string }) => {
const text = data?.text ?? '';
/** `/build [prompt]` hands off to the hanzo.app builder (new tab). */
const buildPrompt = parseBuildCommand(text);
if (buildPrompt !== null) {
methods.reset();
openAppBuilder(buildPrompt);
return;
}
const command = parseAgentCommand(text);
const command = parseAgentCommand(data?.text ?? '');
if (command) {
methods.reset();
setShowAgentsPopover(false);
@@ -352,7 +343,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
</div>
<BuildAppButton />
<BadgeRow
showEphemeralBadges={
!!endpoint && !isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)
+2 -2
View File
@@ -171,7 +171,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${name}`}
text={name}
className={`${getTextSizeClass(name)} font-medium tracking-tight text-text-primary`}
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
@@ -186,7 +186,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${greetingText}${user?.name ? '-user' : ''}`}
text={greetingText}
className={`${getTextSizeClass(greetingText)} font-medium tracking-tight text-text-primary`}
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
@@ -126,7 +126,7 @@ export const CustomMenuSeparator = React.forwardRef<HTMLHRElement, Ariakit.MenuS
ref={ref}
{...props}
className={cn(
'my-0.5 h-0 w-full border-t border-border-light',
'my-0.5 h-0 w-full border-t border-slate-200 dark:border-slate-700',
props.className,
)}
/>
@@ -1,13 +1,12 @@
import React, { useState, useMemo, memo } from 'react';
import { useRecoilState } from 'recoil';
import { AppWindow } from 'lucide-react';
import type { TConversation, TMessage, TFeedback } from 'librechat-data-provider';
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '@librechat/client';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import MessageAudio from './MessageAudio';
import Feedback from './Feedback';
import { cn, openAppBuilder } from '~/utils';
import { cn } from '~/utils';
import store from '~/store';
type THoverButtons = {
@@ -235,16 +234,6 @@ const HoverButtons = ({
/>
)}
{/* Build-as-app: hand off this assistant reply to the hanzo.app builder */}
{!isCreatedByUser && (
<HoverButton
onClick={() => openAppBuilder(extractMessageContent(message))}
title={localize('com_ui_build_app_message')}
icon={<AppWindow size="19" />}
isLast={isLast}
/>
)}
{/* Fork Button */}
<Fork
messageId={message.messageId}
+1 -1
View File
@@ -25,7 +25,7 @@ const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAva
const renderDefaultAvatar = () => (
<div
style={{
backgroundColor: 'rgb(64, 64, 64)',
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
@@ -28,7 +28,7 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center justify-between border-b border-border-medium bg-gray-900 px-4 py-2 font-sans text-xs text-gray-300">
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
@@ -218,7 +218,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
return (
<div
ref={containerRef}
className="relative w-full overflow-hidden rounded-lg border border-border-medium bg-gray-925 text-xs text-white/80"
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
+1 -1
View File
@@ -72,7 +72,7 @@ export default function NavToggle({
<span className="" data-state="closed">
<div
className="flex h-[72px] w-8 items-center justify-center"
style={{ ...transition, opacity: isHovering ? 1 : 0 }}
style={{ ...transition, opacity: isHovering ? 1 : 0.25 }}
>
<div className="flex h-6 w-6 flex-col items-center">
{/* Top bar */}
-13
View File
@@ -1,7 +1,6 @@
import { memo } from 'react';
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
import { CustomMinimalIcon, XAIcon, MoonshotIcon } from '@librechat/client';
import ZenLogoIcon from '~/components/svg/ZenLogoIcon';
import { IconContext } from '~/common';
import { cn } from '~/utils';
@@ -25,12 +24,6 @@ const knownEndpointAssets = {
[KnownEndpoints.shuttleai]: 'assets/shuttleai.png',
[KnownEndpoints['together.ai']]: 'assets/together.png',
[KnownEndpoints.unify]: 'assets/unify.webp',
// Hanzo's custom provider families are matched by their lowercased endpoint
// NAME (the KnownEndpoints enum lacks qwen/google/openai keys, so those fall
// through). Gives each family its real provider mark in the model picker.
qwen: 'assets/qwen.svg',
'google gemma': 'assets/google.svg',
'openai gpt-oss': 'assets/openai.svg',
};
const knownEndpointClasses = {
@@ -76,12 +69,6 @@ function UnknownIcon({
const currentEndpoint = endpoint.toLowerCase();
// Hanzo's house endpoint (the Zen family) renders the ensō (円相), matching the
// assistant message avatar. Without this it fell through to the generic bot glyph.
if (currentEndpoint === 'hanzo' || currentEndpoint === 'zen') {
return <ZenLogoIcon className={cn(className, 'text-black dark:text-white')} />;
}
if (currentEndpoint === KnownEndpoints.xai) {
return <XAIcon className={cn(className, 'text-black dark:text-white')} />;
}
+1 -6
View File
@@ -32,6 +32,7 @@
"com_agents_cloud_command_placeholder": "Run a cloud agent by name",
"com_agents_cloud_run_failed": "Agent run failed: {{0}}",
"com_agents_cloud_running": "Running cloud agent {{0}}…",
"com_agents_cloud_section": "Cloud agents",
"com_agents_cloud_signin_required": "Sign in with hanzo.id to run cloud agents.",
"com_agents_clear_search": "Clear search",
"com_agents_code_interpreter": "When enabled, allows your agent to leverage the Hanzo Chat Code Interpreter API to run generated code, including file processing, securely. Requires a valid API key.",
@@ -781,12 +782,6 @@
"com_ui_beta": "Beta",
"com_ui_bookmark_delete_confirm": "Are you sure you want to delete this bookmark?",
"com_ui_bookmarks": "Bookmarks",
"com_ui_build_app": "Build an app",
"com_ui_build_app_message": "Build this as an app",
"com_ui_build_app_open_in_app": "Open in App",
"com_ui_build_app_preview": "App preview",
"com_ui_build_app_preview_placeholder": "Your app preview will render here",
"com_ui_build_app_preview_hint": "Describe your app in the chat, then open it in the full hanzo.app builder to generate and preview it.",
"com_ui_bookmarks_add": "Add Bookmarks",
"com_ui_bookmarks_add_to_conversation": "Add to current conversation",
"com_ui_bookmarks_count_selected": "Bookmarks, {{count}} selected",
-22
View File
@@ -1,22 +0,0 @@
import { useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
/**
* Deep-link entry to inline build mode: `hanzo.chat/build[?prompt=...]`. Flips the
* build-mode flag and lands on a fresh chat; the composer auto-fills from
* `?prompt=` / `?q=` (via useQueryParams). ChatView renders the split chat +
* preview shell whenever buildMode is on.
*/
export default function BuildRoute() {
const setBuildMode = useSetRecoilState(store.buildMode);
const [searchParams] = useSearchParams();
useEffect(() => {
setBuildMode(true);
}, [setBuildMode]);
const qs = searchParams.toString();
return <Navigate to={`/c/new${qs ? `?${qs}` : ''}`} replace />;
}
-6
View File
@@ -14,7 +14,6 @@ import AgentMarketplace from '~/components/Agents/Marketplace';
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext';
import RouteErrorBoundary from './RouteErrorBoundary';
import BuildRoute from './BuildRoute';
import StartupLayout from './Layouts/Startup';
import LoginLayout from './Layouts/Login';
import dashboardRoutes from './Dashboard';
@@ -40,11 +39,6 @@ export const router = createBrowserRouter(
element: <ShareRoute />,
errorElement: <RouteErrorBoundary />,
},
{
path: 'build',
element: <BuildRoute />,
errorElement: <RouteErrorBoundary />,
},
{
path: 'auth/callback',
element: (
-14
View File
@@ -1,14 +0,0 @@
import { atom } from 'recoil';
/**
* Inline "build an app" mode. When true, the chat surface renders a stripped-down
* split shell: the chat thread on the left + a side preview pane on the right
* (scaffold for the inline app builder — see components/BuildApp/BuildPreviewPane).
*
* A plain session flag (not persisted): entered from the composer "Build an app"
* button or the `/build` route, exited from the preview pane's close button.
*/
export const buildMode = atom<boolean>({
key: 'buildMode',
default: false,
});
-2
View File
@@ -12,7 +12,6 @@ import lang from './language';
import settings from './settings';
import misc from './misc';
import isTemporary from './temporary';
import * as build from './build';
export * from './agents';
export * from './mcp';
export * from './favorites';
@@ -32,5 +31,4 @@ export default {
...settings,
...misc,
...isTemporary,
...build,
};
+4 -16
View File
@@ -359,16 +359,6 @@ html {
src: url('$fonts/roboto-mono-latin-400-italic.woff2') format('woff2');
}
/* Crisp type: never synthesize weights/italics — Basel ships 400 + 500 only, so
faux-bold would blur. Hierarchy comes from size + the real Medium face, not
smeared glyphs. Antialiased for a clean Linear/Claude-caliber render. */
html {
font-synthesis: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/*
The default ChatGPT fonts, according to OpenAI's brand guidelines, are proprietary and require appropriate font license according to your use case.
@@ -1222,13 +1212,11 @@ pre {
code,
pre {
font-family:
'Geist Mono',
'Roboto Mono',
ui-monospace,
'SF Mono',
Menlo,
Monaco,
Consolas,
Söhne Mono,
Monaco,
Andale Mono,
Ubuntu Mono,
monospace !important;
}
code.language-text,
-61
View File
@@ -1,61 +0,0 @@
import {
buildAppUrl,
parseBuildCommand,
isBuildCommandPrefix,
HANZO_APP_BUILDER_URL,
} from './buildApp';
describe('buildAppUrl', () => {
it('omits the prompt param when empty', () => {
expect(buildAppUrl('')).toBe(HANZO_APP_BUILDER_URL);
expect(buildAppUrl(' ')).toBe(HANZO_APP_BUILDER_URL);
expect(buildAppUrl()).toBe(HANZO_APP_BUILDER_URL);
});
it('encodes the prompt into ?prompt=', () => {
expect(buildAppUrl('a todo app')).toBe(`${HANZO_APP_BUILDER_URL}?prompt=a%20todo%20app`);
expect(buildAppUrl('me & you?')).toBe(`${HANZO_APP_BUILDER_URL}?prompt=me%20%26%20you%3F`);
});
it('trims surrounding whitespace before encoding', () => {
expect(buildAppUrl(' hello ')).toBe(`${HANZO_APP_BUILDER_URL}?prompt=hello`);
});
});
describe('parseBuildCommand', () => {
it('parses the bare command to an empty prompt', () => {
expect(parseBuildCommand('/build')).toBe('');
expect(parseBuildCommand(' /build ')).toBe('');
});
it('parses the command with a prompt', () => {
expect(parseBuildCommand('/build make a todo app')).toBe('make a todo app');
});
it('trims surrounding whitespace and preserves multiline prompts', () => {
expect(parseBuildCommand(' /build line1\nline2 ')).toBe('line1\nline2');
});
it('returns null for non-commands', () => {
expect(parseBuildCommand('hello world')).toBeNull();
expect(parseBuildCommand('/agent foo')).toBeNull();
expect(parseBuildCommand('/buildx foo')).toBeNull(); // must be word-bounded
expect(parseBuildCommand('please /build a thing')).toBeNull(); // not at start
expect(parseBuildCommand('')).toBeNull();
});
});
describe('isBuildCommandPrefix', () => {
it('is true once /build is begun', () => {
expect(isBuildCommandPrefix('/build')).toBe(true);
expect(isBuildCommandPrefix('/build ')).toBe(true);
expect(isBuildCommandPrefix('/build a todo app')).toBe(true);
});
it('is false for other slash commands or plain text', () => {
expect(isBuildCommandPrefix('/')).toBe(false);
expect(isBuildCommandPrefix('/buildx')).toBe(false);
expect(isBuildCommandPrefix('/agent')).toBe(false);
expect(isBuildCommandPrefix('hello')).toBe(false);
});
});
-50
View File
@@ -1,50 +0,0 @@
/**
* The ONE definition of the "Build an app" handoff to the hanzo.app builder and
* the `/build [prompt]` command grammar. Pure + unit-testable; shared by the
* composer button, the `/build` slash interception in ChatForm, the assistant
* message "Build this as an app" action, and the inline build preview pane.
*
* hanzo.app `/dev` reads `?prompt=` — that IS the real builder wire. Keeping the
* URL construction in one place makes the chat -> app handoff uniform across
* every surface (chat -> app -> console).
*/
/** The hanzo.app builder route. It reads the app spec from `?prompt=`. */
export const HANZO_APP_BUILDER_URL = 'https://hanzo.app/dev';
/** Build the hanzo.app builder deep-link for a prompt (omits an empty prompt). */
export function buildAppUrl(prompt?: string): string {
const text = (prompt ?? '').trim();
if (!text) {
return HANZO_APP_BUILDER_URL;
}
return `${HANZO_APP_BUILDER_URL}?prompt=${encodeURIComponent(text)}`;
}
/** Open the hanzo.app builder for a prompt in a new tab (noopener). */
export function openAppBuilder(prompt?: string): void {
if (typeof window === 'undefined') {
return;
}
window.open(buildAppUrl(prompt), '_blank', 'noopener,noreferrer');
}
const BUILD_COMMAND_RE = /^\/build(?:\s+([\s\S]*))?$/;
/**
* Parse `/build [prompt]` into the prompt (possibly ''), or null if the text is
* not a build command. Accepts `/build`, `/build make a todo app`. The prompt is
* trimmed. `/buildx ...` is NOT a command (word-bounded, like `/agent`).
*/
export function parseBuildCommand(text: string): string | null {
const m = BUILD_COMMAND_RE.exec((text ?? '').trim());
if (!m) {
return null;
}
return (m[1] ?? '').trim();
}
/** True once the input has begun a `/build` command (word-bounded). */
export function isBuildCommandPrefix(text: string): boolean {
return /^\/build(\s|$)/.test((text ?? '').trimStart());
}
-1
View File
@@ -11,7 +11,6 @@ export * from './latex';
export * from './forms';
export * from './agents';
export * from './agentCommand';
export * from './buildApp';
export * from './drafts';
export * from './convos';
export * from './routes';
-132
View File
@@ -1,132 +0,0 @@
/**
* One-shot Mongo → SQLite backfill + reconcile for the chat-docdb drop.
*
* Copies every migrated collection from MongoDB (chat-docdb) into the embedded
* SQLite document store at CHAT_SQLITE_PATH, keyed by each document's own `_id`
* (BSON ObjectId → hex, preserved exactly). It uses the SAME `upsertRaw`
* primitive the live dual-write mirror uses, so running it while dual-write is
* enabled is safe and idempotent: a doc already mirrored by a live write and the
* same doc copied here share the `_id` and collapse to one row (INSERT OR
* REPLACE) — no duplicates. Re-run it as many times as you like.
*
* Then it reconciles: for every collection it prints `mongo == sqlite` and exits
* non-zero if ANY collection's SQLite count is short of Mongo — the gate that
* must be green before flipping CHAT_STORE_SQLITE.
*
* Run INSIDE the chat pod (has MONGO_URI + CHAT_SQLITE_PATH + the built package):
* node config/backfill-sqlite.js
* Optional: pass a comma list of collection (model) names to limit scope.
*/
const path = require('path');
const { MongoClient } = require('mongodb');
// The built package. The pnpm workspace does not hoist a bare
// `@librechat/data-schemas` symlink to the app root (where this script runs via
// `node config/backfill-sqlite.js`), so fall back to the workspace path.
function loadDataSchemas() {
try {
return require('@librechat/data-schemas');
} catch {
return require(path.resolve(__dirname, '../packages/data-schemas'));
}
}
const { createSqliteHandle, CHAT_COLLECTION_SPECS } = loadDataSchemas();
// model (spec) name -> mongo collection name. Explicit, not derived, so the
// mapping is auditable and never depends on mongoose pluralization quirks.
const MONGO_COLLECTION = {
Conversation: 'conversations',
Message: 'messages',
Preset: 'presets',
ConversationTag: 'conversationtags',
SharedLink: 'sharedlinks',
Project: 'projects',
File: 'files',
Key: 'keys',
PluginAuth: 'pluginauths',
Banner: 'banners',
MCPServer: 'mcpservers',
Prompt: 'prompts',
PromptGroup: 'promptgroups',
AgentApiKey: 'agentapikeys',
Assistant: 'assistants',
Action: 'actions',
ToolCall: 'toolcalls',
MemoryEntry: 'memoryentries',
Role: 'roles',
AccessRole: 'accessroles',
Agent: 'agents',
AgentCategory: 'agentcategories',
AclEntry: 'aclentries',
Group: 'groups',
User: 'users',
Session: 'sessions',
Token: 'tokens',
Balance: 'balances',
Transaction: 'transactions',
};
async function main() {
const uri = process.env.MONGO_URI;
const dbPath = process.env.CHAT_SQLITE_PATH;
if (!uri) {
throw new Error('MONGO_URI is required');
}
if (!dbPath) {
throw new Error('CHAT_SQLITE_PATH is required');
}
const only = (process.argv[2] || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const names = (only.length ? only : Object.keys(MONGO_COLLECTION)).filter(
(n) => n in CHAT_COLLECTION_SPECS && n in MONGO_COLLECTION,
);
console.log(`[backfill] sqlite=${dbPath} collections=${names.length}`);
const client = new MongoClient(uri);
await client.connect();
const db = client.db();
const handle = createSqliteHandle(names, { dbPath });
const report = [];
let failed = false;
for (const name of names) {
const coll = db.collection(MONGO_COLLECTION[name]);
const model = handle.models[name];
const mongoCount = await coll.countDocuments();
let copied = 0;
const cursor = coll.find({});
for await (const doc of cursor) {
model.upsertRaw(doc);
copied++;
}
const sqliteCount = await model.countDocuments({});
const ok = sqliteCount >= mongoCount;
if (!ok) {
failed = true;
}
report.push({ name, mongo: mongoCount, copied, sqlite: sqliteCount, ok });
console.log(
`[backfill] ${ok ? 'OK ' : 'MISMATCH'} ${name.padEnd(16)} mongo=${String(mongoCount).padStart(6)} sqlite=${String(sqliteCount).padStart(6)}`,
);
}
handle.close();
await client.close();
const matched = report.filter((r) => r.ok).length;
console.log(`[backfill] reconcile: ${matched}/${report.length} collections mongo<=sqlite`);
if (failed) {
console.error('[backfill] FAILED — at least one collection is short in SQLite. Re-run.');
process.exit(1);
}
console.log('[backfill] DONE — all collections reconciled. Safe to flip CHAT_STORE_SQLITE.');
}
main().catch((err) => {
console.error('[backfill] ERROR:', err);
process.exit(1);
});
+8 -16
View File
@@ -113,17 +113,9 @@ endpoints:
- "zen3-nano"
fetch: false
titleConvo: true
# Title/summary model = zen5-flash: a FAST, NON-reasoning, in-catalog model
# (live upstream deepseek-4-flash) that returns a clean single-line title in
# ~1-2s. Replaces zen3-nano, which maps to the reasoning model qwen3-8b and
# took 16-31s — routinely near the 45s #titleConvo timeout, so new chats often
# never got a title. zen5-flash was picked by direct api.hanzo.ai/v1 probes:
# zen5-flash ~1.5s clean, zen3-nano 16-31s, zen5-mini 10-20s, zen5/zen3-vl >40s.
# The #titleConvo path is also envelope-safe now (wrapHanzoGatewayFetch parity
# with agent runs), so a 200 error-envelope degrades to a clean skip, not a crash.
titleModel: "zen5-flash"
titleModel: "zen5-nano"
summarize: true
summaryModel: "zen5-flash"
summaryModel: "zen5-nano"
modelDisplayLabel: "Hanzo"
# Third-party provider families — same api.hanzo.ai/v1 gateway + per-user
# ${HANZO_API_KEY} (identical Commerce metering). CURRENT-GEN models only from
@@ -141,7 +133,7 @@ endpoints:
- "qwen3-32b"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "qwen3-coder-flash"
modelDisplayLabel: "Qwen"
- name: "Meta Llama"
customOrder: 2
@@ -154,7 +146,7 @@ endpoints:
- "llama-3.1-8b"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "llama-3.1-8b"
modelDisplayLabel: "Meta Llama"
- name: "DeepSeek"
customOrder: 3
@@ -168,7 +160,7 @@ endpoints:
- "deepseek-3.2"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "deepseek-v4-flash"
modelDisplayLabel: "DeepSeek"
- name: "Mistral"
customOrder: 4
@@ -182,7 +174,7 @@ endpoints:
- "mistral-nemo"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "mistral-nemo"
modelDisplayLabel: "Mistral"
- name: "Google Gemma"
customOrder: 5
@@ -194,7 +186,7 @@ endpoints:
- "gemma-3-31b"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "gemma-3-31b"
modelDisplayLabel: "Google Gemma"
- name: "OpenAI GPT-OSS"
customOrder: 6
@@ -206,7 +198,7 @@ endpoints:
- "gpt-oss-20b"
fetch: false
titleConvo: true
titleModel: "zen5-flash"
titleModel: "gpt-oss-20b"
modelDisplayLabel: "OpenAI GPT-OSS"
balance:
enabled: false
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzochat/chat",
"version": "0.9.16",
"version": "0.9.3",
"description": "Chat - Unified interface to Agents, LLMs, MCPs and any AI model or OpenAPI documented API with full RAG stack",
"packageManager": "pnpm@10.27.0",
"workspaces": [
@@ -190,8 +190,7 @@
"sharp",
"protobufjs",
"mongodb-memory-server",
"unrs-resolver",
"better-sqlite3-multiple-ciphers"
"unrs-resolver"
],
"overrides": {
"@ariakit/react": "0.4.29",
-1
View File
@@ -20,7 +20,6 @@
"build:watch:prod": "rollup -c -w --bundleConfigAsCjs",
"test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"",
"test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.*integration\\.|\\.*helper\\.\"",
"test:transport": "jest --ci --coverage=false --testPathPatterns=\"MCPConnection|MCPRedirectSSRFGuard|reconnection-storm|MCPManager|auth/domain\\.spec|auth/agent\\.spec|oauth/hardenedFetch|oauth/OAuthReconnection\"",
"test:cache-integration:core": "jest --testPathPatterns=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
"test:cache-integration:cluster": "jest --testPathPatterns=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand",
"test:cache-integration:mcp": "jest --testPathPatterns=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
+1 -220
View File
@@ -7,20 +7,11 @@ jest.mock('node:dns', () => {
});
import dns from 'node:dns';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from './agent';
type LookupCallback = (
err: NodeJS.ErrnoException | null,
address: string | dns.LookupAddress[],
family?: number,
) => void;
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string, family: number) => void;
const mockedDnsLookup = dns.lookup as jest.MockedFunction<typeof dns.lookup>;
const httpAgentPrototype = http.Agent.prototype as unknown as {
createConnection: (options: Record<string, unknown>) => unknown;
};
function mockDnsResult(address: string, family: number): void {
mockedDnsLookup.mockImplementation(((
@@ -32,16 +23,6 @@ function mockDnsResult(address: string, family: number): void {
}) as never);
}
function mockDnsAllResult(addresses: dns.LookupAddress[]): void {
mockedDnsLookup.mockImplementation(((
_hostname: string,
_options: unknown,
callback: LookupCallback,
) => {
callback(null, addresses);
}) as never);
}
function mockDnsError(err: NodeJS.ErrnoException): void {
mockedDnsLookup.mockImplementation(((
_hostname: string,
@@ -54,7 +35,6 @@ function mockDnsError(err: NodeJS.ErrnoException): void {
describe('createSSRFSafeAgents', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
@@ -71,29 +51,6 @@ describe('createSSRFSafeAgents', () => {
};
expect(internal.createConnection).toBeInstanceOf(Function);
});
it('should scope allowedAddresses by the request port in the HTTP agent lookup', async () => {
mockDnsResult('10.0.0.5', 4);
let lookupError: NodeJS.ErrnoException | null = null;
jest.spyOn(httpAgentPrototype, 'createConnection').mockImplementation(((
options: Record<string, unknown>,
) => {
const lookup = options.lookup as LookupFunction;
lookup('private.example.com', {}, (err) => {
lookupError = err;
});
return {};
}) as never);
const agents = createSSRFSafeAgents(['10.0.0.5:11434']);
const internal = agents.httpAgent as unknown as {
createConnection: (opts: Record<string, unknown>) => unknown;
};
internal.createConnection({ host: 'private.example.com', port: 22 });
expect(lookupError).toBeTruthy();
expect(lookupError!.code).toBe('ESSRF');
});
});
describe('createSSRFSafeUndiciConnect', () => {
@@ -137,73 +94,6 @@ describe('createSSRFSafeUndiciConnect', () => {
expect(result.address).toBe('93.184.216.34');
});
it('lookup should block private IPs when DNS returns all addresses', async () => {
mockDnsAllResult([{ address: '127.0.0.1', family: 4 }]);
const connect = createSSRFSafeUndiciConnect();
const result = await new Promise<{ err: NodeJS.ErrnoException | null }>((resolve) => {
connect.lookup('localhost', { all: true }, (err) => {
resolve({ err });
});
});
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('lookup should allow public IPs when DNS returns all addresses', async () => {
const addresses = [{ address: '93.184.216.34', family: 4 }];
mockDnsAllResult(addresses);
const connect = createSSRFSafeUndiciConnect();
const result = await new Promise<{
err: NodeJS.ErrnoException | null;
address: string | dns.LookupAddress[];
}>((resolve) => {
connect.lookup('example.com', { all: true }, (err, address) => {
resolve({ err, address });
});
});
expect(result.err).toBeNull();
expect(result.address).toEqual(addresses);
});
it('lookup should block mixed public and private all-address results', async () => {
mockDnsAllResult([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.1', family: 4 },
]);
const connect = createSSRFSafeUndiciConnect();
const result = await new Promise<{ err: NodeJS.ErrnoException | null }>((resolve) => {
connect.lookup('rebinding.example.com', { all: true }, (err) => {
resolve({ err });
});
});
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('lookup should honor allowedAddresses when DNS returns all addresses', async () => {
const addresses = [{ address: '10.0.0.5', family: 4 }];
mockDnsAllResult(addresses);
const connect = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await new Promise<{
err: NodeJS.ErrnoException | null;
address: string | dns.LookupAddress[];
}>((resolve) => {
connect.lookup('private.example.com', { all: true }, (err, address) => {
resolve({ err, address });
});
});
expect(result.err).toBeNull();
expect(result.address).toEqual(addresses);
});
it('lookup should forward DNS errors', async () => {
const dnsError = Object.assign(new Error('ENOTFOUND'), {
code: 'ENOTFOUND',
@@ -221,112 +111,3 @@ describe('createSSRFSafeUndiciConnect', () => {
expect(result.err!.code).toBe('ENOTFOUND');
});
});
/**
* Connect-time exemption is the TOCTOU-safe layer: pre-flight validation can
* be bypassed by DNS rebinding, but the agent's `lookup` runs on every TCP
* connect and must honor `allowedAddresses` consistently. These tests cover
* the runtime path that the domain.ts spec doesn't reach.
*/
describe('SSRF agents — allowedAddresses exemption', () => {
afterEach(() => {
jest.clearAllMocks();
});
function runLookup(lookup: LookupFunction, hostname: string) {
return new Promise<{ err: NodeJS.ErrnoException | null; address: string }>((resolve) => {
(lookup as (h: string, o: object, cb: LookupCallback) => void)(hostname, {}, (err, address) =>
resolve({ err, address: address as string }),
);
});
}
it('exempts a hostname literal on the admin-permitted port', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['ollama.internal:11434'], '11434');
const result = await runLookup(lookup, 'ollama.internal');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('exempts a private IP on the admin-permitted port (DNS resolves to it)', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('blocks a private IP on a different port of an allowed address', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '22');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('ignores legacy bare allowedAddresses entries', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('still blocks an unlisted private IP when allowedAddresses is set', async () => {
mockDnsResult('192.168.1.42', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'other.private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('drops public-IP entries from allowedAddresses (private-IP scope only)', async () => {
// Admin mistakenly listed a public IP. It must NOT grant exemption.
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['8.8.8.8:53'], '53');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('drops URL/CIDR/whitespace entries from allowedAddresses', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(
['http://10.0.0.5', '10.0.0.0/24', ' 10.0.0.5 '],
'11434',
);
// Even though the value 10.0.0.5 is among the admin entries, none of them
// pass the schema-shape filter (URL, CIDR, embedded whitespace), so no
// exemption is granted.
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('createSSRFSafeAgents propagates the exemption-aware lookup to both agents', async () => {
// The agent factory wraps `createConnection` to inject a custom lookup.
// The HTTP wrapper is covered above; this keeps the shared lookup factory
// expectation explicit for the exemption list.
mockDnsResult('10.0.0.5', 4);
const agents = createSSRFSafeAgents(['10.0.0.5:11434']);
expect(agents.httpAgent).toBeDefined();
expect(agents.httpsAgent).toBeDefined();
// The undici-connect path uses the same `buildSSRFSafeLookup` factory, so
// verifying the exemption holds there is sufficient evidence that the
// agent factory built the right lookup.
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('default lookup (no exemption list) blocks private IPs', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect();
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
});
+36 -105
View File
@@ -2,106 +2,52 @@ import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import {
normalizePort,
normalizeAllowedAddressesSet,
isAddressInAllowedSet,
} from './allowedAddresses';
import { isPrivateIP } from './ip';
type LookupResult = string | dns.LookupAddress[];
function createSSRFLookupError(hostname: string, address: string): NodeJS.ErrnoException {
return Object.assign(
new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`),
{ code: 'ESSRF' },
) as NodeJS.ErrnoException;
}
function getBlockedLookupAddress(
lookupResult: LookupResult,
hostnameAllowed: boolean,
exemptSet: Set<string> | null,
normalizedPort: string,
): string | null {
if (hostnameAllowed) {
return null;
}
const addresses = Array.isArray(lookupResult)
? lookupResult.map(({ address }) => address)
: [lookupResult];
return (
addresses.find(
(address) =>
isPrivateIP(address) && !isAddressInAllowedSet(address, exemptSet, normalizedPort),
) ?? null
);
}
import { isPrivateIP } from './domain';
/**
* Builds a DNS lookup wrapper that blocks resolution to private/reserved IP
* addresses. When `allowedAddresses` is provided, hostname/IP + port pairs
* matching the list bypass the block admins can permit known-good internal
* services (self-hosted Ollama, Docker host, etc.) without disabling SSRF
* protection for every port on the same host.
*
* The exemption list is pre-normalized once at construction so the per-
* connection lookup runs an O(1) Set membership check. Normalization and
* scoping rules live in `./allowedAddresses`, shared with the preflight
* helper in `./domain` so the two layers cannot diverge.
* Returns the first resolved address that is private/reserved, or null.
* `dns.lookup` yields a single string when called without `{ all: true }` and a
* `LookupAddress[]` when called with it. undici's connector asks for all
* addresses, so a `typeof address === 'string'` guard silently fails open on
* the array shape a hostname resolving to a private IP would pass unchecked.
* Normalize both shapes to a flat list and reject if ANY entry is private.
*/
function buildSSRFSafeLookup(
allowedAddresses?: string[] | null,
port?: string | number | null,
): LookupFunction {
const exemptSet = normalizeAllowedAddressesSet(allowedAddresses);
const normalizedPort = normalizePort(port);
return (hostname, options, callback) => {
const hostnameAllowed = isAddressInAllowedSet(hostname, exemptSet, normalizedPort);
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, '', 0);
return;
}
const blockedAddress = getBlockedLookupAddress(
address,
hostnameAllowed,
exemptSet,
normalizedPort,
);
if (blockedAddress) {
callback(createSSRFLookupError(hostname, blockedAddress), blockedAddress, family);
return;
}
callback(null, address, family);
});
};
function findBlockedLookupAddress(address: string | dns.LookupAddress[]): string | null {
const addresses = Array.isArray(address) ? address.map((entry) => entry.address) : [address];
return addresses.find((entry) => isPrivateIP(entry)) ?? null;
}
/** Default lookup with no exemptions. Kept for callers that don't need allowedAddresses. */
const ssrfSafeLookup: LookupFunction = buildSSRFSafeLookup();
/** DNS lookup wrapper that blocks resolution to private/reserved IP addresses */
const ssrfSafeLookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, '', 0);
return;
}
const blockedAddress = findBlockedLookupAddress(address as string | dns.LookupAddress[]);
if (blockedAddress) {
const ssrfError = Object.assign(
new Error(`SSRF protection: ${hostname} resolved to blocked address ${blockedAddress}`),
{ code: 'ESSRF' },
) as NodeJS.ErrnoException;
callback(ssrfError, blockedAddress, family as number);
return;
}
callback(null, address as string, family as number);
});
};
/** Internal agent shape exposing createConnection (exists at runtime but not in TS types) */
type AgentInternal = {
createConnection: (options: Record<string, unknown>, oncreate?: unknown) => unknown;
};
function getConnectionPort(options: Record<string, unknown>): string {
return normalizePort(options.port ?? options.defaultPort);
}
/** Patches an agent instance to inject SSRF-safe DNS lookup at connect time */
function withSSRFProtection<T extends http.Agent>(agent: T, allowedAddresses?: string[] | null): T {
function withSSRFProtection<T extends http.Agent>(agent: T): T {
const internal = agent as unknown as AgentInternal;
const origCreate = internal.createConnection.bind(agent);
internal.createConnection = (options: Record<string, unknown>, oncreate?: unknown) => {
options.lookup = allowedAddresses?.length
? buildSSRFSafeLookup(allowedAddresses, getConnectionPort(options))
: ssrfSafeLookup;
options.lookup = ssrfSafeLookup;
return origCreate(options, oncreate);
};
return agent;
@@ -112,33 +58,18 @@ function withSSRFProtection<T extends http.Agent>(agent: T, allowedAddresses?: s
* Provides TOCTOU-safe SSRF protection by validating the resolved IP at connect time,
* preventing DNS rebinding attacks where a hostname resolves to a public IP during
* pre-validation but to a private IP when the actual connection is made.
*
* @param allowedAddresses - Optional admin exemption list of host:port pairs that bypass the block.
*/
export function createSSRFSafeAgents(allowedAddresses?: string[] | null): {
httpAgent: http.Agent;
httpsAgent: https.Agent;
} {
export function createSSRFSafeAgents(): { httpAgent: http.Agent; httpsAgent: https.Agent } {
return {
httpAgent: withSSRFProtection(new http.Agent(), allowedAddresses),
httpsAgent: withSSRFProtection(new https.Agent(), allowedAddresses),
httpAgent: withSSRFProtection(new http.Agent()),
httpsAgent: withSSRFProtection(new https.Agent()),
};
}
/**
* Returns undici-compatible `connect` options with SSRF-safe DNS lookup.
* Pass the result as the `connect` property when constructing an undici `Agent`.
*
* @param allowedAddresses - Optional admin exemption list of host:port pairs that bypass the block.
*/
export function createSSRFSafeUndiciConnect(
allowedAddresses?: string[] | null,
port?: string | number | null,
): {
lookup: LookupFunction;
} {
const lookup = allowedAddresses?.length
? buildSSRFSafeLookup(allowedAddresses, port)
: ssrfSafeLookup;
return { lookup };
export function createSSRFSafeUndiciConnect(): { lookup: LookupFunction } {
return { lookup: ssrfSafeLookup };
}
+8 -775
View File
@@ -7,14 +7,11 @@ import { lookup } from 'node:dns/promises';
import {
extractMCPServerDomain,
isActionDomainAllowed,
isAddressAllowed,
isEmailDomainAllowed,
isOAuthUrlAllowed,
isMCPDomainAllowed,
isPrivateIP,
isSSRFTarget,
resolveHostnameSSRF,
validateEndpointURL,
} from './domain';
const mockedLookup = lookup as jest.MockedFunction<typeof lookup>;
@@ -156,9 +153,8 @@ describe('isSSRFTarget', () => {
expect(isSSRFTarget('169.254.0.1')).toBe(true);
});
it('should block 0.0.0.0/8 (current network)', () => {
it('should block 0.0.0.0', () => {
expect(isSSRFTarget('0.0.0.0')).toBe(true);
expect(isSSRFTarget('0.1.2.3')).toBe(true);
});
it('should allow public IPs', () => {
@@ -180,20 +176,6 @@ describe('isSSRFTarget', () => {
expect(isSSRFTarget('fd00::1')).toBe(true);
expect(isSSRFTarget('fe80::1')).toBe(true);
});
it('should block full fe80::/10 link-local range (fe80febf)', () => {
expect(isSSRFTarget('fe90::1')).toBe(true);
expect(isSSRFTarget('fea0::1')).toBe(true);
expect(isSSRFTarget('feb0::1')).toBe(true);
expect(isSSRFTarget('febf::1')).toBe(true);
expect(isSSRFTarget('fec0::1')).toBe(false);
});
it('should NOT false-positive on hostnames whose first label resembles a link-local prefix', () => {
expect(isSSRFTarget('fe90.example.com')).toBe(false);
expect(isSSRFTarget('fea0.api.io')).toBe(false);
expect(isSSRFTarget('febf.service.net')).toBe(false);
});
});
describe('internal hostnames', () => {
@@ -248,36 +230,8 @@ describe('isPrivateIP', () => {
expect(isPrivateIP('169.254.0.1')).toBe(true);
});
it('should detect 0.0.0.0/8 (current network)', () => {
it('should detect 0.0.0.0', () => {
expect(isPrivateIP('0.0.0.0')).toBe(true);
expect(isPrivateIP('0.1.2.3')).toBe(true);
});
it('should detect 100.64.0.0/10 (CGNAT / shared address space)', () => {
expect(isPrivateIP('100.64.0.1')).toBe(true);
expect(isPrivateIP('100.127.255.255')).toBe(true);
expect(isPrivateIP('100.63.255.255')).toBe(false);
expect(isPrivateIP('100.128.0.1')).toBe(false);
});
it('should detect 192.0.0.0/24 (IETF protocol assignments)', () => {
expect(isPrivateIP('192.0.0.1')).toBe(true);
expect(isPrivateIP('192.0.0.255')).toBe(true);
expect(isPrivateIP('192.0.1.1')).toBe(false);
});
it('should detect 198.18.0.0/15 (benchmarking)', () => {
expect(isPrivateIP('198.18.0.1')).toBe(true);
expect(isPrivateIP('198.19.255.255')).toBe(true);
expect(isPrivateIP('198.17.0.1')).toBe(false);
expect(isPrivateIP('198.20.0.1')).toBe(false);
});
it('should detect 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved)', () => {
expect(isPrivateIP('224.0.0.1')).toBe(true);
expect(isPrivateIP('239.255.255.255')).toBe(true);
expect(isPrivateIP('240.0.0.1')).toBe(true);
expect(isPrivateIP('255.255.255.255')).toBe(true);
});
it('should allow public IPs', () => {
@@ -294,17 +248,10 @@ describe('isPrivateIP', () => {
expect(isPrivateIP('[::1]')).toBe(true);
});
it('should detect unique local (fc/fd) and link-local (fe80::/10)', () => {
it('should detect unique local (fc/fd) and link-local (fe80)', () => {
expect(isPrivateIP('fc00::1')).toBe(true);
expect(isPrivateIP('fd00::1')).toBe(true);
expect(isPrivateIP('fe80::1')).toBe(true);
expect(isPrivateIP('fe90::1')).toBe(true);
expect(isPrivateIP('fea0::1')).toBe(true);
expect(isPrivateIP('feb0::1')).toBe(true);
expect(isPrivateIP('febf::1')).toBe(true);
expect(isPrivateIP('[fe90::1]')).toBe(true);
expect(isPrivateIP('fec0::1')).toBe(false);
expect(isPrivateIP('fe90.example.com')).toBe(false);
});
});
@@ -323,144 +270,6 @@ describe('isPrivateIP', () => {
});
});
describe('isPrivateIP - IPv4-mapped IPv6 hex-normalized form (CVE-style SSRF bypass)', () => {
/**
* Node.js URL parser normalizes IPv4-mapped IPv6 from dotted-decimal to hex:
* new URL('http://[::ffff:169.254.169.254]/').hostname '::ffff:a9fe:a9fe'
*
* These tests confirm whether isPrivateIP catches the hex form that actually
* reaches it in production (via parseDomainSpec new URL hostname).
*/
it('should detect hex-normalized AWS metadata address (::ffff:a9fe:a9fe)', () => {
// ::ffff:169.254.169.254 → hex form after URL parsing
expect(isPrivateIP('::ffff:a9fe:a9fe')).toBe(true);
});
it('should detect hex-normalized loopback (::ffff:7f00:1)', () => {
// ::ffff:127.0.0.1 → hex form after URL parsing
expect(isPrivateIP('::ffff:7f00:1')).toBe(true);
});
it('should detect hex-normalized 192.168.x.x (::ffff:c0a8:101)', () => {
// ::ffff:192.168.1.1 → hex form after URL parsing
expect(isPrivateIP('::ffff:c0a8:101')).toBe(true);
});
it('should detect hex-normalized 10.x.x.x (::ffff:a00:1)', () => {
// ::ffff:10.0.0.1 → hex form after URL parsing
expect(isPrivateIP('::ffff:a00:1')).toBe(true);
});
it('should detect hex-normalized 172.16.x.x (::ffff:ac10:1)', () => {
// ::ffff:172.16.0.1 → hex form after URL parsing
expect(isPrivateIP('::ffff:ac10:1')).toBe(true);
});
it('should detect hex-normalized 0.0.0.0 (::ffff:0:0)', () => {
// ::ffff:0.0.0.0 → hex form after URL parsing
expect(isPrivateIP('::ffff:0:0')).toBe(true);
});
it('should allow hex-normalized public IPs (::ffff:808:808 = 8.8.8.8)', () => {
expect(isPrivateIP('::ffff:808:808')).toBe(false);
});
it('should detect IPv4-compatible addresses without ffff prefix (::XXXX:XXXX)', () => {
expect(isPrivateIP('::7f00:1')).toBe(true);
expect(isPrivateIP('::a9fe:a9fe')).toBe(true);
expect(isPrivateIP('::c0a8:101')).toBe(true);
expect(isPrivateIP('::a00:1')).toBe(true);
});
it('should allow public IPs in IPv4-compatible form', () => {
expect(isPrivateIP('::808:808')).toBe(false);
});
it('should detect 6to4 addresses embedding private IPv4 (2002:XXXX:XXXX::)', () => {
expect(isPrivateIP('2002:7f00:1::')).toBe(true);
expect(isPrivateIP('2002:a9fe:a9fe::')).toBe(true);
expect(isPrivateIP('2002:c0a8:101::')).toBe(true);
expect(isPrivateIP('2002:a00:1::')).toBe(true);
});
it('should allow 6to4 addresses embedding public IPv4', () => {
expect(isPrivateIP('2002:808:808::')).toBe(false);
});
it('should detect NAT64 addresses embedding private IPv4 (64:ff9b::XXXX:XXXX)', () => {
expect(isPrivateIP('64:ff9b::7f00:1')).toBe(true);
expect(isPrivateIP('64:ff9b::a9fe:a9fe')).toBe(true);
});
it('should detect Teredo addresses with complement-encoded private IPv4 (RFC 4380)', () => {
// Teredo stores external IPv4 as bitwise complement in last 32 bits
// 127.0.0.1 → complement: 0x80ff:0xfffe
expect(isPrivateIP('2001::80ff:fffe')).toBe(true);
// 169.254.169.254 → complement: 0x5601:0x5601
expect(isPrivateIP('2001::5601:5601')).toBe(true);
// 10.0.0.1 → complement: 0xf5ff:0xfffe
expect(isPrivateIP('2001::f5ff:fffe')).toBe(true);
});
it('should allow Teredo addresses with complement-encoded public IPv4', () => {
// 8.8.8.8 → complement: 0xf7f7:0xf7f7
expect(isPrivateIP('2001::f7f7:f7f7')).toBe(false);
});
it('should confirm URL parser produces the hex form that bypasses dotted regex', () => {
// This test documents the exact normalization gap
const hostname = new URL('http://[::ffff:169.254.169.254]/').hostname.replace(/^\[|\]$/g, '');
expect(hostname).toBe('::ffff:a9fe:a9fe'); // hex, not dotted
// The hostname that actually reaches isPrivateIP must be caught
expect(isPrivateIP(hostname)).toBe(true);
});
});
describe('isActionDomainAllowed - IPv4-mapped IPv6 hex SSRF bypass (end-to-end)', () => {
beforeEach(() => {
mockedLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should block http://[::ffff:169.254.169.254]/ (AWS metadata via IPv6)', async () => {
expect(await isActionDomainAllowed('http://[::ffff:169.254.169.254]/', null)).toBe(false);
});
it('should block http://[::ffff:127.0.0.1]/ (loopback via IPv6)', async () => {
expect(await isActionDomainAllowed('http://[::ffff:127.0.0.1]/', null)).toBe(false);
});
it('should block http://[::ffff:192.168.1.1]/ (private via IPv6)', async () => {
expect(await isActionDomainAllowed('http://[::ffff:192.168.1.1]/', null)).toBe(false);
});
it('should block http://[::ffff:10.0.0.1]/ (private via IPv6)', async () => {
expect(await isActionDomainAllowed('http://[::ffff:10.0.0.1]/', null)).toBe(false);
});
it('should allow http://[::ffff:8.8.8.8]/ (public via IPv6)', async () => {
expect(await isActionDomainAllowed('http://[::ffff:8.8.8.8]/', null)).toBe(true);
});
it('should block IPv4-compatible IPv6 without ffff prefix', async () => {
expect(await isActionDomainAllowed('http://[::127.0.0.1]/', null)).toBe(false);
expect(await isActionDomainAllowed('http://[::169.254.169.254]/', null)).toBe(false);
expect(await isActionDomainAllowed('http://[0:0:0:0:0:0:127.0.0.1]/', null)).toBe(false);
});
it('should block 6to4 addresses embedding private IPv4', async () => {
expect(await isActionDomainAllowed('http://[2002:7f00:1::]/', null)).toBe(false);
expect(await isActionDomainAllowed('http://[2002:a9fe:a9fe::]/', null)).toBe(false);
});
it('should block NAT64 addresses embedding private IPv4', async () => {
expect(await isActionDomainAllowed('http://[64:ff9b::127.0.0.1]/', null)).toBe(false);
expect(await isActionDomainAllowed('http://[64:ff9b::169.254.169.254]/', null)).toBe(false);
});
});
describe('resolveHostnameSSRF', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -489,52 +298,16 @@ describe('resolveHostnameSSRF', () => {
expect(await resolveHostnameSSRF('example.com')).toBe(false);
});
it('should detect private literal IPv4 addresses without DNS lookup', async () => {
expect(await resolveHostnameSSRF('169.254.169.254')).toBe(true);
expect(await resolveHostnameSSRF('127.0.0.1')).toBe(true);
expect(await resolveHostnameSSRF('10.0.0.1')).toBe(true);
it('should skip literal IPv4 addresses (handled by isSSRFTarget)', async () => {
expect(await resolveHostnameSSRF('169.254.169.254')).toBe(false);
expect(mockedLookup).not.toHaveBeenCalled();
});
it('should allow public literal IPv4 addresses without DNS lookup', async () => {
expect(await resolveHostnameSSRF('8.8.8.8')).toBe(false);
expect(await resolveHostnameSSRF('93.184.216.34')).toBe(false);
it('should skip literal IPv6 addresses', async () => {
expect(await resolveHostnameSSRF('::1')).toBe(false);
expect(mockedLookup).not.toHaveBeenCalled();
});
it('should detect private IPv6 literals without DNS lookup', async () => {
expect(await resolveHostnameSSRF('::1')).toBe(true);
expect(await resolveHostnameSSRF('fc00::1')).toBe(true);
expect(await resolveHostnameSSRF('fe80::1')).toBe(true);
expect(await resolveHostnameSSRF('fe90::1')).toBe(true);
expect(await resolveHostnameSSRF('febf::1')).toBe(true);
expect(mockedLookup).not.toHaveBeenCalled();
});
it('should detect hex-normalized IPv4-mapped IPv6 literals', async () => {
expect(await resolveHostnameSSRF('::ffff:a9fe:a9fe')).toBe(true);
expect(await resolveHostnameSSRF('::ffff:7f00:1')).toBe(true);
expect(await resolveHostnameSSRF('[::ffff:a9fe:a9fe]')).toBe(true);
expect(mockedLookup).not.toHaveBeenCalled();
});
it('should allow public IPv6 literals without DNS lookup', async () => {
expect(await resolveHostnameSSRF('2001:db8::1')).toBe(false);
expect(await resolveHostnameSSRF('::ffff:808:808')).toBe(false);
expect(mockedLookup).not.toHaveBeenCalled();
});
it('should detect private IPv6 addresses returned from DNS lookup', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '::1', family: 6 }] as never);
expect(await resolveHostnameSSRF('ipv6-loopback.example.com')).toBe(true);
mockedLookup.mockResolvedValueOnce([{ address: 'fc00::1', family: 6 }] as never);
expect(await resolveHostnameSSRF('ula.example.com')).toBe(true);
mockedLookup.mockResolvedValueOnce([{ address: '::ffff:a9fe:a9fe', family: 6 }] as never);
expect(await resolveHostnameSSRF('meta.example.com')).toBe(true);
});
it('should fail open on DNS resolution failure', async () => {
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
expect(await resolveHostnameSSRF('nonexistent.example.com')).toBe(false);
@@ -1049,37 +822,8 @@ describe('isMCPDomainAllowed', () => {
});
describe('invalid URL handling', () => {
it('should reject invalid URL when allowlist is configured', async () => {
it('should allow config with invalid URL (treated as stdio)', async () => {
const config = { url: 'not-a-valid-url' };
expect(await isMCPDomainAllowed(config, ['example.com'])).toBe(false);
});
it('should reject templated URL when allowlist is configured', async () => {
const config = { url: 'http://{{CUSTOM_HOST}}/mcp' };
expect(await isMCPDomainAllowed(config, ['example.com'])).toBe(false);
});
it('should allow invalid URL when no allowlist is configured (defers to connection-level SSRF)', async () => {
const config = { url: 'http://{{CUSTOM_HOST}}/mcp' };
expect(await isMCPDomainAllowed(config, null)).toBe(true);
expect(await isMCPDomainAllowed(config, undefined)).toBe(true);
expect(await isMCPDomainAllowed(config, [])).toBe(true);
});
it('should allow config with whitespace-only URL (treated as absent)', async () => {
const config = { url: ' ' };
expect(await isMCPDomainAllowed(config, [])).toBe(true);
expect(await isMCPDomainAllowed(config, ['example.com'])).toBe(true);
expect(await isMCPDomainAllowed(config, null)).toBe(true);
});
it('should allow config with empty string URL (treated as absent)', async () => {
const config = { url: '' };
expect(await isMCPDomainAllowed(config, ['example.com'])).toBe(true);
});
it('should allow config with no url property (stdio)', async () => {
const config = { command: 'node', args: ['server.js'] };
expect(await isMCPDomainAllowed(config, ['example.com'])).toBe(true);
});
});
@@ -1171,515 +915,4 @@ describe('isMCPDomainAllowed', () => {
expect(await isMCPDomainAllowed({ url: 'wss://example.com' }, ['example.com'])).toBe(true);
});
});
describe('IPv4-mapped IPv6 hex SSRF bypass', () => {
it('should block MCP server targeting AWS metadata via IPv6-mapped address', async () => {
const config = { url: 'http://[::ffff:169.254.169.254]/mcp' };
expect(await isMCPDomainAllowed(config, null)).toBe(false);
});
it('should block MCP server targeting loopback via IPv6-mapped address', async () => {
const config = { url: 'http://[::ffff:127.0.0.1]/mcp' };
expect(await isMCPDomainAllowed(config, null)).toBe(false);
});
it('should block MCP server targeting private range via IPv6-mapped address', async () => {
expect(await isMCPDomainAllowed({ url: 'http://[::ffff:10.0.0.1]/mcp' }, null)).toBe(false);
expect(await isMCPDomainAllowed({ url: 'http://[::ffff:192.168.1.1]/mcp' }, null)).toBe(
false,
);
});
it('should block WebSocket MCP targeting private range via IPv6-mapped address', async () => {
expect(await isMCPDomainAllowed({ url: 'ws://[::ffff:127.0.0.1]/mcp' }, null)).toBe(false);
expect(await isMCPDomainAllowed({ url: 'wss://[::ffff:10.0.0.1]/mcp' }, null)).toBe(false);
});
it('should allow MCP server targeting public IP via IPv6-mapped address', async () => {
const config = { url: 'http://[::ffff:8.8.8.8]/mcp' };
expect(await isMCPDomainAllowed(config, null)).toBe(true);
});
it('should block MCP server targeting 6to4 embedded private IPv4', async () => {
expect(await isMCPDomainAllowed({ url: 'http://[2002:7f00:1::]/mcp' }, null)).toBe(false);
expect(await isMCPDomainAllowed({ url: 'ws://[2002:a9fe:a9fe::]/mcp' }, null)).toBe(false);
});
it('should block MCP server targeting NAT64 embedded private IPv4', async () => {
expect(await isMCPDomainAllowed({ url: 'http://[64:ff9b::127.0.0.1]/mcp' }, null)).toBe(
false,
);
});
});
});
describe('isOAuthUrlAllowed', () => {
it('should return false when allowedDomains is null/undefined/empty', () => {
expect(isOAuthUrlAllowed('https://example.com/token', null)).toBe(false);
expect(isOAuthUrlAllowed('https://example.com/token', undefined)).toBe(false);
expect(isOAuthUrlAllowed('https://example.com/token', [])).toBe(false);
});
it('should return false for unparseable URLs', () => {
expect(isOAuthUrlAllowed('not-a-url', ['example.com'])).toBe(false);
});
it('should match exact hostnames', () => {
expect(isOAuthUrlAllowed('https://example.com/token', ['example.com'])).toBe(true);
expect(isOAuthUrlAllowed('https://other.com/token', ['example.com'])).toBe(false);
});
it('should match wildcard subdomains', () => {
expect(isOAuthUrlAllowed('https://api.example.com/token', ['*.example.com'])).toBe(true);
expect(isOAuthUrlAllowed('https://deep.nested.example.com/token', ['*.example.com'])).toBe(
true,
);
expect(isOAuthUrlAllowed('https://example.com/token', ['*.example.com'])).toBe(true);
expect(isOAuthUrlAllowed('https://other.com/token', ['*.example.com'])).toBe(false);
});
it('should be case-insensitive', () => {
expect(isOAuthUrlAllowed('https://EXAMPLE.COM/token', ['example.com'])).toBe(true);
expect(isOAuthUrlAllowed('https://example.com/token', ['EXAMPLE.COM'])).toBe(true);
});
it('should match private/internal URLs when hostname is in allowedDomains', () => {
expect(isOAuthUrlAllowed('http://localhost:8080/token', ['localhost'])).toBe(true);
expect(isOAuthUrlAllowed('http://10.0.0.1/token', ['10.0.0.1'])).toBe(true);
expect(
isOAuthUrlAllowed('http://host.docker.internal:8044/token', ['host.docker.internal']),
).toBe(true);
expect(isOAuthUrlAllowed('http://myserver.local/token', ['*.local'])).toBe(true);
});
it('should match internal URLs with wildcard patterns', () => {
expect(isOAuthUrlAllowed('https://auth.company.internal/token', ['*.company.internal'])).toBe(
true,
);
expect(isOAuthUrlAllowed('https://company.internal/token', ['*.company.internal'])).toBe(true);
});
it('should not match when hostname is absent from allowedDomains', () => {
expect(isOAuthUrlAllowed('http://10.0.0.1/token', ['192.168.1.1'])).toBe(false);
expect(isOAuthUrlAllowed('http://localhost/token', ['host.docker.internal'])).toBe(false);
});
describe('protocol and port constraint enforcement', () => {
it('should enforce protocol when allowedDomains specifies one', () => {
expect(isOAuthUrlAllowed('https://auth.internal/token', ['https://auth.internal'])).toBe(
true,
);
expect(isOAuthUrlAllowed('http://auth.internal/token', ['https://auth.internal'])).toBe(
false,
);
});
it('should allow any protocol when allowedDomains has bare hostname', () => {
expect(isOAuthUrlAllowed('http://auth.internal/token', ['auth.internal'])).toBe(true);
expect(isOAuthUrlAllowed('https://auth.internal/token', ['auth.internal'])).toBe(true);
});
it('should enforce port when allowedDomains specifies one', () => {
expect(
isOAuthUrlAllowed('https://auth.internal:8443/token', ['https://auth.internal:8443']),
).toBe(true);
expect(
isOAuthUrlAllowed('https://auth.internal:6379/token', ['https://auth.internal:8443']),
).toBe(false);
expect(isOAuthUrlAllowed('https://auth.internal/token', ['https://auth.internal:8443'])).toBe(
false,
);
});
it('should allow any port when allowedDomains has no explicit port', () => {
expect(isOAuthUrlAllowed('https://auth.internal:8443/token', ['auth.internal'])).toBe(true);
expect(isOAuthUrlAllowed('https://auth.internal:22/token', ['auth.internal'])).toBe(true);
});
it('should reject wrong port even when hostname matches (prevents port-scanning)', () => {
expect(isOAuthUrlAllowed('http://10.0.0.1:6379/token', ['http://10.0.0.1:8080'])).toBe(false);
expect(isOAuthUrlAllowed('http://10.0.0.1:25/token', ['http://10.0.0.1:8080'])).toBe(false);
});
});
});
describe('validateEndpointURL', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should throw for unparseable URLs', async () => {
await expect(validateEndpointURL('not-a-url', 'test-ep')).rejects.toThrow(
'Invalid base URL for test-ep',
);
});
it('should throw for localhost URLs', async () => {
await expect(validateEndpointURL('http://localhost:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for private IP URLs', async () => {
await expect(validateEndpointURL('http://192.168.1.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://10.0.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://172.16.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for link-local / metadata IP', async () => {
await expect(
validateEndpointURL('http://169.254.169.254/latest/meta-data/', 'test-ep'),
).rejects.toThrow('targets a restricted address');
});
it('should throw for loopback IP', async () => {
await expect(validateEndpointURL('http://127.0.0.1:11434/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for internal Docker/Kubernetes hostnames', async () => {
await expect(validateEndpointURL('http://redis:6379/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://mongodb:27017/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw when hostname DNS-resolves to a private IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(validateEndpointURL('https://evil.example.com/v1', 'test-ep')).rejects.toThrow(
'resolves to a restricted address',
);
});
it('should allow public URLs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '104.18.7.192', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.openai.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should allow public URLs that resolve to public IPs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.example.com/v1/chat', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw for non-HTTP/HTTPS schemes', async () => {
await expect(validateEndpointURL('ftp://example.com/v1', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('file:///etc/passwd', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('data:text/plain,hello', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
});
it('should throw for IPv6 loopback URL', async () => {
await expect(validateEndpointURL('http://[::1]:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 link-local URL', async () => {
await expect(validateEndpointURL('http://[fe80::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 unique-local URL', async () => {
await expect(validateEndpointURL('http://[fc00::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .local TLD hostname', async () => {
await expect(validateEndpointURL('http://myservice.local/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .internal TLD hostname', async () => {
await expect(validateEndpointURL('http://api.internal/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should pass when DNS lookup fails (fail-open)', async () => {
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
await expect(
validateEndpointURL('https://nonexistent.example.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw structured JSON with type invalid_base_url', async () => {
const error = await validateEndpointURL('http://169.254.169.254/latest/', 'my-ep').catch(
(err: Error) => err,
);
expect(error).toBeInstanceOf(Error);
const parsed = JSON.parse((error as Error).message);
expect(parsed.type).toBe('invalid_base_url');
expect(parsed.message).toContain('my-ep');
expect(parsed.message).toContain('targets a restricted address');
});
});
describe('isAddressAllowed', () => {
it('returns false when no allowedAddresses configured', () => {
expect(isAddressAllowed('127.0.0.1')).toBe(false);
expect(isAddressAllowed('127.0.0.1', null)).toBe(false);
expect(isAddressAllowed('127.0.0.1', [])).toBe(false);
});
it('matches literal IPv4 entries', () => {
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1:11434'], '11434')).toBe(true);
expect(isAddressAllowed('10.0.0.5', ['127.0.0.1:11434', '10.0.0.5:8080'], 8080)).toBe(true);
expect(isAddressAllowed('192.168.1.1', ['10.0.0.5:8080'], '8080')).toBe(false);
});
it('matches literal hostnames case-insensitively', () => {
expect(isAddressAllowed('localhost', ['localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('LOCALHOST', ['localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('host.docker.internal', ['HOST.DOCKER.INTERNAL:8080'], 8080)).toBe(
true,
);
});
it('strips IPv6 brackets when matching', () => {
expect(isAddressAllowed('[::1]', ['[::1]:11434'], '11434')).toBe(true);
expect(isAddressAllowed('::1', ['[::1]:11434'], '11434')).toBe(true);
});
it('does not match a different port on the same address', () => {
expect(isAddressAllowed('localhost', ['localhost:11434'], '8080')).toBe(false);
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1:11434'], '8080')).toBe(false);
});
it('does not match bare allowedAddresses entries', () => {
expect(isAddressAllowed('localhost', ['localhost'], '11434')).toBe(false);
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1'], '11434')).toBe(false);
});
it('does not partial-match hostnames', () => {
expect(isAddressAllowed('evil.localhost', ['localhost:11434'], '11434')).toBe(false);
expect(isAddressAllowed('host', ['host.docker.internal:11434'], '11434')).toBe(false);
});
it('ignores empty entries', () => {
expect(isAddressAllowed('localhost', ['', ' ', 'localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('localhost', ['', ' '], '11434')).toBe(false);
});
});
describe('SSRF allowedAddresses exemption', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('isSSRFTarget', () => {
it('exempts a hostname listed in allowedAddresses', () => {
expect(isSSRFTarget('localhost')).toBe(true);
expect(isSSRFTarget('localhost', ['localhost:11434'], '11434')).toBe(false);
});
it('exempts a private IP listed in allowedAddresses', () => {
expect(isSSRFTarget('10.0.0.5')).toBe(true);
expect(isSSRFTarget('10.0.0.5', ['10.0.0.5:11434'], '11434')).toBe(false);
});
it('does not exempt a different port on an allowed private target', () => {
expect(isSSRFTarget('localhost', ['localhost:11434'], '22')).toBe(true);
expect(isSSRFTarget('10.0.0.5', ['10.0.0.5:11434'], '22')).toBe(true);
});
it('does not exempt an unlisted private target', () => {
expect(isSSRFTarget('192.168.1.1', ['10.0.0.5:11434'], '11434')).toBe(true);
});
it('leaves public destinations unaffected when allowedAddresses is set', () => {
expect(isSSRFTarget('api.openai.com', ['localhost:11434'], '11434')).toBe(false);
});
});
describe('resolveHostnameSSRF', () => {
it('exempts when the hostname itself matches allowedAddresses', async () => {
// No lookup mock needed: a hostname-literal match short-circuits before DNS.
expect(await resolveHostnameSSRF('ollama.internal', ['ollama.internal:11434'], '11434')).toBe(
false,
);
});
it('exempts when a resolved IP matches allowedAddresses', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['10.0.0.5:11434'], '11434')).toBe(
false,
);
});
it('blocks when the host matches but the port does not', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['10.0.0.5:11434'], '22')).toBe(true);
});
it('still blocks when no entry matches', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['127.0.0.1:11434'], '11434')).toBe(
true,
);
});
it('blocks when one of multiple resolved IPs is private and unlisted', async () => {
mockedLookup.mockResolvedValueOnce([
{ address: '8.8.8.8', family: 4 },
{ address: '10.0.0.5', family: 4 },
] as never);
expect(await resolveHostnameSSRF('mixed.example.com', ['127.0.0.1:11434'], '11434')).toBe(
true,
);
});
it('passes when all resolved private IPs are listed', async () => {
mockedLookup.mockResolvedValueOnce([
{ address: '10.0.0.5', family: 4 },
{ address: '10.0.0.6', family: 4 },
] as never);
expect(
await resolveHostnameSSRF(
'cluster.example.com',
['10.0.0.5:11434', '10.0.0.6:11434'],
'11434',
),
).toBe(false);
});
});
describe('validateEndpointURL', () => {
it('passes a private-IP URL when its IP is in allowedAddresses', async () => {
await expect(
validateEndpointURL('http://10.0.0.5/v1', 'ollama', ['10.0.0.5:80']),
).resolves.toBeUndefined();
});
it('passes a localhost URL when localhost is in allowedAddresses', async () => {
await expect(
validateEndpointURL('http://localhost:11434/v1', 'ollama', ['localhost:11434']),
).resolves.toBeUndefined();
});
it('passes a hostname URL when DNS resolves to an exempted IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(
validateEndpointURL('https://ollama.example.com/v1', 'ollama', ['10.0.0.5:443']),
).resolves.toBeUndefined();
});
it('rejects a different port on the same allowed private address', async () => {
await expect(
validateEndpointURL('http://localhost:22/v1', 'ollama', ['localhost:11434']),
).rejects.toThrow('targets a restricted address');
});
it('still rejects unlisted private IPs when allowedAddresses is set', async () => {
await expect(
validateEndpointURL('http://192.168.1.1/v1', 'test-ep', ['10.0.0.5:80']),
).rejects.toThrow('targets a restricted address');
});
it('still rejects non-http schemes regardless of allowedAddresses', async () => {
await expect(
validateEndpointURL('file:///etc/passwd', 'test-ep', ['localhost:11434']),
).rejects.toThrow('only HTTP and HTTPS are permitted');
});
});
describe('isActionDomainAllowed', () => {
it('exempts a private IP when listed in allowedAddresses (no allowedDomains)', async () => {
expect(await isActionDomainAllowed('http://10.0.0.5:8080/api', null, ['10.0.0.5:8080'])).toBe(
true,
);
});
it('does not exempt a different port on an allowed private IP', async () => {
expect(await isActionDomainAllowed('http://10.0.0.5:8081/api', null, ['10.0.0.5:8080'])).toBe(
false,
);
});
it('still blocks unlisted private IPs', async () => {
expect(await isActionDomainAllowed('http://192.168.1.1/api', null, ['10.0.0.5:80'])).toBe(
false,
);
});
});
describe('isMCPDomainAllowed', () => {
it('exempts a private-IP MCP server when its IP is in allowedAddresses', async () => {
expect(
await isMCPDomainAllowed({ url: 'https://10.0.0.5:8443/mcp' }, null, ['10.0.0.5:8443']),
).toBe(true);
});
it('still blocks an unlisted private MCP target', async () => {
expect(
await isMCPDomainAllowed({ url: 'https://192.168.1.1/mcp' }, null, ['10.0.0.5:443']),
).toBe(false);
});
});
describe('isOAuthUrlAllowed', () => {
it('returns true when the URL host:port is in allowedAddresses (no allowedDomains)', () => {
expect(isOAuthUrlAllowed('https://10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(true);
});
it('returns false when the OAuth URL uses a different port than allowedAddresses', () => {
expect(isOAuthUrlAllowed('https://10.0.0.5:8443/oauth', null, ['10.0.0.5:443'])).toBe(false);
});
it('still requires allowedDomains match for non-exempted URLs', () => {
expect(isOAuthUrlAllowed('https://other.example.com/oauth', null, ['10.0.0.5:443'])).toBe(
false,
);
expect(
isOAuthUrlAllowed(
'https://other.example.com/oauth',
['other.example.com'],
['10.0.0.5:443'],
),
).toBe(true);
});
it('does not let allowedAddresses bypass a configured allowedDomains list', () => {
// Admin set `mcpSettings.allowedDomains` to constrain OAuth metadata/token/revocation
// hosts. An unrelated `allowedAddresses` entry (e.g. for self-hosted Ollama) must NOT
// broaden that bound — otherwise a malicious MCP server could advertise OAuth at any
// exempted private address and pass validation.
expect(
isOAuthUrlAllowed('http://10.0.0.5/oauth', ['oauth.trusted.com'], ['10.0.0.5:80']),
).toBe(false);
expect(
isOAuthUrlAllowed('http://127.0.0.1/oauth', ['oauth.trusted.com'], ['127.0.0.1:80']),
).toBe(false);
});
it('rejects schemeless inputs even when the host:port is in allowedAddresses', () => {
// `parseDomainSpec` quietly prepends `https://` to schemeless inputs.
// Without a strict `new URL(url)` gate, a value like `10.0.0.5/oauth`
// would short-circuit the trust-bypass and skip `validateOAuthUrl`'s
// own parse-or-throw. The check must require an absolute URL.
expect(isOAuthUrlAllowed('10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(false);
expect(isOAuthUrlAllowed('127.0.0.1', null, ['127.0.0.1:443'])).toBe(false);
expect(isOAuthUrlAllowed('//10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(false);
});
});
});
+68 -241
View File
@@ -1,13 +1,4 @@
import { lookup } from 'node:dns/promises';
import {
normalizePort,
isAddressInAllowedSet,
normalizeAllowedAddressesSet,
} from './allowedAddresses';
import { isPrivateIP } from './ip';
/** Re-exported here for backward compatibility; canonical location is `./ip`. */
export { isPrivateIP };
/**
* @param email
@@ -33,61 +24,83 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
}
/**
* Checks whether a hostname/IP literal and port appear in an admin-supplied
* exemption list. Address match is case-insensitive and bracket-stripped, so
* `[::1]` matches `::1` and `LOCALHOST` matches `localhost` when the port
* also matches.
*
* The normalization and scoping rules live in `./allowedAddresses` so the
* connect-time DNS lookup in `agent.ts` and this preflight helper share a
* single implementation. See that module for the security invariants.
*/
export function isAddressAllowed(
hostnameOrIP: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): boolean {
const set = normalizeAllowedAddressesSet(allowedAddresses);
return isAddressInAllowedSet(hostnameOrIP, set, port);
/** Checks if IPv4 octets fall within private, reserved, or link-local ranges */
function isPrivateIPv4(a: number, b: number, c: number): boolean {
if (a === 127) {
return true;
}
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a === 0 && b === 0 && c === 0) {
return true;
}
return false;
}
/**
* Checks if a hostname resolves to a private/reserved IP address.
* Directly validates literal IPv4 and IPv6 addresses without DNS lookup.
* For hostnames, resolves via DNS and checks all returned addresses.
* Fails open on DNS errors (returns false), since the HTTP request would also fail.
*
* When `allowedAddresses` is provided, the hostname/port and any resolved
* IP/port are matched against the list a match short-circuits to `false`
* so admin-exempted private services bypass the SSRF block.
* Checks if an IP address belongs to a private, reserved, or link-local range.
* Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses (::ffff:A.B.C.D).
*/
export async function resolveHostnameSSRF(
hostname: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): Promise<boolean> {
const normalizedHost = hostname.toLowerCase().trim();
export function isPrivateIP(ip: string): boolean {
const normalized = ip.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
return false;
const mappedMatch = normalized.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (mappedMatch) {
const [, a, b, c] = mappedMatch.map(Number);
return isPrivateIPv4(a, b, c);
}
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b, c] = ipv4Match.map(Number);
return isPrivateIPv4(a, b, c);
}
const ipv6 = normalized.replace(/^\[|\]$/g, '');
if (
ipv6 === '::1' ||
ipv6 === '::' ||
ipv6.startsWith('fc') ||
ipv6.startsWith('fd') ||
ipv6.startsWith('fe80')
) {
return true;
}
return false;
}
/**
* Resolves a hostname via DNS and checks if any resolved address is a private/reserved IP.
* Detects DNS-based SSRF bypasses (e.g., nip.io wildcard DNS, attacker-controlled nameservers).
* Fails open: returns false if DNS resolution fails, since hostname-only checks still apply
* and the actual HTTP request would also fail.
*/
export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
const normalizedHost = hostname.toLowerCase().trim();
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
return isPrivateIP(normalizedHost);
return false;
}
const ipv6Check = normalizedHost.replace(/^\[|\]$/g, '');
if (ipv6Check.includes(':')) {
return isPrivateIP(ipv6Check);
return false;
}
try {
const addresses = await lookup(hostname, { all: true });
return addresses.some(
(entry) =>
isPrivateIP(entry.address) && !isAddressAllowed(entry.address, allowedAddresses, port),
);
return addresses.some((entry) => isPrivateIP(entry.address));
} catch {
return false;
}
@@ -96,27 +109,12 @@ export async function resolveHostnameSSRF(
/**
* SSRF Protection: Checks if a hostname/IP is a potentially dangerous internal target.
* Blocks private IPs, localhost, cloud metadata IPs, and common internal hostnames.
*
* When `allowedAddresses` is provided, a literal host:port match exempts the
* target used by admins to permit known-good internal services (self-hosted
* Ollama, Docker host, etc.) without disabling SSRF protection for every port
* on the same host.
*
* @param hostname - The hostname or IP to check
* @param allowedAddresses - Optional admin exemption list of host:port pairs
* @returns true if the target is blocked (SSRF risk), false if safe
*/
export function isSSRFTarget(
hostname: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): boolean {
export function isSSRFTarget(hostname: string): boolean {
const normalizedHost = hostname.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
return false;
}
if (
normalizedHost === 'localhost' ||
normalizedHost === 'localhost.localdomain' ||
@@ -270,33 +268,17 @@ function hostnameMatches(inputHostname: string, allowedSpec: ParsedDomainSpec):
const HTTP_PROTOCOLS: SupportedProtocol[] = ['http:', 'https:'];
const MCP_PROTOCOLS: SupportedProtocol[] = ['http:', 'https:', 'ws:', 'wss:'];
function defaultPortForProtocol(protocol: SupportedProtocol | string | null): string {
if (protocol === 'http:' || protocol === 'ws:') return '80';
if (protocol === 'https:' || protocol === 'wss:') return '443';
return '';
}
function getEffectivePort(
protocol: SupportedProtocol | string | null,
port?: string | null,
): string {
return normalizePort(port ? port : defaultPortForProtocol(protocol));
}
/**
* Core domain validation logic with configurable protocol support.
* SECURITY: When no allowedDomains is configured, blocks SSRF-prone targets.
* @param domain - The domain to check (can include protocol/port)
* @param allowedDomains - List of allowed domain patterns
* @param supportedProtocols - Protocols to accept (others are rejected)
* @param allowedAddresses - Optional admin exemption list of host:port pairs
* that bypass the private-IP block when no allowedDomains whitelist is active
*/
async function isDomainAllowedCore(
domain: string,
allowedDomains: string[] | null | undefined,
supportedProtocols: SupportedProtocol[],
allowedAddresses?: string[] | null,
): Promise<boolean> {
const inputSpec = parseDomainSpec(domain);
if (!inputSpec) {
@@ -310,13 +292,12 @@ async function isDomainAllowedCore(
/** If no domain restrictions configured, block SSRF targets but allow all else */
if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
const effectivePort = getEffectivePort(inputSpec.protocol, inputSpec.port);
/** SECURITY: Block SSRF-prone targets when no allowlist is configured */
if (isSSRFTarget(inputSpec.hostname, allowedAddresses, effectivePort)) {
if (isSSRFTarget(inputSpec.hostname)) {
return false;
}
/** SECURITY: Resolve hostname and block if it points to a private/reserved IP */
if (await resolveHostnameSSRF(inputSpec.hostname, allowedAddresses, effectivePort)) {
if (await resolveHostnameSSRF(inputSpec.hostname)) {
return false;
}
return true;
@@ -367,26 +348,21 @@ async function isDomainAllowedCore(
* SECURITY: WebSocket protocols are NOT allowed per OpenAPI specification.
* @param domain - The domain to check (can include protocol/port)
* @param allowedDomains - List of allowed domain patterns
* @param allowedAddresses - Optional admin exemption list of host:port pairs
*/
export async function isActionDomainAllowed(
domain?: string | null,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<boolean> {
if (!domain || typeof domain !== 'string') {
return false;
}
return isDomainAllowedCore(domain, allowedDomains, HTTP_PROTOCOLS, allowedAddresses);
return isDomainAllowedCore(domain, allowedDomains, HTTP_PROTOCOLS);
}
/**
* Extracts full domain spec (protocol://hostname:port) from MCP server config URL.
* Returns the full origin for proper protocol/port matching against allowedDomains.
* @returns The full origin string, or null when:
* - No `url` property, non-string, or empty (stdio transport always allowed upstream)
* - URL string present but cannot be parsed (rejected fail-closed upstream when allowlist active)
* Callers must distinguish these two null cases; see {@link isMCPDomainAllowed}.
* Returns null for stdio transports (no URL) or invalid URLs.
* @param config - MCP server configuration (accepts any config with optional url field)
*/
export function extractMCPServerDomain(config: Record<string, unknown>): string | null {
@@ -410,169 +386,20 @@ export function extractMCPServerDomain(config: Record<string, unknown>): string
* Validates MCP server domain against allowedDomains.
* Supports HTTP, HTTPS, WS, and WSS protocols (per MCP specification).
* Stdio transports (no URL) are always allowed.
* Configs with a non-empty URL that cannot be parsed are rejected fail-closed when an
* allowlist is active, preventing template placeholders (e.g. `{{HOST}}`) from bypassing
* domain validation after `processMCPEnv` resolves them at connection time.
* When no allowlist is configured, unparseable URLs fall through to connection-level
* SSRF protection (`createSSRFSafeUndiciConnect`).
* @param config - MCP server configuration with optional url field
* @param allowedDomains - List of allowed domains (with wildcard support)
*/
export async function isMCPDomainAllowed(
config: Record<string, unknown>,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<boolean> {
const domain = extractMCPServerDomain(config);
const hasAllowlist = Array.isArray(allowedDomains) && allowedDomains.length > 0;
const hasExplicitUrl =
Object.prototype.hasOwnProperty.call(config, 'url') &&
typeof config.url === 'string' &&
config.url.trim().length > 0;
if (!domain && hasExplicitUrl && hasAllowlist) {
return false;
}
// Stdio transports (no URL) are always allowed
// Stdio transports don't have domains - always allowed
if (!domain) {
return true;
}
// Use MCP_PROTOCOLS (HTTP/HTTPS/WS/WSS) for MCP server validation
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS, allowedAddresses);
}
/**
* Checks whether an OAuth URL matches any entry in the MCP allowedDomains list,
* honoring protocol and port constraints when specified by the admin.
*
* Mirrors the allowlist-matching logic of {@link isDomainAllowedCore} (hostname,
* protocol, and explicit-port checks) but is synchronous no DNS resolution is
* needed because the caller is deciding whether to *skip* the subsequent
* SSRF/DNS checks, not replace them.
*
* @remarks `parseDomainSpec` normalizes `www.` prefixes, so both the input URL
* and allowedDomains entries starting with `www.` are matched without that prefix.
*/
export function isOAuthUrlAllowed(
url: string,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): boolean {
/**
* Require an absolute URL with an explicit scheme. `parseDomainSpec` is
* lenient: it prepends `https://` to schemeless inputs so a value like
* `10.0.0.5/oauth` would otherwise short-circuit the trust-bypass via
* `allowedAddresses` and skip `validateOAuthUrl`'s strict `new URL(url)`
* parse-or-throw check, only to fail later in OAuth discovery with a less
* clear error. Falling through here lets the caller's strict parse run.
*/
try {
new URL(url);
} catch {
return false;
}
const inputSpec = parseDomainSpec(url);
if (!inputSpec) {
return false;
}
/**
* When `allowedDomains` is configured, treat it as the authoritative bound
* on which OAuth URLs may bypass the SSRF/DNS check. `allowedAddresses` is
* an SSRF-private-IP exemption, not a domain allowlist letting it
* short-circuit here would broaden a strict admin-configured OAuth scope
* (e.g. an MCP server could advertise OAuth endpoints at any address the
* admin permitted for an unrelated reason like a self-hosted LLM).
*/
if (Array.isArray(allowedDomains) && allowedDomains.length > 0) {
for (const allowedDomain of allowedDomains) {
const allowedSpec = parseDomainSpec(allowedDomain);
if (!allowedSpec) {
continue;
}
if (!hostnameMatches(inputSpec.hostname, allowedSpec)) {
continue;
}
if (allowedSpec.protocol !== null) {
if (inputSpec.protocol === null || inputSpec.protocol !== allowedSpec.protocol) {
continue;
}
}
if (allowedSpec.explicitPort) {
if (!inputSpec.explicitPort || inputSpec.port !== allowedSpec.port) {
continue;
}
}
return true;
}
return false;
}
/**
* No `allowedDomains` configured: the address exemption may permit specific
* private host:port pairs (matches the semantics of `validateOAuthUrl`'s
* downstream SSRF checks, which also consult `allowedAddresses`).
*/
return isAddressAllowed(
inputSpec.hostname,
allowedAddresses,
getEffectivePort(inputSpec.protocol, inputSpec.port),
);
}
/** Matches ErrorTypes.INVALID_BASE_URL — string literal avoids build-time dependency on data-provider */
const INVALID_BASE_URL_TYPE = 'invalid_base_url';
function throwInvalidBaseURL(message: string): never {
throw new Error(JSON.stringify({ type: INVALID_BASE_URL_TYPE, message }));
}
/**
* Validates that a user-provided endpoint URL does not target private/internal addresses.
* Throws if the URL is unparseable, uses a non-HTTP(S) scheme, targets a known SSRF hostname,
* or DNS-resolves to a private IP.
*
* When `allowedAddresses` is provided, hostname/IP + port pairs are matched
* against the list admin-exempted private services bypass the SSRF block.
* This lets operators permit known-good private services (self-hosted Ollama,
* Docker host, etc.) without disabling protection for every port on the same host.
*
* @note DNS rebinding: validation performs a single DNS lookup. An adversary controlling
* DNS with TTL=0 could respond with a public IP at validation time and a private IP
* at request time. This is an accepted limitation of point-in-time DNS checks.
* @note Fail-open on DNS errors: a resolution failure here implies a failure at request
* time as well, matching {@link resolveHostnameSSRF} semantics.
*/
export async function validateEndpointURL(
url: string,
endpoint: string,
allowedAddresses?: string[] | null,
): Promise<void> {
let hostname: string;
let protocol: string;
let port: string;
try {
const parsed = new URL(url);
hostname = parsed.hostname;
protocol = parsed.protocol;
port = getEffectivePort(protocol, parsed.port);
} catch {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: unable to parse URL.`);
}
if (protocol !== 'http:' && protocol !== 'https:') {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: only HTTP and HTTPS are permitted.`);
}
if (isSSRFTarget(hostname, allowedAddresses, port)) {
throwInvalidBaseURL(`Base URL for ${endpoint} targets a restricted address.`);
}
if (await resolveHostnameSSRF(hostname, allowedAddresses, port)) {
throwInvalidBaseURL(`Base URL for ${endpoint} resolves to a restricted address.`);
}
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS);
}
@@ -80,20 +80,4 @@ describe('wrapHanzoGatewayFetch', () => {
spy.mockRestore();
}
});
it('is idempotent under double-wrapping (title path re-wraps the agent-run fetch)', async () => {
// #titleConvo re-applies the wrapper on top of the fetch initializeCustom
// already wrapped for agent runs. The second pass must not corrupt the result:
// it sees the inner pass's 402 `{error:{...}}` (no top-level `status:"error"`),
// so it passes through unchanged.
const envelope = JSON.stringify({ status: 'error', msg: 'invalid API key', data: null });
const doubleWrapped = wrapHanzoGatewayFetch(
wrapHanzoGatewayFetch(async () => makeResponse(envelope)),
);
const res = await doubleWrapped('https://api.hanzo.ai/v1/chat/completions');
expect(res.status).toBe(402);
const parsed = (await res.json()) as { error?: { message?: string; code?: string } };
expect(parsed.error?.message).toContain('invalid API key');
expect(parsed.error?.code).toBe('insufficient_quota');
});
});
@@ -1,4 +1,3 @@
export * from './config';
export * from './hanzoCloudKey';
export * from './hanzoGatewayFetch';
export * from './initialize';
File diff suppressed because it is too large Load Diff
-14
View File
@@ -12,18 +12,4 @@ export const mcpConfig = {
USER_CONNECTION_IDLE_TIMEOUT: math(
process.env.MCP_USER_CONNECTION_IDLE_TIMEOUT ?? 15 * 60 * 1000,
),
/** Max connect/disconnect cycles before the circuit breaker trips. Default: 7 */
CB_MAX_CYCLES: math(process.env.MCP_CB_MAX_CYCLES ?? 7),
/** Sliding window (ms) for counting cycles. Default: 45s */
CB_CYCLE_WINDOW_MS: math(process.env.MCP_CB_CYCLE_WINDOW_MS ?? 45_000),
/** Cooldown (ms) after the cycle breaker trips. Default: 15s */
CB_CYCLE_COOLDOWN_MS: math(process.env.MCP_CB_CYCLE_COOLDOWN_MS ?? 15_000),
/** Max consecutive failed connection rounds before backoff. Default: 3 */
CB_MAX_FAILED_ROUNDS: math(process.env.MCP_CB_MAX_FAILED_ROUNDS ?? 3),
/** Sliding window (ms) for counting failed rounds. Default: 120s */
CB_FAILED_WINDOW_MS: math(process.env.MCP_CB_FAILED_WINDOW_MS ?? 120_000),
/** Base backoff (ms) after failed round threshold is reached. Default: 30s */
CB_BASE_BACKOFF_MS: math(process.env.MCP_CB_BASE_BACKOFF_MS ?? 30_000),
/** Max backoff cap (ms) for exponential backoff. Default: 300s */
CB_MAX_BACKOFF_MS: math(process.env.MCP_CB_MAX_BACKOFF_MS ?? 300_000),
};
@@ -1,12 +1,6 @@
interface FailedMeta {
attempts: number;
lastFailedAt: number;
}
const COOLDOWN_SCHEDULE_MS = [5 * 60 * 1000, 10 * 60 * 1000, 20 * 60 * 1000, 30 * 60 * 1000];
export class OAuthReconnectionTracker {
private failedMeta: Map<string, Map<string, FailedMeta>> = new Map();
/** Map of userId -> Set of serverNames that have failed reconnection */
private failed: Map<string, Set<string>> = new Map();
/** Map of userId -> Set of serverNames that are actively reconnecting */
private active: Map<string, Set<string>> = new Map();
/** Map of userId:serverName -> timestamp when reconnection started */
@@ -15,17 +9,7 @@ export class OAuthReconnectionTracker {
private readonly RECONNECTION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
public isFailed(userId: string, serverName: string): boolean {
const meta = this.failedMeta.get(userId)?.get(serverName);
if (!meta) {
return false;
}
const idx = Math.min(meta.attempts - 1, COOLDOWN_SCHEDULE_MS.length - 1);
const cooldown = COOLDOWN_SCHEDULE_MS[idx];
const elapsed = Date.now() - meta.lastFailedAt;
if (elapsed >= cooldown) {
return false;
}
return true;
return this.failed.get(userId)?.has(serverName) ?? false;
}
/** Check if server is in the active set (original simple check) */
@@ -64,15 +48,11 @@ export class OAuthReconnectionTracker {
}
public setFailed(userId: string, serverName: string): void {
if (!this.failedMeta.has(userId)) {
this.failedMeta.set(userId, new Map());
if (!this.failed.has(userId)) {
this.failed.set(userId, new Set());
}
const userMap = this.failedMeta.get(userId)!;
const existing = userMap.get(serverName);
userMap.set(serverName, {
attempts: (existing?.attempts ?? 0) + 1,
lastFailedAt: Date.now(),
});
this.failed.get(userId)?.add(serverName);
}
public setActive(userId: string, serverName: string): void {
@@ -88,10 +68,10 @@ export class OAuthReconnectionTracker {
}
public removeFailed(userId: string, serverName: string): void {
const userMap = this.failedMeta.get(userId);
userMap?.delete(serverName);
if (userMap?.size === 0) {
this.failedMeta.delete(userId);
const userServers = this.failed.get(userId);
userServers?.delete(serverName);
if (userServers?.size === 0) {
this.failed.delete(userId);
}
}
@@ -106,17 +86,4 @@ export class OAuthReconnectionTracker {
const key = `${userId}:${serverName}`;
this.activeTimestamps.delete(key);
}
/** Returns map sizes for diagnostics */
public getStats(): {
usersWithFailedServers: number;
usersWithActiveReconnections: number;
activeTimestamps: number;
} {
return {
usersWithFailedServers: this.failedMeta.size,
usersWithActiveReconnections: this.active.size,
activeTimestamps: this.activeTimestamps.size,
};
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ const Avatar: React.FC<AvatarProps> = ({
() => (
<div
style={{
backgroundColor: 'rgb(64, 64, 64)',
backgroundColor: 'rgb(121, 137, 255)',
width: `${size}px`,
height: `${size}px`,
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
+16 -3
View File
@@ -26,9 +26,22 @@ const useAvatar = (user: TUser | undefined) => {
fontFamily: ['Verdana'],
fontSize: 36,
backgroundType: ['solid'],
// Monochrome brand: neutral grey ramp only — no colored avatars in the UI chrome.
// The seed still hashes to one of these, so avatars stay subtly distinct yet neutral.
backgroundColor: ['404040', '525252', '2f2f2f', '595959', '343434', '4d4d4d'],
backgroundColor: [
'd81b60',
'8e24aa',
'5e35b1',
'3949ab',
'DB3733',
'1B79CC',
'027CB8',
'008291',
'008577',
'58802F',
'8A761D',
'9C6D00',
'B06200',
'D1451A',
],
textColor: ['ffffff'],
});
@@ -59,11 +59,10 @@ const isValidThemeColors = (value: unknown): value is IThemeRGB => {
};
/**
* Get initial theme from localStorage. Hard default is 'dark' (true-black) the
* brand canvas unless the user has explicitly chosen light or system.
* Get initial theme from localStorage or default to 'system'
*/
const getInitialTheme = (): string => {
if (typeof window === 'undefined') return 'dark';
if (typeof window === 'undefined') return 'system';
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored && ['light', 'dark', 'system'].includes(stored)) {
@@ -72,7 +71,7 @@ const getInitialTheme = (): string => {
} catch {
// localStorage not available
}
return 'dark';
return 'system';
};
/**
+2 -39
View File
@@ -20,8 +20,6 @@ const BaseOptionsSchema = z.object({
iconPath: z.string().optional(),
timeout: z.number().optional(),
initTimeout: z.number().optional(),
/** Idle read timeout (ms) for streamable-HTTP GET SSE streams between server pushes */
sseReadTimeout: z.number().int().positive().optional(),
/** Controls visibility in chat dropdown menu (MCPSelect) */
chatMenu: z.boolean().optional(),
/**
@@ -103,30 +101,6 @@ const BaseOptionsSchema = z.object({
.optional(),
});
/**
* Outbound proxy URL for a remote MCP transport. Accepts http(s) and SOCKS
* proxies; the value may reference an env var via `${VAR}` which is resolved
* before validation. Admin-only never accepted from UI/API input.
*/
const ProxyUrlSchema = z
.string()
.transform((val: string) => extractEnvVariable(val))
.pipe(z.string().url())
.refine(
(val: string) => {
const protocol = new URL(val).protocol;
return (
protocol === 'http:' ||
protocol === 'https:' ||
protocol === 'socks:' ||
protocol === 'socks5:'
);
},
{
message: 'Proxy URL must use http://, https://, socks://, or socks5://',
},
);
export const StdioOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('stdio').optional(),
/**
@@ -187,8 +161,6 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
export const SSEOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('sse').optional(),
headers: z.record(z.string(), z.string()).optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@@ -207,8 +179,6 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({
type: z.union([z.literal('streamable-http'), z.literal('http')]),
headers: z.record(z.string(), z.string()).optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@@ -243,7 +213,6 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
startup: true,
timeout: true,
initTimeout: true,
sseReadTimeout: true,
chatMenu: true,
serverInstructions: true,
requiresOAuth: true,
@@ -263,14 +232,8 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
*/
export const MCPServerUserInputSchema = z.union([
omitServerManagedFields(WebSocketOptionsSchema),
omitServerManagedFields(SSEOptionsSchema).extend({
/** SECURITY: outbound proxy is admin-only; never accepted from UI/API input */
proxy: z.never().optional(),
}),
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
/** SECURITY: outbound proxy is admin-only; never accepted from UI/API input */
proxy: z.never().optional(),
}),
omitServerManagedFields(SSEOptionsSchema),
omitServerManagedFields(StreamableHTTPOptionsSchema),
]);
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
+1 -32
View File
@@ -102,33 +102,6 @@ const processQueue = (error: AxiosError | null, token: string | null = null) =>
failedQueue = [];
};
/** The bearer currently on the axios default header, or null when anonymous. */
function currentBearer(): string | null {
const auth = axios.defaults.headers.common['Authorization'];
return typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7) : null;
}
/**
* A guest (anonymous preview) session carries a `{ guest: true }` JWT that is
* only valid on the chat-completion route; every other endpoint answers 401 by
* design. Those expected 401s must NOT hard-redirect the guest to /login that
* wipes the guest session and loops. Derive guest-ness from the active bearer so
* the interceptor leaves guests on the chat surface.
*/
function isGuestSession(): boolean {
const tok = currentBearer();
if (tok == null) {
return false;
}
try {
const b64 = tok.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')));
return payload?.guest === true;
} catch {
return false;
}
}
// Auto-retry on 401 (access token expired): refresh once, then replay.
if (typeof window !== 'undefined') {
axios.interceptors.response.use(
@@ -181,11 +154,7 @@ if (typeof window !== 'undefined') {
console.log(
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else if (currentBearer() != null && !isGuestSession()) {
// Only hard-redirect a genuinely expired *session* bearer. An
// anonymous cold-start (no bearer yet, guest token still in flight)
// must never be bounced to /login — that races the guest-acquire and
// strands the visitor. Routing/login-gate handles the no-bearer case.
} else {
window.location.href = endpoints.loginPage();
}
} catch (err) {
-35
View File
@@ -114,10 +114,6 @@ const defaultRolesSchema = z.object({
name: z.literal(SystemRoles.USER),
permissions: permissionsSchema,
}),
[SystemRoles.GUEST]: roleSchema.extend({
name: z.literal(SystemRoles.GUEST),
permissions: permissionsSchema,
}),
});
export const roleDefaults = defaultRolesSchema.parse({
@@ -211,35 +207,4 @@ export const roleDefaults = defaultRolesSchema.parse({
[PermissionTypes.REMOTE_AGENTS]: {},
},
},
// Ephemeral anonymous guests (never persisted, scoped to the free Zen
// endpoint by enforceGuestScope middleware). Their JWT carries role GUEST,
// so getRoleByName('GUEST') must resolve to a real, named role or every
// guest generation dies at the role lookup. Mirrors USER's minimal grant;
// guest containment (endpoint/model/rate) is enforced at the middleware
// layer, not by these permissions.
[SystemRoles.GUEST]: {
name: SystemRoles.GUEST,
permissions: {
[PermissionTypes.PROMPTS]: {},
[PermissionTypes.BOOKMARKS]: {},
[PermissionTypes.MEMORIES]: {},
[PermissionTypes.AGENTS]: {},
[PermissionTypes.MULTI_CONVO]: {},
[PermissionTypes.TEMPORARY_CHAT]: {},
[PermissionTypes.RUN_CODE]: {},
[PermissionTypes.WEB_SEARCH]: {},
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: false,
},
[PermissionTypes.FILE_SEARCH]: {},
[PermissionTypes.FILE_CITATIONS]: {},
[PermissionTypes.MCP_SERVERS]: {},
[PermissionTypes.REMOTE_AGENTS]: {},
},
},
});
+154 -2416
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+156 -2414
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-2
View File
@@ -5,8 +5,6 @@ export * from './schema';
export * from './utils';
export { createModels } from './models';
export { createMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY } from './methods';
export { createSqliteHandle, openDatabase, DocModel, CHAT_COLLECTION_SPECS, type SqliteHandle, type CollectionSpec, } from './stores/sqlite';
export type { DataHandle } from './common/dataHandle';
export type * from './types';
export type * from './methods';
export { default as logger } from './config/winston';
+1 -2
View File
@@ -1,7 +1,6 @@
import { Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type * as t from '~/types';
export declare function createMemoryMethods(handle: DataHandle): {
export declare function createMemoryMethods(mongoose: typeof import('mongoose')): {
setMemory: ({ userId, key, value, tokenCount, }: t.SetMemoryParams) => Promise<t.MemoryResult>;
createMemory: ({ userId, key, value, tokenCount, }: t.SetMemoryParams) => Promise<t.MemoryResult>;
deleteMemory: ({ userId, key }: t.DeleteMemoryParams) => Promise<t.MemoryResult>;
+1 -2
View File
@@ -1,7 +1,6 @@
import type { DeleteResult } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type { FindPluginAuthsByKeysParams, UpdatePluginAuthParams, DeletePluginAuthParams, FindPluginAuthParams, IPluginAuth } from '~/types';
export declare function createPluginAuthMethods(handle: DataHandle): {
export declare function createPluginAuthMethods(mongoose: typeof import('mongoose')): {
findOnePluginAuth: ({ userId, authField, pluginKey, }: FindPluginAuthParams) => Promise<IPluginAuth | null>;
findPluginAuthsByKeys: ({ userId, pluginKeys, }: FindPluginAuthsByKeysParams) => Promise<IPluginAuth[]>;
updatePluginAuth: ({ userId, authField, pluginKey, value, }: UpdatePluginAuthParams) => Promise<IPluginAuth>;
+6 -3
View File
@@ -1,6 +1,9 @@
import type { DataHandle } from '~/common/dataHandle';
export declare function createRoleMethods(handle: DataHandle): {
listRoles: () => Promise<any>;
export declare function createRoleMethods(mongoose: typeof import('mongoose')): {
listRoles: () => Promise<(import("mongoose").FlattenMaps<any> & Required<{
_id: unknown;
}> & {
__v: number;
})[]>;
initializeRoles: () => Promise<void>;
};
export type RoleMethods = ReturnType<typeof createRoleMethods>;
+1 -2
View File
@@ -1,7 +1,6 @@
import type { DataHandle } from '~/common/dataHandle';
import type * as t from '~/types';
/** Factory function that takes mongoose instance and returns the methods */
export declare function createShareMethods(handle: DataHandle): {
export declare function createShareMethods(mongoose: typeof import('mongoose')): {
getSharedLink: (user: string, conversationId: string) => Promise<t.GetShareLinkResult>;
getSharedLinks: (user: string, pageParam?: Date, pageSize?: number, isPublic?: boolean, sortBy?: string, sortDirection?: string, search?: string) => Promise<t.SharedLinksResult>;
createSharedLink: (user: string, conversationId: string, targetMessageId?: string) => Promise<t.CreateShareResult>;
-7
View File
@@ -1,9 +1,3 @@
/**
* Closes and clears the process-shared SQLite handle. Idempotent. The prod path
* keeps one handle for the process lifetime; this exists so tests that build the
* handle tear it down every native Database opened MUST be closed.
*/
export declare function closeSharedSqliteHandle(): void;
/**
* Creates all database models for all collections
*/
@@ -37,5 +31,4 @@ export declare function createModels(mongoose: typeof import('mongoose')): {
AccessRole: import("mongoose").Model<any, {}, {}, {}, any, any>;
AclEntry: import("mongoose").Model<any, {}, {}, {}, any, any>;
Group: import("mongoose").Model<any, {}, {}, {}, any, any>;
SystemGrant: import("mongoose").Model<any, {}, {}, {}, any, any>;
};
-1
View File
@@ -1,5 +1,4 @@
export default {
setupFiles: ['<rootDir>/test/creds.setup.cjs'],
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
coverageReporters: ['text', 'cobertura'],
+1 -2
View File
@@ -21,7 +21,7 @@
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w",
"test": "jest --coverage --watch",
"test:ci": "node test/ci.mjs",
"test:ci": "jest --coverage --ci",
"verify": "npm run test:ci",
"b:clean": "bun run rimraf dist",
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs"
@@ -59,7 +59,6 @@
"typescript": "^5.0.4"
},
"dependencies": {
"better-sqlite3-multiple-ciphers": "12.11.1",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
+1 -1
View File
@@ -36,5 +36,5 @@ export default {
}),
],
// Do not bundle these external dependencies
external: ['mongoose', 'better-sqlite3-multiple-ciphers', 'node:crypto'],
external: ['mongoose'],
};
@@ -1,20 +0,0 @@
/**
* The minimal storage handle the data-access methods depend on: a registry of
* collection models, each exposing the Model API the methods call. This is the
* migration seam it is satisfied structurally by both:
* - mongoose (`mongoose.models`), the legacy backend, and
* - the SQLite document store (`createSqliteHandle().models`), the new backend.
*
* Methods take a `DataHandle` instead of `typeof import('mongoose')` so a domain
* can be flipped from mongoose to SQLite/Base by swapping the handle, with no
* change to the method logic.
*/
export interface DataHandle {
models: Record<string, unknown>;
/**
* BSON value helpers. Satisfied by mongoose (`mongoose.Types`) and by the
* SQLite handle (a hex-string ObjectId shim). Optional only method files
* that construct ObjectIds (Skill, MCPServer) read it.
*/
Types?: { ObjectId: new (id?: string | { toHexString(): string }) => unknown };
}
+4 -33
View File
@@ -1,7 +1,6 @@
import { klona } from 'klona';
import winston from 'winston';
import traverse from '../utils/object-traverse';
import { SYSTEM_TENANT_ID } from './tenantContext';
import type { TraverseContext } from '../utils/object-traverse';
const SPLAT_SYMBOL = Symbol.for('splat');
@@ -9,7 +8,6 @@ const MESSAGE_SYMBOL = Symbol.for('message');
const CONSOLE_JSON_STRING_LENGTH: number =
parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255;
const DEBUG_MESSAGE_LENGTH: number = parseInt(process.env.DEBUG_MESSAGE_LENGTH || '', 10) || 150;
const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const;
const sensitiveKeys: RegExp[] = [
/^(sk-)[^\s]+/, // OpenAI API key pattern
@@ -106,30 +104,6 @@ const condenseArray = (item: unknown): string | unknown => {
return item;
};
/**
* Serializes request-scoped identity (tenant / user / request ids) into a
* compact JSON suffix, omitting the internal `__SYSTEM__` tenant sentinel so it
* never leaks into logs.
*/
function formatRequestContext(metadata: Record<string, unknown>): string {
const context: Partial<Record<(typeof LOG_CONTEXT_KEYS)[number], string>> = {};
LOG_CONTEXT_KEYS.forEach((key) => {
const value = metadata[key];
if (key === 'tenantId' && value === SYSTEM_TENANT_ID) {
return;
}
if (typeof value === 'string' && value) {
context[key] = value;
}
});
return Object.keys(context).length > 0 ? JSON.stringify(context) : '';
}
function appendRequestContext(line: string, metadata: Record<string, unknown>): string {
const context = formatRequestContext(metadata);
return context ? `${line} ${context}` : line;
}
/**
* Formats log messages for debugging purposes.
* - Truncates long strings within log messages.
@@ -157,7 +131,7 @@ const debugTraverse = winston.format.printf(
try {
if (level !== 'debug') {
return appendRequestContext(msgParts[0], metadata);
return msgParts[0];
}
if (!metadata) {
@@ -170,25 +144,22 @@ const debugTraverse = winston.format.printf(
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
if (!debugValue) {
return appendRequestContext(msgParts[0], metadata);
return msgParts[0];
}
if (debugValue && Array.isArray(debugValue)) {
msgParts.push(`\n${JSON.stringify(debugValue.map(condenseArray))}`);
return appendRequestContext(msgParts.join(''), metadata);
return msgParts.join('');
}
if (typeof debugValue !== 'object') {
msgParts.push(` ${debugValue}`);
return appendRequestContext(msgParts.join(''), metadata);
return msgParts.join('');
}
msgParts.push('\n{');
const copy = klona(metadata);
if (copy.tenantId === SYSTEM_TENANT_ID) {
delete copy.tenantId;
}
try {
const traversal = traverse(copy);
traversal.forEach(function (this: TraverseContext, value: unknown) {
-9
View File
@@ -5,15 +5,6 @@ export * from './schema';
export * from './utils';
export { createModels } from './models';
export { createMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY } from './methods';
export {
createSqliteHandle,
openDatabase,
DocModel,
CHAT_COLLECTION_SPECS,
type SqliteHandle,
type CollectionSpec,
} from './stores/sqlite';
export type { DataHandle } from './common/dataHandle';
export type * from './types';
export type * from './methods';
export { default as logger } from './config/winston';
+11 -12
View File
@@ -1,17 +1,16 @@
import { AccessRoleIds, ResourceType, PermissionBits } from 'librechat-data-provider';
import type { DataHandle } from '~/common/dataHandle';
import type { Model, Types, DeleteResult } from 'mongoose';
import type { IAccessRole } from '~/types';
import { RoleBits } from '~/common';
export function createAccessRoleMethods(handle: DataHandle) {
export function createAccessRoleMethods(mongoose: typeof import('mongoose')) {
/**
* Find an access role by its ID
* @param roleId - The role ID
* @returns The role document or null if not found
*/
async function findRoleById(roleId: string | Types.ObjectId): Promise<IAccessRole | null> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.findById(roleId).lean();
}
@@ -23,7 +22,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
async function findRoleByIdentifier(
accessRoleId: string | Types.ObjectId,
): Promise<IAccessRole | null> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.findOne({ accessRoleId }).lean();
}
@@ -33,7 +32,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
* @returns Array of role documents
*/
async function findRolesByResourceType(resourceType: string): Promise<IAccessRole[]> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.find({ resourceType }).lean();
}
@@ -47,7 +46,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
resourceType: string,
permBits: PermissionBits | RoleBits,
): Promise<IAccessRole | null> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.findOne({ resourceType, permBits }).lean();
}
@@ -57,7 +56,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
* @returns The created role document
*/
async function createRole(roleData: Partial<IAccessRole>): Promise<IAccessRole> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.create(roleData);
}
@@ -71,7 +70,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
accessRoleId: string | Types.ObjectId,
updateData: Partial<IAccessRole>,
): Promise<IAccessRole | null> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.findOneAndUpdate(
{ accessRoleId },
{ $set: updateData },
@@ -85,7 +84,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
* @returns The result of the delete operation
*/
async function deleteRole(accessRoleId: string | Types.ObjectId): Promise<DeleteResult> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.deleteOne({ accessRoleId });
}
@@ -94,7 +93,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
* @returns Array of all role documents
*/
async function getAllRoles(): Promise<IAccessRole[]> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
return await AccessRole.find().lean();
}
@@ -103,7 +102,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
* @returns Object containing created roles
*/
async function seedDefaultRoles() {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
const defaultRoles = [
{
accessRoleId: AccessRoleIds.AGENT_VIEWER,
@@ -216,7 +215,7 @@ export function createAccessRoleMethods(handle: DataHandle) {
resourceType: string,
permBits: PermissionBits | RoleBits,
): Promise<IAccessRole | null> {
const AccessRole = handle.models.AccessRole as Model<IAccessRole>;
const AccessRole = mongoose.models.AccessRole as Model<IAccessRole>;
const exactMatch = await AccessRole.findOne({ resourceType, permBits }).lean();
if (exactMatch) {
return exactMatch;
+10 -11
View File
@@ -1,10 +1,9 @@
import { Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { Model, DeleteResult, ClientSession } from 'mongoose';
import type { IAclEntry } from '~/types';
export function createAclEntryMethods(handle: DataHandle) {
export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
/**
* Find ACL entries for a specific principal (user or group)
* @param principalType - The type of principal ('user', 'group')
@@ -17,7 +16,7 @@ export function createAclEntryMethods(handle: DataHandle) {
principalId: string | Types.ObjectId,
resourceType?: string,
): Promise<IAclEntry[]> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = { principalType, principalId };
if (resourceType) {
query.resourceType = resourceType;
@@ -35,7 +34,7 @@ export function createAclEntryMethods(handle: DataHandle) {
resourceType: string,
resourceId: string | Types.ObjectId,
): Promise<IAclEntry[]> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
return await AclEntry.find({ resourceType, resourceId }).lean();
}
@@ -51,7 +50,7 @@ export function createAclEntryMethods(handle: DataHandle) {
resourceType: string,
resourceId: string | Types.ObjectId,
): Promise<IAclEntry[]> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
@@ -78,7 +77,7 @@ export function createAclEntryMethods(handle: DataHandle) {
resourceId: string | Types.ObjectId,
permissionBit: number,
): Promise<boolean> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
@@ -147,7 +146,7 @@ export function createAclEntryMethods(handle: DataHandle) {
return new Map();
}
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
@@ -193,7 +192,7 @@ export function createAclEntryMethods(handle: DataHandle) {
session?: ClientSession,
roleId?: string | Types.ObjectId,
): Promise<IAclEntry | null> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
@@ -248,7 +247,7 @@ export function createAclEntryMethods(handle: DataHandle) {
resourceId: string | Types.ObjectId,
session?: ClientSession,
): Promise<DeleteResult> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
@@ -287,7 +286,7 @@ export function createAclEntryMethods(handle: DataHandle) {
removeBits?: number | null,
session?: ClientSession,
): Promise<IAclEntry | null> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
@@ -333,7 +332,7 @@ export function createAclEntryMethods(handle: DataHandle) {
resourceType: string,
requiredPermBit: number,
): Promise<Types.ObjectId[]> {
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
+5 -6
View File
@@ -1,10 +1,9 @@
import type { FilterQuery, Model } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type { IAction } from '~/types';
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'] as const;
export function createActionMethods(handle: DataHandle) {
export function createActionMethods(mongoose: typeof import('mongoose')) {
/**
* Update an action with new data without overwriting existing properties,
* or create a new action if it doesn't exist.
@@ -13,7 +12,7 @@ export function createActionMethods(handle: DataHandle) {
searchParams: FilterQuery<IAction>,
updateData: Partial<IAction>,
): Promise<IAction | null> {
const Action = handle.models.Action as Model<IAction>;
const Action = mongoose.models.Action as Model<IAction>;
const options = { new: true, upsert: true };
return await Action.findOneAndUpdate(searchParams, updateData, options).lean<IAction>();
}
@@ -25,7 +24,7 @@ export function createActionMethods(handle: DataHandle) {
searchParams: FilterQuery<IAction>,
includeSensitive = false,
): Promise<IAction[]> {
const Action = handle.models.Action as Model<IAction>;
const Action = mongoose.models.Action as Model<IAction>;
const actions = await Action.find(searchParams).lean<IAction[]>();
if (!includeSensitive) {
@@ -50,7 +49,7 @@ export function createActionMethods(handle: DataHandle) {
* Deletes an action by params.
*/
async function deleteAction(searchParams: FilterQuery<IAction>): Promise<IAction | null> {
const Action = handle.models.Action as Model<IAction>;
const Action = mongoose.models.Action as Model<IAction>;
return await Action.findOneAndDelete(searchParams).lean<IAction>();
}
@@ -58,7 +57,7 @@ export function createActionMethods(handle: DataHandle) {
* Deletes actions by params.
*/
async function deleteActions(searchParams: FilterQuery<IAction>): Promise<number> {
const Action = handle.models.Action as Model<IAction>;
const Action = mongoose.models.Action as Model<IAction>;
const result = await Action.deleteMany(searchParams);
return result.deletedCount;
}
+23 -24
View File
@@ -1,5 +1,4 @@
import crypto from 'node:crypto';
import type { DataHandle } from '~/common/dataHandle';
import {
Constants,
EToolResources,
@@ -247,14 +246,14 @@ async function generateActionMetadataHash(
return hashHex;
}
export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
export function createAgentMethods(mongoose: typeof import('mongoose'), deps: AgentDeps) {
const { removeAllPermissions, getActions, getSoleOwnedResourceIds } = deps;
/**
* Create an agent with the provided data.
*/
async function createAgent(agentData: Record<string, unknown>): Promise<IAgent> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const { author: _author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
@@ -277,7 +276,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
* Get an agent document based on the provided search parameter.
*/
async function getAgent(searchParameter: FilterQuery<IAgent>): Promise<IAgent | null> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
return await Agent.findOne(searchParameter).lean<IAgent>();
}
@@ -285,7 +284,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
* Get multiple agent documents based on the provided search parameters.
*/
async function getAgents(searchParameter: FilterQuery<IAgent>): Promise<IAgent[]> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
return await Agent.find(searchParameter).lean<IAgent[]>();
}
@@ -300,7 +299,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
return false;
}
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const agent = await Agent.exists({
_id: { $in: agentIds },
mcpServerNames: serverName,
@@ -314,7 +313,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
return [];
}
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const agents = await Agent.find(
{
_id: { $in: agentIds },
@@ -347,7 +346,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
skipVersioning?: boolean;
} = {},
): Promise<IAgent | null> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const { updatingUserId = null, forceVersion = false, skipVersioning = false } = options;
const mongoOptions = { new: true, upsert: false };
@@ -429,7 +428,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
}
if (updatingUserId) {
versionEntry.updatedBy = new handle.Types!.ObjectId(updatingUserId);
versionEntry.updatedBy = new mongoose.Types.ObjectId(updatingUserId);
}
if (shouldCreateVersion) {
@@ -461,7 +460,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
file_id: string;
updatingUserId?: string;
}): Promise<IAgent> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const searchParameter = { id: agent_id };
const agent = await getAgent(searchParameter);
if (!agent) {
@@ -507,7 +506,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
agent_id: string;
files: Array<{ tool_resource: string; file_id: string }>;
}): Promise<IAgent> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const searchParameter = { id: agent_id };
const filesByResource = files.reduce(
@@ -556,7 +555,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
return { matchedCount: 0, modifiedCount: 0 };
}
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const orQuery = TOOL_RESOURCE_KEYS.map((key) => ({
[`tool_resources.${key}.file_ids`]: { $in: file_ids },
@@ -578,8 +577,8 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
* Deletes an agent based on the provided search parameter.
*/
async function deleteAgent(searchParameter: FilterQuery<IAgent>): Promise<IAgent | null> {
const Agent = handle.models.Agent as Model<IAgent>;
const User = handle.models.User as Model<unknown>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const User = mongoose.models.User as Model<unknown>;
const agent = await Agent.findOneAndDelete(searchParameter);
if (agent) {
await Promise.all([
@@ -621,12 +620,12 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
* ensuring they are not orphaned if no permission migration has been run.
*/
async function deleteUserAgents(userId: string): Promise<void> {
const Agent = handle.models.Agent as Model<IAgent>;
const AclEntry = handle.models.AclEntry as Model<IAclEntry>;
const User = handle.models.User as Model<unknown>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const User = mongoose.models.User as Model<unknown>;
try {
const userObjectId = new handle.Types!.ObjectId(userId);
const userObjectId = new mongoose.Types.ObjectId(userId);
const soleOwnedObjectIds = await getSoleOwnedResourceIds(userObjectId, [
ResourceType.AGENT,
ResourceType.REMOTE_AGENT,
@@ -715,7 +714,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
has_more: boolean;
after: string | null;
}> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated
? Math.min(Math.max(1, parseInt(String(limit)) || 20), 1000)
@@ -736,7 +735,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
{ updatedAt: { $lt: new Date(updatedAt) } },
{
updatedAt: new Date(updatedAt),
_id: { $gt: new handle.Types!.ObjectId(_id) },
_id: { $gt: new mongoose.Types.ObjectId(_id) },
},
],
};
@@ -818,7 +817,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
searchParameter: FilterQuery<IAgent>,
versionIndex: number,
): Promise<IAgent> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const agent = await Agent.findOne(searchParameter);
if (!agent) {
throw new Error('Agent not found');
@@ -848,7 +847,7 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
* Counts the number of promoted agents.
*/
async function countPromotedAgents(): Promise<number> {
const Agent = handle.models.Agent as Model<IAgent>;
const Agent = mongoose.models.Agent as Model<IAgent>;
return await Agent.countDocuments({ is_promoted: true });
}
@@ -857,8 +856,8 @@ export function createAgentMethods(handle: DataHandle, deps: AgentDeps) {
resourceId: string,
userIds: string[],
): Promise<void> {
const Agent = handle.models.Agent as Model<IAgent>;
const User = handle.models.User as Model<unknown>;
const Agent = mongoose.models.Agent as Model<IAgent>;
const User = mongoose.models.User as Model<unknown>;
const agent = await Agent.findOne({ _id: resourceId }, { id: 1 }).lean();
if (!agent) {
@@ -1,5 +1,4 @@
import type { Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type {
AgentApiKeyCreateResult,
AgentApiKeyCreateData,
@@ -12,7 +11,7 @@ import logger from '~/config/winston';
const API_KEY_PREFIX = 'sk-';
const API_KEY_LENGTH = 32;
export function createAgentApiKeyMethods(handle: DataHandle) {
export function createAgentApiKeyMethods(mongoose: typeof import('mongoose')) {
async function generateApiKey(): Promise<{ key: string; keyHash: string; keyPrefix: string }> {
const randomPart = await getRandomValues(API_KEY_LENGTH);
const key = `${API_KEY_PREFIX}${randomPart}`;
@@ -23,7 +22,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
async function createAgentApiKey(data: AgentApiKeyCreateData): Promise<AgentApiKeyCreateResult> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const { key, keyHash, keyPrefix } = await generateApiKey();
const apiKeyDoc = await AgentApiKey.create({
@@ -52,7 +51,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
apiKey: string,
): Promise<{ userId: Types.ObjectId; keyId: Types.ObjectId } | null> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const keyHash = await hashToken(apiKey);
const keyDoc = (await AgentApiKey.findOne({ keyHash }).lean()) as IAgentApiKey | null;
@@ -79,7 +78,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
async function listAgentApiKeys(userId: string | Types.ObjectId): Promise<AgentApiKeyListItem[]> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const keys = (await AgentApiKey.find({ userId })
.sort({ createdAt: -1 })
.lean()) as unknown as IAgentApiKey[];
@@ -103,7 +102,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
userId: string | Types.ObjectId,
): Promise<boolean> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const result = await AgentApiKey.deleteOne({ _id: keyId, userId });
return result.deletedCount > 0;
} catch (error) {
@@ -114,7 +113,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
async function deleteAllAgentApiKeys(userId: string | Types.ObjectId): Promise<number> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const result = await AgentApiKey.deleteMany({ userId });
return result.deletedCount;
} catch (error) {
@@ -128,7 +127,7 @@ export function createAgentApiKeyMethods(handle: DataHandle) {
userId: string | Types.ObjectId,
): Promise<AgentApiKeyListItem | null> {
try {
const AgentApiKey = handle.models.AgentApiKey;
const AgentApiKey = mongoose.models.AgentApiKey;
const keyDoc = (await AgentApiKey.findOne({
_id: keyId,
userId,
@@ -1,14 +1,13 @@
import type { Model, Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type { IAgentCategory } from '~/types';
export function createAgentCategoryMethods(handle: DataHandle) {
export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) {
/**
* Get all active categories sorted by order
* @returns Array of active categories
*/
async function getActiveCategories(): Promise<IAgentCategory[]> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({ isActive: true }).sort({ order: 1, label: 1 }).lean();
}
@@ -17,7 +16,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns Categories with agent counts
*/
async function getCategoriesWithCounts(): Promise<(IAgentCategory & { agentCount: number })[]> {
const Agent = handle.models.Agent;
const Agent = mongoose.models.Agent;
const categoryCounts = await Agent.aggregate([
{ $match: { category: { $exists: true, $ne: null } } },
@@ -38,7 +37,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns Array of valid category values
*/
async function getValidCategoryValues(): Promise<string[]> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({ isActive: true }).distinct('value').lean();
}
@@ -56,7 +55,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
custom?: boolean;
}>,
): Promise<import('mongoose').mongo.BulkWriteResult> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const operations = categories.map((category, index) => ({
updateOne: {
@@ -84,7 +83,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns The category document or null
*/
async function findCategoryByValue(value: string): Promise<IAgentCategory | null> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findOne({ value }).lean();
}
@@ -94,7 +93,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns The created category
*/
async function createCategory(categoryData: Partial<IAgentCategory>): Promise<IAgentCategory> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const category = await AgentCategory.create(categoryData);
return category.toObject() as IAgentCategory;
}
@@ -109,7 +108,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
value: string,
updateData: Partial<IAgentCategory>,
): Promise<IAgentCategory | null> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findOneAndUpdate(
{ value },
{ $set: updateData },
@@ -123,7 +122,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns Whether the deletion was successful
*/
async function deleteCategory(value: string): Promise<boolean> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const result = await AgentCategory.deleteOne({ value });
return result.deletedCount > 0;
}
@@ -134,7 +133,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns The category document or null
*/
async function findCategoryById(id: string | Types.ObjectId): Promise<IAgentCategory | null> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.findById(id).lean();
}
@@ -143,7 +142,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns Array of all categories
*/
async function getAllCategories(): Promise<IAgentCategory[]> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
return await AgentCategory.find({}).sort({ order: 1, label: 1 }).lean();
}
@@ -152,7 +151,7 @@ export function createAgentCategoryMethods(handle: DataHandle) {
* @returns Promise<boolean> - true if categories were created/updated, false if no changes
*/
async function ensureDefaultCategories(): Promise<boolean> {
const AgentCategory = handle.models.AgentCategory as Model<IAgentCategory>;
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
const defaultCategories = [
{
@@ -1,8 +1,7 @@
import type { FilterQuery, Model } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type { IAssistant } from '~/types';
export function createAssistantMethods(handle: DataHandle) {
export function createAssistantMethods(mongoose: typeof import('mongoose')) {
/**
* Update an assistant with new data without overwriting existing properties,
* or create a new assistant if it doesn't exist.
@@ -11,7 +10,7 @@ export function createAssistantMethods(handle: DataHandle) {
searchParams: FilterQuery<IAssistant>,
updateData: Partial<IAssistant>,
): Promise<IAssistant | null> {
const Assistant = handle.models.Assistant as Model<IAssistant>;
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
const options = { new: true, upsert: true };
return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean<IAssistant>();
}
@@ -20,7 +19,7 @@ export function createAssistantMethods(handle: DataHandle) {
* Retrieves an assistant document based on the provided search params.
*/
async function getAssistant(searchParams: FilterQuery<IAssistant>): Promise<IAssistant | null> {
const Assistant = handle.models.Assistant as Model<IAssistant>;
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
return await Assistant.findOne(searchParams).lean<IAssistant>();
}
@@ -31,7 +30,7 @@ export function createAssistantMethods(handle: DataHandle) {
searchParams: FilterQuery<IAssistant>,
select: string | Record<string, number> | null = null,
): Promise<IAssistant[]> {
const Assistant = handle.models.Assistant as Model<IAssistant>;
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
const query = Assistant.find(searchParams);
return await (select ? query.select(select) : query).lean<IAssistant[]>();
@@ -41,7 +40,7 @@ export function createAssistantMethods(handle: DataHandle) {
* Deletes an assistant based on the provided search params.
*/
async function deleteAssistant(searchParams: FilterQuery<IAssistant>) {
const Assistant = handle.models.Assistant as Model<IAssistant>;
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
return await Assistant.findOneAndDelete(searchParams);
}
@@ -49,7 +48,7 @@ export function createAssistantMethods(handle: DataHandle) {
* Deletes all assistants matching the given search parameters.
*/
async function deleteAssistants(searchParams: FilterQuery<IAssistant>): Promise<number> {
const Assistant = handle.models.Assistant as Model<IAssistant>;
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
const result = await Assistant.deleteMany(searchParams);
return result.deletedCount;
}
+2 -3
View File
@@ -1,15 +1,14 @@
import type { Model } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import logger from '~/config/winston';
import type { IBanner, IUser } from '~/types';
export function createBannerMethods(handle: DataHandle) {
export function createBannerMethods(mongoose: typeof import('mongoose')) {
/**
* Retrieves the current active banner.
*/
async function getBanner(user?: IUser | null): Promise<IBanner | null> {
try {
const Banner = handle.models.Banner as Model<IBanner>;
const Banner = mongoose.models.Banner as Model<IBanner>;
const now = new Date();
const banner = (await Banner.findOne({
displayFrom: { $lte: now },
@@ -1,164 +0,0 @@
/**
* Batch 2 contract proof: Preset, ConversationTag, SharedLink running the REAL
* production methods against the SQLite document store zero mongoose.
* Same createPresetMethods / createConversationTagMethods / createShareMethods
* the app uses; only the handle is swapped.
*/
import { v4 as uuidv4 } from 'uuid';
import { createPresetMethods } from './preset';
import { createConversationTagMethods } from './conversationTag';
import { createShareMethods } from './share';
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let handle: SqliteHandle;
let preset: ReturnType<typeof createPresetMethods>;
let tags: ReturnType<typeof createConversationTagMethods>;
let share: ReturnType<typeof createShareMethods>;
beforeEach(() => {
handle = createSqliteHandle(['Preset', 'ConversationTag', 'Conversation', 'Message', 'SharedLink']);
preset = createPresetMethods(handle);
tags = createConversationTagMethods(handle);
share = createShareMethods(handle);
});
afterEach(() => handle.close());
describe('Preset on SQLite (real methods)', () => {
it('savePreset upserts; getPreset/getPresets read back sorted by order', async () => {
await preset.savePreset('u1', { presetId: 'p1', title: 'A', order: 2, endpoint: 'openAI' });
await preset.savePreset('u1', { presetId: 'p2', title: 'B', order: 1, endpoint: 'openAI' });
const p1 = await preset.getPreset('u1', 'p1');
expect((p1 as { title: string }).title).toBe('A');
const list = (await preset.getPresets('u1')) as Array<{ presetId: string }>;
expect(list.map((p) => p.presetId)).toEqual(['p2', 'p1']); // order 1 before 2
});
it('savePreset(defaultPreset:true) unsets the previous default', async () => {
await preset.savePreset('u1', { presetId: 'p1', defaultPreset: true });
await preset.savePreset('u1', { presetId: 'p2', defaultPreset: true });
const p1 = (await preset.getPreset('u1', 'p1')) as { defaultPreset?: boolean };
const p2 = (await preset.getPreset('u1', 'p2')) as { defaultPreset?: boolean };
expect(p1.defaultPreset).toBeUndefined(); // demoted
expect(p2.defaultPreset).toBe(true);
});
it('deletePresets removes', async () => {
await preset.savePreset('u1', { presetId: 'p1' });
await preset.deletePresets('u1', { presetId: 'p1' });
expect(await preset.getPreset('u1', 'p1')).toBeNull();
});
});
describe('ConversationTag on SQLite (real methods)', () => {
it('createConversationTag assigns positions and dedupes', async () => {
const t1 = (await tags.createConversationTag('u1', { tag: 'work' })) as { position: number };
const t2 = (await tags.createConversationTag('u1', { tag: 'urgent' })) as { position: number };
expect(t1.position).toBe(1);
expect(t2.position).toBe(2);
const dup = (await tags.createConversationTag('u1', { tag: 'work' })) as { position: number };
expect(dup.position).toBe(1); // returned existing, no new row
const all = await tags.getConversationTags('u1');
expect(all).toHaveLength(2);
});
it('createConversationTag addToConversation $addToSet on Conversation', async () => {
const conversationId = uuidv4();
await handle.models.Conversation.create({ conversationId, user: 'u1', tags: [] });
await tags.createConversationTag('u1', { tag: 'star', addToConversation: true, conversationId });
const c = (await handle.models.Conversation.findOne({ conversationId }).lean()) as {
tags: string[];
};
expect(c.tags).toContain('star');
});
it('updateTagsForConversation sets conversation tags and adjusts counts', async () => {
const conversationId = uuidv4();
await handle.models.Conversation.create({ conversationId, user: 'u1', tags: ['a'] });
const result = await tags.updateTagsForConversation('u1', conversationId, ['a', 'b']);
expect(result).toEqual(['a', 'b']);
const c = (await handle.models.Conversation.findOne({ conversationId }).lean()) as {
tags: string[];
};
expect(c.tags).toEqual(['a', 'b']);
});
it('deleteConversationTag returns the deleted tag and reindexes positions', async () => {
await tags.createConversationTag('u1', { tag: 'first' }); // pos 1
await tags.createConversationTag('u1', { tag: 'second' }); // pos 2
const deleted = (await tags.deleteConversationTag('u1', 'first')) as { tag: string } | null;
expect(deleted?.tag).toBe('first');
const remaining = (await tags.getConversationTags('u1')) as Array<{
tag: string;
position: number;
}>;
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({ tag: 'second', position: 1 }); // shifted down
});
});
describe('SharedLink on SQLite (real methods, incl. cross-collection populate)', () => {
async function seedConversation(user = 'u1') {
const conversationId = uuidv4();
await handle.models.Conversation.create({ conversationId, user, title: 'Shared Chat' });
for (let i = 0; i < 3; i++) {
await handle.models.Message.create({
messageId: uuidv4(),
conversationId,
user,
text: `m${i}`,
parentMessageId: i === 0 ? null : `m${i - 1}`,
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
});
}
return conversationId;
}
it('createSharedLink + getSharedMessages resolves messages via populate', async () => {
const conversationId = await seedConversation();
const { shareId } = await share.createSharedLink('u1', conversationId);
expect(shareId).toBeTruthy();
const shared = await share.getSharedMessages(shareId);
expect(shared).not.toBeNull();
expect(shared?.messages.length).toBe(3); // populate resolved the message refs
expect(shared?.shareId).toBe(shareId);
});
it('getSharedLink resolves by conversation; updateSharedLink rotates shareId', async () => {
const conversationId = await seedConversation();
const { shareId } = await share.createSharedLink('u1', conversationId);
const found = await share.getSharedLink('u1', conversationId);
expect(found).toEqual({ shareId, success: true });
const updated = await share.updateSharedLink('u1', shareId);
expect(updated.shareId).toBeTruthy();
expect(updated.shareId).not.toBe(shareId);
// old shareId no longer resolves
expect(await share.getSharedMessages(shareId)).toBeNull();
expect(await share.getSharedMessages(updated.shareId!)).not.toBeNull();
});
it('getSharedLinks lists a user shares; deleteSharedLink removes', async () => {
const conversationId = await seedConversation();
const { shareId } = await share.createSharedLink('u1', conversationId);
const list = await share.getSharedLinks('u1', undefined, 10, true, 'createdAt', 'desc');
expect(list.links.some((l) => l.shareId === shareId)).toBe(true);
await share.deleteSharedLink('u1', shareId);
expect(await share.getSharedMessages(shareId)).toBeNull();
});
});
@@ -1,86 +0,0 @@
/**
* Batch 3 contract proof: Project on the SQLite document store.
*
* Project's methods live in the app layer (api/models/Project.js), not
* data-schemas so this exercises that file's EXACT DB operations against the
* store: getProjectByName (findOneAndUpdate upsert), addGroupIdsToProject
* ($addToSet $each), removeGroupIdsFromProject ($pull $in), removeGroupFrom
* AllProjects (updateMany $pull), and findById reads. Proving the store backs
* Project's real behavior when CHAT_STORE_SQLITE flips it.
*
* Prompt / PromptGroup are intentionally NOT in this batch: they construct
* mongoose.Types.ObjectId, use an aggregate $lookup/$unwind pipeline + populate
* and manual tenant/ACL filtering (accessibleIds: ObjectId[]) they need an
* aggregate primitive, not the mechanical recipe. Deferred with rationale.
*/
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
import type { DocModel } from '~/stores/sqlite';
const GLOBAL_PROJECT_NAME = 'instance';
describe('Project on SQLite (mirrors api/models/Project.js operations)', () => {
let handle: SqliteHandle;
let Project: DocModel;
beforeEach(() => {
handle = createSqliteHandle(['Project']);
Project = handle.models.Project;
});
afterEach(() => handle.close());
// getProjectByName: findOneAndUpdate({name}, {$setOnInsert:{name}}, {new, upsert})
async function getProjectByName(name: string, fields = ['name', 'promptGroupIds', 'agentIds']) {
return Project.findOneAndUpdate(
{ name },
{ $setOnInsert: { name } },
{ new: true, upsert: name === GLOBAL_PROJECT_NAME },
)
.select(fields.join(' '))
.lean();
}
it('getProjectByName upserts the global project with default arrays', async () => {
const p = (await getProjectByName(GLOBAL_PROJECT_NAME)) as {
name: string;
promptGroupIds: string[];
agentIds: string[];
_id: string;
};
expect(p.name).toBe(GLOBAL_PROJECT_NAME);
expect(p.promptGroupIds).toEqual([]);
expect(p.agentIds).toEqual([]);
// second call returns the same row (no duplicate)
const again = (await getProjectByName(GLOBAL_PROJECT_NAME)) as { _id: string };
expect(again._id).toBe(p._id);
expect(await Project.countDocuments({})).toBe(1);
});
it('addGroupIdsToProject $addToSet $each; removeGroupIdsFromProject $pull $in', async () => {
const created = (await Project.create({ name: 'proj', promptGroupIds: [], agentIds: [] })) as {
_id: string;
};
// addGroupIdsToProject
await Project.findByIdAndUpdate(created._id, {
$addToSet: { promptGroupIds: { $each: ['g1', 'g2', 'g1'] } },
});
let p = (await Project.findById(created._id).lean()) as { promptGroupIds: string[] };
expect(p.promptGroupIds).toEqual(['g1', 'g2']); // deduped
// removeGroupIdsFromProject
await Project.findByIdAndUpdate(created._id, {
$pull: { promptGroupIds: { $in: ['g1'] } },
});
p = (await Project.findById(created._id).lean()) as { promptGroupIds: string[] };
expect(p.promptGroupIds).toEqual(['g2']);
});
it('removeGroupFromAllProjects updateMany $pull across projects', async () => {
await Project.create({ name: 'a', promptGroupIds: ['x', 'y'], agentIds: [] });
await Project.create({ name: 'b', promptGroupIds: ['x'], agentIds: [] });
await Project.updateMany({}, { $pull: { promptGroupIds: 'x' } });
const a = (await Project.findOne({ name: 'a' }).lean()) as { promptGroupIds: string[] };
const b = (await Project.findOne({ name: 'b' }).lean()) as { promptGroupIds: string[] };
expect(a.promptGroupIds).toEqual(['y']);
expect(b.promptGroupIds).toEqual([]);
});
});
@@ -1,121 +0,0 @@
/**
* Batch 4 contract proof: File, Key, PluginAuth, Banner running the REAL
* production methods against the SQLite document store zero mongoose.
*
* MCPServer is intentionally excluded: it constructs `new mongoose.Types.ObjectId`
* and paginates on `_id` as an ObjectId cursor ObjectId-coupled, deferred.
*
* Crypto keys (CREDS_KEY/CREDS_IV) for the Key encrypt/decrypt roundtrip are set
* by jest setupFiles (test/creds.setup.cjs) before any module loads.
*/
import { createFileMethods } from './file';
import { createKeyMethods } from './key';
import { createPluginAuthMethods } from './pluginAuth';
import { createBannerMethods } from './banner';
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let handle: SqliteHandle;
let files: ReturnType<typeof createFileMethods>;
let keys: ReturnType<typeof createKeyMethods>;
let plugins: ReturnType<typeof createPluginAuthMethods>;
let banners: ReturnType<typeof createBannerMethods>;
beforeEach(() => {
handle = createSqliteHandle(['File', 'Key', 'PluginAuth', 'Banner']);
files = createFileMethods(handle);
keys = createKeyMethods(handle);
plugins = createPluginAuthMethods(handle);
banners = createBannerMethods(handle);
});
afterEach(() => handle.close());
describe('File on SQLite (real methods)', () => {
it('createFile upserts; findFileById reads; getFiles filters; deleteFile removes', async () => {
await files.createFile({ file_id: 'f1', user: 'u1', filename: 'a.png', filepath: '/a', bytes: 10, type: 'image/png', context: 'message_attachment' });
const got = (await files.findFileById('f1')) as { filename: string } | null;
expect(got?.filename).toBe('a.png');
await files.createFile({ file_id: 'f2', user: 'u1', filename: 'b.png', filepath: '/b', bytes: 20, type: 'image/png', context: 'message_attachment' });
const list = (await files.getFiles({ user: 'u1' })) as unknown[];
expect(list).toHaveLength(2);
await files.deleteFile('f1');
expect(await files.findFileById('f1')).toBeNull();
});
});
describe('Key on SQLite (real methods, encrypted roundtrip)', () => {
it('updateUserKey encrypts; getUserKey decrypts back the original', async () => {
await keys.updateUserKey({ userId: 'u1', name: 'openAI', value: 'sk-secret' });
const value = await keys.getUserKey({ userId: 'u1', name: 'openAI' });
expect(value).toBe('sk-secret'); // decrypt(encrypt(x)) === x, stored in SQLite
});
it('getUserKeyExpiry, re-key upsert, and deleteUserKey', async () => {
const exp = new Date(Date.now() + 3600_000);
await keys.updateUserKey({ userId: 'u1', name: 'k', value: 'v1', expiresAt: exp });
const e = await keys.getUserKeyExpiry({ userId: 'u1', name: 'k' });
expect(new Date(e.expiresAt as Date).getTime()).toBe(exp.getTime());
await keys.updateUserKey({ userId: 'u1', name: 'k', value: 'v2' }); // re-key (upsert same row)
expect(await keys.getUserKey({ userId: 'u1', name: 'k' })).toBe('v2');
await keys.deleteUserKey({ userId: 'u1', name: 'k' });
await expect(keys.getUserKey({ userId: 'u1', name: 'k' })).rejects.toThrow();
});
});
describe('PluginAuth on SQLite (real methods)', () => {
it('updatePluginAuth upserts; findOnePluginAuth reads; deletePluginAuth removes', async () => {
await plugins.updatePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'API_KEY', value: 'abc' });
const found = (await plugins.findOnePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'API_KEY' })) as { value: string } | null;
expect(found?.value).toBeTruthy();
await plugins.deletePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'API_KEY' });
expect(await plugins.findOnePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'API_KEY' })).toBeNull();
});
it('deleteAllUserPluginAuths clears all of a user', async () => {
await plugins.updatePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'A', value: '1' });
await plugins.updatePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'B', value: '2' });
await plugins.deleteAllUserPluginAuths('u1');
expect(await plugins.findOnePluginAuth({ userId: 'u1', pluginKey: 'web', authField: 'A' })).toBeNull();
});
});
describe('Banner on SQLite (real methods)', () => {
it('getBanner returns the active public banner (date window + type filter)', async () => {
const now = Date.now();
await handle.models.Banner.create({
bannerId: 'b1',
message: 'hi',
displayFrom: new Date(now - 3600_000),
displayTo: new Date(now + 3600_000),
type: 'banner',
isPublic: true,
});
const b = (await banners.getBanner(null)) as { bannerId: string } | null;
expect(b?.bannerId).toBe('b1');
});
it('getBanner returns null for an expired banner', async () => {
const now = Date.now();
await handle.models.Banner.create({
bannerId: 'old',
message: 'gone',
displayFrom: new Date(now - 7200_000),
displayTo: new Date(now - 3600_000),
type: 'banner',
isPublic: true,
});
expect(await banners.getBanner(null)).toBeNull();
});
});
@@ -1,87 +0,0 @@
/**
* Batch 5 contract proof: Config on the tenant-aware SQLite document store.
*
* Config carries the mongoose `applyTenantIsolation` plugin upstream. This proves
* the DocModel `tenantIsolated` variant reproduces it: filters are scoped to the
* active tenant, inserts are stamped with tenantId, and one tenant cannot see or
* delete another's rows running the REAL createConfigMethods.
*/
import { createConfigMethods } from './config';
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
import { tenantStorage } from '~/config/tenantContext';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let handle: SqliteHandle;
let config: ReturnType<typeof createConfigMethods>;
const asTenant = <T>(tenantId: string, fn: () => Promise<T>): Promise<T> =>
tenantStorage.run({ tenantId }, fn);
beforeEach(() => {
handle = createSqliteHandle(['Config']);
config = createConfigMethods(handle);
});
afterEach(() => handle.close());
describe('Config on SQLite (tenant-isolated, real methods)', () => {
it('stamps tenantId on insert and isolates identical principals per tenant', async () => {
await asTenant('tenantA', () =>
config.upsertConfig('user', 'p1', 'User', { theme: 'dark' }, 10),
);
await asTenant('tenantB', () =>
config.upsertConfig('user', 'p1', 'User', { theme: 'light' }, 10),
);
// Same principal (user/p1) in two tenants => two distinct rows.
expect(await handle.models.Config.countDocuments({})).toBe(2);
const a = (await asTenant('tenantA', () =>
config.findConfigByPrincipal('user', 'p1'),
)) as { overrides: { theme: string }; tenantId: string } | null;
const b = (await asTenant('tenantB', () =>
config.findConfigByPrincipal('user', 'p1'),
)) as { overrides: { theme: string }; tenantId: string } | null;
expect(a?.overrides.theme).toBe('dark');
expect(a?.tenantId).toBe('tenantA');
expect(b?.overrides.theme).toBe('light'); // tenant B sees its OWN row, not A's
expect(b?.tenantId).toBe('tenantB');
});
it('re-upsert within a tenant updates the same row (no duplicate)', async () => {
await asTenant('A', () => config.upsertConfig('user', 'p1', 'User', { v: 1 }, 5));
await asTenant('A', () => config.upsertConfig('user', 'p1', 'User', { v: 2 }, 5));
expect(await handle.models.Config.countDocuments({})).toBe(1);
const c = (await asTenant('A', () => config.findConfigByPrincipal('user', 'p1'))) as {
overrides: { v: number };
} | null;
expect(c?.overrides.v).toBe(2);
});
it('listAllConfigs and findConfigByPrincipal are tenant-scoped', async () => {
await asTenant('A', () => config.upsertConfig('user', 'p1', 'User', {}, 1));
await asTenant('B', () => config.upsertConfig('user', 'p2', 'User', {}, 1));
const listA = (await asTenant('A', () => config.listAllConfigs())) as Array<{
tenantId: string;
}>;
expect(listA).toHaveLength(1);
expect(listA[0].tenantId).toBe('A');
// Tenant B cannot see tenant A's principal p1.
expect(await asTenant('B', () => config.findConfigByPrincipal('user', 'p1'))).toBeNull();
});
it('a tenant cannot delete another tenants config', async () => {
await asTenant('A', () => config.upsertConfig('user', 'p1', 'User', {}, 1));
await asTenant('B', () => config.deleteConfig('user', 'p1')); // scoped to B => no-op on A
// A's row still resolves for A
expect(await asTenant('A', () => config.findConfigByPrincipal('user', 'p1'))).not.toBeNull();
});
});
@@ -1,97 +0,0 @@
/**
* Batch 6 contract proof: SystemGrant on the tenant-aware SQLite store the
* REAL createSystemGrantMethods (capability grants / checks / revokes, platform
* and tenant-scoped). Exercises DocModel.exists() and the tenantIsolated variant.
*/
import { PrincipalType } from 'librechat-data-provider';
import { SystemCapabilities } from '~/admin/capabilities';
import { createSystemGrantMethods } from './systemGrant';
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let handle: SqliteHandle;
let grants: ReturnType<typeof createSystemGrantMethods>;
const principal = (principalId: string) => [{ principalType: PrincipalType.USER, principalId }];
beforeEach(() => {
handle = createSqliteHandle(['SystemGrant']);
grants = createSystemGrantMethods(handle);
});
afterEach(() => handle.close());
describe('SystemGrant on SQLite (real methods)', () => {
it('grant → has (via exists) → revoke, and unrelated capability is not held', async () => {
await grants.grantCapability({
principalType: PrincipalType.USER,
principalId: '507f1f77bcf86cd799439011',
capability: SystemCapabilities.READ_USERS,
});
expect(
await grants.hasCapabilityForPrincipals({
principals: principal('507f1f77bcf86cd799439011'),
capability: SystemCapabilities.READ_USERS,
}),
).toBe(true);
expect(
await grants.hasCapabilityForPrincipals({
principals: principal('507f1f77bcf86cd799439011'),
capability: SystemCapabilities.READ_CONFIGS,
}),
).toBe(false);
await grants.revokeCapability({
principalType: PrincipalType.USER,
principalId: '507f1f77bcf86cd799439011',
capability: SystemCapabilities.READ_USERS,
});
expect(
await grants.hasCapabilityForPrincipals({
principals: principal('507f1f77bcf86cd799439011'),
capability: SystemCapabilities.READ_USERS,
}),
).toBe(false);
});
it('grantCapability is idempotent (upsert, one row)', async () => {
const params = {
principalType: PrincipalType.USER,
principalId: '507f1f77bcf86cd799439011',
capability: SystemCapabilities.READ_USERS,
};
await grants.grantCapability(params);
await grants.grantCapability(params);
expect(await handle.models.SystemGrant.countDocuments({})).toBe(1);
});
it('tenant-scoped grants isolate across tenants (platform-vs-tenant)', async () => {
await grants.grantCapability({
principalType: PrincipalType.USER,
principalId: '507f1f77bcf86cd799439011',
capability: SystemCapabilities.READ_USERS,
tenantId: 'tenantA',
});
expect(
await grants.hasCapabilityForPrincipals({
principals: principal('507f1f77bcf86cd799439011'),
capability: SystemCapabilities.READ_USERS,
tenantId: 'tenantA',
}),
).toBe(true);
// tenant B does not inherit tenant A's grant
expect(
await grants.hasCapabilityForPrincipals({
principals: principal('507f1f77bcf86cd799439011'),
capability: SystemCapabilities.READ_USERS,
tenantId: 'tenantB',
}),
).toBe(false);
});
});
@@ -1,118 +0,0 @@
/**
* Batch 7 contract proof: ObjectId-coupled domains on the SQLite store, using
* the handle.Types.ObjectId shim + engine coerceId.
*
* - MCPServer: full real-method spec (create/find/findById/author/update/delete)
* exercises DocModel.findById + ObjectId author fields.
* - Skill/SkillFile: storage-level proof of the operations their methods perform
* (tenant-isolated compound-unique, `_id: { $in: accessibleIds }` ObjectId ACL
* filtering via coercion, SkillFile compound-unique upsert). The 800-line Skill
* method layer (ACL-injected deps + frontmatter validation) rides on exactly
* these store ops; a full real-method harness for it is a follow-up.
*/
import { createMCPServerMethods } from './mcpServer';
import { createSqliteHandle, ObjectId, type SqliteHandle } from '~/stores/sqlite';
import type { DocModel } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
describe('MCPServer on SQLite (real methods, ObjectId author + findById)', () => {
let handle: SqliteHandle;
let mcp: ReturnType<typeof createMCPServerMethods>;
beforeEach(() => {
handle = createSqliteHandle(['MCPServer']);
mcp = createMCPServerMethods(handle);
});
afterEach(() => handle.close());
it('create → findByServerName → findByObjectId → byAuthor → update → delete', async () => {
const author = new ObjectId().toHexString();
const created = await mcp.createMCPServer({
config: { title: 'My Server' } as never,
author,
});
expect(created.serverName).toBeTruthy();
expect(created._id).toMatch(/^[0-9a-f]{24}$/);
const byName = await mcp.findMCPServerByServerName(created.serverName);
expect(byName?._id).toBe(created._id);
// findById via ObjectId string — coerceId matches the stored hex _id
const byId = await mcp.findMCPServerByObjectId(created._id);
expect(byId?.serverName).toBe(created.serverName);
// findById via an ObjectId instance operand — coercion path
const byIdObj = await mcp.findMCPServerByObjectId(new ObjectId(created._id) as never);
expect(byIdObj?.serverName).toBe(created.serverName);
const byAuthor = await mcp.findMCPServersByAuthor(author);
expect(byAuthor).toHaveLength(1);
await mcp.updateMCPServer(created.serverName, { config: { title: 'Renamed' } as never });
const updated = await mcp.findMCPServerByServerName(created.serverName);
expect((updated?.config as { title: string }).title).toBe('Renamed');
await mcp.deleteMCPServer(created.serverName);
expect(await mcp.findMCPServerByServerName(created.serverName)).toBeNull();
});
it('serverName is unique (create generates a distinct name each time)', async () => {
const author = new ObjectId().toHexString();
const a = await mcp.createMCPServer({ config: { title: 'Dup' } as never, author });
const b = await mcp.createMCPServer({ config: { title: 'Dup' } as never, author });
expect(a.serverName).not.toBe(b.serverName); // findNextAvailableServerName dedupes
expect(await handle.models.MCPServer.countDocuments({})).toBe(2);
});
});
describe('Skill / SkillFile storage ops (tenant-isolated, ObjectId ACL)', () => {
let handle: SqliteHandle;
let Skill: DocModel;
let SkillFile: DocModel;
beforeEach(() => {
handle = createSqliteHandle(['Skill', 'SkillFile']);
Skill = handle.models.Skill;
SkillFile = handle.models.SkillFile;
});
afterEach(() => handle.close());
it('Skill compound unique {name,author,tenantId}; ACL `_id: {$in}` filters by ObjectId', async () => {
const author = new ObjectId().toHexString();
const s1 = (await Skill.create({ name: 'writer', author, tenantId: 't1', body: 'x' })) as {
_id: string;
};
// same name+author, different tenant => allowed
await Skill.create({ name: 'writer', author, tenantId: 't2', body: 'y' });
// same name+author+tenant => rejected
await expect(Skill.create({ name: 'writer', author, tenantId: 't1' })).rejects.toThrow();
// ACL-style listing: `_id: { $in: accessibleIds }` where ids are ObjectIds
const rows = (await Skill.find({ _id: { $in: [new ObjectId(s1._id)] } }).lean()) as unknown[];
expect(rows).toHaveLength(1);
});
it('SkillFile compound unique {skillId,relativePath} upsert', async () => {
const skillId = new ObjectId().toHexString();
await SkillFile.findOneAndUpdate(
{ skillId, relativePath: 'README.md' },
{ $set: { content: 'v1' } },
{ upsert: true, new: true },
);
await SkillFile.findOneAndUpdate(
{ skillId, relativePath: 'README.md' },
{ $set: { content: 'v2' } },
{ upsert: true, new: true },
);
expect(await SkillFile.countDocuments({ skillId })).toBe(1); // upsert, not dup
const f = (await SkillFile.findOne({ skillId, relativePath: 'README.md' }).lean()) as {
content: string;
};
expect(f.content).toBe('v2');
});
});
@@ -1,128 +0,0 @@
/**
* Batch 7b contract proof: the aggregate primitive + Prompt/PromptGroup.
*
* - DocModel.aggregate: $match/$lookup/$unwind directly (the new primitive).
* - getPromptGroup: the REAL method, which casts _id -> ObjectId and runs a
* $match/$lookup(from:'prompts', foreignField:'_id')/$unwind pipeline to
* populate productionPrompt. Proven end-to-end on SQLite.
*/
import { createPromptMethods } from './prompt';
import { createSqliteHandle, ObjectId, type SqliteHandle } from '~/stores/sqlite';
import type { DocModel } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
const deps = {
removeAllPermissions: jest.fn(async () => undefined),
getSoleOwnedResourceIds: jest.fn(async () => []),
} as never;
describe('DocModel.aggregate primitive', () => {
let handle: SqliteHandle;
let PromptGroup: DocModel;
let Prompt: DocModel;
beforeEach(() => {
handle = createSqliteHandle(['PromptGroup', 'Prompt']);
PromptGroup = handle.models.PromptGroup;
Prompt = handle.models.Prompt;
});
afterEach(() => handle.close());
it('$match → $lookup(_id) → $unwind joins the production prompt', async () => {
const p = (await Prompt.create({ prompt: 'hello', author: 'a1', type: 'text' })) as {
_id: string;
};
const g = (await PromptGroup.create({
name: 'greetings',
author: 'a1',
productionId: p._id,
})) as { _id: string };
const result = await PromptGroup.aggregate([
{ $match: { _id: g._id } },
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
]);
expect(result).toHaveLength(1);
expect((result[0].productionPrompt as { prompt: string }).prompt).toBe('hello');
});
it('$unwind preserveNullAndEmptyArrays keeps a group with no production prompt', async () => {
const g = (await PromptGroup.create({ name: 'orphan', author: 'a1' })) as { _id: string };
const result = await PromptGroup.aggregate([
{ $match: { _id: g._id } },
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
]);
expect(result).toHaveLength(1);
expect(result[0].productionPrompt).toBeUndefined();
});
});
describe('getPromptGroup on SQLite (real method, aggregate join)', () => {
let handle: SqliteHandle;
let prompts: ReturnType<typeof createPromptMethods>;
beforeEach(() => {
handle = createSqliteHandle(['PromptGroup', 'Prompt']);
prompts = createPromptMethods(handle, deps);
});
afterEach(() => handle.close());
it('populates productionPrompt via the $lookup pipeline', async () => {
const p = (await handle.models.Prompt.create({
prompt: 'production body',
author: '507f1f77bcf86cd799439011',
type: 'text',
})) as { _id: string };
const g = (await handle.models.PromptGroup.create({
name: 'grp',
author: '507f1f77bcf86cd799439011',
productionId: new ObjectId(p._id),
})) as { _id: string };
// real method casts _id string -> ObjectId, aggregates, unwinds
const group = (await prompts.getPromptGroup({ _id: g._id })) as {
name: string;
productionPrompt: { prompt: string };
author: string;
} | null;
expect(group?.name).toBe('grp');
expect(group?.productionPrompt.prompt).toBe('production body');
expect(typeof group?.author).toBe('string');
});
it('returns the group with no productionPrompt when productionId is unset', async () => {
const g = (await handle.models.PromptGroup.create({
name: 'empty',
author: '507f1f77bcf86cd799439011',
})) as { _id: string };
const group = (await prompts.getPromptGroup({ _id: g._id })) as {
name: string;
productionPrompt?: unknown;
} | null;
expect(group?.name).toBe('empty');
expect(group?.productionPrompt).toBeUndefined();
});
});
@@ -1,137 +0,0 @@
/**
* Batch 8a contract proof: seven chat-native domains with no external subsystem
* owner, running the REAL production methods against the SQLite store.
* MemoryEntry, ToolCall, Assistant, Action, AccessRole, Role, AgentApiKey.
*/
import { createMemoryMethods } from './memory';
import { createToolCallMethods } from './toolCall';
import { createAssistantMethods } from './assistant';
import { createActionMethods } from './action';
import { createAccessRoleMethods } from './accessRole';
import { createRoleMethods } from './role';
import { createAgentApiKeyMethods } from './agentApiKey';
import { createSqliteHandle, ObjectId, type SqliteHandle } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let handle: SqliteHandle;
beforeEach(() => {
handle = createSqliteHandle([
'MemoryEntry',
'ToolCall',
'Assistant',
'Action',
'AccessRole',
'Role',
'AgentApiKey',
]);
});
afterEach(() => handle.close());
describe('MemoryEntry (real methods)', () => {
it('setMemory upserts; getAllUserMemories lists; createMemory rejects dup; delete', async () => {
const m = createMemoryMethods(handle);
await m.setMemory({ userId: 'u1', key: 'likes', value: 'coffee' } as never);
await m.setMemory({ userId: 'u1', key: 'likes', value: 'tea' } as never); // upsert same row
const all = (await m.getAllUserMemories('u1' as never)) as Array<{ key: string; value: string }>;
expect(all).toHaveLength(1);
expect(all[0].value).toBe('tea');
await expect(m.createMemory({ userId: 'u1', key: 'likes', value: 'x' } as never)).rejects.toThrow(/already exists/);
await m.deleteMemory({ userId: 'u1', key: 'likes' } as never);
expect(await m.getAllUserMemories('u1' as never)).toHaveLength(0);
});
});
describe('ToolCall (real methods)', () => {
it('create → getById → getByConvo → deleteToolCalls', async () => {
const tc = createToolCallMethods(handle);
const created = (await tc.createToolCall({
conversationId: 'c1',
messageId: 'm1',
toolId: 'search',
user: 'u1',
result: { ok: 1 },
} as never)) as { _id: string };
expect((await tc.getToolCallById(created._id))?.toolId).toBe('search');
expect(await tc.getToolCallsByConvo('c1', 'u1')).toHaveLength(1);
await tc.deleteToolCalls('u1', 'c1');
expect(await tc.getToolCallsByConvo('c1', 'u1')).toHaveLength(0);
});
});
describe('Assistant (real methods)', () => {
it('updateAssistantDoc upsert → getAssistant → deleteAssistant', async () => {
const a = createAssistantMethods(handle);
await a.updateAssistantDoc(
{ assistant_id: 'as1', user: 'u1' } as never,
{ avatar: { filepath: '/a' } } as never,
{ upsert: true, new: true } as never,
);
expect((await a.getAssistant({ assistant_id: 'as1' } as never))?.user).toBe('u1');
await a.deleteAssistant({ assistant_id: 'as1' } as never);
expect(await a.getAssistant({ assistant_id: 'as1' } as never)).toBeNull();
});
});
describe('Action (real methods)', () => {
it('updateAction upsert → getActions → deleteAction', async () => {
const ac = createActionMethods(handle);
await ac.updateAction(
{ action_id: 'act1', user: 'u1' } as never,
{ type: 'action', metadata: { domain: 'example.com' } } as never,
{ upsert: true, new: true } as never,
);
const list = (await ac.getActions({ user: 'u1' } as never)) as unknown[];
expect(list).toHaveLength(1);
await ac.deleteAction({ action_id: 'act1' } as never);
expect(await ac.getActions({ user: 'u1' } as never)).toHaveLength(0);
});
});
describe('AccessRole (real methods)', () => {
it('createRole → findRoleByIdentifier → getAllRoles → deleteRole', async () => {
const ar = createAccessRoleMethods(handle);
await ar.createRole({
accessRoleId: 'owner',
name: 'Owner',
resourceType: 'agent',
permBits: 7,
} as never);
expect((await ar.findRoleByIdentifier('owner' as never))?.name).toBe('Owner');
expect(await ar.getAllRoles()).toHaveLength(1);
await ar.deleteRole('owner' as never);
expect(await ar.findRoleByIdentifier('owner' as never)).toBeNull();
});
});
describe('Role (real methods)', () => {
it('initializeRoles seeds defaults; listRoles returns them', async () => {
const r = createRoleMethods(handle);
await r.initializeRoles();
const roles = (await r.listRoles()) as Array<{ name: string }>;
expect(roles.length).toBeGreaterThan(0);
expect(roles.every((x) => typeof x.name === 'string')).toBe(true);
});
});
describe('AgentApiKey (real methods)', () => {
it('createAgentApiKey → validateAgentApiKey → list → delete', async () => {
const k = createAgentApiKeyMethods(handle);
const userId = new ObjectId().toHexString();
const created = (await k.createAgentApiKey({ userId, name: 'ci' } as never)) as { key: string };
expect(created.key).toBeTruthy();
const valid = await k.validateAgentApiKey(created.key as never);
expect(String(valid?.userId)).toBe(userId);
expect(await k.listAgentApiKeys(userId as never)).toHaveLength(1);
expect(await k.validateAgentApiKey('wrong-key' as never)).toBeNull();
});
});
@@ -1,114 +0,0 @@
/**
* Batch 8b contract proof: the ObjectId/aggregate/ACL chat-native domains
* Agent, AgentCategory (aggregate $group), AclEntry, Group real methods on
* the SQLite store. Exercises the ObjectId shim + the new $group primitive.
*/
import { createAgentMethods } from './agent';
import { createAgentCategoryMethods } from './agentCategory';
import { createAclEntryMethods } from './aclEntry';
import { createUserGroupMethods } from './userGroup';
import { createSqliteHandle, ObjectId, CHAT_COLLECTION_SPECS, type SqliteHandle } from '~/stores/sqlite';
// User is identity (held from the registry); provide a minimal spec inline so
// Group.addUserToGroup can resolve it in this test without flipping identity.
const specs = {
...CHAT_COLLECTION_SPECS,
User: { name: 'User', index: ['idOnTheSource', 'email'], dateFields: ['createdAt', 'updatedAt'] },
};
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
const agentDeps = {
removeAllPermissions: jest.fn(async () => undefined),
getActions: jest.fn(async () => []),
getSoleOwnedResourceIds: jest.fn(async () => []),
} as never;
let handle: SqliteHandle;
beforeEach(() => {
handle = createSqliteHandle(['Agent', 'AgentCategory', 'AclEntry', 'Group', 'User'], { specs });
});
afterEach(() => handle.close());
describe('Agent (real methods, ObjectId versions)', () => {
it('createAgent → getAgent → updateAgent → deleteAgent', async () => {
const a = createAgentMethods(handle, agentDeps);
const author = new ObjectId().toHexString();
await a.createAgent({ id: 'agent_x', name: 'Helper', author, provider: 'openai', model: 'gpt-4' });
expect((await a.getAgent({ id: 'agent_x' }))?.name).toBe('Helper');
await a.updateAgent({ id: 'agent_x' }, { name: 'Helper v2' }, { updatingUserId: author });
expect((await a.getAgent({ id: 'agent_x' }))?.name).toBe('Helper v2');
await a.deleteAgent({ id: 'agent_x' });
expect(await a.getAgent({ id: 'agent_x' })).toBeNull();
expect(agentDeps.removeAllPermissions).toHaveBeenCalled();
});
});
describe('AgentCategory (real methods, aggregate $group)', () => {
it('getCategoriesWithCounts groups agents by category via $group', async () => {
const cat = createAgentCategoryMethods(handle);
await cat.createCategory({ value: 'coding', label: 'Coding', isActive: true, order: 1 } as never);
await cat.createCategory({ value: 'writing', label: 'Writing', isActive: true, order: 2 } as never);
const author = new ObjectId().toHexString();
await handle.models.Agent.create({ id: 'a1', name: 'A1', author, category: 'coding' });
await handle.models.Agent.create({ id: 'a2', name: 'A2', author, category: 'coding' });
await handle.models.Agent.create({ id: 'a3', name: 'A3', author, category: 'writing' });
const withCounts = (await cat.getCategoriesWithCounts()) as Array<{
value: string;
agentCount: number;
}>;
const coding = withCounts.find((c) => c.value === 'coding');
const writing = withCounts.find((c) => c.value === 'writing');
expect(coding?.agentCount).toBe(2); // $group { _id: '$category', count: { $sum: 1 } }
expect(writing?.agentCount).toBe(1);
});
});
describe('AclEntry (real methods, ObjectId principals/resources)', () => {
it('grantPermission → hasPermission → revokePermission', async () => {
const acl = createAclEntryMethods(handle);
const principalId = new ObjectId().toHexString();
const resourceId = new ObjectId().toHexString();
const grantedBy = new ObjectId().toHexString();
await acl.grantPermission('user', principalId, 'agent', resourceId, 15, grantedBy);
expect(
await acl.hasPermission([{ principalType: 'user', principalId }], 'agent', resourceId, 1),
).toBe(true);
await acl.revokePermission('user', principalId, 'agent', resourceId);
expect(
await acl.hasPermission([{ principalType: 'user', principalId }], 'agent', resourceId, 1),
).toBe(false);
});
});
describe('Group (real methods, ObjectId members)', () => {
it('createGroup → findGroupById → addUserToGroup → getUserGroups', async () => {
const g = createUserGroupMethods(handle);
const created = (await g.createGroup({ name: 'devs' } as never)) as { _id: string };
expect((await g.findGroupById(created._id as never))?.name).toBe('devs');
const userId = new ObjectId().toHexString();
await handle.models.User.create({ _id: userId, email: 'dev@x.io' });
await g.addUserToGroup(userId as never, created._id as never);
// write landed: userId is a member of the group
const stored = (await handle.models.Group.findById(created._id).lean()) as { memberIds: string[] };
expect(stored.memberIds).toContain(userId);
// read path: getUserGroups resolves the group by member id
const groups = (await g.getUserGroups(userId as never)) as Array<{ name: string }>;
expect(groups.some((x) => x.name === 'devs')).toBe(true);
});
});
+9 -10
View File
@@ -1,19 +1,18 @@
import { Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
import type { TCustomConfig } from 'librechat-data-provider';
import type { Model, ClientSession } from 'mongoose';
import type { IConfig } from '~/types';
export function createConfigMethods(handle: DataHandle) {
export function createConfigMethods(mongoose: typeof import('mongoose')) {
async function findConfigByPrincipal(
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { includeInactive?: boolean },
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const filter: { principalType: PrincipalType; principalId: string; isActive?: boolean } = {
principalType,
principalId: principalId.toString(),
@@ -30,7 +29,7 @@ export function createConfigMethods(handle: DataHandle) {
filter?: { isActive?: boolean },
session?: ClientSession,
): Promise<IConfig[]> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const where: { isActive?: boolean } = {};
if (filter?.isActive !== undefined) {
where.isActive = filter.isActive;
@@ -45,7 +44,7 @@ export function createConfigMethods(handle: DataHandle) {
principals?: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
session?: ClientSession,
): Promise<IConfig[]> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const basePrincipal = {
principalType: PrincipalType.ROLE as string,
@@ -82,7 +81,7 @@ export function createConfigMethods(handle: DataHandle) {
priority: number,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const query = {
principalType,
@@ -128,7 +127,7 @@ export function createConfigMethods(handle: DataHandle) {
priority: number,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const setPayload: { principalModel: PrincipalModel; priority: number; [key: string]: unknown } =
{
@@ -160,7 +159,7 @@ export function createConfigMethods(handle: DataHandle) {
fieldPath: string,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
const options = {
new: true,
@@ -179,7 +178,7 @@ export function createConfigMethods(handle: DataHandle) {
principalId: string | Types.ObjectId,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
return await Config.findOneAndDelete({
principalType,
@@ -193,7 +192,7 @@ export function createConfigMethods(handle: DataHandle) {
isActive: boolean,
session?: ClientSession,
): Promise<IConfig | null> {
const Config = handle.models.Config as Model<IConfig>;
const Config = mongoose.models.Config as Model<IConfig>;
return await Config.findOneAndUpdate(
{ principalType, principalId: principalId.toString() },
{ $set: { isActive } },
@@ -1,5 +1,4 @@
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import { RetentionMode } from 'librechat-data-provider';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
@@ -62,7 +61,7 @@ export interface ConversationMethods {
}
export function createConversationMethods(
handle: DataHandle,
mongoose: typeof import('mongoose'),
messageMethods?: Pick<MessageMethods, 'getMessages' | 'deleteMessages'>,
): ConversationMethods {
function getMessageMethods() {
@@ -81,7 +80,7 @@ export function createConversationMethods(
*/
async function searchConversation(conversationId: string) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return await Conversation.findOne(
{ conversationId },
'conversationId user',
@@ -97,7 +96,7 @@ export function createConversationMethods(
*/
async function getConvo(user: string, conversationId: string) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return await Conversation.findOne({ user, conversationId }).lean<IConversation>();
} catch (error) {
logger.error('[getConvo] Error getting single conversation', error);
@@ -113,7 +112,7 @@ export function createConversationMethods(
conversationId: string,
): Promise<Pick<IConversation, 'expiredAt'> | null> {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return await Conversation.findOne({ user, conversationId }, 'expiredAt').lean<
Pick<IConversation, 'expiredAt'>
>();
@@ -128,7 +127,7 @@ export function createConversationMethods(
*/
async function deleteNullOrEmptyConversations() {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const { deleteMessages } = getMessageMethods();
const filter = {
$or: [
@@ -160,7 +159,7 @@ export function createConversationMethods(
*/
async function getConvoFiles(conversationId: string): Promise<string[]> {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return (
(await Conversation.findOne({ conversationId }, 'files').lean<IConversation>())?.files ?? []
);
@@ -200,7 +199,7 @@ export function createConversationMethods(
},
) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const { getMessages } = getMessageMethods();
if (metadata?.context) {
@@ -299,7 +298,7 @@ export function createConversationMethods(
*/
async function bulkSaveConvos(conversations: Array<Record<string, unknown>>) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const bulkOps = conversations.map((convo) => ({
updateOne: {
filter: {
@@ -343,7 +342,7 @@ export function createConversationMethods(
sortDirection?: string;
} = {},
) {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const filters: FilterQuery<IConversation>[] = [{ user } as FilterQuery<IConversation>];
if (isArchived) {
filters.push({ isArchived: true } as FilterQuery<IConversation>);
@@ -472,7 +471,7 @@ export function createConversationMethods(
limit = 25,
) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
if (!convoIds?.length) {
return { conversations: [], nextCursor: null, convoMap: {} };
}
@@ -536,7 +535,7 @@ export function createConversationMethods(
*/
async function deleteConvos(user: string, filter: FilterQuery<IConversation>) {
try {
const Conversation = handle.models.Conversation as Model<IConversation>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const { deleteMessages } = getMessageMethods();
const userFilter = { ...filter, user };
const conversations = await Conversation.find(userFilter).select('conversationId');
@@ -1,5 +1,4 @@
import type { Model } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
@@ -13,13 +12,13 @@ interface IConversationTag {
[key: string]: unknown;
}
export function createConversationTagMethods(handle: DataHandle) {
export function createConversationTagMethods(mongoose: typeof import('mongoose')) {
/**
* Retrieves all conversation tags for a user.
*/
async function getConversationTags(user: string) {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
} catch (error) {
logger.error('[getConversationTags] Error getting conversation tags', error);
@@ -40,8 +39,8 @@ export function createConversationTagMethods(handle: DataHandle) {
},
) {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const Conversation = handle.models.Conversation;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const Conversation = mongoose.models.Conversation;
const { tag, description, addToConversation, conversationId } = data;
const existingTag = await ConversationTag.findOne({ user, tag }).lean();
@@ -92,7 +91,7 @@ export function createConversationTagMethods(handle: DataHandle) {
return;
}
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const update =
oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
@@ -119,8 +118,8 @@ export function createConversationTagMethods(handle: DataHandle) {
data: { tag?: string; description?: string; position?: number },
) {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const Conversation = handle.models.Conversation;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const Conversation = mongoose.models.Conversation;
const { tag: newTag, description, position } = data;
const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
@@ -164,8 +163,8 @@ export function createConversationTagMethods(handle: DataHandle) {
*/
async function deleteConversationTag(user: string, tag: string) {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const Conversation = handle.models.Conversation;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const Conversation = mongoose.models.Conversation;
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
if (!deletedTag) {
@@ -191,8 +190,8 @@ export function createConversationTagMethods(handle: DataHandle) {
*/
async function updateTagsForConversation(user: string, conversationId: string, tags: string[]) {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const Conversation = handle.models.Conversation;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const Conversation = mongoose.models.Conversation;
const conversation = await Conversation.findOne({ user, conversationId }).lean();
if (!conversation) {
@@ -262,7 +261,7 @@ export function createConversationTagMethods(handle: DataHandle) {
}
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const uniqueTags = [...new Set(tags.filter(Boolean))];
if (uniqueTags.length === 0) {
return;
@@ -291,7 +290,7 @@ export function createConversationTagMethods(handle: DataHandle) {
*/
async function deleteConversationTags(filter: Record<string, unknown>): Promise<number> {
try {
const ConversationTag = handle.models.ConversationTag as Model<IConversationTag>;
const ConversationTag = mongoose.models.ConversationTag as Model<IConversationTag>;
const result = await ConversationTag.deleteMany(filter);
return result.deletedCount;
} catch (error) {
@@ -1,241 +0,0 @@
/**
* Contract proof: the REAL production conversation + message methods running
* against the SQLite document store with ZERO mongoose in the data path.
*
* Same `createMessageMethods` / `createConversationMethods` the app uses only
* the handle is swapped (mongoose -> createSqliteHandle). This is the migration
* seam demonstrated end to end for the conversations+messages domain.
*/
import { v4 as uuidv4 } from 'uuid';
import { createMessageMethods } from './message';
import { createConversationMethods } from './conversation';
import { createSqliteHandle, type SqliteHandle } from '~/stores/sqlite';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
/**
* Isolate the unit-under-test from an unrelated, pre-existing fork gap: this
* repo's `librechat-data-provider` never synced the `RetentionMode` enum that
* upstream PR #13049 added, so both message.ts and conversation.ts (mongoose
* path included) reference an undefined export. Both files import ONLY this enum
* from the package; we supply the upstream value shape so the retention gate
* evaluates instead of throwing. Our tests do not exercise retentionMode paths.
*/
jest.mock('librechat-data-provider', () => ({ RetentionMode: { ANY: 'any', ALL: 'all' } }));
let handle: SqliteHandle;
let msg: ReturnType<typeof createMessageMethods>;
let convo: ReturnType<typeof createConversationMethods>;
beforeEach(() => {
handle = createSqliteHandle(['Conversation', 'Message']);
msg = createMessageMethods(handle);
convo = createConversationMethods(handle, {
getMessages: msg.getMessages,
deleteMessages: msg.deleteMessages,
});
});
afterEach(() => {
handle.close();
});
const ctx = (userId = 'user123') => ({ userId, interfaceConfig: { temporaryChatRetention: 24 } });
describe('Message domain on SQLite (real methods)', () => {
it('saveMessage creates, re-save upserts the same row, toObject is plain', async () => {
const conversationId = uuidv4();
const saved = await msg.saveMessage(ctx(), {
messageId: 'm1',
conversationId,
text: 'Hello',
user: 'user123',
});
expect(saved?.messageId).toBe('m1');
expect(saved?.text).toBe('Hello');
expect(typeof (saved as { toObject?: unknown }).toObject).toBe('undefined');
await msg.saveMessage(ctx(), { messageId: 'm1', conversationId, text: 'Edited' });
const got = await msg.getMessage({ user: 'user123', messageId: 'm1' });
expect(got?.text).toBe('Edited');
const all = await msg.getMessages({ conversationId });
expect(all).toHaveLength(1); // upsert, not duplicate
});
it('saveMessage rejects unauthenticated + ignores invalid conversationId', async () => {
await expect(
msg.saveMessage({ userId: null as unknown as string }, { messageId: 'x', conversationId: uuidv4() }),
).rejects.toThrow('User not authenticated');
const bad = await msg.saveMessage(ctx(), { messageId: 'x', conversationId: 'not-a-uuid' });
expect(bad).toBeUndefined();
});
it('getMessages returns ascending by createdAt with projection', async () => {
const conversationId = uuidv4();
for (let i = 0; i < 3; i++) {
await msg.saveMessage(ctx(), { messageId: `m${i}`, conversationId, text: `t${i}`, user: 'user123' });
}
const rows = await msg.getMessages({ conversationId }, 'messageId text');
expect(rows.map((r) => r.messageId)).toEqual(['m0', 'm1', 'm2']);
expect(rows[0]).not.toHaveProperty('user');
});
it('updateMessageText + updateMessage', async () => {
const conversationId = uuidv4();
await msg.saveMessage(ctx(), { messageId: 'm1', conversationId, text: 'a', user: 'user123' });
await msg.updateMessageText('user123', { messageId: 'm1', text: 'b' });
expect((await msg.getMessage({ user: 'user123', messageId: 'm1' }))?.text).toBe('b');
const updated = await msg.updateMessage('user123', { messageId: 'm1', sender: 'AI' });
expect(updated.sender).toBe('AI');
await expect(msg.updateMessage('user123', { messageId: 'missing' })).rejects.toThrow();
});
it('deleteMessagesSince deletes strictly-later messages in the conversation', async () => {
const conversationId = uuidv4();
for (let i = 0; i < 4; i++) {
await msg.saveMessage(ctx(), { messageId: `m${i}`, conversationId, user: 'user123' });
await new Promise((r) => setTimeout(r, 2)); // distinct createdAt
}
await msg.deleteMessagesSince('user123', { messageId: 'm1', conversationId });
const left = await msg.getMessages({ conversationId });
expect(left.map((m) => m.messageId).sort()).toEqual(['m0', 'm1']);
});
it('getMessagesByCursor paginates with a cursor', async () => {
const conversationId = uuidv4();
// Distinct second-level createdAt: the method's cursor is String(Date)
// (second precision), so space timestamps a second apart for determinism.
const base = Date.UTC(2026, 0, 1, 0, 0, 0);
for (let i = 0; i < 5; i++) {
await msg.saveMessage(ctx(), {
messageId: `m${i}`,
conversationId,
user: 'user123',
createdAt: new Date(base + i * 1000),
});
}
const page1 = await msg.getMessagesByCursor({ conversationId }, { limit: 2 });
expect(page1.messages).toHaveLength(2);
expect(page1.nextCursor).toBeTruthy();
const page2 = await msg.getMessagesByCursor(
{ conversationId },
{ limit: 2, cursor: page1.nextCursor },
);
expect(page2.messages).toHaveLength(2);
const ids1 = page1.messages.map((m) => m.messageId);
const ids2 = page2.messages.map((m) => m.messageId);
expect(ids1.some((id) => ids2.includes(id))).toBe(false); // no overlap
});
it('bulkSaveMessages upserts many', async () => {
const conversationId = uuidv4();
await msg.bulkSaveMessages([
{ messageId: 'a', conversationId, user: 'user123', text: 'A' },
{ messageId: 'b', conversationId, user: 'user123', text: 'B' },
]);
expect(await msg.getMessages({ conversationId })).toHaveLength(2);
});
});
describe('Conversation domain on SQLite (real methods)', () => {
it('saveConvo upserts and captures message _ids; getConvo/getConvoTitle read back', async () => {
const conversationId = uuidv4();
await msg.saveMessage(ctx(), { messageId: 'm1', conversationId, user: 'user123', text: 'hi' });
const saved = await convo.saveConvo(ctx(), { conversationId, title: 'My Chat' });
expect(saved).not.toBeNull();
expect((saved as { title: string }).title).toBe('My Chat');
expect(Array.isArray((saved as { messages: unknown[] }).messages)).toBe(true);
expect((saved as { messages: unknown[] }).messages).toHaveLength(1);
expect(await convo.getConvoTitle('user123', conversationId)).toBe('My Chat');
const c = await convo.getConvo('user123', conversationId);
expect(c?.conversationId).toBe(conversationId);
// re-save is an upsert (no duplicate)
await convo.saveConvo(ctx(), { conversationId, title: 'Renamed' });
expect(await convo.getConvoTitle('user123', conversationId)).toBe('Renamed');
});
it('getConvosByCursor honors archived + retention visibility and paginates', async () => {
const ids: string[] = [];
for (let i = 0; i < 5; i++) {
const conversationId = uuidv4();
ids.push(conversationId);
await convo.saveConvo(ctx(), { conversationId, title: `c${i}` });
await new Promise((r) => setTimeout(r, 2));
}
// archive one — must be excluded from the default (non-archived) listing
await convo.saveConvo(ctx(), { conversationId: ids[0], isArchived: true });
const page1 = await convo.getConvosByCursor('user123', { limit: 2 });
expect(page1.conversations).toHaveLength(2);
expect(page1.nextCursor).toBeTruthy();
expect(page1.conversations.some((c) => c.conversationId === ids[0])).toBe(false);
const page2 = await convo.getConvosByCursor('user123', { limit: 2, cursor: page1.nextCursor });
expect(page2.conversations.length).toBeGreaterThan(0);
const archived = await convo.getConvosByCursor('user123', { isArchived: true });
expect(archived.conversations.map((c) => c.conversationId)).toEqual([ids[0]]);
});
it('getConvosQueried returns requested ids with a map', async () => {
const a = uuidv4();
const b = uuidv4();
await convo.saveConvo(ctx(), { conversationId: a, title: 'A' });
await convo.saveConvo(ctx(), { conversationId: b, title: 'B' });
const res = await convo.getConvosQueried('user123', [{ conversationId: a }, { conversationId: b }]);
expect(res.conversations).toHaveLength(2);
expect(res.convoMap[a]).toBeTruthy();
});
it('deleteConvos removes the conversation AND its messages (cross-collection)', async () => {
const conversationId = uuidv4();
await msg.saveMessage(ctx(), { messageId: 'm1', conversationId, user: 'user123' });
await msg.saveMessage(ctx(), { messageId: 'm2', conversationId, user: 'user123' });
await convo.saveConvo(ctx(), { conversationId, title: 'doomed' });
const res = await convo.deleteConvos('user123', { conversationId });
expect(res.deletedCount).toBe(1);
expect(res.messages.deletedCount).toBe(2);
expect(await convo.getConvo('user123', conversationId)).toBeNull();
expect(await msg.getMessages({ conversationId })).toHaveLength(0);
});
it('getConvoFiles + searchConversation', async () => {
const conversationId = uuidv4();
await convo.saveConvo(ctx(), { conversationId, files: ['f1', 'f2'] });
expect(await convo.getConvoFiles(conversationId)).toEqual(['f1', 'f2']);
const found = await convo.searchConversation(conversationId);
expect(found?.conversationId).toBe(conversationId);
expect(found?.user).toBe('user123');
});
it('deleteNullOrEmptyConversations clears blank ids across both collections', async () => {
// valid one stays
const good = uuidv4();
await convo.saveConvo(ctx(), { conversationId: good, title: 'keep' });
// craft an empty-id convo + message directly through the store
await handle.models.Conversation.create({ conversationId: '', user: 'user123' });
await handle.models.Message.create({ messageId: 'orphan', conversationId: '', user: 'user123' });
const res = await convo.deleteNullOrEmptyConversations();
expect(res.conversations.deletedCount).toBe(1);
expect(res.messages.deletedCount).toBe(1);
expect(await convo.getConvo('user123', good)).not.toBeNull();
});
});
describe('data path is mongoose-free', () => {
it('never requires mongoose to satisfy the conversations+messages contract', () => {
// The handle is a pure SQLite store; the methods above ran green against it.
expect(handle.models.Conversation.constructor.name).toBe('DocModel');
expect(handle.models.Message.constructor.name).toBe('DocModel');
});
});
+11 -12
View File
@@ -1,11 +1,10 @@
import logger from '../config/winston';
import type { DataHandle } from '~/common/dataHandle';
import { EToolResources, FileContext } from 'librechat-data-provider';
import type { FilterQuery, SortOrder, Model } from 'mongoose';
import type { IMongoFile } from '~/types/file';
/** Factory function that takes mongoose instance and returns the file methods */
export function createFileMethods(handle: DataHandle) {
export function createFileMethods(mongoose: typeof import('mongoose')) {
/**
* Finds a file by its file_id with additional query options.
* @param file_id - The unique identifier of the file
@@ -16,7 +15,7 @@ export function createFileMethods(handle: DataHandle) {
file_id: string,
options: Record<string, unknown> = {},
): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
return File.findOne({ file_id, ...options }).lean();
}
@@ -36,7 +35,7 @@ export function createFileMethods(handle: DataHandle) {
_sortOptions?: Record<string, SortOrder> | null,
selectFields?: SelectProjection | string | null,
): Promise<IMongoFile[] | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const sortOptions = { updatedAt: -1 as SortOrder, ..._sortOptions };
const query = File.find(filter);
if (selectFields != null) {
@@ -183,7 +182,7 @@ export function createFileMethods(handle: DataHandle) {
file_id: string;
user: string;
}): Promise<IMongoFile> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const result = await File.findOneAndUpdate(
{
filename: data.filename,
@@ -211,7 +210,7 @@ export function createFileMethods(handle: DataHandle) {
data: Partial<IMongoFile>,
disableTTL?: boolean,
): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const fileData: Partial<IMongoFile> = {
...data,
expiresAt: new Date(Date.now() + 3600 * 1000),
@@ -235,7 +234,7 @@ export function createFileMethods(handle: DataHandle) {
async function updateFile(
data: Partial<IMongoFile> & { file_id: string },
): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const { file_id, ...update } = data;
const updateOperation = {
$set: update,
@@ -255,7 +254,7 @@ export function createFileMethods(handle: DataHandle) {
file_id: string;
inc?: number;
}): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const { file_id, inc = 1 } = data;
const updateOperation = {
$inc: { usage: inc },
@@ -272,7 +271,7 @@ export function createFileMethods(handle: DataHandle) {
* @returns A promise that resolves to the deleted file document or null
*/
async function deleteFile(file_id: string): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
return File.findOneAndDelete({ file_id }).lean();
}
@@ -282,7 +281,7 @@ export function createFileMethods(handle: DataHandle) {
* @returns A promise that resolves to the deleted file document or null
*/
async function deleteFileByFilter(filter: FilterQuery<IMongoFile>): Promise<IMongoFile | null> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
return File.findOneAndDelete(filter).lean();
}
@@ -296,7 +295,7 @@ export function createFileMethods(handle: DataHandle) {
file_ids: string[],
user?: string,
): Promise<{ deletedCount?: number }> {
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
let deleteQuery: FilterQuery<IMongoFile> = { file_id: { $in: file_ids } };
if (user) {
deleteQuery = { user: user };
@@ -315,7 +314,7 @@ export function createFileMethods(handle: DataHandle) {
return;
}
const File = handle.models.File as Model<IMongoFile>;
const File = mongoose.models.File as Model<IMongoFile>;
const bulkOperations = updates.map((update) => ({
updateOne: {
filter: { file_id: update.file_id },
+15 -24
View File
@@ -1,4 +1,3 @@
import { createModels } from '~/models';
import { createSessionMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, type SessionMethods } from './session';
import { createTokenMethods, type TokenMethods } from './token';
import { createRoleMethods, type RoleMethods } from './role';
@@ -44,30 +43,22 @@ export type AllMethods = UserMethods &
* @param mongoose - Mongoose instance
*/
export function createMethods(mongoose: typeof import('mongoose')): AllMethods {
// Registry-aware handle so migrated domains resolve to the backend selected by
// CHAT_STORE_SQLITE / CHAT_STORE_DUALWRITE. Unset flags => createModels returns
// pure mongoose => unchanged. User/Session/Token read only `.models`/`.Types`,
// both of which this handle provides, so they route through the seam via the
// cast below without any edit to their factory bodies (a mongoose instance is
// itself structurally `{ models, Types }`, and so is this handle).
const dbHandle = { models: createModels(mongoose), Types: mongoose.Types };
const storeHandle = dbHandle as unknown as typeof import('mongoose');
return {
...createUserMethods(storeHandle),
...createSessionMethods(storeHandle),
...createTokenMethods(storeHandle),
...createRoleMethods(dbHandle),
...createKeyMethods(dbHandle),
...createFileMethods(dbHandle),
...createMemoryMethods(dbHandle),
...createAgentCategoryMethods(dbHandle),
...createAgentApiKeyMethods(dbHandle),
...createMCPServerMethods(dbHandle),
...createAccessRoleMethods(dbHandle),
...createUserGroupMethods(dbHandle),
...createAclEntryMethods(dbHandle),
...createShareMethods(dbHandle),
...createPluginAuthMethods(dbHandle),
...createUserMethods(mongoose),
...createSessionMethods(mongoose),
...createTokenMethods(mongoose),
...createRoleMethods(mongoose),
...createKeyMethods(mongoose),
...createFileMethods(mongoose),
...createMemoryMethods(mongoose),
...createAgentCategoryMethods(mongoose),
...createAgentApiKeyMethods(mongoose),
...createMCPServerMethods(mongoose),
...createAccessRoleMethods(mongoose),
...createUserGroupMethods(mongoose),
...createAclEntryMethods(mongoose),
...createShareMethods(mongoose),
...createPluginAuthMethods(mongoose),
};
}
+5 -6
View File
@@ -1,11 +1,10 @@
import { ErrorTypes } from 'librechat-data-provider';
import type { DataHandle } from '~/common/dataHandle';
// Note: checkUserKeyExpiry moved to @hanzochat/api (utils/key.ts) as it's a pure validation utility
import { encrypt, decrypt } from '~/crypto';
import logger from '~/config/winston';
/** Factory function that takes mongoose instance and returns the key methods */
export function createKeyMethods(handle: DataHandle) {
export function createKeyMethods(mongoose: typeof import('mongoose')) {
/**
* Retrieves and decrypts the key value for a given user identified by userId and identifier name.
* @param params - The parameters object
@@ -19,7 +18,7 @@ export function createKeyMethods(handle: DataHandle) {
*/
async function getUserKey(params: { userId: string; name: string }): Promise<string> {
const { userId, name } = params;
const Key = handle.models.Key;
const Key = mongoose.models.Key;
const keyValue = (await Key.findOne({ userId, name }).lean()) as {
value: string;
} | null;
@@ -76,7 +75,7 @@ export function createKeyMethods(handle: DataHandle) {
name: string;
}): Promise<{ expiresAt: Date | 'never' | null }> {
const { userId, name } = params;
const Key = handle.models.Key;
const Key = mongoose.models.Key;
const keyValue = (await Key.findOne({ userId, name }).lean()) as {
expiresAt?: Date;
} | null;
@@ -104,7 +103,7 @@ export function createKeyMethods(handle: DataHandle) {
expiresAt?: Date | null;
}): Promise<unknown> {
const { userId, name, value, expiresAt = null } = params;
const Key = handle.models.Key;
const Key = mongoose.models.Key;
const encryptedValue = await encrypt(value);
const updateObject: { userId: string; name: string; value: string; expiresAt?: Date } = {
userId,
@@ -142,7 +141,7 @@ export function createKeyMethods(handle: DataHandle) {
all?: boolean;
}): Promise<unknown> {
const { userId, name, all = false } = params;
const Key = handle.models.Key;
const Key = mongoose.models.Key;
if (all) {
return await Key.deleteMany({ userId });
}
+11 -12
View File
@@ -1,5 +1,4 @@
import type { Model, RootFilterQuery, Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import type { MCPServerDocument } from '../types';
import type { MCPOptions } from 'librechat-data-provider';
import logger from '~/config/winston';
@@ -45,13 +44,13 @@ function generateServerNameFromTitle(title: string): string {
return slug || 'mcp-server'; // Fallback if empty
}
export function createMCPServerMethods(handle: DataHandle) {
export function createMCPServerMethods(mongoose: typeof import('mongoose')) {
/**
* Finds the next available server name by checking for duplicates.
* If baseName exists, returns baseName-2, baseName-3, etc.
*/
async function findNextAvailableServerName(baseName: string): Promise<string> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
// Find all servers with matching base name pattern (baseName or baseName-N)
const escapedBaseName = escapeRegex(baseName);
@@ -88,7 +87,7 @@ export function createMCPServerMethods(handle: DataHandle) {
config: MCPOptions;
author: string | Types.ObjectId;
}): Promise<MCPServerDocument> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
let lastError: unknown;
for (let attempt = 0; attempt < MAX_CREATE_RETRIES; attempt++) {
@@ -139,7 +138,7 @@ export function createMCPServerMethods(handle: DataHandle) {
* @returns The MCP server document or null
*/
async function findMCPServerByServerName(serverName: string): Promise<MCPServerDocument | null> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
return await MCPServer.findOne({ serverName }).lean();
}
@@ -151,7 +150,7 @@ export function createMCPServerMethods(handle: DataHandle) {
async function findMCPServerByObjectId(
_id: string | Types.ObjectId,
): Promise<MCPServerDocument | null> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
return await MCPServer.findById(_id).lean();
}
@@ -163,7 +162,7 @@ export function createMCPServerMethods(handle: DataHandle) {
async function findMCPServersByAuthor(
authorId: string | Types.ObjectId,
): Promise<MCPServerDocument[]> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
return await MCPServer.find({ author: authorId }).sort({ updatedAt: -1 }).lean();
}
@@ -190,7 +189,7 @@ export function createMCPServerMethods(handle: DataHandle) {
has_more: boolean;
after: string | null;
}> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated
? Math.min(Math.max(1, parseInt(String(limit)) || NORMALIZED_LIMIT_DEFAULT), 100)
@@ -208,7 +207,7 @@ export function createMCPServerMethods(handle: DataHandle) {
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new handle.Types!.ObjectId(_id) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
@@ -276,7 +275,7 @@ export function createMCPServerMethods(handle: DataHandle) {
serverName: string,
updateData: { config?: MCPOptions },
): Promise<MCPServerDocument | null> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
return await MCPServer.findOneAndUpdate(
{ serverName },
{ $set: updateData },
@@ -290,7 +289,7 @@ export function createMCPServerMethods(handle: DataHandle) {
* @returns The deleted MCP server document or null
*/
async function deleteMCPServer(serverName: string): Promise<MCPServerDocument | null> {
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
return await MCPServer.findOneAndDelete({ serverName }).lean();
}
@@ -305,7 +304,7 @@ export function createMCPServerMethods(handle: DataHandle) {
if (names.length === 0) {
return { data: [] };
}
const MCPServer = handle.models.MCPServer as Model<MCPServerDocument>;
const MCPServer = mongoose.models.MCPServer as Model<MCPServerDocument>;
const servers = await MCPServer.find({ serverName: { $in: names } }).lean();
return { data: servers };
}
+5 -6
View File
@@ -1,5 +1,4 @@
import { Types } from 'mongoose';
import type { DataHandle } from '~/common/dataHandle';
import logger from '~/config/winston';
import type * as t from '~/types';
@@ -11,7 +10,7 @@ const formatDate = (date: Date): string => {
};
// Factory function that takes mongoose instance and returns the methods
export function createMemoryMethods(handle: DataHandle) {
export function createMemoryMethods(mongoose: typeof import('mongoose')) {
/**
* Creates a new memory entry for a user
* Throws an error if a memory with the same key already exists
@@ -27,7 +26,7 @@ export function createMemoryMethods(handle: DataHandle) {
return { ok: false };
}
const MemoryEntry = handle.models.MemoryEntry;
const MemoryEntry = mongoose.models.MemoryEntry;
const existingMemory = await MemoryEntry.findOne({ userId, key });
if (existingMemory) {
throw new Error('Memory with this key already exists');
@@ -63,7 +62,7 @@ export function createMemoryMethods(handle: DataHandle) {
return { ok: false };
}
const MemoryEntry = handle.models.MemoryEntry;
const MemoryEntry = mongoose.models.MemoryEntry;
await MemoryEntry.findOneAndUpdate(
{ userId, key },
{
@@ -90,7 +89,7 @@ export function createMemoryMethods(handle: DataHandle) {
*/
async function deleteMemory({ userId, key }: t.DeleteMemoryParams): Promise<t.MemoryResult> {
try {
const MemoryEntry = handle.models.MemoryEntry;
const MemoryEntry = mongoose.models.MemoryEntry;
const result = await MemoryEntry.findOneAndDelete({ userId, key });
return { ok: !!result };
} catch (error) {
@@ -107,7 +106,7 @@ export function createMemoryMethods(handle: DataHandle) {
userId: string | Types.ObjectId,
): Promise<t.IMemoryEntryLean[]> {
try {
const MemoryEntry = handle.models.MemoryEntry;
const MemoryEntry = mongoose.models.MemoryEntry;
return (await MemoryEntry.find({ userId }).lean()) as t.IMemoryEntryLean[];
} catch (error) {
throw new Error(

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