Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa2d3e224 |
@@ -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"
|
||||
@@ -21,9 +21,6 @@ jobs:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
CI: true
|
||||
# pnpm run frontend builds packages/api (rollup, ~5 GiB peak) + the client;
|
||||
# the default ~2 GiB heap OOMs. Match the review workflows' heap knob.
|
||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||
NODE_ENV: CI
|
||||
SEARCH: false
|
||||
MONGO_URI: mongodb://localhost:27017/librechat-test
|
||||
|
||||
@@ -119,31 +119,30 @@ jobs:
|
||||
echo "unused_keys=[]" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Post via github-script (Octokit + the built-in token) rather than the gh
|
||||
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
|
||||
# exits 127. The job already grants `pull-requests: write`.
|
||||
- name: Post verified comment on PR
|
||||
if: env.unused_keys != '[]'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const keys = JSON.parse(process.env.unused_keys || '[]').filter(Boolean);
|
||||
const list = keys.map((k) => `- [ ] \`${k}\``).join('\n');
|
||||
const body = [
|
||||
'### 🚨 Unused i18next Keys Detected',
|
||||
'',
|
||||
'The following translation keys are defined in `translation.json` but are **not used** in the codebase:',
|
||||
'',
|
||||
list,
|
||||
'',
|
||||
'⚠️ **Please remove these unused keys to keep the translation files clean.**',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
run: |
|
||||
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
# Format the unused keys list as checkboxes for easy manual checking.
|
||||
FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
### 🚨 Unused i18next Keys Detected
|
||||
|
||||
The following translation keys are defined in \`translation.json\` but are **not used** in the codebase:
|
||||
|
||||
$FILTERED_KEYS
|
||||
|
||||
⚠️ **Please remove these unused keys to keep the translation files clean.**
|
||||
EOF
|
||||
)
|
||||
|
||||
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
-f body="$COMMENT_BODY" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fail workflow if unused keys found
|
||||
if: env.unused_keys != '[]'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: LiteLLM Linting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install openai==1.81.0
|
||||
poetry install --with dev
|
||||
pip install openai==1.81.0
|
||||
|
||||
|
||||
|
||||
- name: Run Black formatting
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run black .
|
||||
cd ..
|
||||
|
||||
- name: Run Ruff linting
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run ruff check .
|
||||
cd ..
|
||||
|
||||
- name: Run MyPy type checking
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run mypy . --ignore-missing-imports
|
||||
cd ..
|
||||
|
||||
- name: Check for circular imports
|
||||
run: |
|
||||
cd litellm
|
||||
poetry run python ../tests/documentation_tests/test_circular_imports.py
|
||||
cd ..
|
||||
|
||||
- name: Check import safety
|
||||
run: |
|
||||
poetry run python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
|
||||
@@ -10,12 +10,6 @@ jobs:
|
||||
test:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
|
||||
# The packages/api rollup build peaks ~5 GiB RSS and OOMs at Node's default
|
||||
# ~2 GiB old-space heap. Match the heap the review workflows already use
|
||||
# (well under the 8 GiB runner limit). One knob, same expression everywhere.
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
@@ -72,15 +66,5 @@ jobs:
|
||||
MEILI_HOST: http://localhost:7700
|
||||
MEILI_MASTER_KEY: test_master_key
|
||||
|
||||
# Gate against tests-without-implementation drift in the MCP connection/
|
||||
# transport layer (proxy, response-size caps, WS SSRF, allowedAddresses).
|
||||
# These suites live in packages/api and were never run by this workflow —
|
||||
# which is how the connection.ts port drifted from its byte-identical tests.
|
||||
# A future half-port now fails here loudly.
|
||||
- name: Run MCP connection/transport tests
|
||||
run: cd packages/api && pnpm run test:transport
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Run client tests
|
||||
run: pnpm run test:client
|
||||
@@ -248,35 +248,35 @@ jobs:
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Post via github-script (Octokit + the built-in token) rather than the gh
|
||||
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
|
||||
# exits 127. The job already grants `pull-requests: write`.
|
||||
- name: Post comment on PR if unused dependencies are found
|
||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const section = (title, val) => {
|
||||
const items = (val || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
if (!items.length) return '';
|
||||
return `#### 📂 ${title}\n\n${items.map((i) => `- \`${i}\``).join('\n')}\n`;
|
||||
};
|
||||
const body = [
|
||||
'### 🚨 Unused NPM Packages Detected',
|
||||
'',
|
||||
'The following **unused dependencies** were found:',
|
||||
'',
|
||||
section('Root `package.json`', process.env.ROOT_UNUSED),
|
||||
section('Client `client/package.json`', process.env.CLIENT_UNUSED),
|
||||
section('API `api/package.json`', process.env.API_UNUSED),
|
||||
'⚠️ **Please remove these unused dependencies to keep your project clean.**',
|
||||
].filter(Boolean).join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
run: |
|
||||
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
### 🚨 Unused NPM Packages Detected
|
||||
|
||||
The following **unused dependencies** were found:
|
||||
|
||||
$(if [[ ! -z "$ROOT_UNUSED" ]]; then echo "#### 📂 Root \`package.json\`"; echo ""; echo "$ROOT_LIST"; echo ""; fi)
|
||||
|
||||
$(if [[ ! -z "$CLIENT_UNUSED" ]]; then echo "#### 📂 Client \`client/package.json\`"; echo ""; echo "$CLIENT_LIST"; echo ""; fi)
|
||||
|
||||
$(if [[ ! -z "$API_UNUSED" ]]; then echo "#### 📂 API \`api/package.json\`"; echo ""; echo "$API_LIST"; echo ""; fi)
|
||||
|
||||
⚠️ **Please remove these unused dependencies to keep your project clean.**
|
||||
EOF
|
||||
)
|
||||
|
||||
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
-f body="$COMMENT_BODY" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fail workflow if unused dependencies found
|
||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||
|
||||
+2
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,6 @@
|
||||
const {
|
||||
CacheKeys,
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
permissionsSchema,
|
||||
removeNullishValues,
|
||||
@@ -30,13 +31,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
||||
}
|
||||
let role = await query.lean().exec();
|
||||
|
||||
// Only self-heal a missing system role when we actually hold its canonical
|
||||
// defaults. Keying on SystemRoles[roleName] (enum membership) alone let a
|
||||
// role that exists in the enum but lacks a roleDefaults entry (e.g. GUEST)
|
||||
// fall into `new Role(undefined).save()` -> "name is required" -> every
|
||||
// generation for that role died. roleDefaults[roleName] always carries a
|
||||
// name, so this guard makes the nameless create structurally impossible.
|
||||
if (!role && roleDefaults[roleName]) {
|
||||
if (!role && SystemRoles[roleName]) {
|
||||
role = await new Role(roleDefaults[roleName]).save();
|
||||
await cache.set(roleName, role);
|
||||
return role.toObject();
|
||||
|
||||
@@ -214,14 +214,6 @@ async function createTransaction(_txData) {
|
||||
incrementValue,
|
||||
});
|
||||
|
||||
// NB: chat records NO debit to Commerce here. The single debit for an AI spend
|
||||
// is the cloud gateway's (api.hanzo.ai): every authenticated request forwards
|
||||
// the user's own hk- key and the gateway debits that key's Commerce balance
|
||||
// (per-user, fail-closed, 402 when empty). A second debit from chat would
|
||||
// double-charge — the incident that removed the write in recordCollectedUsage
|
||||
// (packages/api usage.ts). This local Transaction/Balance is the in-app usage
|
||||
// ledger only; in prod the balance gate is off (chat.yaml balance.enabled=false).
|
||||
|
||||
return {
|
||||
rate: transaction.rate,
|
||||
user: transaction.user.toString(),
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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' };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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')} />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 (fe80–febf)', () => {
|
||||
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
@@ -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';
|
||||
|
||||
+165
-1257
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+154
-2416
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
+156
-2414
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
-2
@@ -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
@@ -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,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
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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,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'],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 tenant’s 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user