Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9fd1cfc07 | ||
|
|
5feca644e2 | ||
|
|
72a5c71fe3 | ||
|
|
31bd2144cf | ||
|
|
8360c518b6 | ||
|
|
bd938fc6c6 | ||
|
|
b6a82bcf8f | ||
|
|
650ad06b15 | ||
|
|
52bcd7de06 | ||
|
|
495340a3b7 | ||
|
|
864c8a7bc7 | ||
|
|
1bcdcfc1e1 | ||
|
|
72700de2f0 | ||
|
|
32acfb52e3 | ||
|
|
3cb0488797 | ||
|
|
43cc8a73bb | ||
|
|
e4c75ffdd1 | ||
|
|
8486223c89 | ||
|
|
fa01f11732 | ||
|
|
6e56f51429 | ||
|
|
e27fb77e5b | ||
|
|
d2ded972bc | ||
|
|
5e9a043837 |
@@ -0,0 +1,16 @@
|
||||
# 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,6 +21,9 @@ 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,30 +119,31 @@ 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 != '[]'
|
||||
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 }}
|
||||
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,
|
||||
});
|
||||
|
||||
- name: Fail workflow if unused keys found
|
||||
if: env.unused_keys != '[]'
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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,6 +10,12 @@ 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
|
||||
@@ -66,5 +72,15 @@ 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 != ''
|
||||
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 }}
|
||||
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,
|
||||
});
|
||||
|
||||
- name: Fail workflow if unused dependencies found
|
||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
# v0.8.3-rc1
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
# 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
|
||||
|
||||
# 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 node:20-alpine AS base-min
|
||||
FROM ghcr.io/hanzoai/nodejs:v24.18.0 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 node:22-alpine
|
||||
FROM ghcr.io/hanzoai/nodejs:v24.18.0
|
||||
|
||||
RUN npm install -g serve@14
|
||||
|
||||
|
||||
+15
-3
@@ -5,9 +5,11 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const mongoose = require('mongoose');
|
||||
const MONGO_URI = process.env.MONGO_URI;
|
||||
|
||||
if (!MONGO_URI) {
|
||||
throw new Error('Please define the MONGO_URI environment variable');
|
||||
}
|
||||
// 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.
|
||||
/** 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. */
|
||||
@@ -45,6 +47,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
getBalanceConfig,
|
||||
getProviderConfig,
|
||||
omitTitleOptions,
|
||||
wrapHanzoGatewayFetch,
|
||||
memoryInstructions,
|
||||
applyContextToAgent,
|
||||
createTokenCounter,
|
||||
@@ -1069,6 +1070,22 @@ 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;
|
||||
}
|
||||
@@ -1085,6 +1102,16 @@ 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 ||
|
||||
|
||||
@@ -50,15 +50,25 @@ 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 30000; a run is
|
||||
* a real chat completion so it needs more headroom than a metadata read)
|
||||
* @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.maxConcurrent] - process-wide in-flight ceiling
|
||||
*/
|
||||
constructor({ endpoint, timeout = 30000, maxConcurrent = MAX_CONCURRENT }) {
|
||||
constructor({ endpoint, timeout = DEFAULT_TIMEOUT, maxConcurrent = MAX_CONCURRENT }) {
|
||||
this.endpoint = endpoint.replace(/\/+$/, '');
|
||||
this.timeout = timeout;
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
|
||||
@@ -210,6 +210,36 @@ 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(() => {
|
||||
|
||||
+9
-15
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<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,21 +24,15 @@
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const theme = localStorage.getItem('color-theme');
|
||||
// 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 loadingContainerStyle = document.createElement('style');
|
||||
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';
|
||||
}
|
||||
|
||||
const backgroundColor = isDark ? '#000000' : '#ffffff';
|
||||
loadingContainerStyle.innerHTML = `
|
||||
#loading-container {
|
||||
display: flex;
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
|
||||
<SplitText
|
||||
key={`split-text-${name}`}
|
||||
text={name}
|
||||
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
|
||||
className={`${getTextSizeClass(name)} font-medium tracking-tight 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 text-text-primary`}
|
||||
className={`${getTextSizeClass(greetingText)} font-medium tracking-tight 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-slate-200 dark:border-slate-700',
|
||||
'my-0.5 h-0 w-full border-t border-border-light',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAva
|
||||
const renderDefaultAvatar = () => (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
backgroundColor: 'rgb(64, 64, 64)',
|
||||
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 rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
|
||||
<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">
|
||||
<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 rounded-md bg-gray-900 text-xs text-white/80"
|
||||
className="relative w-full overflow-hidden rounded-lg border border-border-medium bg-gray-925 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.25 }}
|
||||
style={{ ...transition, opacity: isHovering ? 1 : 0 }}
|
||||
>
|
||||
<div className="flex h-6 w-6 flex-col items-center">
|
||||
{/* Top bar */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -24,6 +25,12 @@ 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 = {
|
||||
@@ -69,6 +76,12 @@ 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,7 +32,6 @@
|
||||
"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.",
|
||||
|
||||
+16
-4
@@ -359,6 +359,16 @@ 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.
|
||||
@@ -1212,11 +1222,13 @@ pre {
|
||||
code,
|
||||
pre {
|
||||
font-family:
|
||||
Consolas,
|
||||
Söhne Mono,
|
||||
'Geist Mono',
|
||||
'Roboto Mono',
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
Menlo,
|
||||
Monaco,
|
||||
Andale Mono,
|
||||
Ubuntu Mono,
|
||||
Consolas,
|
||||
monospace !important;
|
||||
}
|
||||
code.language-text,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
+16
-8
@@ -113,9 +113,17 @@ endpoints:
|
||||
- "zen3-nano"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "zen5-nano"
|
||||
# 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"
|
||||
summarize: true
|
||||
summaryModel: "zen5-nano"
|
||||
summaryModel: "zen5-flash"
|
||||
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
|
||||
@@ -133,7 +141,7 @@ endpoints:
|
||||
- "qwen3-32b"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "qwen3-coder-flash"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "Qwen"
|
||||
- name: "Meta Llama"
|
||||
customOrder: 2
|
||||
@@ -146,7 +154,7 @@ endpoints:
|
||||
- "llama-3.1-8b"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "llama-3.1-8b"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "Meta Llama"
|
||||
- name: "DeepSeek"
|
||||
customOrder: 3
|
||||
@@ -160,7 +168,7 @@ endpoints:
|
||||
- "deepseek-3.2"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "deepseek-v4-flash"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "DeepSeek"
|
||||
- name: "Mistral"
|
||||
customOrder: 4
|
||||
@@ -174,7 +182,7 @@ endpoints:
|
||||
- "mistral-nemo"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "mistral-nemo"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "Mistral"
|
||||
- name: "Google Gemma"
|
||||
customOrder: 5
|
||||
@@ -186,7 +194,7 @@ endpoints:
|
||||
- "gemma-3-31b"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "gemma-3-31b"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "Google Gemma"
|
||||
- name: "OpenAI GPT-OSS"
|
||||
customOrder: 6
|
||||
@@ -198,7 +206,7 @@ endpoints:
|
||||
- "gpt-oss-20b"
|
||||
fetch: false
|
||||
titleConvo: true
|
||||
titleModel: "gpt-oss-20b"
|
||||
titleModel: "zen5-flash"
|
||||
modelDisplayLabel: "OpenAI GPT-OSS"
|
||||
balance:
|
||||
enabled: false
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzochat/chat",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.16",
|
||||
"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,7 +190,8 @@
|
||||
"sharp",
|
||||
"protobufjs",
|
||||
"mongodb-memory-server",
|
||||
"unrs-resolver"
|
||||
"unrs-resolver",
|
||||
"better-sqlite3-multiple-ciphers"
|
||||
],
|
||||
"overrides": {
|
||||
"@ariakit/react": "0.4.29",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"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,11 +7,20 @@ 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, family: number) => void;
|
||||
type LookupCallback = (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
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(((
|
||||
@@ -23,6 +32,16 @@ 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,
|
||||
@@ -35,6 +54,7 @@ function mockDnsError(err: NodeJS.ErrnoException): void {
|
||||
|
||||
describe('createSSRFSafeAgents', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -51,6 +71,29 @@ 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', () => {
|
||||
@@ -94,6 +137,73 @@ 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',
|
||||
@@ -111,3 +221,112 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
+107
-38
@@ -2,52 +2,106 @@ import dns from 'node:dns';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { isPrivateIP } from './domain';
|
||||
import {
|
||||
normalizePort,
|
||||
normalizeAllowedAddressesSet,
|
||||
isAddressInAllowedSet,
|
||||
} from './allowedAddresses';
|
||||
import { isPrivateIP } from './ip';
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
};
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Default lookup with no exemptions. Kept for callers that don't need allowedAddresses. */
|
||||
const ssrfSafeLookup: LookupFunction = buildSSRFSafeLookup();
|
||||
|
||||
/** 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): T {
|
||||
function withSSRFProtection<T extends http.Agent>(agent: T, allowedAddresses?: string[] | null): T {
|
||||
const internal = agent as unknown as AgentInternal;
|
||||
const origCreate = internal.createConnection.bind(agent);
|
||||
internal.createConnection = (options: Record<string, unknown>, oncreate?: unknown) => {
|
||||
options.lookup = ssrfSafeLookup;
|
||||
options.lookup = allowedAddresses?.length
|
||||
? buildSSRFSafeLookup(allowedAddresses, getConnectionPort(options))
|
||||
: ssrfSafeLookup;
|
||||
return origCreate(options, oncreate);
|
||||
};
|
||||
return agent;
|
||||
@@ -58,18 +112,33 @@ function withSSRFProtection<T extends http.Agent>(agent: T): T {
|
||||
* 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(): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||
export function createSSRFSafeAgents(allowedAddresses?: string[] | null): {
|
||||
httpAgent: http.Agent;
|
||||
httpsAgent: https.Agent;
|
||||
} {
|
||||
return {
|
||||
httpAgent: withSSRFProtection(new http.Agent()),
|
||||
httpsAgent: withSSRFProtection(new https.Agent()),
|
||||
httpAgent: withSSRFProtection(new http.Agent(), allowedAddresses),
|
||||
httpsAgent: withSSRFProtection(new https.Agent(), allowedAddresses),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(): { lookup: LookupFunction } {
|
||||
return { lookup: ssrfSafeLookup };
|
||||
export function createSSRFSafeUndiciConnect(
|
||||
allowedAddresses?: string[] | null,
|
||||
port?: string | number | null,
|
||||
): {
|
||||
lookup: LookupFunction;
|
||||
} {
|
||||
const lookup = allowedAddresses?.length
|
||||
? buildSSRFSafeLookup(allowedAddresses, port)
|
||||
: ssrfSafeLookup;
|
||||
return { lookup };
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ 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>;
|
||||
@@ -153,8 +156,9 @@ describe('isSSRFTarget', () => {
|
||||
expect(isSSRFTarget('169.254.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block 0.0.0.0', () => {
|
||||
it('should block 0.0.0.0/8 (current network)', () => {
|
||||
expect(isSSRFTarget('0.0.0.0')).toBe(true);
|
||||
expect(isSSRFTarget('0.1.2.3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow public IPs', () => {
|
||||
@@ -176,6 +180,20 @@ 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', () => {
|
||||
@@ -230,8 +248,36 @@ describe('isPrivateIP', () => {
|
||||
expect(isPrivateIP('169.254.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 0.0.0.0', () => {
|
||||
it('should detect 0.0.0.0/8 (current network)', () => {
|
||||
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', () => {
|
||||
@@ -248,10 +294,17 @@ describe('isPrivateIP', () => {
|
||||
expect(isPrivateIP('[::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unique local (fc/fd) and link-local (fe80)', () => {
|
||||
it('should detect unique local (fc/fd) and link-local (fe80::/10)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,6 +323,144 @@ 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();
|
||||
@@ -298,16 +489,52 @@ describe('resolveHostnameSSRF', () => {
|
||||
expect(await resolveHostnameSSRF('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip literal IPv4 addresses (handled by isSSRFTarget)', async () => {
|
||||
expect(await resolveHostnameSSRF('169.254.169.254')).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);
|
||||
expect(mockedLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip literal IPv6 addresses', async () => {
|
||||
expect(await resolveHostnameSSRF('::1')).toBe(false);
|
||||
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);
|
||||
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);
|
||||
@@ -822,8 +1049,37 @@ describe('isMCPDomainAllowed', () => {
|
||||
});
|
||||
|
||||
describe('invalid URL handling', () => {
|
||||
it('should allow config with invalid URL (treated as stdio)', async () => {
|
||||
it('should reject invalid URL when allowlist is configured', 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);
|
||||
});
|
||||
});
|
||||
@@ -915,4 +1171,515 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+242
-69
@@ -1,4 +1,13 @@
|
||||
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
|
||||
@@ -24,83 +33,61 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
|
||||
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
|
||||
}
|
||||
|
||||
/** 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 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 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).
|
||||
* 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.
|
||||
*/
|
||||
export function isPrivateIP(ip: string): boolean {
|
||||
const normalized = ip.toLowerCase().trim();
|
||||
|
||||
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> {
|
||||
export async function resolveHostnameSSRF(
|
||||
hostname: string,
|
||||
allowedAddresses?: string[] | null,
|
||||
port?: string | number | null,
|
||||
): Promise<boolean> {
|
||||
const normalizedHost = hostname.toLowerCase().trim();
|
||||
|
||||
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
|
||||
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
|
||||
return isPrivateIP(normalizedHost);
|
||||
}
|
||||
|
||||
const ipv6Check = normalizedHost.replace(/^\[|\]$/g, '');
|
||||
if (ipv6Check.includes(':')) {
|
||||
return false;
|
||||
return isPrivateIP(ipv6Check);
|
||||
}
|
||||
|
||||
try {
|
||||
const addresses = await lookup(hostname, { all: true });
|
||||
return addresses.some((entry) => isPrivateIP(entry.address));
|
||||
return addresses.some(
|
||||
(entry) =>
|
||||
isPrivateIP(entry.address) && !isAddressAllowed(entry.address, allowedAddresses, port),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -109,12 +96,27 @@ export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
|
||||
/**
|
||||
* 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): boolean {
|
||||
export function isSSRFTarget(
|
||||
hostname: string,
|
||||
allowedAddresses?: string[] | null,
|
||||
port?: string | number | null,
|
||||
): boolean {
|
||||
const normalizedHost = hostname.toLowerCase().trim();
|
||||
|
||||
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedHost === 'localhost' ||
|
||||
normalizedHost === 'localhost.localdomain' ||
|
||||
@@ -268,17 +270,33 @@ 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) {
|
||||
@@ -292,12 +310,13 @@ 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)) {
|
||||
if (isSSRFTarget(inputSpec.hostname, allowedAddresses, effectivePort)) {
|
||||
return false;
|
||||
}
|
||||
/** SECURITY: Resolve hostname and block if it points to a private/reserved IP */
|
||||
if (await resolveHostnameSSRF(inputSpec.hostname)) {
|
||||
if (await resolveHostnameSSRF(inputSpec.hostname, allowedAddresses, effectivePort)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -348,21 +367,26 @@ 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);
|
||||
return isDomainAllowedCore(domain, allowedDomains, HTTP_PROTOCOLS, allowedAddresses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts full domain spec (protocol://hostname:port) from MCP server config URL.
|
||||
* Returns the full origin for proper protocol/port matching against allowedDomains.
|
||||
* Returns null for stdio transports (no URL) or invalid URLs.
|
||||
* @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}.
|
||||
* @param config - MCP server configuration (accepts any config with optional url field)
|
||||
*/
|
||||
export function extractMCPServerDomain(config: Record<string, unknown>): string | null {
|
||||
@@ -386,20 +410,169 @@ 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;
|
||||
|
||||
// Stdio transports don't have domains - always allowed
|
||||
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
|
||||
if (!domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use MCP_PROTOCOLS (HTTP/HTTPS/WS/WSS) for MCP server validation
|
||||
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS);
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +80,20 @@ 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,3 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './hanzoCloudKey';
|
||||
export * from './hanzoGatewayFetch';
|
||||
export * from './initialize';
|
||||
|
||||
+1233
-141
File diff suppressed because it is too large
Load Diff
@@ -12,4 +12,18 @@ 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,6 +1,12 @@
|
||||
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 {
|
||||
/** Map of userId -> Set of serverNames that have failed reconnection */
|
||||
private failed: Map<string, Set<string>> = new Map();
|
||||
private failedMeta: Map<string, Map<string, FailedMeta>> = 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 */
|
||||
@@ -9,7 +15,17 @@ export class OAuthReconnectionTracker {
|
||||
private readonly RECONNECTION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
|
||||
|
||||
public isFailed(userId: string, serverName: string): boolean {
|
||||
return this.failed.get(userId)?.has(serverName) ?? false;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Check if server is in the active set (original simple check) */
|
||||
@@ -48,11 +64,15 @@ export class OAuthReconnectionTracker {
|
||||
}
|
||||
|
||||
public setFailed(userId: string, serverName: string): void {
|
||||
if (!this.failed.has(userId)) {
|
||||
this.failed.set(userId, new Set());
|
||||
if (!this.failedMeta.has(userId)) {
|
||||
this.failedMeta.set(userId, new Map());
|
||||
}
|
||||
|
||||
this.failed.get(userId)?.add(serverName);
|
||||
const userMap = this.failedMeta.get(userId)!;
|
||||
const existing = userMap.get(serverName);
|
||||
userMap.set(serverName, {
|
||||
attempts: (existing?.attempts ?? 0) + 1,
|
||||
lastFailedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
public setActive(userId: string, serverName: string): void {
|
||||
@@ -68,10 +88,10 @@ export class OAuthReconnectionTracker {
|
||||
}
|
||||
|
||||
public removeFailed(userId: string, serverName: string): void {
|
||||
const userServers = this.failed.get(userId);
|
||||
userServers?.delete(serverName);
|
||||
if (userServers?.size === 0) {
|
||||
this.failed.delete(userId);
|
||||
const userMap = this.failedMeta.get(userId);
|
||||
userMap?.delete(serverName);
|
||||
if (userMap?.size === 0) {
|
||||
this.failedMeta.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +106,17 @@ 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(121, 137, 255)',
|
||||
backgroundColor: 'rgb(64, 64, 64)',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
|
||||
@@ -26,22 +26,9 @@ const useAvatar = (user: TUser | undefined) => {
|
||||
fontFamily: ['Verdana'],
|
||||
fontSize: 36,
|
||||
backgroundType: ['solid'],
|
||||
backgroundColor: [
|
||||
'd81b60',
|
||||
'8e24aa',
|
||||
'5e35b1',
|
||||
'3949ab',
|
||||
'DB3733',
|
||||
'1B79CC',
|
||||
'027CB8',
|
||||
'008291',
|
||||
'008577',
|
||||
'58802F',
|
||||
'8A761D',
|
||||
'9C6D00',
|
||||
'B06200',
|
||||
'D1451A',
|
||||
],
|
||||
// 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'],
|
||||
textColor: ['ffffff'],
|
||||
});
|
||||
|
||||
|
||||
@@ -59,10 +59,11 @@ const isValidThemeColors = (value: unknown): value is IThemeRGB => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get initial theme from localStorage or default to 'system'
|
||||
* Get initial theme from localStorage. Hard default is 'dark' (true-black) — the
|
||||
* brand canvas — unless the user has explicitly chosen light or system.
|
||||
*/
|
||||
const getInitialTheme = (): string => {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
||||
@@ -71,7 +72,7 @@ const getInitialTheme = (): string => {
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return 'system';
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ 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(),
|
||||
/**
|
||||
@@ -101,6 +103,30 @@ 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(),
|
||||
/**
|
||||
@@ -161,6 +187,8 @@ 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))
|
||||
@@ -179,6 +207,8 @@ 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))
|
||||
@@ -213,6 +243,7 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
||||
startup: true,
|
||||
timeout: true,
|
||||
initTimeout: true,
|
||||
sseReadTimeout: true,
|
||||
chatMenu: true,
|
||||
serverInstructions: true,
|
||||
requiresOAuth: true,
|
||||
@@ -232,8 +263,14 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
||||
*/
|
||||
export const MCPServerUserInputSchema = z.union([
|
||||
omitServerManagedFields(WebSocketOptionsSchema),
|
||||
omitServerManagedFields(SSEOptionsSchema),
|
||||
omitServerManagedFields(StreamableHTTPOptionsSchema),
|
||||
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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
|
||||
|
||||
Vendored
+2416
-154
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
+2414
-156
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,6 +5,8 @@ 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';
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { Types } from 'mongoose';
|
||||
import type { DataHandle } from '~/common/dataHandle';
|
||||
import type * as t from '~/types';
|
||||
export declare function createMemoryMethods(mongoose: typeof import('mongoose')): {
|
||||
export declare function createMemoryMethods(handle: DataHandle): {
|
||||
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,6 +1,7 @@
|
||||
import type { DeleteResult } from 'mongoose';
|
||||
import type { DataHandle } from '~/common/dataHandle';
|
||||
import type { FindPluginAuthsByKeysParams, UpdatePluginAuthParams, DeletePluginAuthParams, FindPluginAuthParams, IPluginAuth } from '~/types';
|
||||
export declare function createPluginAuthMethods(mongoose: typeof import('mongoose')): {
|
||||
export declare function createPluginAuthMethods(handle: DataHandle): {
|
||||
findOnePluginAuth: ({ userId, authField, pluginKey, }: FindPluginAuthParams) => Promise<IPluginAuth | null>;
|
||||
findPluginAuthsByKeys: ({ userId, pluginKeys, }: FindPluginAuthsByKeysParams) => Promise<IPluginAuth[]>;
|
||||
updatePluginAuth: ({ userId, authField, pluginKey, value, }: UpdatePluginAuthParams) => Promise<IPluginAuth>;
|
||||
|
||||
+3
-6
@@ -1,9 +1,6 @@
|
||||
export declare function createRoleMethods(mongoose: typeof import('mongoose')): {
|
||||
listRoles: () => Promise<(import("mongoose").FlattenMaps<any> & Required<{
|
||||
_id: unknown;
|
||||
}> & {
|
||||
__v: number;
|
||||
})[]>;
|
||||
import type { DataHandle } from '~/common/dataHandle';
|
||||
export declare function createRoleMethods(handle: DataHandle): {
|
||||
listRoles: () => Promise<any>;
|
||||
initializeRoles: () => Promise<void>;
|
||||
};
|
||||
export type RoleMethods = ReturnType<typeof createRoleMethods>;
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
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(mongoose: typeof import('mongoose')): {
|
||||
export declare function createShareMethods(handle: DataHandle): {
|
||||
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,3 +1,9 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -31,4 +37,5 @@ 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>;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
|
||||
"build:watch": "rollup -c -w",
|
||||
"test": "jest --coverage --watch",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"test:ci": "node test/ci.mjs",
|
||||
"verify": "npm run test:ci",
|
||||
"b:clean": "bun run rimraf dist",
|
||||
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs"
|
||||
@@ -59,6 +59,7 @@
|
||||
"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', 'node:sqlite', 'node:crypto'],
|
||||
external: ['mongoose', 'better-sqlite3-multiple-ciphers', 'node:crypto'],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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');
|
||||
@@ -8,6 +9,7 @@ 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
|
||||
@@ -104,6 +106,30 @@ 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.
|
||||
@@ -131,7 +157,7 @@ const debugTraverse = winston.format.printf(
|
||||
|
||||
try {
|
||||
if (level !== 'debug') {
|
||||
return msgParts[0];
|
||||
return appendRequestContext(msgParts[0], metadata);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
@@ -144,22 +170,25 @@ const debugTraverse = winston.format.printf(
|
||||
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
|
||||
|
||||
if (!debugValue) {
|
||||
return msgParts[0];
|
||||
return appendRequestContext(msgParts[0], metadata);
|
||||
}
|
||||
|
||||
if (debugValue && Array.isArray(debugValue)) {
|
||||
msgParts.push(`\n${JSON.stringify(debugValue.map(condenseArray))}`);
|
||||
return msgParts.join('');
|
||||
return appendRequestContext(msgParts.join(''), metadata);
|
||||
}
|
||||
|
||||
if (typeof debugValue !== 'object') {
|
||||
msgParts.push(` ${debugValue}`);
|
||||
return msgParts.join('');
|
||||
return appendRequestContext(msgParts.join(''), metadata);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('Conversation domain on SQLite (real methods)', () => {
|
||||
|
||||
describe('data path is mongoose-free', () => {
|
||||
it('never requires mongoose to satisfy the conversations+messages contract', () => {
|
||||
// The handle is pure node:sqlite; the methods above ran green against it.
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -44,15 +44,18 @@ export type AllMethods = UserMethods &
|
||||
* @param mongoose - Mongoose instance
|
||||
*/
|
||||
export function createMethods(mongoose: typeof import('mongoose')): AllMethods {
|
||||
// Registry-aware handle so already-migrated domains (Share) resolve to the
|
||||
// backend selected by CHAT_STORE_SQLITE. Not-yet-migrated factories keep
|
||||
// reading the mongoose registry directly; they move to `dbHandle` as they
|
||||
// migrate. Unset flag => createModels returns pure mongoose => unchanged.
|
||||
// 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(mongoose),
|
||||
...createSessionMethods(mongoose),
|
||||
...createTokenMethods(mongoose),
|
||||
...createUserMethods(storeHandle),
|
||||
...createSessionMethods(storeHandle),
|
||||
...createTokenMethods(storeHandle),
|
||||
...createRoleMethods(dbHandle),
|
||||
...createKeyMethods(dbHandle),
|
||||
...createFileMethods(dbHandle),
|
||||
|
||||
@@ -27,34 +27,97 @@ import { createMemoryModel } from './memory';
|
||||
import { createAccessRoleModel } from './accessRole';
|
||||
import { createAclEntryModel } from './aclEntry';
|
||||
import { createGroupModel } from './group';
|
||||
import { createSqliteHandle, CHAT_COLLECTION_SPECS } from '~/stores/sqlite';
|
||||
import { createSystemGrantModel } from './systemGrant';
|
||||
import {
|
||||
createSqliteHandle,
|
||||
createDualWriteModel,
|
||||
CHAT_COLLECTION_SPECS,
|
||||
type SqliteHandle,
|
||||
} from '~/stores/sqlite';
|
||||
|
||||
/**
|
||||
* Per-domain backend selection — the migration seam.
|
||||
* Per-domain backend selection — the migration seam. Two orthogonal CSV env
|
||||
* flags of collection names (only names with a CollectionSpec are honored;
|
||||
* unknown names are ignored, failing closed):
|
||||
*
|
||||
* `CHAT_STORE_SQLITE` is a CSV of collection names to serve from the SQLite
|
||||
* document store instead of mongoose (e.g. `Conversation,Message`). Unset (the
|
||||
* default) leaves every collection on mongoose, byte-for-byte unchanged — so the
|
||||
* live deployment is untouched until a domain is explicitly flipped. Only
|
||||
* collections with a CollectionSpec can be overridden; anything else throws,
|
||||
* failing closed rather than silently mis-storing.
|
||||
* CHAT_STORE_SQLITE — collections SERVED from the SQLite document store
|
||||
* (reads + the primary write target).
|
||||
* CHAT_STORE_DUALWRITE — collections written to BOTH stores, mirrored by the
|
||||
* primary store's `_id`.
|
||||
*
|
||||
* The four states this yields drive the whole Mongo→SQLite cutover, and any one
|
||||
* is a pure config change (no redeploy of logic):
|
||||
*
|
||||
* neither → mongoose only (untouched default).
|
||||
* dualwrite only → mongoose primary (served) + SQLite mirror [pre-flip].
|
||||
* sqlite + dualwrite → SQLite primary (served) + mongoose mirror [post-flip,
|
||||
* Mongo kept intact as the instant escape hatch].
|
||||
* sqlite only → SQLite only, no mirror [Mongo gone].
|
||||
*
|
||||
* Unsetting CHAT_STORE_SQLITE reverts serving to mongoose instantly (the mirror
|
||||
* kept it current), so the flip is reversible until Mongo is deleted.
|
||||
*/
|
||||
function applySqliteOverrides<T extends Record<string, unknown>>(models: T): T {
|
||||
const csv = process.env.CHAT_STORE_SQLITE?.trim();
|
||||
if (!csv) {
|
||||
return models;
|
||||
}
|
||||
const names = csv
|
||||
function parseStoreCsv(value?: string): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s in CHAT_COLLECTION_SPECS);
|
||||
if (names.length === 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One SQLite handle (one driver connection) per process, shared across
|
||||
* every `createModels` call — the api requires the data-schemas index from three
|
||||
* entry points, and a per-call handle would open three connections to the same
|
||||
* file and race for the WAL write lock. Keyed by the collection set so a changed
|
||||
* flag set (only happens across a restart) rebuilds cleanly.
|
||||
*/
|
||||
let sharedHandle: SqliteHandle | undefined;
|
||||
let sharedHandleKey = '';
|
||||
|
||||
function sharedSqliteHandle(names: string[]): SqliteHandle {
|
||||
const key = [...names].sort().join(',');
|
||||
if (!sharedHandle || sharedHandleKey !== key) {
|
||||
// Close the prior native connection before replacing it — a bare reassign
|
||||
// leaks the better-sqlite3 handle (its late GC finalizer corrupts sibling
|
||||
// SQLite state in a shared worker; latent prod leak on any rekey).
|
||||
sharedHandle?.close();
|
||||
sharedHandle = createSqliteHandle(names);
|
||||
sharedHandleKey = key;
|
||||
}
|
||||
return sharedHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 function closeSharedSqliteHandle(): void {
|
||||
sharedHandle?.close();
|
||||
sharedHandle = undefined;
|
||||
sharedHandleKey = '';
|
||||
}
|
||||
|
||||
function applySqliteOverrides<T extends Record<string, unknown>>(models: T): T {
|
||||
const sqliteNames = new Set(parseStoreCsv(process.env.CHAT_STORE_SQLITE));
|
||||
const dualNames = new Set(parseStoreCsv(process.env.CHAT_STORE_DUALWRITE));
|
||||
const union = [...new Set([...sqliteNames, ...dualNames])];
|
||||
if (union.length === 0) {
|
||||
return models;
|
||||
}
|
||||
const handle = createSqliteHandle(names);
|
||||
const handle = sharedSqliteHandle(union);
|
||||
const out = { ...models } as Record<string, unknown>;
|
||||
for (const name of names) {
|
||||
out[name] = handle.models[name];
|
||||
for (const name of union) {
|
||||
const mongooseModel = models[name];
|
||||
const sqliteModel = handle.models[name];
|
||||
const sqlitePrimary = sqliteNames.has(name);
|
||||
if (dualNames.has(name)) {
|
||||
const primary = sqlitePrimary ? sqliteModel : mongooseModel;
|
||||
const mirror = sqlitePrimary ? mongooseModel : sqliteModel;
|
||||
out[name] = createDualWriteModel(primary, mirror);
|
||||
} else {
|
||||
out[name] = sqliteModel;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
@@ -93,6 +156,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
|
||||
AccessRole: createAccessRoleModel(mongoose),
|
||||
AclEntry: createAclEntryModel(mongoose),
|
||||
Group: createGroupModel(mongoose),
|
||||
SystemGrant: createSystemGrantModel(mongoose),
|
||||
};
|
||||
return applySqliteOverrides(models);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { createModels } from '../index';
|
||||
import { createModels, closeSharedSqliteHandle } from '../index';
|
||||
|
||||
/**
|
||||
* Global symbol set by applyTenantIsolation() on every schema it processes.
|
||||
@@ -25,6 +25,12 @@ describe('tenant-isolation plugin coverage', () => {
|
||||
createModels(mongoose);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// If store flags are set in the environment, createModels opens the shared
|
||||
// native handle — close it so no open SQLite connection leaks past this file.
|
||||
closeSharedSqliteHandle();
|
||||
});
|
||||
|
||||
it('applies the tenant-isolation plugin to every model that has a tenantId field', () => {
|
||||
const missing: string[] = [];
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { createModels } from './index';
|
||||
import { createModels, closeSharedSqliteHandle } from './index';
|
||||
|
||||
describe('createModels — per-domain store registry', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CHAT_STORE_SQLITE;
|
||||
// Rekeying createModels opens the shared native handle; close it so no open
|
||||
// SQLite connection leaks into a sibling spec sharing this jest worker.
|
||||
closeSharedSqliteHandle();
|
||||
});
|
||||
|
||||
it('defaults to mongoose for every collection (live path unchanged)', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSqliteHandle, type SqliteHandle } from './index';
|
||||
import type { DocModel } from './DocModel';
|
||||
|
||||
describe('DocModel (node:sqlite backend)', () => {
|
||||
describe('DocModel (better-sqlite3 backend)', () => {
|
||||
let handle: SqliteHandle;
|
||||
let Message: DocModel;
|
||||
let Conversation: DocModel;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* CollectionSpec when migrated). MeiliSearch stays a separate concern: `.meiliSearch`
|
||||
* is intentionally absent, matching a mongoose model with no MEILI_HOST configured.
|
||||
*/
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type Database from 'better-sqlite3-multiple-ciphers';
|
||||
import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext';
|
||||
import {
|
||||
matchesFilter,
|
||||
@@ -90,7 +90,7 @@ const SQL_SAFE_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export class DocModel {
|
||||
readonly modelName: string;
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly db: Database.Database;
|
||||
private readonly dateFields: Set<string>;
|
||||
private readonly anchorFields: Set<string>;
|
||||
private readonly refs: Record<string, string>;
|
||||
@@ -99,7 +99,7 @@ export class DocModel {
|
||||
/** Resolves sibling collections for `.populate()`; wired by createSqliteHandle. */
|
||||
resolver?: (name: string) => DocModel | undefined;
|
||||
|
||||
constructor(db: DatabaseSync, spec: CollectionSpec) {
|
||||
constructor(db: Database.Database, spec: CollectionSpec) {
|
||||
this.db = db;
|
||||
this.modelName = spec.name;
|
||||
this.dateFields = new Set(spec.dateFields ?? ['createdAt', 'updatedAt', 'expiredAt']);
|
||||
@@ -273,8 +273,9 @@ export class DocModel {
|
||||
// array-contains (Mongo {field: value} over an array) — and remains a
|
||||
// superset prefilter (the JS matcher is authoritative).
|
||||
clauses.push(`EXISTS (SELECT 1 FROM json_each(doc, '$.${key}') WHERE value = ?)`);
|
||||
// node:sqlite binds only null/number/bigint/string/blob. json_each
|
||||
// yields 1/0 for JSON booleans, so map booleans; dates -> ISO.
|
||||
// The driver binds only null/number/bigint/string/Buffer (boolean and
|
||||
// undefined throw). json_each yields 1/0 for JSON booleans, so map
|
||||
// booleans to 1/0; dates -> ISO.
|
||||
const param =
|
||||
c instanceof Date ? c.toISOString() : typeof c === 'boolean' ? (c ? 1 : 0) : c;
|
||||
params.push(param);
|
||||
@@ -686,6 +687,27 @@ export class DocModel {
|
||||
return hydrate(this.insertDoc(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact by-`_id` upsert — the shared primitive for the dual-write mirror and
|
||||
* the Mongo→SQLite backfill. Writes the document verbatim: ObjectId-like
|
||||
* values coerced to hex (so a doc read from a mongoose `.lean()` serializes
|
||||
* correctly), Dates preserved, keyed by the document's own `_id`. NO timestamp
|
||||
* stamping, NO schema defaults, NO tenant scoping — the source store already
|
||||
* owns those. Idempotent: re-running with the same `_id` replaces the row, so
|
||||
* the backfill and live mirroring converge on the same keyspace and never
|
||||
* duplicate (both sides key on the primary store's `_id`).
|
||||
*/
|
||||
upsertRaw(input: Doc): void {
|
||||
const doc = deepCoerceIds(input) as Doc;
|
||||
const id = doc._id;
|
||||
if (id == null) {
|
||||
throw new Error(`[DocModel:${this.modelName}] upsertRaw requires _id`);
|
||||
}
|
||||
this.db
|
||||
.prepare(`INSERT OR REPLACE INTO ${this.table} (_id, doc) VALUES (?, ?)`)
|
||||
.run(String(id), this.serialize(doc));
|
||||
}
|
||||
|
||||
async bulkWrite(
|
||||
ops: Array<Record<string, unknown>>,
|
||||
): Promise<BulkResult> {
|
||||
@@ -828,7 +850,7 @@ export class QueryBuilder implements PromiseLike<Doc | Doc[] | null> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** No-op: node:sqlite is a single connection; Mongo sessions don't apply. */
|
||||
/** No-op: the store is a single connection; Mongo sessions don't apply. */
|
||||
session(_session?: unknown): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createSqliteHandle, type SqliteHandle } from './index';
|
||||
import { createDualWriteModel } from './DualWriteModel';
|
||||
import { CHAT_COLLECTION_SPECS } from './collections';
|
||||
import type { DocModel } from './DocModel';
|
||||
|
||||
/**
|
||||
* A minimal mongoose-Model stand-in for the mongo-mirror branch: it is NOT a
|
||||
* DocModel, exposes `.base.Types.ObjectId` and a native-driver-shaped
|
||||
* `.collection` with replaceOne/deleteOne, and records writes in a Map so the
|
||||
* test can assert the mirror is keyed by the primary's `_id` (as an ObjectId).
|
||||
*/
|
||||
class FakeObjectId {
|
||||
constructor(private readonly hex: string) {}
|
||||
toString() {
|
||||
return this.hex;
|
||||
}
|
||||
toHexString() {
|
||||
return this.hex;
|
||||
}
|
||||
}
|
||||
function makeFakeMongoModel() {
|
||||
const store = new Map<string, Record<string, unknown>>();
|
||||
return {
|
||||
store,
|
||||
base: { Types: { ObjectId: FakeObjectId } },
|
||||
collection: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
replaceOne: async (filter: any, doc: any) => {
|
||||
store.set(String(filter._id), doc);
|
||||
return { acknowledged: true };
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deleteOne: async (filter: any) => {
|
||||
store.delete(String(filter._id));
|
||||
return { acknowledged: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
describe('DualWriteModel — SQLite primary, SQLite mirror', () => {
|
||||
let primaryH: SqliteHandle;
|
||||
let mirrorH: SqliteHandle;
|
||||
let primary: DocModel;
|
||||
let mirror: DocModel;
|
||||
let dual: any;
|
||||
|
||||
beforeEach(() => {
|
||||
primaryH = createSqliteHandle(['Conversation']);
|
||||
mirrorH = createSqliteHandle(['Conversation']);
|
||||
primary = primaryH.models.Conversation;
|
||||
mirror = mirrorH.models.Conversation;
|
||||
dual = createDualWriteModel(primary, mirror);
|
||||
});
|
||||
afterEach(() => {
|
||||
primaryH.close();
|
||||
mirrorH.close();
|
||||
});
|
||||
|
||||
it('create mirrors the doc by the primary _id', async () => {
|
||||
const created: any = await dual.create({ conversationId: 'c1', user: 'u1', title: 'A' });
|
||||
expect(created._id).toBeTruthy();
|
||||
const inMirror = await mirror.findOne({ _id: created._id }).lean();
|
||||
expect(inMirror).toBeTruthy();
|
||||
expect((inMirror as any).conversationId).toBe('c1');
|
||||
// Same _id in both stores (mirror keyed by primary _id, not by value).
|
||||
expect(String((inMirror as any)._id)).toBe(String(created._id));
|
||||
expect(await primary.countDocuments({})).toBe(1);
|
||||
expect(await mirror.countDocuments({})).toBe(1);
|
||||
});
|
||||
|
||||
it('reads pass through to primary only', async () => {
|
||||
await primary.create({ conversationId: 'only-primary', user: 'u1' });
|
||||
const viaDual = await dual.findOne({ conversationId: 'only-primary' }).lean();
|
||||
expect(viaDual).toBeTruthy();
|
||||
// mirror never saw a direct primary.create — proves reads do not touch it
|
||||
expect(await mirror.countDocuments({})).toBe(0);
|
||||
});
|
||||
|
||||
it('updateOne mirrors the mutation', async () => {
|
||||
const c: any = await dual.create({ conversationId: 'c2', user: 'u1', title: 'old' });
|
||||
await dual.updateOne({ conversationId: 'c2' }, { $set: { title: 'new' } });
|
||||
const m = await mirror.findOne({ _id: c._id }).lean();
|
||||
expect((m as any).title).toBe('new');
|
||||
});
|
||||
|
||||
it('findOneAndUpdate upsert mirrors the inserted doc', async () => {
|
||||
const created: any = await dual.findOneAndUpdate(
|
||||
{ conversationId: 'c3', user: 'u1' },
|
||||
{ $set: { title: 'up' } },
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
expect(created.conversationId).toBe('c3');
|
||||
const m = await mirror.findOne({ conversationId: 'c3' }).lean();
|
||||
expect(m).toBeTruthy();
|
||||
expect(String((m as any)._id)).toBe(String(created._id));
|
||||
});
|
||||
|
||||
it('deleteOne removes the doc from the mirror', async () => {
|
||||
const c: any = await dual.create({ conversationId: 'c4', user: 'u1' });
|
||||
expect(await mirror.countDocuments({})).toBe(1);
|
||||
await dual.deleteOne({ conversationId: 'c4' });
|
||||
expect(await mirror.findOne({ _id: c._id }).lean()).toBeNull();
|
||||
expect(await mirror.countDocuments({})).toBe(0);
|
||||
});
|
||||
|
||||
it('bulkWrite mirrors inserts, updates and deletes', async () => {
|
||||
const seed: any = await dual.create({ conversationId: 'keep', user: 'u1', title: 't' });
|
||||
await dual.bulkWrite([
|
||||
{ insertOne: { document: { conversationId: 'bulk-ins', user: 'u1' } } },
|
||||
{ updateOne: { filter: { conversationId: 'keep' }, update: { $set: { title: 't2' } } } },
|
||||
]);
|
||||
expect(await mirror.findOne({ conversationId: 'bulk-ins' }).lean()).toBeTruthy();
|
||||
const kept = await mirror.findOne({ _id: seed._id }).lean();
|
||||
expect((kept as any).title).toBe('t2');
|
||||
});
|
||||
|
||||
it('idempotent: a re-mirror of the same _id does not duplicate (backfill safety)', async () => {
|
||||
const c: any = await dual.create({ conversationId: 'c5', user: 'u1' });
|
||||
// simulate the backfill upserting the same primary doc again by _id
|
||||
const raw = await primary.findById(String(c._id)).lean();
|
||||
mirror.upsertRaw(raw as any);
|
||||
mirror.upsertRaw(raw as any);
|
||||
expect(await mirror.countDocuments({ conversationId: 'c5' })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DualWriteModel — SQLite primary, mongo mirror (escape-hatch direction)', () => {
|
||||
let primaryH: SqliteHandle;
|
||||
let primary: DocModel;
|
||||
let fake: ReturnType<typeof makeFakeMongoModel>;
|
||||
let dual: any;
|
||||
|
||||
beforeEach(() => {
|
||||
primaryH = createSqliteHandle(['Conversation']);
|
||||
primary = primaryH.models.Conversation;
|
||||
fake = makeFakeMongoModel();
|
||||
dual = createDualWriteModel(primary, fake);
|
||||
});
|
||||
afterEach(() => primaryH.close());
|
||||
|
||||
it('mirrors creates to the mongo collection keyed by an ObjectId _id', async () => {
|
||||
const created: any = await dual.create({ conversationId: 'm1', user: 'u1' });
|
||||
const hex = String(created._id);
|
||||
expect(fake.store.has(hex)).toBe(true);
|
||||
expect((fake.store.get(hex) as any)._id).toBeInstanceOf(FakeObjectId);
|
||||
expect((fake.store.get(hex) as any).conversationId).toBe('m1');
|
||||
});
|
||||
|
||||
it('mirrors deletes to the mongo collection', async () => {
|
||||
const created: any = await dual.create({ conversationId: 'm2', user: 'u1' });
|
||||
const hex = String(created._id);
|
||||
expect(fake.store.has(hex)).toBe(true);
|
||||
await dual.deleteOne({ conversationId: 'm2' });
|
||||
expect(fake.store.has(hex)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAT_COLLECTION_SPECS — all specs build a valid store', () => {
|
||||
it('opens a handle over every spec without throwing (validates the 5 new specs)', () => {
|
||||
const names = Object.keys(CHAT_COLLECTION_SPECS);
|
||||
const handle = createSqliteHandle(names);
|
||||
for (const name of names) {
|
||||
expect(handle.models[name]).toBeTruthy();
|
||||
}
|
||||
// the auth+billing batch must be present and tableable
|
||||
for (const n of ['User', 'Session', 'Token', 'Balance', 'Transaction']) {
|
||||
expect(names).toContain(n);
|
||||
}
|
||||
handle.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Dual-write mirror — the migration bridge between the mongoose store and the
|
||||
* SQLite document store.
|
||||
*
|
||||
* A `DualWriteModel` wraps a `primary` model (the one that SERVES reads) and a
|
||||
* `mirror` model (kept convergent for the escape hatch / the flip). Every read
|
||||
* and every non-write property falls through to `primary` unchanged; the ten
|
||||
* write methods run on `primary` first, then replicate the resulting document
|
||||
* state to `mirror` KEYED BY THE PRIMARY'S `_id` (upsert if the doc still
|
||||
* exists, delete if it was removed). Because the mirror is addressed by the
|
||||
* primary's `_id` — never by value — the live mirror and the one-shot backfill
|
||||
* write the same keyspace and converge without duplicating.
|
||||
*
|
||||
* It is symmetric, so it powers both migration directions with the same code:
|
||||
* - pre-flip (CHAT_STORE_DUALWRITE only): primary = mongoose, mirror = SQLite
|
||||
* - post-flip (CHAT_STORE_SQLITE + DUALWRITE): primary = SQLite, mirror = mongoose
|
||||
*
|
||||
* `primary` / `mirror` are each either a mongoose `Model` or a `DocModel`; both
|
||||
* expose the same bounded Model API the chat data methods use (the pure-SQLite
|
||||
* seam already proves the app runs against that subset), so the wrapper only has
|
||||
* to special-case the mirror WRITE primitives per store kind.
|
||||
*/
|
||||
import { DocModel } from './DocModel';
|
||||
import { deepCoerceIds, type Doc, type Filter } from './engine';
|
||||
|
||||
/** Minimal shape the wrapper depends on — satisfied by mongoose Model and DocModel. */
|
||||
interface ModelLike {
|
||||
findById(id: unknown, projection?: unknown): { select(p: string): { lean(): Promise<Doc | null> }; lean(): Promise<Doc | null> };
|
||||
findOne(filter: Filter, projection?: unknown): { select(p: string): { lean(): Promise<Doc | null> } };
|
||||
find(filter: Filter, projection?: unknown): { select(p: string): { lean(): Promise<Doc[]> } };
|
||||
create(input: unknown): Promise<unknown>;
|
||||
insertMany(docs: unknown[], options?: unknown): Promise<unknown>;
|
||||
updateOne(filter: Filter, update: unknown, options?: unknown): Promise<{ upsertedId?: unknown }>;
|
||||
updateMany(filter: Filter, update: unknown, options?: unknown): Promise<{ upsertedId?: unknown }>;
|
||||
deleteOne(filter: Filter): Promise<unknown>;
|
||||
deleteMany(filter: Filter): Promise<unknown>;
|
||||
bulkWrite(ops: Array<Record<string, unknown>>, options?: unknown): Promise<Record<string, unknown>>;
|
||||
findOneAndUpdate(filter: Filter, update: unknown, options?: unknown): unknown;
|
||||
findByIdAndUpdate(id: unknown, update: unknown, options?: unknown): unknown;
|
||||
findOneAndDelete(filter: Filter, projection?: unknown): unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function idOf(doc: unknown): string | null {
|
||||
if (doc == null) {
|
||||
return null;
|
||||
}
|
||||
const id = (doc as Doc)._id ?? doc;
|
||||
return id == null ? null : String((id as { toString(): string }).toString());
|
||||
}
|
||||
|
||||
/** Extracts the id list from a bulkWrite result's insertedIds/upsertedIds map. */
|
||||
function idsFromResultMap(map: unknown): string[] {
|
||||
if (map == null || typeof map !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.values(map as Record<string, unknown>)
|
||||
.map((v) => idOf(v))
|
||||
.filter((v): v is string => v != null);
|
||||
}
|
||||
|
||||
class DualWriteModel {
|
||||
readonly primary: ModelLike;
|
||||
private readonly mirror: ModelLike;
|
||||
private readonly mirrorIsSqlite: boolean;
|
||||
/** mongoose `Types.ObjectId` ctor, needed to key the native mongo mirror. */
|
||||
private readonly ObjectId?: new (hex: string) => unknown;
|
||||
|
||||
constructor(primary: ModelLike, mirror: ModelLike) {
|
||||
this.primary = primary;
|
||||
this.mirror = mirror;
|
||||
this.mirrorIsSqlite = mirror instanceof DocModel;
|
||||
if (!this.mirrorIsSqlite) {
|
||||
// mongoose Model: `.base` is the mongoose instance carrying Types.ObjectId.
|
||||
const base = (mirror as { base?: { Types?: { ObjectId?: unknown } } }).base;
|
||||
this.ObjectId = base?.Types?.ObjectId as new (hex: string) => unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- mirror sync */
|
||||
|
||||
/**
|
||||
* Re-reads each id from `primary` and makes `mirror` match (upsert or delete).
|
||||
* A mirror failure is logged, never thrown: the primary (served) write already
|
||||
* succeeded, so the request must not fail because the insurance copy hiccuped.
|
||||
* Any gap is caught by the pre-flip count reconcile + the idempotent backfill.
|
||||
*/
|
||||
private async syncIds(ids: Array<string | null | undefined>): Promise<void> {
|
||||
const uniq = [...new Set(ids.map((i) => (i == null ? null : String(i))))].filter(
|
||||
(i): i is string => !!i,
|
||||
);
|
||||
for (const id of uniq) {
|
||||
try {
|
||||
const doc = await this.primary.findById(id).lean();
|
||||
if (doc) {
|
||||
await this.mirrorUpsert(doc);
|
||||
} else {
|
||||
await this.mirrorDelete(id);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[dual-write] mirror sync failed for _id=${id}:`, (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async mirrorUpsert(doc: Doc): Promise<void> {
|
||||
if (this.mirrorIsSqlite) {
|
||||
(this.mirror as unknown as DocModel).upsertRaw(doc);
|
||||
return;
|
||||
}
|
||||
// mongoose mirror: write verbatim through the native driver (bypasses schema
|
||||
// casting + timestamp stamping) so the copy is exact. Top-level `_id` must be
|
||||
// a real ObjectId for mongo; nested id refs stay as stored (acceptable — the
|
||||
// mongo mirror is the escape hatch, never the served store post-flip).
|
||||
const oid = this.toObjectId(doc._id);
|
||||
const raw = { ...doc, _id: oid };
|
||||
await (this.mirror as { collection: { replaceOne: Function } }).collection.replaceOne(
|
||||
{ _id: oid },
|
||||
raw,
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async mirrorDelete(id: string): Promise<void> {
|
||||
if (this.mirrorIsSqlite) {
|
||||
await (this.mirror as unknown as DocModel).deleteOne({ _id: id });
|
||||
return;
|
||||
}
|
||||
await (this.mirror as { collection: { deleteOne: Function } }).collection.deleteOne({
|
||||
_id: this.toObjectId(id),
|
||||
});
|
||||
}
|
||||
|
||||
private toObjectId(id: unknown): unknown {
|
||||
const hex = String((id as { toString(): string }).toString());
|
||||
if (this.ObjectId && /^[0-9a-fA-F]{24}$/.test(hex)) {
|
||||
return new this.ObjectId(hex);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ writes */
|
||||
|
||||
async create(...args: unknown[]): Promise<unknown> {
|
||||
const res = await (this.primary.create as (...a: unknown[]) => Promise<unknown>)(...args);
|
||||
const docs = Array.isArray(res) ? res : [res];
|
||||
await this.syncIds(docs.map(idOf));
|
||||
return res;
|
||||
}
|
||||
|
||||
async insertMany(docs: unknown[], options?: unknown): Promise<unknown> {
|
||||
const res = await this.primary.insertMany(docs, options);
|
||||
const arr = Array.isArray(res) ? res : [res];
|
||||
await this.syncIds(arr.map(idOf));
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter, update: unknown, options?: unknown): Promise<unknown> {
|
||||
const pre = await this.primary.findOne(filter).select('_id').lean();
|
||||
const res = await this.primary.updateOne(filter, update, options);
|
||||
await this.syncIds([idOf(pre), idOf(res?.upsertedId)]);
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter, update: unknown, options?: unknown): Promise<unknown> {
|
||||
const pre = await this.primary.find(filter).select('_id').lean();
|
||||
const res = await this.primary.updateMany(filter, update, options);
|
||||
await this.syncIds([...pre.map(idOf), idOf(res?.upsertedId)]);
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteOne(filter: Filter): Promise<unknown> {
|
||||
const pre = await this.primary.findOne(filter).select('_id').lean();
|
||||
const res = await this.primary.deleteOne(filter);
|
||||
await this.syncIds([idOf(pre)]);
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteMany(filter: Filter): Promise<unknown> {
|
||||
const pre = await this.primary.find(filter).select('_id').lean();
|
||||
const res = await this.primary.deleteMany(filter);
|
||||
await this.syncIds(pre.map(idOf));
|
||||
return res;
|
||||
}
|
||||
|
||||
async bulkWrite(ops: Array<Record<string, unknown>>, options?: unknown): Promise<unknown> {
|
||||
const preIds: Array<string | null> = [];
|
||||
for (const op of ops) {
|
||||
const one = (op.updateOne ?? op.deleteOne) as { filter?: Filter } | undefined;
|
||||
const many = (op.updateMany ?? op.deleteMany) as { filter?: Filter } | undefined;
|
||||
if (many?.filter) {
|
||||
const rows = await this.primary.find(many.filter).select('_id').lean();
|
||||
preIds.push(...rows.map(idOf));
|
||||
} else if (one?.filter) {
|
||||
const row = await this.primary.findOne(one.filter).select('_id').lean();
|
||||
preIds.push(idOf(row));
|
||||
}
|
||||
}
|
||||
const res = await this.primary.bulkWrite(ops, options);
|
||||
await this.syncIds([
|
||||
...preIds,
|
||||
...idsFromResultMap(res?.insertedIds),
|
||||
...idsFromResultMap(res?.upsertedIds),
|
||||
]);
|
||||
return res;
|
||||
}
|
||||
|
||||
findOneAndUpdate(filter: Filter, update: unknown, options: { upsert?: boolean } = {}): DualWriteQuery {
|
||||
return new DualWriteQuery(
|
||||
this,
|
||||
() => this.primary.findOneAndUpdate(filter, update, options),
|
||||
filter,
|
||||
!!options.upsert,
|
||||
);
|
||||
}
|
||||
|
||||
findByIdAndUpdate(id: unknown, update: unknown, options: { upsert?: boolean } = {}): DualWriteQuery {
|
||||
return new DualWriteQuery(
|
||||
this,
|
||||
() => this.primary.findByIdAndUpdate(id, update, options),
|
||||
{ _id: id } as Filter,
|
||||
!!options.upsert,
|
||||
);
|
||||
}
|
||||
|
||||
findOneAndDelete(filter: Filter, projection?: unknown): DualWriteQuery {
|
||||
return new DualWriteQuery(
|
||||
this,
|
||||
() => this.primary.findOneAndDelete(filter, projection),
|
||||
filter,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy, chainable result for the `findOneAnd*` methods. Forwards the mongoose /
|
||||
* DocModel query-builder chain (`select/lean/populate/sort/session/…`) to the
|
||||
* primary query so the caller's return shape is preserved, and mirrors the
|
||||
* affected document once the write resolves. Order matters: the pre-image id is
|
||||
* captured BEFORE the (lazy) primary query executes its write.
|
||||
*/
|
||||
class DualWriteQuery implements PromiseLike<unknown> {
|
||||
private pq: { [k: string]: Function } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly dwm: DualWriteModel,
|
||||
private readonly makePrimaryQuery: () => unknown,
|
||||
private readonly filter: Filter,
|
||||
private readonly upsert: boolean,
|
||||
) {}
|
||||
|
||||
private query(): { [k: string]: Function } {
|
||||
if (!this.pq) {
|
||||
this.pq = this.makePrimaryQuery() as { [k: string]: Function };
|
||||
}
|
||||
return this.pq;
|
||||
}
|
||||
|
||||
private chain(method: string, ...args: unknown[]): this {
|
||||
const q = this.query();
|
||||
if (typeof q[method] === 'function') {
|
||||
q[method](...args);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
select(p: unknown): this {
|
||||
return this.chain('select', p);
|
||||
}
|
||||
lean(): this {
|
||||
return this.chain('lean');
|
||||
}
|
||||
populate(p: unknown): this {
|
||||
return this.chain('populate', p);
|
||||
}
|
||||
sort(p: unknown): this {
|
||||
return this.chain('sort', p);
|
||||
}
|
||||
skip(n: unknown): this {
|
||||
return this.chain('skip', n);
|
||||
}
|
||||
limit(n: unknown): this {
|
||||
return this.chain('limit', n);
|
||||
}
|
||||
session(s: unknown): this {
|
||||
return this.chain('session', s);
|
||||
}
|
||||
|
||||
private async run(): Promise<unknown> {
|
||||
const primary = (this.dwm as unknown as { primary: ModelLike }).primary;
|
||||
const pre = await primary.findOne(this.filter).select('_id').lean();
|
||||
const result = await (this.query() as unknown as Promise<Doc | null>);
|
||||
const ids = [idOf(pre), idOf(result)];
|
||||
if (!pre && !result && this.upsert) {
|
||||
const seeded = await primary.findOne(this.filter).select('_id').lean();
|
||||
ids.push(idOf(seeded));
|
||||
}
|
||||
await (this.dwm as unknown as { syncIds(ids: unknown[]): Promise<void> })['syncIds'](ids);
|
||||
return result;
|
||||
}
|
||||
|
||||
then<TResult1 = unknown, TResult2 = never>(
|
||||
onfulfilled?: ((value: unknown) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.run().then(onfulfilled, onrejected);
|
||||
}
|
||||
catch<T = never>(onrejected?: ((reason: unknown) => T | PromiseLike<T>) | null): Promise<unknown> {
|
||||
return this.run().catch(onrejected);
|
||||
}
|
||||
exec(): Promise<unknown> {
|
||||
return this.run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a dual-write model as a Proxy: the write methods above take precedence,
|
||||
* every other access (find/findOne/aggregate/countDocuments/modelName/…) falls
|
||||
* through to `primary`, so the wrapper's surface is exactly the primary's plus
|
||||
* mirrored writes. `syncIds` is private but reached by DualWriteQuery via the
|
||||
* bracket accessor, so it must resolve on the target, not the primary.
|
||||
*/
|
||||
export function createDualWriteModel(primary: unknown, mirror: unknown): unknown {
|
||||
const dwm = new DualWriteModel(primary as ModelLike, mirror as ModelLike);
|
||||
return new Proxy(dwm, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop in target) {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
const value = (primary as Record<string | symbol, unknown>)[prop];
|
||||
return typeof value === 'function' ? (value as Function).bind(primary) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { DualWriteModel };
|
||||
@@ -15,7 +15,7 @@ import type { CollectionSpec } from './DocModel';
|
||||
* ZERO Mongo change-streams / tailable cursors / `.watch()` / DB-driven
|
||||
* subscriptions. Conversation/message reads are plain request/response
|
||||
* (getConvosByCursor / getMessages). Therefore NO migrated domain here needs a
|
||||
* DB-subscription replacement — plain node:sqlite is correct for all of them.
|
||||
* DB-subscription replacement — the plain SQLite store is correct for all of them.
|
||||
*
|
||||
* CUTOVER TARGET (per architecture directive "Base for realtime, SQLite for
|
||||
* storage"): the live-chat domains — **Conversation** and **Message** — are the
|
||||
@@ -208,4 +208,41 @@ export const CHAT_COLLECTION_SPECS: Record<string, CollectionSpec> = {
|
||||
index: ['source', 'idOnTheSource', 'name', 'memberIds'],
|
||||
dateFields: ['createdAt', 'updatedAt'],
|
||||
},
|
||||
|
||||
// ---- Batch 9: auth + billing — the collections that ACTUALLY hold live data
|
||||
// in chat-docdb outside the domains above (users/sessions/transactions/balances
|
||||
// are populated; tokens is on the same auth path). These must move to SQLite
|
||||
// for chat-docdb to be deletable without data loss — User is the hot-path record
|
||||
// that every authenticated request loads and that every conversation references
|
||||
// by `_id`, so its `_id` MUST survive the migration (backfill preserves it).
|
||||
// Their data methods read `.models`/`.Types` off the seam handle, so they route
|
||||
// here once flagged (see methods/index.ts, api/models/Transaction.js). Uniques
|
||||
// on the sparse social/openid ids are safe: an absent id is NULL in json_extract
|
||||
// and SQLite treats NULLs as distinct, so it mirrors the mongoose `sparse`.
|
||||
User: {
|
||||
name: 'User',
|
||||
unique: ['email', 'openidId', 'googleId', 'githubId'],
|
||||
index: ['organization', 'provider', 'username', 'idOnTheSource', 'role'],
|
||||
dateFields: ['createdAt', 'updatedAt', 'expiresAt'],
|
||||
},
|
||||
Session: {
|
||||
name: 'Session',
|
||||
index: ['refreshToken', 'user', 'expiration'],
|
||||
dateFields: ['expiration', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
Token: {
|
||||
name: 'Token',
|
||||
index: ['userId', 'token', 'email', 'type', 'identifier'],
|
||||
dateFields: ['createdAt', 'expiresAt'],
|
||||
},
|
||||
Balance: {
|
||||
name: 'Balance',
|
||||
index: ['user', 'commerceUserId'],
|
||||
dateFields: ['lastRefill', 'expiresAt', 'creditsGrantedAt', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
Transaction: {
|
||||
name: 'Transaction',
|
||||
index: ['user', 'conversationId', 'model', 'createdAt'],
|
||||
dateFields: ['createdAt', 'updatedAt'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,6 +127,45 @@ describe('sqlite engine — applyUpdate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sqlite engine — prototype pollution guard', () => {
|
||||
afterEach(() => {
|
||||
// Fail loudly if any case leaked onto a shared prototype.
|
||||
delete ({} as Record<string, unknown>).polluted;
|
||||
delete (Object.prototype as Record<string, unknown>).polluted;
|
||||
});
|
||||
|
||||
it('ignores a dotted __proto__ write in $set (no global pollution)', () => {
|
||||
const out = applyUpdate({ a: 1 }, { $set: { '__proto__.polluted': 'yes' } }, false);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(out).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('ignores constructor.prototype pollution in $set', () => {
|
||||
applyUpdate({}, { $set: { 'constructor.prototype.polluted': 'yes' } }, false);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a single-segment own __proto__ key (JSON-sourced payload)', () => {
|
||||
// JSON.parse creates a real own "__proto__" key (unlike an object literal,
|
||||
// which would set the prototype) — the shape a malicious request body takes.
|
||||
const malicious = JSON.parse('{"__proto__":{"polluted":"yes"}}');
|
||||
const out = applyUpdate({ a: 1 }, { $set: malicious }, false);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(out).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('ignores __proto__ paths via $setOnInsert and $inc', () => {
|
||||
applyUpdate({}, { $setOnInsert: { '__proto__.polluted': 1 } }, true);
|
||||
applyUpdate({}, { $inc: { '__proto__.polluted': 1 } }, false);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still writes legitimate nested paths', () => {
|
||||
expect(applyUpdate({}, { $set: { 'a.b.c': 1 } }, false)).toEqual({ a: { b: { c: 1 } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sqlite engine — projection & sort', () => {
|
||||
const doc = { _id: '1', a: 1, b: 2, c: 3 };
|
||||
|
||||
|
||||
@@ -54,12 +54,32 @@ export function hasPath(doc: Doc, path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must never be walked or written through a dotted update path:
|
||||
* writing `__proto__.x` or `constructor.prototype.x` would mutate a shared
|
||||
* prototype (prototype pollution). Update keys are attacker-influenced — they
|
||||
* arrive verbatim from `$set`/`$unset`/`$inc`/`$push`/… documents (conversation
|
||||
* import, `saveConvo`, agent metadata) — so a malicious dotted key is neutralised
|
||||
* here rather than trusted. A path containing any unsafe segment is a no-op.
|
||||
*/
|
||||
const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
||||
function hasUnsafeSegment(keys: string[]): boolean {
|
||||
return keys.some((k) => UNSAFE_KEYS.has(k));
|
||||
}
|
||||
|
||||
function setPath(doc: Doc, path: string, value: unknown): void {
|
||||
if (!path.includes('.')) {
|
||||
if (UNSAFE_KEYS.has(path)) {
|
||||
return;
|
||||
}
|
||||
doc[path] = value;
|
||||
return;
|
||||
}
|
||||
const keys = path.split('.');
|
||||
if (hasUnsafeSegment(keys)) {
|
||||
return;
|
||||
}
|
||||
let cur: Doc = doc;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const next = cur[keys[i]];
|
||||
@@ -73,10 +93,16 @@ function setPath(doc: Doc, path: string, value: unknown): void {
|
||||
|
||||
function deletePath(doc: Doc, path: string): void {
|
||||
if (!path.includes('.')) {
|
||||
if (UNSAFE_KEYS.has(path)) {
|
||||
return;
|
||||
}
|
||||
delete doc[path];
|
||||
return;
|
||||
}
|
||||
const keys = path.split('.');
|
||||
if (hasUnsafeSegment(keys)) {
|
||||
return;
|
||||
}
|
||||
let cur: Doc = doc;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const next = cur[keys[i]];
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* handle or this SQLite handle. A networked Hanzo Base / cloud `/v1` backend is
|
||||
* a future third implementation of the same handle shape.
|
||||
*/
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type Database from 'better-sqlite3-multiple-ciphers';
|
||||
import { DocModel, type CollectionSpec } from './DocModel';
|
||||
import { CHAT_COLLECTION_SPECS } from './collections';
|
||||
import { ObjectId } from './engine';
|
||||
@@ -18,10 +18,11 @@ import { ObjectId } from './engine';
|
||||
export { DocModel, type CollectionSpec } from './DocModel';
|
||||
export { CHAT_COLLECTION_SPECS } from './collections';
|
||||
export { ObjectId } from './engine';
|
||||
export { createDualWriteModel, DualWriteModel } from './DualWriteModel';
|
||||
|
||||
export interface SqliteHandle {
|
||||
models: Record<string, DocModel>;
|
||||
db: DatabaseSync;
|
||||
db: Database.Database;
|
||||
Types: { ObjectId: typeof ObjectId };
|
||||
close(): void;
|
||||
}
|
||||
@@ -29,19 +30,38 @@ export interface SqliteHandle {
|
||||
/**
|
||||
* Opens a SQLite database. Defaults to the path in `CHAT_SQLITE_PATH`, else an
|
||||
* in-memory database (used by tests). WAL + NORMAL sync for durable throughput.
|
||||
* A non-memory file is opened encrypted (SQLCipher AES-256, `hanzoai/sqlite`
|
||||
* contract) when `CHAT_SQLITE_KEY` holds a 64-hex-char raw key; unset opens
|
||||
* unencrypted (tests + local dev).
|
||||
*/
|
||||
export function openDatabase(dbPath?: string): DatabaseSync {
|
||||
// `node:sqlite` (DatabaseSync) exists only on Node >= 22.5. This store is
|
||||
// inert by default (unset CHAT_STORE_SQLITE) yet the data-schemas index is
|
||||
// loaded at server boot, so a static import would crash the Node 20 runtime.
|
||||
// Require it lazily — only when a database is actually opened.
|
||||
export function openDatabase(dbPath?: string): Database.Database {
|
||||
// `better-sqlite3-multiple-ciphers` is a native addon. The data-schemas index
|
||||
// is loaded at server boot even when the store is inert (unset
|
||||
// CHAT_STORE_SQLITE), so require it lazily — only when a database is actually
|
||||
// opened — keeping module import side-effect-free.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const sqlite = require('node:sqlite') as typeof import('node:sqlite');
|
||||
const DatabaseCtor = require('better-sqlite3-multiple-ciphers') as typeof import('better-sqlite3-multiple-ciphers');
|
||||
const path = dbPath ?? process.env.CHAT_SQLITE_PATH ?? ':memory:';
|
||||
const db = new sqlite.DatabaseSync(path);
|
||||
const db = new DatabaseCtor(path);
|
||||
if (path !== ':memory:') {
|
||||
// SQLCipher keying MUST precede any statement that touches DB pages. Never
|
||||
// log the key or the keyed path. KMS/CEK derivation is a later milestone;
|
||||
// this only honors a raw key supplied out-of-band.
|
||||
const key = process.env.CHAT_SQLITE_KEY;
|
||||
if (key) {
|
||||
if (!/^[0-9a-f]{64}$/i.test(key)) {
|
||||
throw new Error('[sqlite-store] CHAT_SQLITE_KEY must be a 64-hex-char raw key');
|
||||
}
|
||||
db.pragma("cipher='sqlcipher'");
|
||||
db.pragma('legacy=4');
|
||||
db.pragma(`key="x'${key}'"`);
|
||||
}
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA synchronous = NORMAL');
|
||||
// The one-shot Mongo→SQLite backfill runs as a SEPARATE process against this
|
||||
// same file; WAL permits one writer at a time, so wait for the lock rather
|
||||
// than erroring when the live pod and the backfill overlap.
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return db;
|
||||
@@ -50,11 +70,11 @@ export function openDatabase(dbPath?: string): DatabaseSync {
|
||||
/**
|
||||
* Builds a mongoose-shaped handle backed by SQLite for the given collection
|
||||
* names (defaults to all known chat collection specs). Pass a shared
|
||||
* `DatabaseSync` to co-locate collections in one file.
|
||||
* `Database` to co-locate collections in one file.
|
||||
*/
|
||||
export function createSqliteHandle(
|
||||
names?: string[],
|
||||
options: { db?: DatabaseSync; dbPath?: string; specs?: Record<string, CollectionSpec> } = {},
|
||||
options: { db?: Database.Database; dbPath?: string; specs?: Record<string, CollectionSpec> } = {},
|
||||
): SqliteHandle {
|
||||
const specs = options.specs ?? CHAT_COLLECTION_SPECS;
|
||||
const db = options.db ?? openDatabase(options.dbPath);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* CI test runner for @librechat/data-schemas.
|
||||
*
|
||||
* The spec files listed in ISOLATED instantiate the native `better-sqlite3-*`
|
||||
* driver. Jest gives every spec file its own JS module realm, but the native
|
||||
* addon is process-cached per worker, so cross-file state corrupts sibling
|
||||
* in-memory databases (driver-agnostic — reproduces with mainstream
|
||||
* better-sqlite3 too; a compound `json_extract` UNIQUE stops firing). Production
|
||||
* is unaffected: it runs one module realm with one shared handle for the process
|
||||
* lifetime. The fix is strict one-file-per-process isolation for those specs
|
||||
* only — NOT `--runInBand`/`--maxWorkers=1` (a single shared worker still
|
||||
* corrupts). Everything else runs in the normal coverage pass.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const JEST = join(dirname(require.resolve('jest/package.json')), 'bin', 'jest.js');
|
||||
|
||||
/** Native-driver specs — each run in its own process. */
|
||||
const ISOLATED = [
|
||||
'src/stores/sqlite/DocModel.spec.ts',
|
||||
'src/stores/sqlite/DualWriteModel.spec.ts',
|
||||
'src/models/storeRegistry.spec.ts',
|
||||
'src/models/plugins/tenantIsolation.coverage.spec.ts',
|
||||
'src/methods/convoMessage.sqlite.spec.ts',
|
||||
'src/methods/skill.harness.sqlite.spec.ts',
|
||||
'src/methods/batch2.sqlite.spec.ts',
|
||||
'src/methods/batch3.sqlite.spec.ts',
|
||||
'src/methods/batch4.sqlite.spec.ts',
|
||||
'src/methods/batch5.sqlite.spec.ts',
|
||||
'src/methods/batch6.sqlite.spec.ts',
|
||||
'src/methods/batch7.sqlite.spec.ts',
|
||||
'src/methods/batch7b.sqlite.spec.ts',
|
||||
'src/methods/batch8a.sqlite.spec.ts',
|
||||
'src/methods/batch8b.sqlite.spec.ts',
|
||||
];
|
||||
|
||||
const runJest = (args) =>
|
||||
spawnSync(process.execPath, [JEST, '--ci', ...args], { stdio: 'inherit' }).status === 0;
|
||||
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
let ok = true;
|
||||
|
||||
// 1) Normal coverage run for everything except the isolated set.
|
||||
console.log(`\n[ci] coverage run (excluding ${ISOLATED.length} native-driver specs)`);
|
||||
const ignore = ['/node_modules/', ...ISOLATED.map((f) => escapeRegExp(f) + '$')];
|
||||
if (!runJest(['--coverage', '--testPathIgnorePatterns', ...ignore])) {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
// 2) Each native-driver spec in its own process.
|
||||
for (const file of ISOLATED) {
|
||||
console.log(`\n[ci] isolated: ${file}`);
|
||||
if (!runJest([file])) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
Generated
+302
-10
@@ -70,7 +70,7 @@ importers:
|
||||
version: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-jest:
|
||||
specifier: ^29.1.0
|
||||
version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0)(typescript@5.9.3)
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2(eslint@9.39.4(jiti@1.21.7))
|
||||
@@ -94,7 +94,7 @@ importers:
|
||||
version: 9.1.7
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
|
||||
version: 30.3.0
|
||||
lint-staged:
|
||||
specifier: ^15.4.3
|
||||
version: 15.5.2
|
||||
@@ -347,7 +347,7 @@ importers:
|
||||
devDependencies:
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
version: 30.3.0
|
||||
mongodb-memory-server:
|
||||
specifier: ^10.1.4
|
||||
version: 10.4.3
|
||||
@@ -693,7 +693,7 @@ importers:
|
||||
version: 1.0.3
|
||||
eslint-plugin-jest:
|
||||
specifier: ^29.1.0
|
||||
version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
version: 29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@20.19.37))(typescript@5.9.3)
|
||||
fs-extra:
|
||||
specifier: ^11.3.2
|
||||
version: 11.3.4
|
||||
@@ -702,7 +702,7 @@ importers:
|
||||
version: 3.0.0
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
version: 30.3.0(@types/node@20.19.37)
|
||||
jest-canvas-mock:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
@@ -1215,7 +1215,7 @@ importers:
|
||||
version: 2.4.4
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
version: 30.3.0(@types/node@20.19.37)
|
||||
jest-junit:
|
||||
specifier: ^16.0.0
|
||||
version: 16.0.0
|
||||
@@ -1243,6 +1243,9 @@ importers:
|
||||
|
||||
packages/data-schemas:
|
||||
dependencies:
|
||||
better-sqlite3-multiple-ciphers:
|
||||
specifier: 12.11.1
|
||||
version: 12.11.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
@@ -7411,6 +7414,10 @@ packages:
|
||||
bcryptjs@2.4.3:
|
||||
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
|
||||
|
||||
better-sqlite3-multiple-ciphers@12.11.1:
|
||||
resolution: {integrity: sha512-pG72+3VkUipnmYx4LwQqoOXNG2CJ1HlmDrlvqgr7xQHgsE+cz5rAZEiaJHnUKTHaGfGifggA/C0Sv9qZlUrfow==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
@@ -7418,6 +7425,12 @@ packages:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
bn.js@4.12.3:
|
||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||
|
||||
@@ -7606,6 +7619,9 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
ci-info@3.9.0:
|
||||
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8201,6 +8217,10 @@ packages:
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
dedent@1.7.2:
|
||||
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
|
||||
peerDependencies:
|
||||
@@ -8213,6 +8233,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
deep-extend@0.6.0:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -8421,6 +8445,9 @@ packages:
|
||||
encoding-sniffer@0.2.1:
|
||||
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
entities@2.2.0:
|
||||
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
|
||||
|
||||
@@ -8748,6 +8775,10 @@ packages:
|
||||
resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect@29.7.0:
|
||||
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -8876,6 +8907,9 @@ packages:
|
||||
resolution: {integrity: sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
filelist@1.0.6:
|
||||
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
|
||||
|
||||
@@ -9000,6 +9034,9 @@ packages:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9112,6 +9149,9 @@ packages:
|
||||
get-tsconfig@4.13.6:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -9437,6 +9477,9 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
@@ -10630,6 +10673,10 @@ packages:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -10662,6 +10709,9 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -10772,6 +10822,9 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -10825,6 +10878,10 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-abi@3.94.0:
|
||||
resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -11668,6 +11725,12 @@ packages:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
precond@0.2.3:
|
||||
resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -11813,6 +11876,9 @@ packages:
|
||||
public-encrypt@4.0.3:
|
||||
resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
punycode@1.4.1:
|
||||
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
|
||||
|
||||
@@ -11878,6 +11944,10 @@ packages:
|
||||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-avatar-editor@13.0.2:
|
||||
resolution: {integrity: sha512-a4ajbi7lwDh98kgEtSEeKMu0vs0CHTczkq4Xcxr1EiwMFH1GlgHCEtwGU8q/H5W8SeLnH4KPK8LUjEEaZXklxQ==}
|
||||
peerDependencies:
|
||||
@@ -12453,6 +12523,12 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
@@ -12655,6 +12731,10 @@ packages:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-json-comments@3.1.1:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -12781,9 +12861,16 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tar-fs@2.1.5:
|
||||
resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
|
||||
|
||||
tar-mini@0.2.0:
|
||||
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.1.8:
|
||||
resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==}
|
||||
|
||||
@@ -12956,6 +13043,9 @@ packages:
|
||||
tty-browserify@0.0.1:
|
||||
resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
turbo@2.8.20:
|
||||
resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==}
|
||||
hasBin: true
|
||||
@@ -17592,6 +17682,41 @@ snapshots:
|
||||
jest-util: 30.3.0
|
||||
slash: 3.0.0
|
||||
|
||||
'@jest/core@30.3.0':
|
||||
dependencies:
|
||||
'@jest/console': 30.3.0
|
||||
'@jest/pattern': 30.0.1
|
||||
'@jest/reporters': 30.3.0
|
||||
'@jest/test-result': 30.3.0
|
||||
'@jest/transform': 30.3.0
|
||||
'@jest/types': 30.3.0
|
||||
'@types/node': 20.19.37
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 4.4.0
|
||||
exit-x: 0.2.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-changed-files: 30.3.0
|
||||
jest-config: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
jest-haste-map: 30.3.0
|
||||
jest-message-util: 30.3.0
|
||||
jest-regex-util: 30.0.1
|
||||
jest-resolve: 30.3.0
|
||||
jest-resolve-dependencies: 30.3.0
|
||||
jest-runner: 30.3.0
|
||||
jest-runtime: 30.3.0
|
||||
jest-snapshot: 30.3.0
|
||||
jest-util: 30.3.0
|
||||
jest-validate: 30.3.0
|
||||
jest-watcher: 30.3.0
|
||||
pretty-format: 30.3.0
|
||||
slash: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@jest/core@30.3.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@jest/console': 30.3.0
|
||||
@@ -21803,10 +21928,25 @@ snapshots:
|
||||
|
||||
bcryptjs@2.4.3: {}
|
||||
|
||||
better-sqlite3-multiple-ciphers@12.11.1:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
bn.js@4.12.3: {}
|
||||
|
||||
bn.js@5.2.3: {}
|
||||
@@ -22051,6 +22191,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
ci-info@3.9.0: {}
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
@@ -22689,6 +22831,10 @@ snapshots:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
dedent@1.7.2: {}
|
||||
|
||||
deep-equal@2.2.3:
|
||||
@@ -22712,6 +22858,8 @@ snapshots:
|
||||
which-collection: 1.0.2
|
||||
which-typed-array: 1.1.20
|
||||
|
||||
deep-extend@0.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@@ -22915,6 +23063,10 @@ snapshots:
|
||||
iconv-lite: 0.6.3
|
||||
whatwg-encoding: 3.1.1
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
entities@2.2.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
@@ -23184,24 +23336,24 @@ snapshots:
|
||||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@20.19.37))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
jest: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
jest: 30.3.0(@types/node@20.19.37)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
eslint-plugin-jest@29.15.0(@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(jest@30.3.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||
jest: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
|
||||
jest: 30.3.0
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -23409,6 +23561,8 @@ snapshots:
|
||||
|
||||
exit-x@0.2.2: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect@29.7.0:
|
||||
dependencies:
|
||||
'@jest/expect-utils': 29.7.0
|
||||
@@ -23581,6 +23735,8 @@ snapshots:
|
||||
strtok3: 7.1.1
|
||||
token-types: 5.0.1
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
filelist@1.0.6:
|
||||
dependencies:
|
||||
minimatch: 5.1.9
|
||||
@@ -23727,6 +23883,8 @@ snapshots:
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -23864,6 +24022,8 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -24270,6 +24430,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
input-otp@1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -24609,6 +24771,44 @@ snapshots:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-cli@30.3.0:
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0
|
||||
'@jest/test-result': 30.3.0
|
||||
'@jest/types': 30.3.0
|
||||
chalk: 4.1.2
|
||||
exit-x: 0.2.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
|
||||
jest-util: 30.3.0
|
||||
jest-validate: 30.3.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@30.3.0(@types/node@20.19.37):
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0
|
||||
'@jest/test-result': 30.3.0
|
||||
'@jest/types': 30.3.0
|
||||
chalk: 4.1.2
|
||||
exit-x: 0.2.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
jest-util: 30.3.0
|
||||
jest-validate: 30.3.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
@@ -25037,6 +25237,32 @@ snapshots:
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
jest@30.3.0:
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0
|
||||
'@jest/types': 30.3.0
|
||||
import-local: 3.2.0
|
||||
jest-cli: 30.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@30.3.0(@types/node@20.19.37):
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0
|
||||
'@jest/types': 30.3.0
|
||||
import-local: 3.2.0
|
||||
jest-cli: 30.3.0(@types/node@20.19.37)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.3.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))
|
||||
@@ -26072,6 +26298,8 @@ snapshots:
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
@@ -26098,6 +26326,8 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
mlly@1.8.2:
|
||||
@@ -26236,6 +26466,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -26284,6 +26516,10 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-abi@3.94.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-exports-info@1.6.0:
|
||||
@@ -27176,6 +27412,21 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.94.0
|
||||
pump: 3.0.4
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.5
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
precond@0.2.3: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
@@ -27282,6 +27533,11 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode@1.4.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
@@ -27342,6 +27598,13 @@ snapshots:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-is: 18.3.1
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-avatar-editor@13.0.2(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -28124,6 +28387,14 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
@@ -28352,6 +28623,8 @@ snapshots:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@1.1.2: {}
|
||||
@@ -28506,8 +28779,23 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
tar-fs@2.1.5:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.4
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-mini@0.2.0: {}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar-stream@3.1.8:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
@@ -28709,6 +28997,10 @@ snapshots:
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
turbo@2.8.20:
|
||||
optionalDependencies:
|
||||
'@turbo/darwin-64': 2.8.20
|
||||
|
||||
Reference in New Issue
Block a user