Compare commits

..
Author SHA1 Message Date
hanzo-dev c9fd1cfc07 ci(data-schemas): isolate native-sqlite specs one-file-per-process; v0.9.16
The 15 specs that instantiate the native better-sqlite3 driver corrupt each
other under a shared jest worker: jest gives each spec file its own JS module
realm while the native addon is process-cached per worker, so cross-file state
breaks sibling in-memory databases (driver-agnostic — reproduces with mainstream
better-sqlite3; a compound json_extract UNIQUE stops firing). Prod is unaffected
(one realm, one shared handle for the process lifetime).

test:ci now runs test/ci.mjs: the normal coverage pass for everything except the
native-driver set (via testPathIgnorePatterns), then each of those 15 specs in
its own jest process, exiting non-zero on any failure. NOT --runInBand /
maxWorkers=1 (a single shared worker still corrupts). No test weakening, no skips.

Bump chat 0.9.15 -> 0.9.16 (patch).
2026-07-04 15:51:44 -07:00
hanzo-dev 5feca644e2 fix(store/sqlite): close shared handle on rekey + test teardown (real leak)
sharedSqliteHandle() reassigned sharedHandle on a collection-key change without
closing the prior connection — a latent native-handle leak on any rekey. Close
it before replacing, and export closeSharedSqliteHandle() so storeRegistry.spec
(rekeys twice) and tenantIsolation.coverage.spec tear the handle down instead of
leaking it past the file.

NOTE: this fixes the real leak the review identified, but does NOT resolve the
cross-file store-spec flake. That flake is a separate, deeper issue (see report):
driver-agnostic (reproduces with mainstream better-sqlite3 too), isolated to the
store's create path, timing/heap-sensitive (any probe masks it), and NOT fixed by
closing handles/GC/statement-cache/wrapper-pinning. Every store suite passes in
its own process; production (single long-lived handle, one module realm) is
unaffected.
2026-07-04 15:35:37 -07:00
hanzo-dev 72a5c71fe3 fix(store/sqlite): swap node:sqlite→better-sqlite3-multiple-ciphers (Node 20) + honor tenant sentinel in debug log
node:sqlite (DatabaseSync) is a Node 22+ builtin; prod runs Node 20 (Alpine),
so require('node:sqlite') threw ERR_UNKNOWN_BUILTIN_MODULE, cascading through
createModels → applySqliteOverrides → openDatabase and breaking the built dist
("createModels is not a function"). Swap to better-sqlite3-multiple-ciphers
12.11.1 (engines include 20.x, synchronous drop-in, SQLCipher AES-256 at rest —
the Node embodiment of the hanzoai/sqlite contract).

- data-schemas dep + root pnpm.onlyBuiltDependencies allowlist (native addon);
  rollup externalizes the driver.
- openDatabase(): lazy require of the driver, identical WAL/synchronous/
  busy_timeout/foreign_keys pragmas. Wire the CHAT_SQLITE_KEY encryption seam
  (SQLCipher cipher/legacy/key pragmas applied before any page-touching
  statement; 64-hex validated, fail-closed; key/path never logged). KMS/CEK
  derivation is Milestone 2.
- DocModel: retype db to the driver's Database instance; binding/return parity
  already correct (booleans→1/0, dates→ISO, TEXT reads only). engine untouched
  (27/27 held).
- parsers: restore the structured-logging-context impl dropped in an upstream
  merge (spec #13110 landed without its parsers.ts) — appendRequestContext for
  non-debug lines and drop the __SYSTEM__ tenant sentinel from debug traversal.
- models: register the SystemGrant model in createModels (schema/model/methods/
  SQLite-spec + coverage guard all existed; only the registration was dangling).

Scrub all node:sqlite/DatabaseSync references from src (comments + spec names).
2026-07-04 14:40:03 -07:00
hanzo-devandz 31bd2144cf fix(store/sqlite): guard engine setPath/deletePath against prototype pollution
The SQLite store's update path feeds attacker-influenced keys ($set/$unset/
$inc/$push/$addToSet — conversation import, saveConvo, agent metadata) straight
into setPath, which walked cur['__proto__'] / cur['constructor'] and could mutate
a shared prototype (global prototype pollution). Reject any dotted path whose
segments include __proto__/prototype/constructor (and the single-segment own-key
case a JSON.parse'd body produces). Legitimate nested writes are unaffected.

engine.spec: +5 guard cases (dotted $set, constructor.prototype, JSON-sourced
own __proto__, $setOnInsert/$inc, legit nested still writes). 27/27 green on
Node 20 (pure-JS engine, no node:sqlite dependency).
2026-07-04 14:14:56 -07:00
zeekayandClaude Fable 5 8360c518b6 fix(backfill): resolve @librechat/data-schemas via workspace path fallback
The pnpm workspace does not hoist a bare @librechat/data-schemas symlink to
the app root where the backfill runs (node config/backfill-sqlite.js), so a
bare require throws MODULE_NOT_FOUND in the pod. Fall back to the workspace
path (packages/data-schemas). Verified: require('/app/packages/data-schemas')
resolves in the chat pod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:15:39 -07:00
zeekayandClaude Fable 5 bd938fc6c6 feat(store): dual-write mirror + auth/billing specs to drop chat-docdb (0.9.15)
The Mongo→SQLite cutover, completed so chat-docdb can be DELETED with zero
data loss and an instant revert at every step.

- DualWriteModel: wraps a primary (served) + mirror model; reads/props fall
  through to primary, the 10 write methods run on primary then replicate the
  affected docs to the mirror KEYED BY THE PRIMARY'S _id (upsert if present,
  delete if gone). Symmetric — same code mirrors mongoose->sqlite (pre-flip)
  and sqlite->mongoose (post-flip escape hatch). Mirror failures are logged,
  never thrown, so the served path can't break; gaps are caught by the
  pre-flip count reconcile + the idempotent backfill.
- DocModel.upsertRaw: exact by-_id upsert (verbatim, no timestamp stamping) —
  the shared primitive for the mirror AND the backfill, so live mirroring and
  the one-shot copy converge on one keyspace without duplicating.
- Seam: applySqliteOverrides now honors TWO flags — CHAT_STORE_SQLITE (served)
  + CHAT_STORE_DUALWRITE (mirrored) — yielding the four cutover states. One
  shared node:sqlite connection per process (was per-createModels-call).
- Route User/Session/Token through the handle (methods/index.ts) and add the
  auth+billing CollectionSpecs (User/Session/Token/Balance/Transaction). These
  are the collections actually populated in chat-docdb outside the 24 already
  wired; User is the hot-path record every request loads and every conversation
  references by _id, so it MUST move for a lossless Mongo delete.
- connectDb() is now Mongo-optional: unset MONGO_URI => SQLite-only mode, skip
  the connection (final state, chat-docdb gone) with bufferCommands off so a
  stray mongoose query fails fast instead of hanging.
- Dockerfile{,.multi,.static}: node:20/22-alpine -> ghcr.io/hanzoai/nodejs
  :v24.18.0 (Node 24; node:sqlite built in, better-sqlite3 compiles).
- config/backfill-sqlite.js: one-shot Mongo->SQLite copy + count reconcile.

Tests: DualWriteModel.spec (10) green — mirror-by-primary-_id both directions,
read passthrough, bulkWrite, idempotent backfill, all 29 specs build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:04:11 -07:00
b6a82bcf8f fix(agents): raise cloud-agent run timeout 30s→180s (long-run 502) (#58)
* fix(agents): raise cloud-agent run timeout 30s→180s (env CLOUD_AGENT_TIMEOUT)

A cloud agent run is a real chat completion — a zen5-mini answer routinely
takes ~25-30s (measured 28s), larger models/prompts longer. The hardcoded 30s
client timeout aborted long runs mid-flight, surfacing in the UI as a 502 even
though the cloud run finished and was recorded. Bump the default to 180s and
make it env-configurable (CLOUD_AGENT_TIMEOUT), matching the existing
CLOUD_AGENT_MAX_CONCURRENT knob. List/get are fast; this headroom only affects
/run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(agents): lock cloud-agent run timeout contract (180s default + env override)

Adds coverage the timeout bump lacked: asserts the 180s default (the fix for
the 30s -> in-UI 502), that an explicit constructor timeout wins, and that
CLOUD_AGENT_TIMEOUT overrides the module-load default (isolated re-require).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:14:04 -07:00
zeekayandClaude Opus 4.8 650ad06b15 ci: ignore all three transitive-pinned otel packages in depcheck
The unpinned depcheck (workflow installs latest each run) shifts which
@opentelemetry/* packages it reports: it flagged only @opentelemetry/core
before, now also exporter-trace-otlp-http and sdk-trace-base. All three are
transitive deps of @opentelemetry/sdk-node (imported/driven via NodeSDK in
packages/api/src/telemetry/sdk.ts), pinned top-level for otel version
alignment, none imported by name. Ignore the complete set so ROOT_UNUSED is
empty regardless of depcheck's version-dependent otel detection. Only
@opentelemetry/api is imported directly and stays checked.

Verified locally: with the three ignored, depcheck's root output contains only
@opentelemetry/api, which the workflow's used-in-code list subtracts -> empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:47:57 -07:00
zeekayandClaude Opus 4.8 52bcd7de06 ci: silence otel-core depcheck false-positive + post PR comments via github-script
detect-unused-packages and detect-unused-i18next-strings each hard-fail on the
comment step because the ARC runners have no `gh` CLI (`gh api` -> exit 127), and
detect-unused-packages additionally flags @opentelemetry/core.

- Add .depcheckrc.yml ignoring @opentelemetry/core: it is a transitive
  requirement of the otel stack in use (sdk-node / sdk-trace-base /
  exporter-trace-otlp-http), intentionally version-pinned at the top level and
  never imported by name, so depcheck reports a false positive. Verified locally:
  with the ignore, depcheck no longer lists it (it was the sole ROOT_UNUSED item).
- Move both workflows' "post comment" step from `gh api` to
  actions/github-script@v7 (Octokit + built-in token; jobs already grant
  pull-requests: write). No dependency on a gh binary that isn't on the runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:33:19 -07:00
zeekayandClaude Opus 4.8 495340a3b7 i18n: remove orphaned en key com_agents_cloud_section
Genuinely unused: defined only in en/translation.json, referenced nowhere in
code (the sibling com_agents_cloud_* keys are all used; this section-header
string never shipped a consumer). Its presence was the sole finding of the
detect-unused-i18next-strings workflow, which hard-fails on any unused key.
Removing it makes that check pass without weakening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:26:23 -07:00
zeekayandClaude Opus 4.8 864c8a7bc7 ci: fix workspace-build OOM heap + drop dead LiteLLM lint workflow
Two pre-existing, repo-wide CI failures that reddened main and every PR:

1. test.yml / e2e.yml OOM'd building packages/api (rollup peaks ~5 GiB RSS;
   Node's default ~2 GiB old-space heap => "heap out of memory", exit 134).
   Add the same NODE_OPTIONS heap knob backend-review.yml/frontend-review.yml
   already use (6144, well under the 8 GiB runner limit). Verified: build:api
   completes in ~54s at 6144 (peak RSS 4.98 GiB); it OOMs at 3072.

2. test-linting.yml ("LiteLLM Linting") lints a litellm/ Python dir that does
   not exist in this repo and runs `poetry install` with no pyproject.toml, so
   it always failed at "Install dependencies". Pure upstream LibreChat/LiteLLM
   residue — the real JS lint is eslint-ci.yml ("Run ESLint Linting"), which
   passes. Remove the dead workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:18:13 -07:00
zeekayandClaude Fable 5 1bcdcfc1e1 chore(chat): runtime image node:20 -> node:22-alpine (0.9.13)
Node >= 22.5 ships `node:sqlite` (DatabaseSync), which the SQLite document
store requires once CHAT_STORE_SQLITE / CHAT_STORE_DUALWRITE is enabled.
Dockerfile.static was already node:22-alpine; this brings the runtime image
to parity. The store lazy-requires node:sqlite (stores/sqlite/index.ts), so
with the flag unset the runtime boots unchanged — this is a pure Node bump,
no SQLite enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 01:10:29 -07:00
72700de2f0 fix(mcp): full connection/transport port (proxy + response-size caps + WS SSRF) + wire suite into CI (#61)
* fix(mcp): restore drifted ~/auth SSRF/allowedAddresses helpers

The tests + callers (hardenedFetch, MCPOAuthHandler, MCP connection) already
expected the port-scoped allowedAddresses SSRF surface (LibreChat #12933/#13022),
but domain.ts/agent.ts had been reduced to the pre-#12933 versions while their
specs were trimmed to match — a tests-without-impl drift babel hid at test time.

Restore the upstream pair (matched impl + spec):
- domain.ts: isAddressAllowed; 3-arg resolveHostnameSSRF/isSSRFTarget (allowed
  host:port exemption); isOAuthUrlAllowed; validateEndpointURL; port-scoped
  isMCPDomainAllowed fail-closed on unparseable allowlisted URLs.
- agent.ts: createSSRFSafeUndiciConnect(allowedAddresses, port) + allowedAddresses
  connect-time exemption (fixes the dead 2-arg call in hardenedFetch.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): restore circuit-breaker config + OAuth reconnection cooldown

mcpConfig gains the CB_* connect/disconnect circuit-breaker knobs and
OAuthReconnectionTracker regains its progressive cooldown (5m/10m/20m/30m capped)
that reconnection-storm.test.ts asserts. Both were trimmed while their tests
stayed at the upstream version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): finish connection.ts transport port — proxy, response caps, WS SSRF

Completes the half-ported MCP connection/transport layer (LibreChat
#13076/#13219/#13224/#13274). The test suite (MCPConnectionSSRF,
MCPConnectionAgentLifecycle, reconnection-storm) was byte-identical to upstream
but the implementation stopped at the redirect-SSRF guard, leaving 22
tests-without-impl in MCPConnectionSSRF alone.

- Proxy support: serverConfig.proxy + PROXY/HTTP(S)_PROXY env with full NO_PROXY
  semantics (wildcard, CIDR, IP-range, IPv6, host-suffix, port scoping); per-URL
  ProxyAgent/Agent dispatcher selection tracked in this.agents; recomputed across
  redirects; proxied-target SSRF preflight (IP literal -> resolveHostnameSSRF,
  hostname -> allowedAddresses exemption or reject).
- Response-size caps: guardStreamableHTTPResponses opt-in wraps the body stream
  with MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES + MCP_STREAMABLE_HTTP_MAX_LINE_BYTES,
  emitting a JSON-RPC error SSE frame instead of unbounded buffering.
- WS SSRF: resolveHostnameSSRF(host, allowedAddresses, port) now runs regardless
  of useSSRFProtection (allowlist deployments), closing DNS-rebind to private IPs.
- Connect-lifecycle circuit breaker + agent cleanup (closeAgents) on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): accept proxy + sseReadTimeout in MCP server config schema

Adds ProxyUrlSchema (http/https/socks, env-var resolved) to SSE/streamable-http
options and sseReadTimeout to the base schema so connection.ts's proxy/idle-read
support is reachable from real config. proxy is admin-only: z.never() in the
UI/API user-input schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(chat): gate MCP connection/transport suite in the primary test workflow

Root cause of the drift: test.yml (the Hanzo-active CI) only ran 'test:api'
(the api/ Express dir) and 'test:client' — packages/api tests never ran here, so
the connection.ts port could drift from its byte-identical tests unnoticed.

Add packages/api 'test:transport' (MCPConnection*, MCPRedirectSSRFGuard,
reconnection-storm, MCPManager, auth domain/agent SSRF specs, hardenedFetch,
OAuthReconnection*) and run it in test.yml. A future half-port of the transport
layer now fails CI loudly. No .skip's exist in these suites; nothing un-skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:44:05 -07:00
zeekayandClaude Opus 4.8 32acfb52e3 fix(chat): stream #titleConvo so the Hanzo gateway response parses (real auto-title fix)
Root cause of new chats never titling: the title path invokes the model
NON-streaming (stream:false). The Hanzo Cloud gateway returns a valid
{choices:[{message}]} body, but the pinned langchain ChatOpenAI parses that
non-streaming body to ZERO generations, so invoke() throws
'Cannot read properties of undefined (reading \'message\')' and no title is
saved. Every agent RUN already streams (SSE), which parses the same gateway
correctly — that is why generation works but titles did not.

Force clientOptions.streaming = true in #titleConvo (after the omitTitleOptions
filter, which strips 'streaming'). Verified against the live gateway with Dave's
per-user key: streaming=true -> clean title; streaming=false -> the crash.

Pairs with the zen5-flash repoint (fast, non-reasoning): streaming fixes the
parse for all models; zen5-flash keeps the call under the 45s title timeout
(zen3-nano/qwen3-8b took ~60s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:38:02 -07:00
zeekayandClaude Opus 4.8 3cb0488797 fix(chat): title/summary model zen3-nano -> zen5-flash (fast, non-reasoning)
New chats didn't reliably auto-title: zen3-nano maps to the reasoning model
qwen3-8b (live upstream), which took 16-31s per title — routinely near the
45s #titleConvo timeout. The other 6 families pointed titleModel at
third-party names (llama-3.1-8b, qwen3-coder-flash, ...) that are NOT in the
live gateway catalog, so they returned a 200 error-envelope.

Repoint every family's titleModel (and the Hanzo summaryModel) to zen5-flash:
a fast, NON-reasoning, in-catalog model (live upstream deepseek-4-flash) that
returns a clean single-line title in ~1-2s. 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; zen5-nano not servable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:57 -07:00
zeekayandClaude Opus 4.8 43cc8a73bb fix(chat): make #titleConvo envelope-safe (parity with agent runs)
The Hanzo Cloud gateway answers some failures with HTTP 200 + a JSON
error-envelope ({status:"error", msg}) that has no `choices`. On the
title path the OpenAI client parsed that 200 to `undefined` and
#titleConvo threw `Cannot read properties of undefined (reading 'message')`,
so new conversations never got a title.

Apply the same wrapHanzoGatewayFetch rewrite agent runs use, now explicitly
on the title client's fetch, so the envelope becomes a clean 402 the outer
try/catch skips gracefully. Export the wrapper from @hanzochat/api; the
re-wrap is idempotent (a second pass sees a 402, not a 200 envelope) and
response-only, so per-user hk- billing and normal completions are untouched.

Adds an idempotency unit test (7/7 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:49 -07:00
zeekayandClaude Opus 4.8 e4c75ffdd1 docs(chat): title-route limitation note (zen3-nano) — config source of truth
The #titleConvo path no longer 404s (zen5-nano was off-catalog), but full
auto-title still needs a follow-up: zen3-nano reasons past the 45s title timeout,
and off-title-path models (e.g. llama-3.1-8b) return a 200 error-envelope the
title client mis-parses. Needs the gateway-envelope rewrite on #titleConvo + a
fast non-reasoning title model. librechat.yaml is the source the chat-config
ConfigMap is generated from (not used at runtime; CONFIG_PATH=/app/chat.yaml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:12:29 -07:00
zeekayandClaude Opus 4.8 8486223c89 chore(chat): 0.9.10 — ensō icon fix folded into the design-maven pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:25:02 -07:00
zeekayandClaude Opus 4.8 fa01f11732 fix(chat): ensō for the Zen family in UnknownIcon (the real icon path)
Custom endpoints resolve their icon via getIconKey -> 'unknown' -> UnknownIcon
(by endpoint NAME), not icons[custom] — so the earlier icons[custom] remap was
inert (reverted). The Hanzo house endpoint had no asset/iconURL match and fell
through to the generic lucide "bot". Now:
- 'hanzo'/'zen' -> ZenLogoIcon (ensō), matching the assistant message avatar,
  on the welcome screen + model pill + picker menu.
- qwen/google-gemma/openai-gpt-oss -> real provider marks by name (the
  KnownEndpoints enum lacks those keys). DeepSeek/Mistral logos unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:24:54 -07:00
zeekayandClaude Opus 4.8 6e56f51429 chore(chat): 0.9.9 — design-maven polish pass (true-black, ensō, monochrome)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:10:46 -07:00
zeekayandClaude Opus 4.8 e27fb77e5b feat(chat): polish welcome heading + code blocks
- Welcome heading: tracking-tight for crisp large-display type (Linear/Vercel).
- Code blocks: hairline-bordered true-black surface (rounded-lg, border-medium,
  #0a0a0a) with a subtle bottom-bordered header instead of the heavier grey bar;
  reads calm + monochrome now that code renders in Geist Mono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:10:00 -07:00
zeekayandClaude Opus 4.8 d2ded972bc feat(chat): monochrome brand — ensō everywhere, neutral avatars, kill leaks
- Zen ensō (円相) replaces the generic lucide "bot" for the custom Zen endpoint:
  welcome screen, model pill and menu icon now match the assistant avatar.
- Monochrome avatars: collapse the colorful DiceBear palette to a neutral grey
  ramp (the green "GU" guest chip is gone), and neutralize the periwinkle
  no-seed fallback in Icon/Avatar.
- Kill the stray "(" glyph: the right control-panel NavToggle handle was floating
  mid-edge at 0.25 opacity; now invisible until hover (header controls remain).
- Model-picker menu separator used cool border-slate-*; now neutral border-light.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:20 -07:00
zeekayandClaude Opus 4.8 5e9a043837 feat(chat): true-black hard-default theme + crisp type + auto-title fix
- Hard-default to dark (true-black) unless the user explicitly picks light:
  ThemeProvider getInitialTheme + index.html no-flash script now default dark,
  and the loading canvas is true #000 (was blue-tinted #070b13). theme-color #000.
- Crisp type: font-synthesis:none + antialiased. Basel ships 400/500 only, so
  faux-bold was blurring headings; hierarchy now comes from size + the real
  Medium face. Code now renders Geist Mono (was forced to Consolas via !important).
- Auto-title: repoint titleModel/summaryModel zen5-nano -> zen3-nano (zen5-nano
  isn't in the live /v1/models catalog, so #titleConvo 404'd and new chats never
  auto-titled). zen3-nano is the known-good guest/test model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:10 -07:00
67 changed files with 9100 additions and 827 deletions
+16
View File
@@ -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"
+3
View File
@@ -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
+23 -22
View File
@@ -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 != '[]'
-57
View File
@@ -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)
+16
View File
@@ -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
+27 -27
View File
@@ -248,35 +248,35 @@ jobs:
cd ..
fi
# Post via github-script (Octokit + the built-in token) rather than the gh
# CLI: gh is not installed on the self-hosted ARC runners, so `gh api` here
# exits 127. The job already grants `pull-requests: write`.
- name: Post comment on PR if unused dependencies are found
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
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
View File
@@ -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
View File
@@ -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
View File
@@ -6,7 +6,7 @@
# Build: docker build -f Dockerfile.static -t ghcr.io/hanzoai/chat:latest .
# Run: docker run -p 3080:3080 ghcr.io/hanzoai/chat:latest
FROM node:22-alpine
FROM ghcr.io/hanzoai/nodejs:v24.18.0
RUN npm install -g serve@14
+15 -3
View File
@@ -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;
}
+27
View File
@@ -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 ||
+13 -3
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -171,7 +171,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${name}`}
text={name}
className={`${getTextSizeClass(name)} font-medium 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,
)}
/>
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -72,7 +72,7 @@ export default function NavToggle({
<span className="" data-state="closed">
<div
className="flex h-[72px] w-8 items-center justify-center"
style={{ ...transition, opacity: isHovering ? 1 : 0.25 }}
style={{ ...transition, opacity: isHovering ? 1 : 0 }}
>
<div className="flex h-6 w-6 flex-col items-center">
{/* Top bar */}
+13
View File
@@ -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')} />;
}
-1
View File
@@ -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
View File
@@ -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,
+132
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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",
+220 -1
View File
@@ -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
View File
@@ -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 };
}
+775 -8
View File
@@ -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 (fe80febf)', () => {
expect(isSSRFTarget('fe90::1')).toBe(true);
expect(isSSRFTarget('fea0::1')).toBe(true);
expect(isSSRFTarget('feb0::1')).toBe(true);
expect(isSSRFTarget('febf::1')).toBe(true);
expect(isSSRFTarget('fec0::1')).toBe(false);
});
it('should NOT false-positive on hostnames whose first label resembles a link-local prefix', () => {
expect(isSSRFTarget('fe90.example.com')).toBe(false);
expect(isSSRFTarget('fea0.api.io')).toBe(false);
expect(isSSRFTarget('febf.service.net')).toBe(false);
});
});
describe('internal hostnames', () => {
@@ -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
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -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',
+3 -16
View File
@@ -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';
};
/**
+39 -2
View File
@@ -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>;
+2416 -154
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+2414 -156
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,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
View File
@@ -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>;
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>;
+7
View File
@@ -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>;
};
+2 -1
View File
@@ -21,7 +21,7 @@
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w",
"test": "jest --coverage --watch",
"test:ci": "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"
},
+1 -1
View File
@@ -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'],
};
+33 -4
View File
@@ -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');
});
+10 -7
View File
@@ -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),
+82 -18
View File
@@ -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 MongoSQLite 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 MongoSQLite 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);
+62
View File
@@ -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);
+302 -10
View File
@@ -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