From 0bb17b91c6d3e5fc245ad9dfee71aaba9edf1a3d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:58 -0300 Subject: [PATCH] maint: final follow-up cherry-pick #9812 (#9907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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:) 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 Co-authored-by: benzntech --- .env.example | 206 ++----------- .gitignore | 26 +- docs/reference/ENVIRONMENT.md | 102 +------ package-lock.json | 385 +++++++----------------- package.json | 58 ++-- src/app/api/telegram/update/route.ts | 160 ++++++++++ src/app/miniapp/page.tsx | 169 +++++++++++ src/lib/telegram/botApi.ts | 111 +++++++ src/lib/telegram/chatProxy.ts | 106 +++++++ src/lib/telegram/config.ts | 30 ++ src/lib/telegram/initData.ts | 75 +++++ src/shared/constants/publicApiRoutes.ts | 5 + tests/unit/telegram-botapi.test.ts | 67 +++++ tests/unit/telegram-init-data.test.ts | 73 +++++ 14 files changed, 973 insertions(+), 600 deletions(-) create mode 100644 src/app/api/telegram/update/route.ts create mode 100644 src/app/miniapp/page.tsx create mode 100644 src/lib/telegram/botApi.ts create mode 100644 src/lib/telegram/chatProxy.ts create mode 100644 src/lib/telegram/config.ts create mode 100644 src/lib/telegram/initData.ts create mode 100644 tests/unit/telegram-botapi.test.ts create mode 100644 tests/unit/telegram-init-data.test.ts diff --git a/.env.example b/.env.example index d3b3f4b364..3dd22b027f 100644 --- a/.env.example +++ b/.env.example @@ -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 (:). 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 diff --git a/.gitignore b/.gitignore index ffccb9d763..f2738f3aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3fad2a7ef2..115d8cfb64 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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` | `/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 `. 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 (`:`). 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`). | diff --git a/package-lock.json b/package-lock.json index 188829e9a6..f83e6a5940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index bcc3b19c4e..f90b56d96e 100644 --- a/package.json +++ b/package.json @@ -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" } } } diff --git a/src/app/api/telegram/update/route.ts b/src/app/api/telegram/update/route.ts new file mode 100644 index 0000000000..cc0676106a --- /dev/null +++ b/src/app/api/telegram/update/route.ts @@ -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/?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; + + // ── 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 { + 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. + } + } +} diff --git a/src/app/miniapp/page.tsx b/src/app/miniapp/page.tsx new file mode 100644 index 0000000000..f194699096 --- /dev/null +++ b/src/app/miniapp/page.tsx @@ -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([]); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [initData, setInitData] = useState(""); + const [error, setError] = useState(""); + const bottomRef = useRef(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 ( +
+

OmniRoute Mini App

+ {error &&

{error}

} + +
+ {messages.map((m, i) => ( +
+ {m.content} +
+ ))} +
+
+ +
+ 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, + }} + /> + +
+
+ ); +} diff --git a/src/lib/telegram/botApi.ts b/src/lib/telegram/botApi.ts new file mode 100644 index 0000000000..4bdc50071a --- /dev/null +++ b/src/lib/telegram/botApi.ts @@ -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(method: string, body: unknown): Promise { + 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 { + return botFetch("sendMessage", params); +} + +export async function editTelegramMessage( + params: TelegramEditMessageParams +): Promise { + return botFetch("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; +} diff --git a/src/lib/telegram/chatProxy.ts b/src/lib/telegram/chatProxy.ts new file mode 100644 index 0000000000..d2b136954e --- /dev/null +++ b/src/lib/telegram/chatProxy.ts @@ -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(); + +export async function resolveUserApiKey(telegramUserId: number): Promise { + 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 { + 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 { + 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 }; diff --git a/src/lib/telegram/config.ts b/src/lib/telegram/config.ts new file mode 100644 index 0000000000..421739ef5e --- /dev/null +++ b/src/lib/telegram/config.ts @@ -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: : (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"; +} diff --git a/src/lib/telegram/initData.ts b/src/lib/telegram/initData.ts new file mode 100644 index 0000000000..479d9063da --- /dev/null +++ b/src/lib/telegram/initData.ts @@ -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 { + const out: Record = {}; + 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 (`:`) — 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); +} diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index ccfe61ccc5..07d8610adf 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -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 = [ diff --git a/tests/unit/telegram-botapi.test.ts b/tests/unit/telegram-botapi.test.ts new file mode 100644 index 0000000000..26a2dc1434 --- /dev/null +++ b/tests/unit/telegram-botapi.test.ts @@ -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 { + 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); +}); diff --git a/tests/unit/telegram-init-data.test.ts b/tests/unit/telegram-init-data.test.ts new file mode 100644 index 0000000000..d755acba6a --- /dev/null +++ b/tests/unit/telegram-init-data.test.ts @@ -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 { + 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); +});