maint: final follow-up cherry-pick #9812 (#9907)

* fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main

Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici)
applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies,
and mermaid.

npm audit: 6→0 vulnerabilities.
Closes Dependabot #161-#166.

* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* feat(telegram): Mini App chat bridge — initData auth, update webhook, chat proxy

Implements the Phase-1 slice of the Telegram Mini App integration
(docs/proposals/TELEGRAM-MINIAPP.md):

- src/lib/telegram/initData.ts — dependency-free WebApp initData HMAC-SHA256
  verification (Telegram Bot API spec), with auth_date freshness check.
- src/lib/telegram/config.ts — TELEGRAM_BOT_TOKEN / model / API base / timeout
  env config; token format validation; enabled gate.
- src/lib/telegram/botApi.ts — minimal fetch-based Bot API client
  (sendMessage, editMessageText, setWebhook) + update shape helpers.
- src/lib/telegram/chatProxy.ts — maps a Telegram user to a per-user
  OmniRoute API key (createApiKey, name telegram:<userId>) and proxies
  prompts through the existing handleChat pipeline.
- src/app/api/telegram/update/route.ts — inbound endpoint serving both the
  Bot API update webhook (/start + chat replies) and the Mini App direct
  path (initData HMAC verified → 401 on mismatch). Public route prefix;
  own auth only.
- src/app/miniapp/page.tsx — Telegram WebApp SDK chat UI.
- Tests: telegram-init-data (7), telegram-botapi (5) — 12/12 pass.
- Env docs: TELEGRAM_* vars in .env.example + ENVIRONMENT.md (sync ✓).
- Route-validation check: PASS (body validated via Zod).

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: benzntech <bensonkbmca@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-09 09:50:58 -03:00
committed by GitHub
parent 3d590c310b
commit 0bb17b91c6
14 changed files with 973 additions and 600 deletions

View File

@@ -67,14 +67,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Used by: src/shared/utils/rateLimiter.ts
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
# REDIS_URL=redis://localhost:6379
# Host interface docker-compose publishes the Redis sidecar on.
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
# `requirepass`, and app containers reach it over the compose network
# (redis:6379) — the published port is only for host-side tooling. Setting this
# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN.
# REDIS_BIND_HOST=127.0.0.1
# Host port for the compose Redis sidecar. Default: 6379.
# REDIS_PORT=6379
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
@@ -345,18 +337,14 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
# Optional opt-in hard message-count cap; excess receives compact-required 413 before
# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
# value only on memory-constrained deployments that need a hard ceiling.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
# Hard message-count cap; excess receives compact-required 413. Default 800.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast
# instead of growing an unbounded string until the V8 heap is exhausted.
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
# Default: 67108864 (64 MB)
# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
# CORS configuration — controls which cross-origin browser clients can call the API.
@@ -457,13 +445,6 @@ ALLOW_API_KEY_REVEAL=false
# Default: false
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
# Per-model concurrency cap for round-robin combos (#9100).
# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore
# was hard-capped at 3 concurrent requests per model with no override, which
# serialized higher-concurrency traffic behind that cap.
# Validated to >= 1, clamped to <= 32. | Default: 3
# COMBO_CONCURRENCY_PER_MODEL=3
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -797,16 +778,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Disable the proactive recovery scheduler entirely (default: false).
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in
# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not
# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip
# per-connection flags in settings.claudeWarmup.connections to activate.
# Used by: src/lib/warmupScheduler.ts.
# OMNIROUTE_WARMUP_ENABLED=false
# OMNIROUTE_WARMUP_CRON="0 7 * * *"
# OMNIROUTE_WARMUP_CONCURRENCY=3
# OMNIROUTE_WARMUP_MODEL=
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
@@ -871,12 +842,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a
# restart, or a retrieve landing on another instance, while the model is told it can retrieve them
# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over
# 512KB and cloud runtimes are memory-only regardless.
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true.
#COMPRESSION_CCR_DURABLE_STORE=true
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
@@ -1061,17 +1026,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# ── Raycast Pro (local auto-import) ──
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
# vars are optional manual overrides used by open-sse/services/raycast.ts
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
# RAYCAST_BEARER_TOKEN=
# RAYCAST_DEVICE_ID=
# RAYCAST_AID=
# RAYCAST_SIG_SECRET=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1212,17 +1166,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
# ── Proxy/relay fetch (connection pooling, #9158) ──
# Used by: open-sse/utils/proxyFetch.ts.
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
# caller sees a relay-specific failure instead of a generic upstream timeout.
# Capped at 29000ms so this timeout always fires first. Default: 25000 (25s).
# OMNIROUTE_RELAY_FETCH_TIMEOUT_MS=25000
# Shared retry backoff (ms) for the direct/relay/proxy retry-once paths.
# 0 = retry immediately. Default: 10.
# OMNIROUTE_RETRY_BACKOFF_MS=10
# ── Firecrawl web-fetch executor ──
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
# When set to a non-cloud base URL, the API key becomes optional.
@@ -1284,14 +1227,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_BROWSER_POOL=on
# WEB_COOKIE_USE_BROWSER=0
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
# user can sign in interactively; the executable is auto-detected from common
# install paths per OS. Set this to override that detection (e.g. a portable
# install or a non-standard path) when auto-detection fails.
# OMNIROUTE_LOGIN_BROWSER_PATH=
# ── Circuit breaker thresholds and reset windows ──
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
@@ -1403,10 +1338,6 @@ APP_LOG_TO_FILE=true
# Default: 100000
# CALL_LOGS_TABLE_MAX_ROWS=100000
# Force detailed request logging on or off, overriding the dashboard setting.
# Values: true | false | Default: unset (follow dashboard setting)
# ENABLE_REQUEST_LOGS=false
# Maximum age for orphaned active request log entries before the in-memory
# pending-request reaper removes them. Accepts milliseconds.
# Default: 3600000 (1 hour)
@@ -1483,6 +1414,10 @@ APP_LOG_TO_FILE=true
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
# OMNIROUTE_PLUGIN_PATH=
# Allow plugins to request the 'exec' permission (spawn child processes from the
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
@@ -1540,15 +1475,6 @@ APP_LOG_TO_FILE=true
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════
# Enable the models.dev capability sync. Default: false (opt-in only).
# Also settable from Dashboard > Settings > AI. This variable wins over that
# setting whenever it is set to anything non-empty, in either direction, so a
# deployment can pin the sync on or off without depending on database state
# surviving a rebuild. Leave it unset to let the dashboard toggle decide.
# On: 1, true, yes or on (any casing). Any other value is off.
# Used by: src/lib/modelsDevSync.ts
# MODELS_DEV_SYNC_ENABLED=false
# Development-time model catalog sync interval in seconds.
# Used by: src/lib/modelsDevSync.ts
# Default: 86400 (24 hours)
@@ -1571,14 +1497,6 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
# ON by default; set false to skip the background fetch entirely (#9324).
# Used by: src/lib/catalog/openrouterProviderStats.ts
# OPENROUTER_PROVIDER_STATS_ENABLED=true
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
# Default: 86400000 (24 hours)
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
@@ -1599,13 +1517,6 @@ APP_LOG_TO_FILE=true
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
# ── Adobe Firefly (Image Upscale) ──
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
# upscale job submission is rate-limited. Used by:
# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs.
# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT).
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
# ── AWS Bedrock (Kiro / Audio) ──
# Region used to construct AWS Bedrock endpoints. Used by:
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
@@ -1700,26 +1611,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Local hostnames (Docker networking) ──
# Comma-separated additional hostnames treated as "local" for provider routing.
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
@@ -1952,18 +1843,6 @@ APP_LOG_TO_FILE=true
# ── Devin CLI binary path ──
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
# CLI_DEVIN_BIN=devin
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
# CLI_DEVIN_AGENTIC_BIN=devin
# Required isolated HOME for the agentic Devin child process.
# DEVIN_AGENTIC_HOME=/home/bridge
# Bounded ACP turn timeout in milliseconds. Default: 120000.
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
# ── Command Code (custom CLI) callback ──
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
@@ -2018,15 +1897,6 @@ APP_LOG_TO_FILE=true
# CHANGELOG_BASE_REF=origin/release/v0.0.0
# ALLOW_CHANGELOG_REMOVALS=1
# ── Remote audio provider nodes ──
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*
# routes use an OpenAI-compatible provider node hosted outside localhost.
# OFF by default: routing audio to a remote host changes egress identity, so it
# must be an explicit operator decision. Loopback/private nodes (localhost,
# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag.
# When enabled, the node authenticates with the API key stored on its connection.
# AUDIO_REMOTE_PROVIDER_NODES=false
# ── 1Proxy egress pool ──
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
@@ -2234,11 +2104,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
# NOTION_API_KEY=
# NOTION_API_URL=
# OBSIDIAN_API_KEY=
# OBSIDIAN_API_URL=
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
@@ -2254,15 +2119,6 @@ INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Shared secret for identity-preserving internal REST hops (#9260): when an
# OmniRoute component calls another local OmniRoute route, this token (sent as
# x-omniroute-internal-service-token) marks the request as internal so the
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
# Used by: src/lib/api/internalServiceAuth.ts
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# File-based variant (secret-file pattern; wins only when the inline var is
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
@@ -2373,11 +2229,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
# already binds 6379. The container's internal port stays 6379.
# OMNIROUTE_REDIS_HOST_PORT=
# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1
# (loopback only). The launcher starts Redis WITHOUT a password, so binding
# 0.0.0.0 hands every host on your LAN an unauthenticated Redis — only widen
# this if you also set a password on the instance yourself.
# OMNIROUTE_REDIS_BIND_HOST=
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
# Override to redis:8-alpine or a private registry mirror as needed.
# OMNIROUTE_REDIS_IMAGE=
@@ -2480,37 +2331,20 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=
# ── Internal service auth (management-plane service-to-service calls) ─────────
# Inline token for internal service authentication; prefer the _FILE variant in
# containerized deployments so the secret never lands in the environment table.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# Path to a file containing the internal service token (overrides the inline var).
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# ─────────────────────────────────────────────────────────────────────────────
# Telegram Mini App (inbound bot webhook + Mini App chat)
# Used by: src/lib/telegram/*, src/app/api/telegram/update/route.ts
# ─────────────────────────────────────────────────────────────────────────────
# Bot token from @BotFather (<numeric_id>:<secret>). Enables the inbound
# update webhook and doubles as the HMAC secret for Mini App initData
# verification. When unset, /api/telegram/update returns 503.
# TELEGRAM_BOT_TOKEN=
# ═══════════════════════════════════════════════════════════════════════════════
# 26. RADAR FEED (SELF-HOSTING)
# ═══════════════════════════════════════════════════════════════════════════════
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
# settings, not an env var) that overlays a signed, freshly-curated free-model
# catalog on top of the release baseline. All four variables below are optional
# and only needed to point the client at a self-hosted/forked feed or
# supporter-key flow instead of the default OmniRoute Radar service. Used by:
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
# Model used for Telegram chat replies (default: auto/chat).
# TELEGRAM_DEFAULT_MODEL=auto/chat
# Base URL of the Radar feed service. Overrides the built-in default so forks
# and self-hosters can point at their own signed feed.
# RADAR_FEED_URL=https://radar.omniroute.online
# Bot API base URL override (for proxies/self-hosted Bot API servers).
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed
# signature, replacing the pinned default key. Required when self-hosting a
# feed signed with a different key pair.
# RADAR_FEED_PUBKEY=
# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth
# supporter-key claim flow). No pricing/value lives in this repo — only the
# link.
# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github
# URL the dashboard's "Support the project" button opens (payment/plans
# page). No pricing/value lives in this repo — only the link.
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
# Timeout (ms) for outbound Bot API calls (sendMessage/setWebhook).
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000

26
.gitignore vendored
View File

@@ -72,7 +72,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.devin-bridge.example
!.env.homolog.example
# Provider API keys (never commit)
*.api-key
@@ -172,6 +171,7 @@ config/quality/test-impact-map.json
# GitNexus local index
.gitnexus
.worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore
.omc/
@@ -201,17 +201,12 @@ scripts/i18n/_pending-keys.json
.codegraph/
# Fumadocs generated source
/.source/
# Temporary local worktrees used to build unpublished npm tarballs
/.deploy-build-*/
.source/
# AI agent local settings and configs
.agents/
.antigravitycli/
.claude/
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
# PR Reviews and local feedback files
pr_reviews*.json
@@ -238,10 +233,7 @@ omniroute.md
# mise configuration
mise.toml
# release-green artifacts (.gitignore has no inline comments — a trailing
# `# ...` becomes part of the pattern, so it must sit on its own line).
# Already covered by /_*/ above; kept explicit for discoverability.
_artifacts/
_artifacts/ # release-green artifacts
.claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets)
@@ -251,8 +243,6 @@ _artifacts/
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
.artifacts/
# Isolated Devin bridge workspaces, evidence, and test databases
.sandbox/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
@@ -260,12 +250,8 @@ tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
.playwright-cli/
# Playwright screenshot/log output. Today every artifact happens to land inside
# output/**/.playwright-cli/ (covered above), but anything written directly to
# output/ would otherwise show up as untracked.
/output/
# _tasks e um repo git SEPARADO (ver AGENTS.md). _tasks/ (com barra) NAO ignora um
# SYMLINK _tasks; /_tasks (ancorado) cobre symlink/dir na raiz (incidente 2026-08-08).
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
/_tasks

View File

@@ -43,7 +43,6 @@ lastUpdated: 2026-06-28
- [22. Debugging](#22-debugging)
- [23. GitHub Integration](#23-github-integration)
- [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380)
- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting)
- [Deployment Scenarios](#deployment-scenarios)
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
@@ -195,16 +194,14 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `0` (disabled) | `src/shared/middleware/chatBodyAdmission.ts` | Optional opt-in chat history cap. Disabled by default: a message count is deployment policy, not a universal property of a request, and capping here rejects conversations with a terminal `413` before the compression pipeline can make them servable. Heap growth is bounded by `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` and the heap-pressure shed. Set a positive value on memory-constrained deployments that need a hard ceiling; excess then receives structured compact-required `413`. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. |
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |
| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. |
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. |
| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) |
| `AUDIO_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/audioProviderNodes.ts` | Let the `/v1/audio/*` routes (transcriptions, speech, translations) use an OpenAI-compatible provider node hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. (#3963) |
### Hardening Checklist
@@ -268,7 +265,6 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. |
---
@@ -382,14 +378,6 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. |
| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. |
| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. |
| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. |
| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. |
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
@@ -427,6 +415,7 @@ detection above).
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. |
| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. |
---
@@ -463,7 +452,6 @@ detection above).
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
| `COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR` | `2` | `open-sse/services/compression/engines/ccr/index.ts` | T08/H8 CCR retrieval-feedback ramp: each prior retrieval of a stored block raises its effective `minChars` linearly (frequently-retrieved content compresses less; `>=3` retrievals = never compressed). `1` disables the ramp (binary skip at the threshold only). |
| `COMPRESSION_CCR_DURABLE_STORE` | `true` | `open-sse/services/compression/engines/ccr/index.ts` | CCR durable block store (#9061). Backs the in-memory store with SQLite so a block survives LRU eviction, the TTL, a restart, or a retrieve landing on another instance. Set `false` to keep blocks in memory only. Blocks over 512KB and cloud runtimes stay memory-only regardless. |
| `COMPRESSION_PREFIX_FREEZE_ENABLED` | `false` | `open-sse/services/compression/prefixFreeze.ts` | T08/H5 usage-observed prefix freeze master switch. **Opt-in (default off)** — when on, a system prompt observed `>=` the threshold is treated as a stable cacheable prefix and preserved from compression even for providers the static cache heuristic misses (freeze only *preserves*, never mutates). |
| `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. |
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
@@ -519,12 +507,8 @@ Built-in credentials for **localhost development**. For remote deployments, regi
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
| `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). |
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY`#1350) so `REQUIRE_API_KEY=true` deployments work. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. |
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. |
| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. |
| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. |
| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. |
> [!WARNING]
>
@@ -672,8 +656,6 @@ REQUEST_TIMEOUT_MS (global override)
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. |
@@ -687,7 +669,6 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. |
Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or
`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo,
@@ -735,7 +716,6 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. |
| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. |
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
| `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. |
| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. |
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. |
| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. |
@@ -781,13 +761,9 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). |
| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. |
| `MEMORY_STATIC_CACHE_DIR` | `<DATA_DIR>/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. |
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
@@ -854,7 +830,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| Variable | Default | Source File | Description |
| ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. |
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
| `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. |
@@ -870,7 +845,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. |
| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. |
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. |
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
@@ -892,10 +866,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
`ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients
@@ -1176,13 +1146,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |
@@ -1208,17 +1171,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). |
| `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. |
### Claude Warmup Scheduler
Cron-driven warmup for opted-in Anthropic OAuth connections, so the 5-hour rate-limit window is opened by a trivial scheduled request instead of by the first real one (#8848). The scheduler is off unless `OMNIROUTE_WARMUP_ENABLED` is truthy **and** the connection is flagged in `settings.claudeWarmup.connections`; an empty connection list means nothing is warmed even with the env var on.
| Variable | Default | Source File | Description |
| ----------------------------- | -------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_WARMUP_ENABLED` | _(unset → off)_ | `src/lib/warmupScheduler.ts` | Master switch for the warmup scheduler. Accepts `1`/`true`/`yes`/`on` (case-insensitive, trimmed). Any other value, or unset, leaves the scheduler off. |
| `OMNIROUTE_WARMUP_CRON` | `0 7 * * *` | `src/lib/warmupScheduler.ts` | Five-field cron expression for the warmup tick, evaluated in `America/Los_Angeles` (Anthropic's reset timezone) regardless of the host clock. |
| `OMNIROUTE_WARMUP_CONCURRENCY` | `3` | `src/lib/warmupScheduler.ts` | How many connections are warmed in parallel per tick. Clamped to `1`-`10`; a non-numeric value falls back to `3`. |
| `OMNIROUTE_WARMUP_MODEL` | `claude-3-5-haiku-20241022` | `src/lib/warmupScheduler.ts` | Model used for the warmup request. Override only if the default is unavailable on your plan; pick the cheapest model that still opens the window. |
### Browser-Login VNC Sessions & Data-Dir Alias
Containerized Chromium+VNC used for interactive browser-login credential capture (`/api/vnc-session`), plus a legacy `DATA_DIR` alias. All optional — the VNC defaults target the bundled `omniroute-vnc-chromium:local` image and are only overridden for a custom container image, ports, or lifecycle tuning.
@@ -1283,25 +1235,6 @@ that should be able to run the docs translator.
---
## 27. Radar Feed (Self-Hosting)
Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature
flag toggled via Settings/DB, not an env var; see
[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)).
The four variables below are optional overrides used only to point the client at a
self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
module doc.
| Variable | Default | Source File | Description |
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). |
| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). |
---
## Audit: Removed / Dead Variables
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
@@ -1369,24 +1302,13 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Internal service auth
### Telegram Mini App
| Variable | Default | Description |
| --- | --- | --- |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | | Inline token for management-plane service-to-service authentication. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | | Path to a file containing the internal service token (preferred in containers; overrides the inline variable). |
Used by `src/lib/telegram/*` and `src/app/api/telegram/update/route.ts` for the inbound bot webhook and Mini App chat proxy. All optional — the endpoint returns 503 when `TELEGRAM_BOT_TOKEN` is unset.
### OpenRouter provider stats
| Variable | Default | Description |
| --- | --- | --- |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | Set to `false` to skip fetching OpenRouter per-provider stats for catalog enrichment. |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `3600000` | Cache TTL (ms) for the fetched OpenRouter provider stats. |
### Embedded Redis binding
| Variable | Default | Description |
| --- | --- | --- |
| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. |
| `REDIS_PORT` | `6379` | Port for the embedded Redis service. |
| `OMNIROUTE_REDIS_BIND_HOST` | | OmniRoute-scoped override for the embedded Redis bind address. |
| Variable | Default | Source File | Description |
| ------------------------------ | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | Bot token from @BotFather (`<numeric_id>:<secret>`). Enables the inbound webhook; doubles as the HMAC secret for Mini App `initData` verification. |
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override (proxies / self-hosted Bot API servers). |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout (ms) for outbound Bot API calls (`sendMessage`/`setWebhook`). |

385
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.8.50",
"version": "3.8.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.8.50",
"version": "3.8.49",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -80,7 +80,7 @@
"sqlite-vec": "^0.1.9",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"undici": "^8.10.0",
"undici": "^8.3.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.18.0",
@@ -133,7 +133,6 @@
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
@@ -153,7 +152,7 @@
"@atjsh/llmlingua-2": "2.0.3",
"@huggingface/transformers": "3.5.2",
"@tensorflow/tfjs": "4.22.0",
"better-sqlite3": "^13.0.2",
"better-sqlite3": "^13.0.1",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
@@ -3693,6 +3692,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3709,6 +3711,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3725,6 +3730,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3741,6 +3749,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3757,6 +3768,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3773,6 +3787,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3789,6 +3806,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3805,6 +3825,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3821,6 +3844,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3843,6 +3869,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3865,6 +3894,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3887,6 +3919,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3909,6 +3944,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3931,6 +3969,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3953,6 +3994,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3975,6 +4019,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5346,6 +5393,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5362,6 +5412,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5378,6 +5431,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5394,6 +5450,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10611,6 +10670,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10628,6 +10690,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10645,6 +10710,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10662,6 +10730,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -12708,6 +12779,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12721,6 +12795,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12734,6 +12811,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12747,6 +12827,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12760,6 +12843,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12773,6 +12859,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -13598,14 +13687,11 @@
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -13670,9 +13756,10 @@
}
},
"node_modules/better-sqlite3": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz",
"integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==",
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz",
"integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -13954,16 +14041,14 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
@@ -24392,25 +24477,6 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/libxmljs2/node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
@@ -26883,24 +26949,6 @@
"node": "*"
}
},
"node_modules/minimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -28739,205 +28787,6 @@
}
}
},
"node_modules/opencode-ai": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.8.tgz",
"integrity": "sha512-eZvYK0rIc/NUDQ+s3LsO9gyUU3MswsbNOLZz06iPwVhbg/2jF6bkTaroBgiIdFWKwUn5sj+kSMc4TBYxFkMrNQ==",
"cpu": [
"arm64",
"x64"
],
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"opencode": "bin/opencode.exe"
},
"optionalDependencies": {
"opencode-darwin-arm64": "1.18.8",
"opencode-darwin-x64": "1.18.8",
"opencode-darwin-x64-baseline": "1.18.8",
"opencode-linux-arm64": "1.18.8",
"opencode-linux-arm64-musl": "1.18.8",
"opencode-linux-x64": "1.18.8",
"opencode-linux-x64-baseline": "1.18.8",
"opencode-linux-x64-baseline-musl": "1.18.8",
"opencode-linux-x64-musl": "1.18.8",
"opencode-windows-arm64": "1.18.8",
"opencode-windows-x64": "1.18.8",
"opencode-windows-x64-baseline": "1.18.8"
}
},
"node_modules/opencode-darwin-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.8.tgz",
"integrity": "sha512-ZZCIEgTvHxOHk52Aeqhq59t/R0aqs29bPIgu45XE4rkgjmn/XCkTWalCPtyzJHipdcEbq/g0lqsE1OlJV0oNbA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.8.tgz",
"integrity": "sha512-2EXRMJbRKnFPWI9oDU9tb7jDGmKiPmfjCLtwJMe3EF57h5wfcdEH9sP25bR3Og5NbE2M+PtMcJm0jMeHn2XoLQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.8.tgz",
"integrity": "sha512-eLXa2tK9LRuZ5e20QG2k4dmWAA5xnLgJ1afRTSD0/ybE6CAeK02i8vFCnFFDaxuBo+gnq+yqO8AkqvN1m64V/Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-linux-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.8.tgz",
"integrity": "sha512-7kj3c9JEdryHgK+o8zE/N9KzTOdbiDn6KpY8dl+hM9n5Cnmxezx4IAlgJeC9QxpIx8Omop6CYuZ+17KfrKdKLw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-arm64-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.8.tgz",
"integrity": "sha512-tww5TF/LIOv/GoTNyzGYgqDRhbJrhoMu8R+p5yD/SpnXPg3rcfYREw2wRy9yikyPU9sAQksuIIteTsyGerPjlA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.8.tgz",
"integrity": "sha512-Sm4fbQ9BdLI6hgN6FYYX8Nql+Sqe/2EKHJu3iWg0UYs93AXN4ROi0rvOmRbMk+ycYgOchb0hL6Ti2opxLx17sg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.8.tgz",
"integrity": "sha512-egeEF4tk1rK9flIQjjeSVB9cR/X3zUti0pNAHW6ROJkNkj72z2C2FmjK1hZbfjtteCueMXPLptS23JROHGWL1w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.8.tgz",
"integrity": "sha512-S+438BXs48gLeXX/ya4TSNytDy9mliU3sOAf6j9rfFjzGiF/S08LedemSAnHkr0riBtamik1aRPSmTjhQ0dOBg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.8.tgz",
"integrity": "sha512-c+E4Zsp0DYVcuqcDtgxw/4YcFLrVYWdGBR8x4CzpW48ga3RshaH+BlmUiy+GY0yr1x6UR+e2V3w4uzvzm/L9UQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-windows-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.8.tgz",
"integrity": "sha512-7NjdtEIiX28kmsKD9jHbFG4bbwBB5T4dAe2UwdnOqCBb2cl+ETV5eO6kbdC/xWxrgOghgZM1Wtw791T5pQPyag==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.8.tgz",
"integrity": "sha512-G+NEgEMvu/dEYshH5IaqHVTmsHVuGdORBvVmgphFiknT7q/NXPuoZCMtMIdfNlEFbu54BlzRDdJCR3Mqe98gUw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.8.tgz",
"integrity": "sha512-IGbjFyWoSN9rdGUJX7TWkQ1Yl673Q3dDna54b5NtqeRcZ839p+Z47zzM5m883HKAxrdKCC6Z22HYuDcXLV0laA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -32174,13 +32023,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
@@ -35155,9 +34997,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
@@ -36948,7 +36790,12 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.8.50"
"version": "3.8.49",
"dependencies": {
"@toon-format/toon": "^4.1.0",
"safe-regex": "^2.1.1",
"smol-toml": "1.7.1"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "omniroute",
"version": "3.8.50",
"description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"version": "3.8.49",
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs",
@@ -23,7 +23,6 @@
".env.example",
"scripts/build/postinstall.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/fixPlaywrightAndroid.mjs",
"bin/cli/runtime/",
"scripts/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
@@ -34,15 +33,11 @@
"scripts/dev/tls-options.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/dev/sync-env.mjs",
"scripts/build/assembleStandalone.mjs",
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-next-isolated.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/native-binary-compat.mjs",
"scripts/build/build-next-isolated.mjs",
"scripts/build/runtime-env.mjs",
"README.md",
"LICENSE",
"!**/node_modules/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
@@ -115,8 +110,6 @@
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:scoped": "bash scripts/quality/test-scoped.sh",
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
@@ -150,7 +143,6 @@
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
"check:pack-boot": "node scripts/check/check-pack-boot.mjs",
"check:install-upgrade": "node scripts/check/check-install-upgrade.mjs",
"check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only",
"check:cli-i18n": "node scripts/check/check-cli-i18n.mjs",
"check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs",
@@ -169,7 +161,6 @@
"check:test-masking": "node scripts/check/check-test-masking.mjs",
"check:test-runner-api": "node scripts/check/check-test-runner-api.mjs",
"check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs",
"sweep:stale-fragments": "node scripts/release/sweep-stale-fragments.mjs",
"changelog:aggregate": "node scripts/release/aggregate-changelog.mjs",
"check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs",
"check:build-scope": "node scripts/check/check-build-scope.mjs",
@@ -193,7 +184,6 @@
"check:bundle-size": "node scripts/check/check-bundle-size.mjs",
"check:circular-deps": "node scripts/check/check-circular-deps.mjs",
"check:mutation-ratchet": "node scripts/check/check-mutation-ratchet.mjs",
"check:rtl-ratchet": "node scripts/check/check-rtl-ratchet.mjs",
"check:licenses": "node scripts/check/check-licenses.mjs",
"check:pr-evidence": "node scripts/check/check-pr-evidence.mjs",
"check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs",
@@ -210,11 +200,9 @@
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
"check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs",
"check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs",
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
"env:sync": "node scripts/dev/sync-env.mjs",
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"",
"test:combo:live:vps": "node scripts/test/combo-live-vps.mjs",
@@ -244,7 +232,6 @@
"prepare": "husky",
"system-info": "node scripts/dev/system-info.mjs",
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs",
"postbuild": "node scripts/build/colocate-standalone.mjs",
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
@@ -318,7 +305,7 @@
"sqlite-vec": "^0.1.9",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"undici": "^8.10.0",
"undici": "^8.3.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.18.0",
@@ -331,7 +318,7 @@
"@atjsh/llmlingua-2": "2.0.3",
"@huggingface/transformers": "3.5.2",
"@tensorflow/tfjs": "4.22.0",
"better-sqlite3": "^13.0.2",
"better-sqlite3": "^13.0.1",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
@@ -377,7 +364,6 @@
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
@@ -409,15 +395,6 @@
"sharp"
]
},
"allowScripts": {
"better-sqlite3": true,
"esbuild": true,
"@swc/core": true,
"@parcel/watcher": true,
"keytar": true,
"protobufjs": true,
"unrs-resolver": true
},
"overrides": {
"fast-xml-parser": "^5.10.1",
"sharp": "^0.35.0",
@@ -448,14 +425,27 @@
"adm-zip": "^0.6.0",
"promptfoo": {
"js-yaml": "^5.2.2",
"@apidevtools/json-schema-ref-parser": {
"js-yaml": "^4.3.1"
},
"undici": "^7.29.0"
},
"socket.io-parser": "^4.2.7",
"tar": "^7.5.21",
"nanoid": "^3.3.17",
"brace-expansion": "^5.0.9",
"minimatch": {
"brace-expansion": "^1.1.18"
},
"libxmljs2": {
"minimatch": {
"brace-expansion": "^2.1.4"
}
},
"rimraf": {
"minimatch": {
"brace-expansion": "^2.1.4"
}
},
"@apidevtools/json-schema-ref-parser": {
"js-yaml": "^4.3.1"
},
"@eslint/eslintrc": {
"js-yaml": "^4.3.1"
},
@@ -465,11 +455,9 @@
"xmlbuilder2": {
"js-yaml": "^4.3.1"
},
"nanoid": "^3.3.17",
"monaco-editor": {
"dompurify": "^3.4.13"
},
"@apidevtools/json-schema-ref-parser": {
"js-yaml": "^4.3.1"
}
}
}

View File

@@ -0,0 +1,160 @@
/**
* Telegram Bot API update webhook + Mini App proxy.
*
* Two callers share this endpoint:
* 1. Telegram POSTs bot updates here when the bot's webhook is registered
* to {publicBase}/api/telegram/update (shape: TelegramUpdate).
* 2. The Mini App frontend POSTs { initData, message } directly; the
* initData HMAC is verified server-side before proxying.
*
* The route:
* 1. Rejects when TELEGRAM_BOT_TOKEN is unset (never silently no-op).
* 2. Verifies initData when present (Mini App path).
* 3. Handles /start (returns the Mini App deep link) and everything else
* as a chat prompt proxied through the OmniRoute pipeline.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import type { TelegramUpdate } from "@/lib/telegram/botApi";
import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi";
import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config";
import { verifyInitData, parseInitData } from "@/lib/telegram/initData";
import { proxyChat } from "@/lib/telegram/chatProxy";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
/**
* Telegram update bodies are open-ended (many update types, evolving schema),
* so validation is deliberately loose: a JSON object with optional string
* fields for the two paths we handle. The initData HMAC check (Mini App path)
* and bot-token gate (webhook path) provide the real security.
*/
const telegramBodySchema = z
.object({
initData: z.string().optional(),
message: z.string().optional(),
update_id: z.number().optional(),
// allow unknown update fields
})
.passthrough();
/** Pull the numeric Telegram user id out of a verified initData string. */
function extractInitDataUserId(initData: string): number {
try {
const parsed = parseInitData(initData);
const userRaw = parsed["user"];
if (userRaw) {
const user = JSON.parse(userRaw) as { id?: number };
if (typeof user.id === "number" && user.id > 0) return user.id;
}
} catch {
// fall through to default
}
return 0;
}
function buildMiniAppLink(botUsername?: string): string {
const base = resolveOmniRouteBaseUrl();
// Deep link: t.me/<bot>?startapp= opens the Mini App with start_param.
const bot = botUsername || "YOUR_BOT";
return `https://t.me/${bot}?startapp=miniapp`;
}
const START_HELP =
"👋 Welcome! This bot bridges Telegram and your OmniRoute gateway.\n\n" +
"• Send any message and I'll route it through your configured models.\n" +
"• Open the Mini App for a full chat UI.";
export async function POST(request: Request) {
if (!isTelegramEnabled()) {
return NextResponse.json({ ok: false, error: "Telegram not configured" }, { status: 503 });
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const validation = validateBody(telegramBodySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ ok: false, error: "Invalid request" }, { status: 400 });
}
const body = validation.data as Record<string, unknown>;
// ── Mini App direct path: { initData, message } ──────────────────────────
const initData = typeof body.initData === "string" ? body.initData : "";
if (initData) {
const botToken = getTelegramBotToken();
if (!verifyInitData(initData, botToken)) {
return NextResponse.json({ ok: false, error: "Invalid initData signature" }, { status: 401 });
}
const message = typeof body.message === "string" ? body.message : "";
if (!message.trim()) {
return NextResponse.json({ ok: false, error: "message is required" }, { status: 400 });
}
// Resolve the Telegram user id from the verified initData for key mapping.
const telegramUserId = extractInitDataUserId(initData);
// Proxy synchronously and return the reply (Mini App awaits the fetch).
const reply = await proxyChat(telegramUserId, message);
return NextResponse.json({ ok: true, reply: reply || "⚠️ Empty gateway response." });
}
// ── Bot webhook path: TelegramUpdate ─────────────────────────────────────
const update = body as unknown as TelegramUpdate;
const chat = extractChatMessage(update);
if (!chat) {
// Non-message updates (callback_query etc.) — acknowledge silently.
return NextResponse.json({ ok: true });
}
// Fire-and-forget reply: Telegram retries on 5xx, so always 200 after
// enqueueing the reply. Keep the handler non-blocking.
void handleAndReply(chat.chatId, chat.text, chat.messageId);
return NextResponse.json({ ok: true });
}
async function handleAndReply(chatId: number, text: string, messageId?: number): Promise<void> {
try {
const trimmed = text.trim();
if (trimmed === "/start" || trimmed === "/start@") {
const link = buildMiniAppLink();
await sendTelegramMessage({
chat_id: chatId,
text: `${START_HELP}\n\n🚀 Open the Mini App: ${link}`,
parse_mode: "Markdown",
});
return;
}
// Strip bot-command prefixes that aren't /start (e.g. /help).
if (trimmed.startsWith("/")) {
await sendTelegramMessage({
chat_id: chatId,
text: "Unsupported command. Try /start or just send a message.",
reply_to_message_id: messageId,
});
return;
}
const answer = await proxyChat(chatId, trimmed);
const reply = answer || "⚠️ The gateway returned an empty response.";
await sendTelegramMessage({
chat_id: chatId,
text: reply.length > 4096 ? `${reply.slice(0, 4090)}` : reply,
parse_mode: "Markdown",
reply_to_message_id: messageId,
});
} catch (err) {
try {
await sendTelegramMessage({
chat_id: chatId,
text: `⚠️ Gateway error: ${(err as Error)?.message || "unknown"}`,
});
} catch {
// Nothing more we can do — the reply channel is down.
}
}
}

169
src/app/miniapp/page.tsx Normal file
View File

@@ -0,0 +1,169 @@
"use client";
/**
* Telegram Mini App — minimal chat UI.
*
* Opens inside Telegram via the WebApp SDK (deep link / inline button).
* Talks to the bot backend at /api/telegram/update with initData attached;
* the backend verifies the HMAC signature server-side.
*/
import { useEffect, useRef, useState } from "react";
declare global {
interface Window {
Telegram?: {
WebApp?: {
ready: () => void;
initData: string;
initDataUnsafe?: {
user?: { id: number; first_name?: string; username?: string };
};
close: () => void;
setHeaderColor?: (c: string) => void;
};
};
}
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
}
const STREAM_URL = "/api/telegram/update";
export default function TelegramMiniApp() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [initData, setInitData] = useState("");
const [error, setError] = useState("");
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
setInitData(tg.initData || "");
const user = tg.initDataUnsafe?.user;
if (user) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: `👋 Hi ${user.first_name || "there"}! Send a message to chat through your OmniRoute gateway.`,
},
]);
}
} else {
setError("This page must be opened inside the Telegram Mini App.");
}
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function send() {
const text = input.trim();
if (!text || busy) return;
setInput("");
setBusy(true);
setMessages((prev) => [...prev, { role: "user", content: text }]);
try {
const res = await fetch(STREAM_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData,
message: text,
}),
});
const data = (await res.json().catch(() => null)) as {
reply?: string;
error?: string;
} | null;
const reply = data?.reply || data?.error || "⚠️ No reply from gateway.";
setMessages((prev) => [...prev, { role: "assistant", content: reply }]);
} catch (err) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: `⚠️ Network error: ${(err as Error).message}` },
]);
} finally {
setBusy(false);
}
}
return (
<main
style={{
maxWidth: 480,
margin: "0 auto",
padding: 16,
display: "flex",
flexDirection: "column",
minHeight: "100vh",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<h1 style={{ fontSize: 18, margin: "0 0 12px" }}>OmniRoute Mini App</h1>
{error && <p style={{ color: "#e5484d", fontSize: 13, margin: "0 0 12px" }}>{error}</p>}
<div style={{ flex: 1, overflowY: "auto", marginBottom: 12 }}>
{messages.map((m, i) => (
<div
key={i}
style={{
margin: "6px 0",
padding: "8px 12px",
borderRadius: 12,
background: m.role === "user" ? "#10a37f" : "#f0f0f0",
color: m.role === "user" ? "#fff" : "#111",
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{m.content}
</div>
))}
<div ref={bottomRef} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Ask anything…"
disabled={busy}
style={{
flex: 1,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid #ccc",
fontSize: 15,
}}
/>
<button
onClick={send}
disabled={busy || !input.trim()}
style={{
padding: "10px 18px",
borderRadius: 10,
border: "none",
background: "#10a37f",
color: "#fff",
fontSize: 15,
cursor: busy ? "default" : "pointer",
}}
>
{busy ? "…" : "Send"}
</button>
</div>
</main>
);
}

111
src/lib/telegram/botApi.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Minimal Telegram Bot API client — the two calls a Mini App backend needs.
*
* Deliberately tiny (fetch-based, no SDK dependency): sendMessage for chat
* replies and setWebhook for webhook registration. Streaming is emulated
* by the caller via progressive edits (sendMessage / editMessageText).
*/
import { getTelegramBotApiBase, getTelegramBotToken, getTelegramWebhookTimeoutMs } from "./config";
export interface TelegramSendMessageParams {
chat_id: number | string;
text: string;
parse_mode?: "Markdown" | "HTML";
reply_to_message_id?: number;
disable_web_page_preview?: boolean;
}
export interface TelegramEditMessageParams {
chat_id: number | string;
message_id: number;
text: string;
parse_mode?: "Markdown" | "HTML";
}
export interface TelegramUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
}
export interface TelegramMessage {
message_id: number;
chat: { id: number; type: string };
text?: string;
from?: TelegramUser;
}
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
// Mini App payloads arrive as callback_query or message.web_app_data;
// the common shape is message.text (commands) — start with those.
callback_query?: {
id: string;
from: TelegramUser;
message?: TelegramMessage;
data?: string;
};
}
async function botFetch<T>(method: string, body: unknown): Promise<T> {
const token = getTelegramBotToken();
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is not set");
const url = `${getTelegramBotApiBase()}/bot${token}/${method}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(getTelegramWebhookTimeoutMs()),
});
const json = (await res.json().catch(() => null)) as {
ok?: boolean;
description?: string;
result?: T;
} | null;
if (!res.ok || !json?.ok) {
throw new Error(`Telegram API ${method} failed: ${json?.description || res.status}`);
}
return json.result as T;
}
export async function sendTelegramMessage(
params: TelegramSendMessageParams
): Promise<TelegramMessage> {
return botFetch<TelegramMessage>("sendMessage", params);
}
export async function editTelegramMessage(
params: TelegramEditMessageParams
): Promise<TelegramMessage> {
return botFetch<TelegramMessage>("editMessageText", params);
}
/**
* Register (or unregister) the bot webhook. Returns the Bot API result.
* Call this once per deployment (e.g. a CLI command or startup when
* TELEGRAM_WEBHOOK_URL is set).
*/
export async function setTelegramWebhook(
url: string | null,
opts: { dropPending?: boolean } = {}
): Promise<{ url: string; pending_update_count?: number }> {
if (url) {
return botFetch("setWebhook", { url, drop_pending_updates: opts.dropPending ?? true });
}
return botFetch("deleteWebhook", { drop_pending_updates: opts.dropPending ?? true });
}
/** Extract a chat id + text from any update shape we care about. */
export function extractChatMessage(update: TelegramUpdate): {
chatId: number;
text: string;
messageId?: number;
} | null {
const msg = update.message;
if (msg?.chat && typeof msg.text === "string") {
return { chatId: msg.chat.id, text: msg.text, messageId: msg.message_id };
}
return null;
}

View File

@@ -0,0 +1,106 @@
/**
* Telegram → OmniRoute chat proxy.
*
* Turns a plain Telegram message into a chat.completions call through the
* existing handleChat pipeline and returns the assistant text. Non-streaming
* for Phase 1 (Telegram has no native SSE); streaming is emulated later via
* progressive editMessageText.
*
* Auth model: each Telegram user is mapped to a generated OmniRoute API key
* (createApiKey) so the existing policy/rate-limit/model-allowlist machinery
* applies unchanged. The key is cached in-memory per user id.
*/
import { handleChat } from "@/sse/handlers/chat";
import { createApiKey, getApiKeys } from "@/lib/db/apiKeys";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { randomUUID } from "node:crypto";
const DEFAULT_MODEL = process.env.TELEGRAM_DEFAULT_MODEL || "auto/chat";
/**
* Resolve (and lazily mint) an OmniRoute API key for a Telegram user.
* Returns the plaintext key value, cached per user id.
*/
const keyCache = new Map<number, string>();
export async function resolveUserApiKey(telegramUserId: number): Promise<string> {
const cached = keyCache.get(telegramUserId);
if (cached) return cached;
const machineId = (await getConsistentMachineId().catch(() => null)) || "0000000000000000";
// Reuse an existing key whose name matches, else mint one.
const existing = await getApiKeys();
const match = existing?.find(
(k) =>
(k as { name?: string }).name === `telegram:${telegramUserId}` &&
typeof (k as { key?: string }).key === "string" &&
((k as { key?: string }).key?.length ?? 0) > 0
);
const matchKey = (match as { key?: string } | undefined)?.key;
if (typeof matchKey === "string" && matchKey.length > 0) {
keyCache.set(telegramUserId, matchKey);
return matchKey;
}
const created = await createApiKey(`telegram:${telegramUserId}`, machineId);
keyCache.set(telegramUserId, created.key);
return created.key;
}
function buildChatRequest(apiKey: string, prompt: string, model: string): Request {
const body = JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
});
const headers = new Headers({
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
});
return new Request("http://127.0.0.1/v1/chat/completions", {
method: "POST",
headers,
body,
});
}
/** Extract plain assistant text from a handleChat Response (stream or not). */
async function extractResponseText(response: Response): Promise<string> {
if (!response) return "";
if (response.body) {
// Non-streaming JSON: {"choices":[{"message":{"content": "..."}}]}
try {
const text = await response.text();
const json = JSON.parse(text) as {
choices?: Array<{ message?: { content?: string }; text?: string }>;
error?: { message?: string };
};
if (json.error?.message) return `⚠️ ${json.error.message}`;
const choice = json.choices?.[0];
return choice?.message?.content ?? choice?.text ?? "";
} catch {
return "";
}
}
return "";
}
/**
* Proxy one user prompt through the OmniRoute chat pipeline.
* @returns assistant text (may be empty on failure)
*/
export async function proxyChat(
telegramUserId: number,
prompt: string,
model = DEFAULT_MODEL
): Promise<string> {
if (!prompt?.trim()) return "";
const apiKey = await resolveUserApiKey(telegramUserId);
const request = buildChatRequest(apiKey, prompt.trim(), model);
const response = await handleChat(request, null, null);
return extractResponseText(response);
}
export { DEFAULT_MODEL };
export { randomUUID };

View File

@@ -0,0 +1,30 @@
/**
* Telegram Mini App configuration.
*
* The bot token is read from the environment (TELEGRAM_BOT_TOKEN) so it is
* never stored in the DB or committed. It doubles as the HMAC secret for
* initData verification (see ./initData.ts).
*/
const DEFAULT_WEBHOOK_TIMEOUT_MS = 60_000;
/** Telegram bot token format: <numeric_id>:<alphanumeric_secret> (min 35 chars after colon). */
const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
export function getTelegramBotToken(): string {
return process.env.TELEGRAM_BOT_TOKEN || "";
}
export function isTelegramEnabled(): boolean {
return BOT_TOKEN_RE.test(getTelegramBotToken());
}
export function getTelegramWebhookTimeoutMs(): number {
const raw = process.env.TELEGRAM_WEBHOOK_TIMEOUT_MS;
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_WEBHOOK_TIMEOUT_MS;
}
export function getTelegramBotApiBase(): string {
return process.env.TELEGRAM_BOT_API_BASE || "https://api.telegram.org";
}

View File

@@ -0,0 +1,75 @@
/**
* Telegram WebApp initData verification.
*
* A Telegram Mini App authenticates by passing `initData` (from the
* Telegram.WebApp SDK's `initData` property) to its backend. The only
* trustworthy anchor is the `hash` field: an HMAC-SHA256 over the sorted
* `key=value` pairs (minus `hash`), keyed with SHA256 of the bot token.
*
* Reference: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
*
* This module is pure and dependency-free (node:crypto only) so it is
* directly unit-testable. Never trust the client-side `initData` alone —
* verification MUST happen server-side.
*/
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
/** Parse a URLSearchParams-style initData string into a record. */
export function parseInitData(initData: string): Record<string, string> {
const out: Record<string, string> = {};
if (!initData) return out;
for (const pair of initData.split("&")) {
const eq = pair.indexOf("=");
if (eq <= 0) continue;
const key = decodeURIComponent(pair.slice(0, eq));
const value = decodeURIComponent(pair.slice(eq + 1));
if (key && !(key in out)) out[key] = value;
}
return out;
}
/**
* Verify a Telegram WebApp initData string against the bot token.
*
* @param initData raw initData string from the Mini App (or `initDataUnsafe` reconstruction)
* @param botToken Telegram bot token (`<id>:<secret>`) — the HMAC secret source
* @param maxAgeSec optional freshness bound on `auth_date` (default 24h per Telegram docs)
* @returns true when the signature matches AND (if maxAgeSec set) auth_date is fresh
*/
export function verifyInitData(
initData: string,
botToken: string,
maxAgeSec = 24 * 60 * 60
): boolean {
if (!initData || !botToken) return false;
const data = parseInitData(initData);
const providedHash = data["hash"];
if (!providedHash) return false;
// Optional freshness check on auth_date (unix seconds).
if (maxAgeSec > 0) {
const authDate = Number.parseInt(data["auth_date"] ?? "", 10);
if (!Number.isFinite(authDate) || authDate <= 0) return false;
const now = Math.floor(Date.now() / 1000);
if (now - authDate > maxAgeSec) return false;
}
// Rebuild the data-check string: sorted key=value pairs, excluding hash.
const pairs = Object.entries(data)
.filter(([k]) => k !== "hash")
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`);
const dataCheckString = pairs.join("\n");
// secret_key = HMAC_SHA256(key="WebAppData", bot_token)
const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
// expected_hash = HMAC_SHA256(secret_key, data_check_string) hex
const expectedHash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
const provided = Buffer.from(providedHash, "utf8");
const expected = Buffer.from(expectedHash, "utf8");
if (provided.length !== expected.length) return false;
return timingSafeEqual(provided, expected);
}

View File

@@ -25,6 +25,11 @@ const PUBLIC_API_ROUTE_PREFIXES = [
// collect/chaos/route.ts. Do not widen this prefix to cover other
// /api/skills/collect/* routes without the same per-handler auth.
"/api/skills/collect/chaos",
// Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates
// here without any dashboard cookie/API key; the handler enforces its own
// auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData
// HMAC). See src/app/api/telegram/update/route.ts. Do not widen.
"/api/telegram/",
];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [

View File

@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { extractChatMessage } from "../../src/lib/telegram/botApi";
import { verifyInitData } from "../../src/lib/telegram/initData";
const BOT_TOKEN = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
function buildValidInitData(botToken: string, fields: Record<string, string>): string {
const pairs = Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n");
const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
const hash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
const withHash = [...pairs, ["hash", hash]];
return withHash.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
}
test("extractChatMessage returns chatId/text/messageId for a text message", () => {
const chat = extractChatMessage({
update_id: 1,
message: {
message_id: 42,
chat: { id: 123456789, type: "private" },
text: "/start",
from: { id: 123456789, first_name: "Benson" },
},
});
assert.deepEqual(chat, { chatId: 123456789, text: "/start", messageId: 42 });
});
test("extractChatMessage returns null for non-message updates", () => {
const chat = extractChatMessage({ update_id: 2, callback_query: { id: "q", from: { id: 1 } } });
assert.equal(chat, null);
});
test("extractChatMessage returns null when text is missing", () => {
const chat = extractChatMessage({
update_id: 3,
message: { message_id: 1, chat: { id: 1, type: "private" } },
});
assert.equal(chat, null);
});
test("Mini App initData with real user payload verifies end-to-end", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
query_id: "AAHdF6IQAAAAAN0XohDhrOrc",
user: '{"id":279058397,"first_name":"Benson","last_name":"KB","username":"benzntech"}',
});
assert.equal(verifyInitData(initData, BOT_TOKEN), true);
// The same initData must fail with a different token (route would 401).
assert.equal(verifyInitData(initData, "9876543210:ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvu"), false);
});
test("Mini App initData fails when user field is swapped after signing", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
user: '{"id":279058397,"first_name":"Benson"}',
});
// Tamper with the user payload but keep the original hash.
const parts = initData.split("&").filter((p) => !p.startsWith("hash="));
const tampered = [...parts, "user=%7B%22id%22%3A1%2C%22first_name%22%3A%22Attacker%22%7D"].join(
"&"
);
assert.equal(verifyInitData(tampered, BOT_TOKEN), false);
});

View File

@@ -0,0 +1,73 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { parseInitData, verifyInitData } from "../../src/lib/telegram/initData";
const BOT_TOKEN = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
/** Build a *valid* initData string for a given bot token (test helper). */
function buildValidInitData(botToken: string, fields: Record<string, string>): string {
const pairs = Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n");
const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
const hash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
const withHash = [...pairs, ["hash", hash]];
return withHash.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
}
test("parseInitData decodes URL-encoded key/value pairs", () => {
const parsed = parseInitData("user=%7B%22id%22%3A42%7D&auth_date=1700000000&hash=abc");
assert.equal(parsed.user, '{"id":42}');
assert.equal(parsed.auth_date, "1700000000");
assert.equal(parsed.hash, "abc");
});
test("verifyInitData accepts a valid signature", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
query_id: "AAHdF6IQAAAAAN0XohDhrOrc",
user: '{"id":279058397,"first_name":"Benson","last_name":"KB","username":"benzntech"}',
});
assert.equal(verifyInitData(initData, BOT_TOKEN), true);
});
test("verifyInitData rejects a tampered user field", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
user: '{"id":279058397,"first_name":"Benson"}',
});
const tampered = initData.replace("Benson", "Attacker");
assert.equal(verifyInitData(tampered, BOT_TOKEN), false);
});
test("verifyInitData rejects a wrong bot token", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
user: '{"id":1}',
});
assert.equal(verifyInitData(initData, "999:WRONGTOKENWRONGTOKENWRONGTOKENWRONG"), false);
});
test("verifyInitData rejects missing hash", () => {
const initData = "auth_date=1700000000&user=%7B%22id%22%3A1%7D";
assert.equal(verifyInitData(initData, BOT_TOKEN), false);
});
test("verifyInitData rejects stale auth_date beyond maxAge", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000) - 48 * 60 * 60), // 48h old
user: '{"id":1}',
});
assert.equal(verifyInitData(initData, BOT_TOKEN, 24 * 60 * 60), false);
// But passes when the window is generous
assert.equal(verifyInitData(initData, BOT_TOKEN, 7 * 24 * 60 * 60), true);
});
test("verifyInitData handles chunked/encoded keys", () => {
const initData = buildValidInitData(BOT_TOKEN, {
auth_date: String(Math.floor(Date.now() / 1000)),
"some-key with spaces": "value with & specials",
});
assert.equal(verifyInitData(initData, BOT_TOKEN), true);
});