diff --git a/Cost-Tracking.md b/Cost-Tracking.md new file mode 100644 index 0000000..8c9260d --- /dev/null +++ b/Cost-Tracking.md @@ -0,0 +1,258 @@ +> 🌍 [View in other languages](Languages) + + +# Cost & Spend Tracking + +How OmniRoute estimates, records, and reports the cost of every request β€” and why the +dashboard number is a **savings tracker**, not a bill. + +See also: [User Guide](./USER_GUIDE.md) Β· [Features Gallery](./FEATURES.md) + +--- + +## What it is (and what it is not) + +OmniRoute attributes a per-request USD cost to every completion by multiplying token +counts by a model's pricing rates. These numbers power the **Costs** dashboard, the +`omniroute cost` / `omniroute usage` CLI, CSV/JSON exports, and per-API-key budgets. + +> **The dashboard "cost" is a savings tracker, not a bill.** OmniRoute never charges you +> β€” it routes your requests to providers you have already connected (your own +> subscriptions, free tiers, and API keys). A "$290 total cost" accrued entirely on free +> models means roughly **$290 you did _not_ pay** a paid API. The figure is an _estimate_ +> of what the same traffic would have cost at standard list prices, so you can see where +> your usage is concentrated and how much routing to cheaper/free providers is saving you. + +This framing is stated directly in the project [README](../../README.md) ("the dashboard +'cost' is a savings tracker, not a bill"). + +Because the number is an estimate: + +- It depends on the pricing table OmniRoute has for each model. A model with no pricing + entry contributes `0` cost (it shows as a "Legacy / Free" row in the explorer). +- Free-tier and subscription traffic still accrues an _estimated_ cost β€” that is the + amount you are saving, not an amount owed. + +--- + +## How costs are estimated + +### The pricing source + +Costs come from a pricing table resolved in this precedence order +([`src/lib/pricingSync.ts`](../../src/lib/pricingSync.ts)): + +1. **User overrides** β€” prices you set in the dashboard / via `PATCH /api/pricing`. +2. **Synced external pricing** β€” fetched from LiteLLM's public + `model_prices_and_context_window.json` when sync is enabled (stored in a separate + `pricing_synced` namespace so it never clobbers your overrides). +3. **Hardcoded defaults** β€” shipped with OmniRoute. + +External pricing sync is **opt-in**, disabled by default. Relevant env vars +(see [`.env.example`](../../.env.example)): + +| Env var | Default | Purpose | +| ----------------------- | --------- | ---------------------------------------------------------------- | +| `PRICING_SYNC_ENABLED` | `false` | Enable the background LiteLLM pricing sync at startup. | +| `PRICING_SYNC_INTERVAL` | `86400` | Sync interval in **seconds** (default daily). | +| `PRICING_SYNC_SOURCES` | `litellm` | Comma-separated source list (only `litellm` is supported today). | + +### The cost formula + +Cost is computed per request from token counts and per-million-token rates in +[`src/lib/usage/costCalculator.ts`](../../src/lib/usage/costCalculator.ts) +(`computeCostFromPricing` / `calculateCost`): + +- **Input tokens** (minus cache reads and cache-creation tokens) Γ— `input` rate. +- **Cache-read tokens** Γ— `cached` rate (falls back to the input rate). +- **Cache-creation tokens** Γ— `cache_creation` rate (falls back to the input rate). +- **Output tokens** Γ— `output` rate. +- **Reasoning tokens** Γ— `reasoning` rate (falls back to the output rate). + +All rates are interpreted as USD per 1,000,000 tokens. A Codex "fast"/"priority" or +"flex" service tier applies a cost multiplier (`getCodexFastCostMultiplier`) β€” e.g. flex +is billed at a 50% token discount, surfaced as **flex savings** in the dashboard. + +Model names are normalized first (provider-path prefixes such as `openai/` or +`accounts/fireworks/models/` are stripped) so historical rows still match a price. + +### How spend is recorded + +- The per-request cost is computed after the response and recorded fire-and-forget so it + never adds latency to the client. Shared-quota consumption is scheduled on the next + event-loop tick via [`src/lib/quota/spendRecorder.ts`](../../src/lib/quota/spendRecorder.ts). +- API-key spend is buffered and flushed in batches by the + [`SpendBatchWriter`](../../src/lib/spend/batchWriter.ts) (default 60s flush interval, + 1,000-entry buffer). Tunable via: + + | Env var | Default | Purpose | + | ----------------------------------- | ------- | ---------------------------------- | + | `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | `60000` | Flush interval in milliseconds. | + | `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | `1000` | Max buffered entries before flush. | + +The dashboard's cost figures are **not** read from a stored per-row dollar amount β€” they +are recomputed on the fly from token counts and the current pricing table each time the +analytics endpoint runs. That means correcting a wrong price (and re-syncing) updates +historical cost estimates retroactively. + +--- + +## Dashboard: the Costs page + +The **Costs** page lives at `/dashboard/costs` +(`src/app/(dashboard)/dashboard/costs/`). +Its main view is the **Cost Overview** tab +(`src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx`), +which loads everything from `GET /api/usage/analytics`. + +What it shows: + +- **Spend tiles** β€” estimated spend for _Today (1d)_, _7d_, _30d_, and the selected + window. Range selector: `7d`, `30d`, `90d`, `all`. +- **Headline metrics** β€” requests in window, active providers, active models, average + cost per request. +- **Cost Explorer** β€” a sortable/filterable table grouped by **provider**, **model**, + **API key**, **account**, or **service tier**, with cost, requests, tokens, avg + cost/request, and share-of-total %. +- **Token usage** β€” total / input / output tokens and input:output ratio. +- **Routing efficiency** β€” fallback count, fallback rate, and requested-model coverage. +- **Monthly forecast** β€” projects month-end spend from the recent daily average. +- **Period comparison** β€” % change between the first and second half of the window. +- **Charts** β€” daily cost trend, provider share (pie), top providers, top models, cost + by API key, cost by account, weekly usage pattern, and an activity heatmap. +- **Export** β€” download the current window as **CSV** or **JSON** (the buttons appear + once there is non-zero cost data). + +When there is no priced traffic, rows render as a "Legacy / Free" label instead of `$0`, +reflecting the savings-tracker model. + +### Related Costs sub-pages + +The Costs area also hosts (all under `/dashboard/costs/`): + +- **Pricing** (`/dashboard/costs/pricing`) β€” view and override per-model prices (renders + the shared Pricing tab). +- **Budget** (`/dashboard/costs/budget`) β€” set per-scope spend limits (renders the shared + Budget tab). +- **Quota Share** (`/dashboard/costs/quota-share`) β€” shared-quota pools and burn-rate + views. + +--- + +## API endpoints + +All of these require management auth (loopback/JWT, via `requireManagementAuth`) unless +noted. + +### Usage & cost analytics + +| Method | Endpoint | Purpose | +| ------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/usage/analytics` | Full cost/usage analytics: summary, daily trend, by provider/model/API key/account/tier. Query: `range`, `startDate`, `endDate`, `apiKeyIds`, `presets`. | +| `GET` | `/api/usage/utilization` | Per-provider quota utilization over time. Query: `range` (`1h`/`24h`/`7d`/`30d`), `provider`. | +| `GET` | `/api/usage/history` | Raw usage history rows. | +| `GET` | `/api/usage/call-logs` | Per-request call logs (model, tokens, cost, latency, status). | +| `GET` | `/api/usage/quota` | Provider quota status. | +| `GET` | `/api/usage/proxy-logs` | Proxy request logs. | + +### Budgets + +| Method | Endpoint | Purpose | +| ------ | ------------------------ | ------------------------------------------------------------------------------ | +| `GET` | `/api/usage/budget` | Cost summary + budget check for one API key (`apiKeyId` query param required). | +| `POST` | `/api/usage/budget` | Set daily/weekly/monthly USD limits + warning threshold for an API key. | +| `GET` | `/api/usage/budget/bulk` | Bulk budget summaries across API keys. | + +> The budget API is scoped per **API key** (`apiKeyId`). Limits returned by +> `GET /api/usage/budget` include `dailyLimitUsd`, `weeklyLimitUsd`, `monthlyLimitUsd`, +> a `warningThreshold`, and the running totals (`totalCostToday`, `totalCostMonth`, …). + +### Pricing + +| Method | Endpoint | Purpose | +| -------- | ----------------------- | ----------------------------------------------------------------------------------------------- | +| `GET` | `/api/pricing` | Current merged pricing (user + synced + defaults). `?includeSources=1` to see source per entry. | +| `PATCH` | `/api/pricing` | Override pricing for `{ provider: { model: { input, output, cached, … } } }`. | +| `DELETE` | `/api/pricing` | Reset pricing to defaults (optionally scoped by `?provider=&model=`). | +| `GET` | `/api/pricing/defaults` | Show default per-1M fallback rates. | +| `GET` | `/api/pricing/models` | Pricing keyed by model. | +| `POST` | `/api/pricing/sync` | Trigger a manual sync from external sources (LiteLLM). | +| `GET` | `/api/pricing/sync` | Current sync status. | +| `DELETE` | `/api/pricing/sync` | Clear all synced pricing data. | + +### Other cost-relevant endpoints + +| Method | Endpoint | Purpose | +| ------ | ----------------------------- | ----------------------------------------------------------------------- | +| `GET` | `/api/free-tier/summary` | Free-model token totals, used-this-month, and remaining free allowance. | +| `GET` | `/api/quota/pools/[id]/usage` | Usage for a shared-quota pool. | + +--- + +## CLI + +OmniRoute's CLI exposes cost, usage, and pricing commands (registered in +[`bin/cli/commands/registry.mjs`](../../bin/cli/commands/registry.mjs)). + +### `omniroute cost` + +A cost report aggregated from `/api/usage/analytics`. + +```bash +omniroute cost # last 30d, grouped by provider +omniroute cost --period 7d # last 7 days +omniroute cost --group-by model # group by provider | model | combo | api-key | day +omniroute cost --since 2026-06-01 --until 2026-06-13 +omniroute cost --api-key --limit 50 +``` + +Columns: group, requests, tokens in/out, cost (USD), and % of total. A grand total line +is printed at the end (suppressed with `--quiet` or `--output json`). + +### `omniroute usage` + +```bash +omniroute usage analytics --period 30d [--provider ] # per-provider cost summary +omniroute usage logs [--limit 100] [--follow] [--api-key ] [--search ] +omniroute usage quota [--provider ] [--check] +omniroute usage utilization [--api-key ] +omniroute usage history [--limit 100] +omniroute usage proxy-logs [--limit 100] + +# Budgets +omniroute usage budget list +omniroute usage budget get [scope] +omniroute usage budget set [--scope global] [--period monthly] +omniroute usage budget reset [scope] +``` + +### `omniroute pricing` + +```bash +omniroute pricing list [--provider

] [--model ] [--limit 200] +omniroute pricing get +omniroute pricing sync [--provider

] [--force] # POST /api/pricing/sync +omniroute pricing diff [--model ] +omniroute pricing defaults show +omniroute pricing defaults set [--input

] [--output

] [--cache-read

] [--cache-write

] +``` + +> `pricing defaults show` reads `GET /api/pricing/defaults`. To edit individual model +> prices instead, use the **Pricing** dashboard page or `PATCH /api/pricing`. + +--- + +## Troubleshooting + +- **All costs show $0 / "Legacy / Free".** The models in use have no pricing entry. + Enable external sync (`PRICING_SYNC_ENABLED=true`) and run `omniroute pricing sync`, or + set prices manually via the Pricing page / `PATCH /api/pricing`. +- **A historical model is mispriced.** Fix the price (override or re-sync) β€” cost is + recomputed from token counts on every analytics read, so estimates update retroactively. +- **Spend lags behind real time.** Per-key spend is batched; lower + `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` if you need fresher numbers. + +--- + +For where this fits in the broader dashboard, see the [User Guide](./USER_GUIDE.md) and +the [Features Gallery](./FEATURES.md). diff --git a/Egress-Policy.md b/Egress-Policy.md new file mode 100644 index 0000000..c8d773a --- /dev/null +++ b/Egress-Policy.md @@ -0,0 +1,247 @@ +> 🌍 [View in other languages](Languages) + + +# Egress IP Family Policy (IPv4/IPv6) + +> **Pin outbound traffic to a single IP family β€” `auto`, `ipv4`, or `ipv6` β€” per proxy, so an IPv6-only egress never silently leaks back to IPv4.** + +> **Source of truth:** `open-sse/utils/proxyFamily.ts`, `open-sse/utils/proxyDispatcher.ts`, `open-sse/utils/proxyFetch.ts`, `open-sse/utils/socksConnectorWithFamily.ts`, `open-sse/utils/proxyFamilyResolve.ts`, `src/shared/validation/schemas.ts`, `src/lib/db/proxies.ts`, `src/lib/db/upstreamProxy.ts`, `src/lib/db/migrations/099_proxy_family.sql` + +OmniRoute lets each proxy carry an **address-family egress directive**. By default the OS picks IPv4 or IPv6 (dual-stack, "Happy Eyeballs"). When you set the directive to `ipv4` or `ipv6`, OmniRoute pins every connection through that proxy to the chosen family and **fails closed** rather than falling back to the other family. + +This page documents what the directive is, why it exists, where you configure it, and how the runtime resolves it. + +--- + +## Table of Contents + +- [What It Is](#what-it-is) +- [Why It Exists](#why-it-exists) +- [The Three Values](#the-three-values) +- [How to Configure It](#how-to-configure-it) +- [How `auto` Resolves](#how-auto-resolves) +- [How `ipv4` / `ipv6` Are Enforced](#how-ipv4--ipv6-are-enforced) +- [SOCKS5 Compatibility](#socks5-compatibility) +- [Fail-Closed Behavior](#fail-closed-behavior) +- [Data Model](#data-model) +- [Related Documentation](#related-documentation) + +--- + +## What It Is + +Every proxy in the registry has a `family` field with three possible values, validated by a Zod enum: + +```ts +// src/shared/validation/schemas.ts +family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"), +``` + +The field defaults to `"auto"`, which preserves the prior dual-stack behavior. Setting it to `ipv4` or `ipv6` pins the connect family for that proxy. + +The directive is normalized everywhere through a single helper so any unknown value collapses to `auto`: + +```ts +// open-sse/utils/proxyFamily.ts +export type ProxyFamily = "auto" | "ipv4" | "ipv6"; + +export function parseProxyFamily(value: unknown): ProxyFamily { + return value === "ipv4" || value === "ipv6" ? value : "auto"; +} +``` + +--- + +## Why It Exists + +Introduced in PR [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777). The motivating problems: + +| Problem | What the directive fixes | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **IPv6-only egress leaking to IPv4** | When a proxy host has both A and AAAA records (or the OS prefers IPv4), Happy Eyeballs can dial out over IPv4 even when you intend an IPv6-only path. Pinning `ipv6` removes that leak. | +| **Shared-egress anomaly revocation** | Rotating providers (codex/openai) revoke tokens when many accounts egress through the **same** IP at high volume. Controlling the egress family is part of keeping accounts on distinct, predictable egress paths (see [`src/lib/proxyEgress.ts`](../../src/lib/proxyEgress.ts) for the egress-IP diagnostics that pair with this). | +| **Deterministic egress for compliance/testing** | When you must guarantee traffic leaves over a specific family, `auto` is not enough. | + +The directive is intentionally **per-proxy**, not global β€” different proxies in your pool can have different policies. + +--- + +## The Three Values + +| Value | UI label | Behavior | +| ------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto` | `Auto (dual-stack)` | OS picks the family. For an IP-literal proxy host, the family is intrinsic to the literal; for a hostname, both families are eligible. This is the default. | +| `ipv4` | `IPv4 only` | Pins the connection to IPv4. Fails closed if the proxy host has no IPv4 (A) record. | +| `ipv6` | `IPv6 only` | Pins the connection to IPv6. Fails closed if the proxy host has no IPv6 (AAAA) record. | + +UI strings live in `src/i18n/messages/en.json` (`labelFamily`, `familyAuto`, `familyIpv4`, `familyIpv6`, `familyHint`). + +--- + +## How to Configure It + +### Dashboard + +The selector is in the proxy form of the **Proxy Pool** tab: + +1. Open **Dashboard β†’ Settings β†’ Proxy β†’ Proxy Pool** +2. Add or edit a proxy +3. Set the **IP family** dropdown to `Auto (dual-stack)`, `IPv4 only`, or `IPv6 only` +4. Save + +The control is rendered by `ProxyRegistryManager.tsx` (mounted in `proxy/ProxyPoolTab.tsx`). + +### API + +The `family` field is part of the proxy registry create/update payloads, validated by `createProxyRegistrySchema` / `updateProxyRegistrySchema` (`src/shared/validation/schemas.ts`) and handled by `POST` / `PATCH /api/v1/management/proxies`: + +```bash +# Create an IPv6-only proxy +curl -X POST http://localhost:20128/api/v1/management/proxies \ + -H "Content-Type: application/json" \ + -d '{ + "name": "IPv6 egress", + "type": "socks5", + "host": "proxy.example.com", + "port": 1080, + "family": "ipv6" + }' + +# Change an existing proxy to IPv4-only +curl -X PATCH http://localhost:20128/api/v1/management/proxies \ + -H "Content-Type: application/json" \ + -d '{ "id": "proxy-uuid-here", "family": "ipv4" }' +``` + +The same field is also accepted by the inline proxy config object used for upstream-proxy entries (`upstream_proxy_config.family`, see [Data Model](#data-model)). + +For the rest of the proxy CRUD/assignment API, see [PROXY_GUIDE.md](../ops/PROXY_GUIDE.md). + +--- + +## How `auto` Resolves + +When `family` is `auto`, OmniRoute does **not** append any directive β€” the proxy URL is used as-is and the connect family is determined intrinsically. + +At URL-build time (`proxyConfigToUrl` / `normalizeProxyUrl` in `open-sse/utils/proxyDispatcher.ts`), an `auto` proxy yields a plain URL with no marker: + +```ts +// open-sse/utils/proxyDispatcher.ts +const fam = parseProxyFamily(config.family); +const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 }); +return fam === "auto" ? normalized : `${normalized}?family=${fam}`; +``` + +At dispatch time (`resolveDispatcherFamily`), `auto` resolves to the intrinsic family of an IP-literal host, or `null` (let the OS decide) for a hostname: + +```ts +// open-sse/utils/proxyDispatcher.ts +function resolveDispatcherFamily(parsed: URL): 4 | 6 | null { + const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined); + const literal = detectIpLiteralFamily(parsed.hostname); + if (directive === "auto") return literal; // null for a hostname β†’ OS picks + // ... +} +``` + +So: + +- `auto` + IP-literal host (`192.0.2.1` / `[2001:db8::1]`) β†’ family of that literal. +- `auto` + hostname β†’ `null` β†’ standard dual-stack OS resolution. + +--- + +## How `ipv4` / `ipv6` Are Enforced + +A non-`auto` directive travels as a single synthetic query marker β€” `?family=ipv4` or `?family=ipv6` β€” appended once to the normalized proxy URL. `normalizeProxyUrl` is careful to strip and re-append this marker exactly once so it never corrupts port parsing. + +When the dispatcher is built, the marker is read and converted to a concrete connect family. If the host is an IP literal of the **opposite** family, OmniRoute throws (contradiction is fail-closed): + +```ts +// open-sse/utils/proxyDispatcher.ts +const want = directive === "ipv6" ? 6 : 4; +if (literal !== null && literal !== want) { + throw new Error( + `[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host` + ); +} +``` + +The concrete family is then pinned on the connector: + +- **HTTP/HTTPS proxies** (`ProxyAgent`): `proxyTls: { family, autoSelectFamily: false }` β€” disables Happy Eyeballs so the chosen family is the only one dialed. +- **SOCKS5 proxies**: a custom connector threads `socket_options: { family, autoSelectFamily: false }` into the SOCKS client (see [SOCKS5 Compatibility](#socks5-compatibility)). + +--- + +## SOCKS5 Compatibility + +The family pin works with SOCKS5 proxies, but stock `fetch-socks` does not expose the socket options needed to pin the family of the proxy hop. OmniRoute ships its own connector for that: + +```ts +// open-sse/utils/socksConnectorWithFamily.ts +export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record { + if (family === 6) return { family: 6, autoSelectFamily: false }; + if (family === 4) return { family: 4, autoSelectFamily: false }; + return {}; +} +``` + +`createProxyDispatcher` chooses the connector based on whether a family is pinned: + +- `family === null` (i.e. `auto` over a hostname) β†’ stock `socksDispatcher` from `fetch-socks`. +- `family === 4 | 6` β†’ `createSocksDispatcherWithFamily`, which threads `socket_options` into `SocksClient.createConnection` so Happy Eyeballs cannot pick IPv4 for an IPv6-only egress policy. + +SOCKS5 support itself is on by default (opt-out via `ENABLE_SOCKS5_PROXY=false`); see [PROXY_GUIDE.md β†’ Environment Variables](../ops/PROXY_GUIDE.md#environment-variables). + +--- + +## Fail-Closed Behavior + +The whole point of the directive is to **refuse** rather than silently fall back to the wrong family. Two guards enforce this: + +1. **Literal contradiction** β€” a directive that contradicts an IP-literal host throws at dispatcher build time (`resolveDispatcherFamily`, shown above). + +2. **Hostname pre-flight DNS check** β€” for a hostname proxy with a pinned family, `proxyFetch.ts` verifies the hostname actually has a record in the required family **before** egressing, via `assertHostnameSupportsFamily`: + + ```ts + // open-sse/utils/proxyFamilyResolve.ts + const hasFamily = records.some((r) => r.family === family); + if (!hasFamily) { + throw new Error( + `[ProxyFamily] Proxy host ${host} has no ${family === 6 ? "IPv6 (AAAA)" : "IPv4 (A)"} record; ` + + `refusing ${family === 6 ? "IPv6" : "IPv4"}-only egress (fail-closed)` + ); + } + ``` + + On failure, `proxyFetch.ts` tags the error with `code = "PROXY_FAMILY_UNAVAILABLE"` and `statusCode = 503`. A DNS resolution failure is likewise treated as fail-closed (refuse to egress). + +IP-literal hosts are a no-op for the DNS pre-flight β€” their family is intrinsic and needs no lookup. + +--- + +## Data Model + +The `family` column was added by migration `099_proxy_family.sql` to **two** tables: + +```sql +-- src/lib/db/migrations/099_proxy_family.sql +ALTER TABLE proxy_registry ADD COLUMN family TEXT NOT NULL DEFAULT 'auto'; +ALTER TABLE upstream_proxy_config ADD COLUMN family TEXT NOT NULL DEFAULT 'auto'; +``` + +- `proxy_registry.family` β€” the per-proxy directive for registry entries (`src/lib/db/proxies.ts`). Resolution queries select `family` alongside the other proxy columns, and a missing/non-string value is coerced to `"auto"`. +- `upstream_proxy_config.family` β€” the directive for upstream-proxy entries (`src/lib/db/upstreamProxy.ts`), with the same `"auto"` default. + +When a resolved proxy object carries a non-`auto` `family`, `proxyConfigToUrl` appends the `?family=` marker so the pin survives all the way to the dispatcher. + +--- + +## Related Documentation + +> πŸ“– **Related documentation:** +> +> - [Proxy Guide](../ops/PROXY_GUIDE.md) β€” full proxy system: registry CRUD, 4-level resolution, rotation, health checking, API reference +> - [Stealth Guide](./STEALTH_GUIDE.md) β€” TLS fingerprint and CLI fingerprint layers that ride on top of the proxy +> - [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) β€” loopback enforcement for local-only routes diff --git a/Feature-Flags.md b/Feature-Flags.md new file mode 100644 index 0000000..ded9f8b --- /dev/null +++ b/Feature-Flags.md @@ -0,0 +1,232 @@ +> 🌍 [View in other languages](Languages) + + +# Feature Flags + +> Runtime toggles that change OmniRoute's behavior **without a redeploy**. +> Every flag listed here is defined in +> [`src/shared/constants/featureFlagDefinitions.ts`](../../src/shared/constants/featureFlagDefinitions.ts) +> β€” the single source of truth. The dashboard and the REST API both read from +> that file, so the table below is generated to match it 1:1. + +--- + +## What Feature Flags Are + +A feature flag is a named toggle (boolean or enum) whose value can be changed at +runtime and persisted in the database, with no process redeploy required. Each +flag is described by a `FeatureFlagDefinition` with a `key`, `label`, +`description`, `category`, `defaultValue`, `type`, and a `requiresRestart` hint. + +### Resolution Order + +The **effective value** of a flag is resolved by +[`resolveFeatureFlag()`](../../src/shared/utils/featureFlags.ts) with this +precedence (highest wins): + +1. **DB override** β€” a value stored in the `key_value` table under the + `feature_flags` namespace (set via the dashboard or the REST API). +2. **Environment variable** β€” `process.env[]`, if set and non-empty. +3. **Definition default** β€” the `defaultValue` from `featureFlagDefinitions.ts`. + +A boolean flag is considered **enabled** when its effective value is `"true"`, +`"1"`, or `"yes"` (see `isFeatureFlagEnabled()`). + +> [!NOTE] +> Most flags also have a matching environment variable of the **same name** +> documented in [`ENVIRONMENT.md`](./ENVIRONMENT.md). The flag's DB override +> takes precedence over that environment variable. A flag with +> `requiresRestart: true` is persisted immediately but only re-read at process +> startup β€” toggling it surfaces a **"Restart Server"** banner in the dashboard. + +--- + +## Flag Catalog + +31 flags across 6 categories. **Default** is the definition default β€” the value +used when neither a DB override nor an environment variable is present. + +### Security (7) + +| Key | Type | Default | Description | +| -------------------------------- | ------- | -------- | ----------------------------------------------------------------------------- | +| `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. | +| `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. | +| `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. | +| `PII_REDACTION_ENABLED` | boolean | `false` | Redact personally identifiable information from requests. | +| `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. | +| `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. | +| `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. | + +### Network (6) + +| Key | Type | Default | Restart | Description | +| --------------------------------------- | ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENABLE_TLS_FINGERPRINT` | boolean | `false` | βœ“ | Enable TLS fingerprint stealth mode. | +| `ONEPROXY_ENABLED` | boolean | `true` | | Enable 1proxy request proxying. | +| `PROXY_AUTO_SELECT_ENABLED` | boolean | `false` | | When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default (otherwise any registry proxy becomes a global fallback β€” #3332). | +| `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | βœ“ | Disable TLS certificate verification for the MITM proxy. **Danger.** | +| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | boolean | `false` | βœ“ | Enable Claude Code compatible provider mode. | + +### Policies (3) + +| Key | Type | Default | Restart | Description | +| ----------------------------------------- | ------- | ---------- | ------- | ---------------------------------------------------------------------- | +| `TOOL_POLICY_MODE` | enum | `disabled` | | Tool-use policy enforcement mode. Values: `disabled`, `warn`, `block`. | +| `RATE_LIMIT_AUTO_ENABLE` | boolean | `false` | | Automatically enable rate limiting based on usage patterns. | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | boolean | `false` | βœ“ | Allow multiple connections per compatibility node. | + +### Runtime (9) + +| Key | Type | Default | Restart | Description | +| ------------------------------------------- | ------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | boolean | `true` | | Enforce scope restrictions on MCP tool access. | +| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | boolean | `false` | | Compress MCP tool descriptions to reduce token usage. | +| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | boolean | `false` | | Enable background task processing at runtime. | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | boolean | `false` | βœ“ | Disable all background services (quota refresh, sync, etc). | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | boolean | `false` | | Trust project-level RTK filters without validation. | +| `OMNIROUTE_ENABLE_LIVE_WS` | boolean | `true` | βœ“ | Start the real-time dashboard WebSocket server on import (port 20129 by default). | +| `OMNIROUTE_CODEX_WS_ENABLED` | boolean | `true` | | Allow Codex to use the Responses-over-WebSocket transport. When off, Codex falls back to HTTP Responses. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) | +| `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. | + +### CLI (3) + +| Key | Type | Default | Restart | Description | +| ---------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `CLI_COMPAT_ALL` | boolean | `false` | βœ“ | Enable compatibility mode for all CLI clients. | +| `MODEL_ALIAS_COMPAT_ENABLED` | boolean | `false` | | Enable model alias compatibility layer. | +| `PRICING_SYNC_ENABLED` | boolean | `false` | | Enable automatic pricing data synchronization (also requires the `PRICING_SYNC_ENABLED` environment variable). | + +### Health (3) + +| Key | Type | Default | Description | +| ------------------------------------- | ------- | ------- | -------------------------------------------------------- | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. | +| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. | + +> [!NOTE] +> The `Restart` column marks flags with `requiresRestart: true` β€” the value is +> persisted instantly but only takes effect after the process reloads. Enum +> flags reject any value outside their allowed set (validated server-side in +> both `setFeatureFlagOverride()` and the REST `PUT` handler). + +--- + +## Toggling Flags + +### Dashboard + +Navigate to **Dashboard β†’ Settings β†’ Feature Flags** +(`/dashboard/settings/feature-flags`). The grid +(`src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx`) +supports: + +- **Search** by key or description, and **filter** by category (plus a synthetic + **Requires Restart** view). +- A **toggle** for boolean flags and a **dropdown** for enum flags + (`src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx`). +- A **source badge** per flag β€” `DB`, `ENV`, or `DEF` β€” showing where the + effective value came from. +- A **Reset** button (shown only for `DB`-sourced flags) to drop the override, + and a **Reset All Overrides** button at the bottom. +- A **Restart Server** banner when a `requiresRestart` flag is changed. + +### REST API + +All operations go through a single route: +[`src/app/api/settings/feature-flags/route.ts`](../../src/app/api/settings/feature-flags/route.ts). +Every method requires an authenticated dashboard session (`401` otherwise). + +#### `GET /api/settings/feature-flags` + +Returns every flag with its effective value, source, and a summary. + +```jsonc +{ + "flags": [ + { + "key": "REQUIRE_API_KEY", + "label": "Require API Key", + "description": "Require an API key for all incoming requests", + "category": "security", + "type": "boolean", + "enumValues": null, + "defaultValue": "false", + "effectiveValue": "false", + "source": "default", // "db" | "env" | "default" + "requiresRestart": false, + "warningLevel": "caution", + }, + // ... all 31 flags + ], + "summary": { + "total": 31, + "active": 0, + "inactive": 0, + "overriddenByDb": 0, + "overriddenByEnv": 0, + }, +} +``` + +#### `PUT /api/settings/feature-flags` + +Set or remove a single override. Body: `{ key: string; value?: string }`. +Omitting `value` removes the override (restoring env / default). + +```bash +# Set a DB override +curl -X PUT http://localhost:20128/api/settings/feature-flags \ + -H "Content-Type: application/json" \ + -d '{"key":"REQUIRE_API_KEY","value":"true"}' + +# Remove the override (no "value") +curl -X PUT http://localhost:20128/api/settings/feature-flags \ + -H "Content-Type: application/json" \ + -d '{"key":"REQUIRE_API_KEY"}' +``` + +The response echoes the new `effectiveValue`/`source`, the `previousValue`/ +`previousSource`, and `requiresRestart`. Unknown keys and out-of-range enum +values are rejected with `400`. + +#### `DELETE /api/settings/feature-flags` + +Clears **all** DB overrides at once, restoring every flag to its env / default +value. Returns `{ cleared: , message: "..." }`. + +> [!NOTE] +> Flags with `requiresRestart: true` only take effect after a process reload. +> The dashboard's restart flow calls `POST /api/restart` and then polls +> `GET /api/health/ping` until the server is back up. + +--- + +## Emergency Budget Fallback + +`OMNIROUTE_EMERGENCY_FALLBACK` (category `runtime`, default `true`) controls the +emergency free-fallback path in +[`open-sse/services/emergencyFallback.ts`](../../open-sse/services/emergencyFallback.ts). +When enabled, requests that exhaust their budget are routed to a free fallback +provider/model instead of failing outright. Set it to `false` (or `0`) β€” via the +dashboard toggle, a DB override, or the `OMNIROUTE_EMERGENCY_FALLBACK` +environment variable β€” to disable the behavior and let budget-exhausted requests +fail. (Surfaced as a dashboard toggle in PRs #3741 / #3752.) + +--- + +## See Also + +- [Environment Variables Reference](./ENVIRONMENT.md) β€” most flags have a + same-named environment variable documented there (the DB override takes + precedence over it). +- [`src/shared/constants/featureFlagDefinitions.ts`](../../src/shared/constants/featureFlagDefinitions.ts) + β€” source of truth for every flag. +- [`src/shared/utils/featureFlags.ts`](../../src/shared/utils/featureFlags.ts) + β€” resolution logic (`resolveFeatureFlag`, `isFeatureFlagEnabled`, + `resolveAllFeatureFlags`). +- [`src/lib/db/featureFlags.ts`](../../src/lib/db/featureFlags.ts) β€” DB override + persistence in the `feature_flags` namespace of the `key_value` table. diff --git a/Free-Provider-Rankings.md b/Free-Provider-Rankings.md new file mode 100644 index 0000000..bf0ca18 --- /dev/null +++ b/Free-Provider-Rankings.md @@ -0,0 +1,250 @@ +> 🌍 [View in other languages](Languages) + + +# Free Provider Rankings (Arena ELO) + +> **TL;DR**: OmniRoute ranks its **free** providers by model quality using **Arena AI +> (LMArena-style) ELO scores**. Open the **Free Provider Rankings** page in the +> dashboard to see which free providers ship the strongest models for your task β€” +> overall, or filtered by category (coding, review, documentation, debugging). + +--- + +## What It Is + +OmniRoute aggregates 160+ providers, many of which expose a **free tier** (no-auth, +free-tier OAuth, or free-tier API key β€” see the +[Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) and the full +[Free Tiers directory](../reference/FREE_TIERS.md)). The catch: free providers vary +wildly in model quality. A no-auth provider serving a frontier model is far more useful +than one serving a small legacy model. + +**Free Provider Rankings** answers "**which free provider gives me the best model?**" by +joining each free provider's catalog with **crowd-sourced quality scores** from the +**Arena AI leaderboard** (human-preference ELO, the same idea behind the LMArena +chatbot arena). Providers are then ranked by the strength of their **best free model**. + +The ranking is computed from three real sources: + +1. The free-provider lists β€” `NOAUTH_PROVIDERS`, plus `OAUTH_PROVIDERS` / + `APIKEY_PROVIDERS` entries flagged `hasFree` + (`src/shared/constants/providers.ts`). +2. Each provider's model catalog from the provider registry + (`open-sse/config/providerRegistry.ts`). +3. ELO-derived task-fit scores stored in the `model_intelligence` DB table by the + Arena ELO sync engine (`src/lib/arenaEloSync.ts`). + +The join logic lives in `src/lib/freeProviderRankings.ts`. + +--- + +## How to Access + +### Dashboard page + +Open the dashboard and go to **Costs β†’ Free Provider Rankings**, or navigate directly to: + +``` +/dashboard/free-provider-rankings +``` + +The page (`src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx`) shows: + +- A **top-3 podium** (πŸ₯‡ πŸ₯ˆ πŸ₯‰) of the best-ranked free providers. +- A full **ranking table** with columns: **Rank**, **Provider**, **Top Model**, + **Score**, **Avg Score**, **Models**, **Type**. +- **Category filter buttons**: _All Categories_, _Default_, _Coding_, _Review_, + _Documentation_, _Debugging_. + +Each provider's **Type** badge tells you how it is free: + +| Badge | Meaning | +| -------- | --------------------------------------------- | +| `NOAUTH` | Always free, no credentials needed | +| `OAUTH` | OAuth provider with a free tier (`hasFree`) | +| `APIKEY` | API-key provider with a free tier (`hasFree`) | + +Scores are shown as human-readable labels (e.g. _Elite_, _Excellent_, _Very Good_, +_Good_, _Average_) rather than raw numbers, because the underlying value is a relative +ranking quality, not a percentage. + +### API endpoint + +The page is backed by a public read endpoint +(`src/app/api/free-provider-rankings/route.ts`): + +``` +GET /api/free-provider-rankings +GET /api/free-provider-rankings?category=coding +GET /api/free-provider-rankings?category=coding&limit=20 +``` + +Query parameters (validated with Zod): + +| Param | Type | Default | Notes | +| ---------- | ------ | ------- | -------------------------------------------------------------------------------------------------- | +| `category` | string | (none) | One of `default`, `coding`, `review`, `documentation`, `debugging`. Omit for the combined ranking. | +| `limit` | number | `50` | Clamped to the range `1–100`. | + +Response shape: + +```json +{ + "rankings": [ + { + "id": "", + "name": "", + "icon": "", + "color": "", + "textIcon": "", + "category": "noauth | oauth | apikey", + "topModel": { + "modelId": "", + "modelName": "", + "score": 0.0, + "eloRaw": 0, + "confidence": "high | medium | low", + "category": "" + }, + "averageScore": 0.0, + "modelCount": 0 + } + ] +} +``` + +`eloRaw` is the original Arena ELO value; `score` is the normalized task-fit value +(see below). Providers with no scored models are omitted from the response. + +--- + +## How the Scores Work + +### Source: Arena AI leaderboard + +The Arena ELO sync engine (`src/lib/arenaEloSync.ts`) fetches two leaderboards β€” `text` +and `code` β€” from the Arena AI leaderboard API +(`https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard`). Each leaderboard entry +carries a model name, vendor, ELO `score`, confidence interval, and vote count. + +Leaderboard categories map to OmniRoute task categories: + +| Arena leaderboard | OmniRoute task categories | +| ----------------- | ------------------------------------------------- | +| `text` | `default`, `review`, `documentation`, `debugging` | +| `code` | `coding` | + +### Normalization (task-fit score) + +Raw ELO scores are normalized per leaderboard into a **task-fit value in `[0.4, 0.98]`**: + +``` +taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo)) +``` + +The score never reaches `0` or `1`, leaving headroom for user overrides. This is the +`score` field you see in the API response and the label shown on the dashboard. + +### Confidence + +Each entry gets a confidence level based on Arena vote count: + +| Confidence | Votes | +| ---------- | ------- | +| `high` | β‰₯ 5,000 | +| `medium` | β‰₯ 1,000 | +| `low` | < 1,000 | + +### Storage and freshness + +Normalized entries are written to the `model_intelligence` DB table with +`source = "arena_elo"` (`src/lib/db/modelIntelligence.ts`). Entries **expire after +7 days**, so a provider that stops syncing eventually drops out rather than serving +stale data. + +The sync runs **on by default**: + +- It runs once at server startup and then on a periodic timer + (`src/lib/arenaEloSync.ts`, wired from `src/server-init.ts`). +- It is **non-blocking and never fatal** β€” if the upstream fetch fails, OmniRoute keeps + running and the rankings simply show the last good data (or an empty state). + +Two environment variables control it (documented in +[`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md)): + +| Variable | Default | Purpose | +| ------------------------- | ------------- | ----------------------------------------------- | +| `ARENA_ELO_SYNC_ENABLED` | `true` | Set to `false` to opt out of the outbound sync. | +| `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | Sync interval, in seconds. | + +### Manual sync / status / clear + +For operators, an authenticated management endpoint exposes manual control +(`src/app/api/intelligence/sync/route.ts` β€” requires management auth): + +``` +GET /api/intelligence/sync # current sync status (enabled, lastSync, nextSync, intervalMs) +POST /api/intelligence/sync # trigger a manual sync; body: { "dryRun": true } to preview without writing +DELETE /api/intelligence/sync # clear all synced arena_elo intelligence entries +``` + +If the rankings page is empty, a manual `POST /api/intelligence/sync` (or simply +restarting the server) repopulates it. + +### Matching models to the leaderboard + +Registry model IDs and Arena model names don't always match exactly. The ranking uses +flexible matching (`findMatchingIntelligence` in `src/lib/freeProviderRankings.ts`): + +1. Exact match on the normalized model ID. +2. Match after stripping a trailing version suffix (e.g. `kimi-k2.6` β†’ `kimi-k2`). +3. Prefix match (a leaderboard model name is a prefix of the registry ID). + +On the sync side, known vendor prefixes (`anthropic/`, `openai/`, `google/`, …) are +stripped and a small alias map expands canonical names into the variants OmniRoute uses +internally, so models stay findable under any name. + +### How a provider is ranked + +For each free provider, the engine scores every model in its catalog, then: + +- **Top Model** = the provider's highest-scoring model. +- **Avg Score** = the mean score across all of that provider's scored models. +- **Models** = how many of the provider's models had an Arena score. + +Providers are sorted by **top-model score** first, then by average score. This rewards a +provider that ships at least one strong free model. + +--- + +## Using It to Choose Free Providers + +1. **Pick the right category.** Use the **Coding** filter for agentic/code workloads, or + leave it on **All Categories** / **Default** for general chat. The same provider can + rank differently across categories because its top model differs per leaderboard. +2. **Prefer the podium for one-shot setups.** If you only want to connect one or two free + providers, start with the top-ranked ones for your category. +3. **Check the Type badge.** `NOAUTH` providers are the fastest to connect (no + credentials). `OAUTH` / `APIKEY` free tiers need a quick sign-up but often expose + stronger models. See [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) for + connection steps. +4. **Connect several and let Auto-Combo decide.** The same Arena ELO data that powers + this page also feeds the **task-fitness factor** of the Auto-Combo scoring engine + (`open-sse/services/autoCombo/taskFitness.ts`, resolution order + `user_override β†’ arena_elo β†’ models_dev_tier β†’ static table`). So after you connect + the top free providers, routing with `model: "auto"` (e.g. `auto/coding`) will + automatically prefer the higher-quality free models per request. See + [Auto-Combo](../routing/AUTO-COMBO.md) for the full 9-factor scoring. + +--- + +## Related Documentation + +- [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) β€” how to connect free + providers, no credit card required. +- [Free Tiers directory](../reference/FREE_TIERS.md) β€” full catalog of free providers + and their limits. +- [Auto-Combo](../routing/AUTO-COMBO.md) β€” the 9-factor routing engine that consumes the + same Arena ELO task-fitness data. +- [Environment variables](../reference/ENVIRONMENT.md) β€” `ARENA_ELO_SYNC_ENABLED` / + `ARENA_ELO_SYNC_INTERVAL` reference. diff --git a/Notion-Context.md b/Notion-Context.md new file mode 100644 index 0000000..c92cce4 --- /dev/null +++ b/Notion-Context.md @@ -0,0 +1,128 @@ +> 🌍 [View in other languages](Languages) + + +# Notion Context Source + +> **Source of truth:** `src/lib/notion/api.ts` (REST client), `src/lib/db/notion.ts` +> (token persistence), `open-sse/mcp-server/tools/notionTools.ts` (6 MCP tools), +> `src/app/api/settings/notion/route.ts` (settings API). Tool registration and scope +> wiring lives in `open-sse/mcp-server/server.ts`. + +## What it is + +OmniRoute can connect to a **Notion** workspace as a **context source** β€” a read/write +knowledge base that agents reach through the built-in MCP server. Once a Notion +integration token is configured, the MCP tools let an LLM search pages and databases, +read page content and block trees, query databases with filters/sorts, and append new +blocks β€” all proxied through OmniRoute (with retry, timeout, and error classification) +so the model never touches the Notion API directly. + +The integration is a thin, hardened wrapper over the official Notion REST API +(`https://api.notion.com/v1`, `Notion-Version: 2026-03-11`). The client +(`src/lib/notion/api.ts`) adds: + +- **Retry with exponential backoff** (up to 3 attempts) for `429` and `5xx`. +- **55-second request timeout** via `AbortController`. +- **Typed error classification** β€” `NotionAuthError` (401/403), + `NotionNotFoundError` (404), `NotionRateLimitError` (429, honors `retry after` + hints), `NotionValidationError` (400/409), `NotionServerError` (5xx), + `NotionTimeoutError`. +- **Message sanitization** that strips stack-trace-like fragments before surfacing. + +## Setup + +There is **no environment variable** for the Notion token β€” it is stored in the +SQLite `key_value` table (namespace `notion`, key `integration_token`) via +`src/lib/db/notion.ts`. Configure it from the **Context Sources** tab of the Endpoint +dashboard (`ObsidianSourceCard`'s sibling `NotionSourceCard`), or via the settings REST API. + +> [!NOTE] +> The token is a **Notion internal integration token**. Create an integration at +> , then share the pages/databases you want +> OmniRoute to access with that integration (Notion's permission model is share-based, +> not workspace-wide). + +### Configure via REST + +```bash +# Save + validate the integration token (POST validates by issuing a test search) +curl -X POST http://localhost:20128/api/settings/notion \ + -H "Content-Type: application/json" \ + -d '{"token":"ntn_xxx"}' + +# Check connection status +curl http://localhost:20128/api/settings/notion + +# Disconnect (clears the stored token) +curl -X DELETE http://localhost:20128/api/settings/notion +``` + +All three methods require dashboard authentication (`isAuthenticated`). On `POST`, +OmniRoute saves the token and immediately runs a 1-result test search; if Notion +returns an error object the token is cleared and the call fails with `400`. + +## MCP tools (6) + +Defined in `open-sse/mcp-server/tools/notionTools.ts`. The token is resolved at call +time via `getNotionToken()`; if none is configured the tool throws +`"Notion integration token not configured. Set it in Settings > Context Sources."` + +| Tool | Scope | Description | +| ---------------------------- | -------------- | --------------------------------------------------------------------------------- | +| `notion_search` | `read:notion` | Search pages and databases by text query (returns titles, IDs, URLs). Paginated. | +| `notion_get_page` | `read:notion` | Get content and metadata of a page by its ID. | +| `notion_list_block_children` | `read:notion` | List all block children of a block or page (the block tree). Paginated. | +| `notion_query_database` | `read:notion` | Query a database with optional `filter` + `sorts` (Notion API format). Paginated. | +| `notion_get_database` | `read:notion` | Get the schema/metadata of a database by ID. | +| `notion_append_blocks` | `write:notion` | Append block children to an existing block or page (max 100 blocks per request). | + +### Input parameters + +- `notion_search` β€” `query` (1–500 chars), `pageSize` (1–100, default 20), + `startCursor` (optional). +- `notion_get_page` β€” `pageId` (32-char hex or UUID). +- `notion_list_block_children` β€” `blockId`, `pageSize` (1–100, default 50), + `startCursor` (optional). +- `notion_query_database` β€” `databaseId`, `filter` (optional, Notion filter format), + `sorts` (optional array), `pageSize` (1–100, default 50), `startCursor` (optional). +- `notion_get_database` β€” `databaseId`. +- `notion_append_blocks` β€” `blockId`, `children` (array of block objects), + `after` (optional position). + +### Scopes + +The read tools require `read:notion` and the write tool requires `write:notion`. +Scopes are enforced by `withScopeEnforcement()` in +`open-sse/mcp-server/server.ts` only when `OMNIROUTE_MCP_ENFORCE_SCOPES=true`; the +caller's allowed scopes come from `OMNIROUTE_MCP_SCOPES` (comma-separated) or the +authenticated API key's scope context. See [MCP-SERVER.md](./MCP-SERVER.md) for the +full scope model. + +## Endpoints + +| Method | Path | Purpose | +| -------- | ---------------------- | -------------------------------------- | +| `GET` | `/api/settings/notion` | Return `{ connected, hasToken }`. | +| `POST` | `/api/settings/notion` | Save + validate the integration token. | +| `DELETE` | `/api/settings/notion` | Disconnect (clear the stored token). | + +> These are dashboard settings routes. There is **no public `/v1` Notion proxy +> endpoint** β€” Notion is reached exclusively through the MCP tools above. + +## Use cases + +- **Knowledge-grounded answers** β€” let an agent `notion_search` the workspace and + `notion_get_page` the top hit before answering, so responses cite real internal docs. +- **Database-backed workflows** β€” `notion_query_database` a tasks/CRM database with + filters + sorts, then summarize or triage the rows. +- **Write-back / logging** β€” `notion_append_blocks` to append meeting notes, run + summaries, or agent output into an existing page (append-only; no destructive edits). +- **Structure exploration** β€” `notion_list_block_children` to walk a page's block tree, + or `notion_get_database` to discover a database's property schema before querying it. + +## Related + +- [MCP Server](./MCP-SERVER.md) β€” transports, scope enforcement, full tool inventory. +- [Obsidian Context Source](./OBSIDIAN_CONTEXT.md) β€” the other built-in context source. +- [Memory System](./MEMORY.md) β€” persistent conversational memory (complementary + context layer, injected automatically rather than tool-fetched). diff --git a/Obsidian-Context.md b/Obsidian-Context.md new file mode 100644 index 0000000..7e92c40 --- /dev/null +++ b/Obsidian-Context.md @@ -0,0 +1,188 @@ +> 🌍 [View in other languages](Languages) + + +# Obsidian Context Source + +> **Source of truth:** `src/lib/obsidian/api.ts` (REST + sync client), +> `src/lib/db/obsidian.ts` (token / base-URL / WebDAV persistence), +> `src/lib/obsidianSync.ts` (WebDAV vault sync), `open-sse/mcp-server/tools/obsidianTools.ts` +> (22 MCP tools), `src/app/api/settings/obsidian/route.ts` + +> `src/app/api/settings/obsidian/webdav/route.ts` (settings APIs). Tool registration +> and scope wiring lives in `open-sse/mcp-server/server.ts`. + +## What it is + +OmniRoute connects to an **Obsidian** vault as a **context source** β€” a local Markdown +knowledge base that agents read and write through the built-in MCP server. The +integration talks to the **Obsidian Local REST API** community plugin running inside the +desktop app, so agents can search notes, read/write/patch files, list the vault, work +with daily/weekly periodic notes, manage tags, run Obsidian commands, and (optionally) +coordinate a bidirectional desktop↔mobile vault sync. + +The client (`src/lib/obsidian/api.ts`) wraps the Local REST API with: + +- **Retry with backoff** for transient `5xx`, **30-second timeout** via `AbortController`. +- **Typed error classification** β€” `ObsidianAuthError` (401/403), + `ObsidianNotFoundError` (404), `ObsidianServerError` (5xx), `ObsidianTimeoutError`. +- A **friendly "cannot reach Obsidian" hint** that calls out the common port mistake + (HTTP on `27123`, **not** the MCP endpoint on `27124`) and the Tailscale form. +- Vault-relative **path encoding** so note paths with spaces/slashes are safe. + +## Setup + +There is **no environment variable** for the Obsidian token or base URL β€” both are +stored in the SQLite `key_value` table (namespace `obsidian`) via +`src/lib/db/obsidian.ts`. The token is **encrypted at rest** (AES-256-GCM, with +plaintext backward-compat fallback). Configure from the **Context Sources** tab of the +Endpoint dashboard (`ObsidianSourceCard`), or via the settings REST API. + +> [!IMPORTANT] +> The **Obsidian Local REST API** plugin must be installed and running. Its REST +> interface listens on **HTTP `127.0.0.1:27123`** (the default base URL). Port `27124` +> is a _separate_ MCP/HTTPS endpoint and is explicitly rejected by the settings route. +> If connecting from another device, use `http://:27123`. + +### Configuration keys (SQLite `key_value`, namespace `obsidian`) + +| Key | Purpose | Encrypted | +| ----------------- | ------------------------------------------------ | --------- | +| `api_key` | Local REST API bearer token | yes | +| `base_url` | REST base URL (default `http://127.0.0.1:27123`) | no | +| `vault_path` | Absolute path to the vault directory (for sync) | no | +| `webdav_username` | Generated WebDAV username (vault sync) | no | +| `webdav_password` | Generated WebDAV password (vault sync) | yes | +| `webdav_enabled` | Whether WebDAV vault sync is enabled | no | + +### Configure via REST + +```bash +# Save + validate the Local REST API token (POST validates via a status check) +curl -X POST http://localhost:20128/api/settings/obsidian \ + -H "Content-Type: application/json" \ + -d '{"token":"","baseUrl":"http://127.0.0.1:27123"}' + +# Check connection status (returns connected, hasToken, baseUrl, vaultPath) +curl http://localhost:20128/api/settings/obsidian + +# Disconnect (clears the stored token) +curl -X DELETE http://localhost:20128/api/settings/obsidian +``` + +All methods require dashboard authentication. `POST` rejects any URL on port `27124` +and validates the token by calling the Local REST API status endpoint before persisting. + +### WebDAV vault sync + +`src/app/api/settings/obsidian/webdav/route.ts` manages an optional WebDAV-backed +vault sync (driven by `src/lib/obsidianSync.ts`). Enabling it points OmniRoute at a +local vault directory and mints a random WebDAV username/password pair: + +```bash +# Enable WebDAV sync for a vault directory (mints username/password) +curl -X POST http://localhost:20128/api/settings/obsidian/webdav \ + -H "Content-Type: application/json" \ + -d '{"vaultPath":"/home/me/MyVault"}' + +# Get WebDAV sync status (credentials returned only while enabled) +curl http://localhost:20128/api/settings/obsidian/webdav + +# Disable WebDAV sync (clears credentials + managed .stignore) +curl -X DELETE http://localhost:20128/api/settings/obsidian/webdav +``` + +### Per-API-key context source (optional) + +Obsidian config can be scoped **per API key** via the `api_key_context_sources` table +(`src/lib/db/apiKeyContextSources.ts`). When an MCP call carries an authenticated API +key id, `getObsidianConfigForApiKey()` prefers that key's own token/base-URL/vault-path +(`source: "api_key"`) and otherwise falls back to the global config (`source: "global"`). + +## MCP tools (22) + +Defined in `open-sse/mcp-server/tools/obsidianTools.ts`. The token/base-URL are resolved +per call (per-API-key first, then global). Tools that hit the OmniRoute **sync server** +(the four `obsidian_sync_*` tools) additionally require the sync auth token configured +in OmniRoute settings. + +### Read tools (`read:obsidian`) + +| Tool | Description | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `obsidian_check_status` | Check whether the Local REST API is reachable and authenticated. | +| `obsidian_search_simple` | Full-text search of note content; returns snippets with file paths. | +| `obsidian_search_structured` | Search using a JSON Logic expression (and/or/regex/path filters). | +| `obsidian_read_note` | Read a note by vault-relative path; optionally a specific heading/block/frontmatter. | +| `obsidian_list_vault` | List files and directories in the vault (tree of entries). | +| `obsidian_get_document_map` | Get the note's heading structure as a map of headings β†’ line numbers. | +| `obsidian_get_note_metadata` | Get frontmatter, tags, links, char/word count without the full content. | +| `obsidian_get_active_file` | Get the path + content of the currently active file in Obsidian. | +| `obsidian_get_periodic_note` | Get the daily/weekly/monthly periodic note for a date (today if omitted). | +| `obsidian_get_tags` | List all vault tags with their frequencies. | +| `obsidian_list_commands` | List available Obsidian command IDs (use with `obsidian_execute_command`). | +| `obsidian_sync_status` | OmniRoute sync server status: running, vault name, port, uptime, last sync. | +| `obsidian_sync_conflicts` | List unresolved sync conflicts (path, conflict path, detected-at). | + +### Write tools (`write:obsidian`) + +| Tool | Description | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| `obsidian_write_note` | Create or overwrite a note with given Markdown content. | +| `obsidian_append_note` | Append content to a note; optionally to a specific heading/block. | +| `obsidian_patch_note` | Surgically append/prepend/replace at a heading, block, or frontmatter field. | +| `obsidian_delete_note` | Permanently delete a note from the vault. | +| `obsidian_move_note` | Move or rename a note within the vault. | +| `obsidian_execute_command` | Execute an Obsidian command by its command ID. | +| `obsidian_open_file` | Open a file in Obsidian (creates it if it does not exist). | +| `obsidian_sync_trigger` | Trigger an immediate bidirectional desktop↔mobile vault sync. | +| `obsidian_sync_resolve_conflict` | Resolve a sync conflict: keep `local` (mobile), `remote` (desktop), or `keep-both`. | + +> [!NOTE] +> `obsidian_patch_note` targets accept `targetType` of `heading | block | frontmatter` +> and `operation` of `append | prepend | replace`, with an optional +> `createTargetIfMissing`. The four `obsidian_sync_*` tools talk to the local sync +> server (`http://127.0.0.1:27781` by default) and require the sync token. + +### Scopes + +Read tools require `read:obsidian`; write tools require `write:obsidian`. Enforcement +is identical to Notion β€” handled by `withScopeEnforcement()` in +`open-sse/mcp-server/server.ts`, gated on `OMNIROUTE_MCP_ENFORCE_SCOPES=true`, with +allowed scopes sourced from `OMNIROUTE_MCP_SCOPES` or the API key's scope context. See +[MCP-SERVER.md](./MCP-SERVER.md). + +## Endpoints + +| Method | Path | Purpose | +| -------- | ------------------------------- | ----------------------------------------------------- | +| `GET` | `/api/settings/obsidian` | Return `{ connected, hasToken, baseUrl, vaultPath }`. | +| `POST` | `/api/settings/obsidian` | Save + validate token (rejects port `27124`). | +| `DELETE` | `/api/settings/obsidian` | Disconnect (clear stored token). | +| `GET` | `/api/settings/obsidian/webdav` | WebDAV sync status + credentials (while enabled). | +| `POST` | `/api/settings/obsidian/webdav` | Enable WebDAV sync for a vault directory. | +| `DELETE` | `/api/settings/obsidian/webdav` | Disable WebDAV sync. | + +> These are dashboard settings routes. The vault itself is reached through the Obsidian +> Local REST API (the configured `base_url`) and through the MCP tools above β€” there is +> no public `/v1` Obsidian proxy endpoint. + +## Use cases + +- **Vault-grounded answers** β€” `obsidian_search_simple` / `obsidian_search_structured` + then `obsidian_read_note` so an agent answers from your real notes. +- **Note authoring / journaling** β€” `obsidian_write_note`, `obsidian_append_note`, or + the surgical `obsidian_patch_note` to log agent output, summaries, or daily notes + (`obsidian_get_periodic_note`) into the vault. +- **Vault navigation** β€” `obsidian_list_vault`, `obsidian_get_document_map`, and + `obsidian_get_tags` to explore structure before reading/writing. +- **Obsidian automation** β€” `obsidian_list_commands` + `obsidian_execute_command` to + drive plugins/commands from an agent; `obsidian_open_file` to surface a note in the UI. +- **Mobile sync** β€” enable WebDAV sync, then `obsidian_sync_trigger` / + `obsidian_sync_status` / `obsidian_sync_conflicts` / `obsidian_sync_resolve_conflict` + to coordinate desktop↔mobile and resolve conflicts. + +## Related + +- [MCP Server](./MCP-SERVER.md) β€” transports, scope enforcement, full tool inventory. +- [Notion Context Source](./NOTION_CONTEXT.md) β€” the other built-in context source. +- [Memory System](./MEMORY.md) β€” persistent conversational memory (complementary + context layer, injected automatically rather than tool-fetched). diff --git a/Plugin-Marketplace.md b/Plugin-Marketplace.md new file mode 100644 index 0000000..7e109d4 --- /dev/null +++ b/Plugin-Marketplace.md @@ -0,0 +1,345 @@ +> 🌍 [View in other languages](Languages) + + +# Plugin Marketplace + +> **Source of truth:** `src/lib/plugins/` (`marketplace.ts`, `manager.ts`, `manifest.ts`, +> `scanner.ts`, `loader.ts`), `src/app/api/plugins/`, and +> `src/app/(dashboard)/dashboard/plugins/` +> **Last updated:** 2026-06-13 β€” v3.8.24 + +OmniRoute ships a WordPress-style plugin system. Plugins are self-contained +directories β€” each with a `plugin.json` manifest and an entry file β€” that hook +into the request pipeline (`onRequest` / `onResponse` / `onError`) and into +lifecycle events (`onInstall` / `onActivate` / `onDeactivate` / `onUninstall`). + +The **Plugin Marketplace** is the discovery layer on top of that system. It +exposes a browsable catalog of installable plugins. By default the catalog is a +small built-in seed registry; an operator can point it at a custom remote +registry URL, in which case the fetch is hardened by a DNS-resolving SSRF guard +(see [Security](#security)). + +Every plugin route is **loopback-only** (Tier 1 β€” `LOCAL_ONLY`): plugins load +and execute code in child processes, so the routes are unreachable from a +non-loopback origin regardless of auth. See +[`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md). + +## How It Fits Together + +``` +Dashboard (/dashboard/plugins) + β”œβ”€ "Installed" tab β†’ GET /api/plugins (listPlugins) + β”‚ POST /api/plugins/scan (pluginManager.scan) + β”‚ POST /api/plugins/{name}/activate|deactivate + β”‚ DELETE /api/plugins/{name} (uninstall) + └─ "Marketplace" tab β†’ GET /api/plugins/marketplace + β†’ listMarketplacePlugins() + β”œβ”€ no custom URL β†’ built-in SEED_REGISTRY + └─ custom URL β†’ isSafeMarketplaceUrl() SSRF guard + β†’ safeOutboundFetch(guard:"public-only") +``` + +- **Registry layer** β€” `src/lib/plugins/marketplace.ts`: lists / searches the + catalog, falling back to the seed registry on any failure. +- **Lifecycle layer** β€” `src/lib/plugins/manager.ts` (`pluginManager` singleton): + install, upgrade, activate, deactivate, uninstall, scan, startup load. +- **Manifest layer** β€” `src/lib/plugins/manifest.ts`: Zod schema + defaults for + `plugin.json`. +- **Scanner** β€” `src/lib/plugins/scanner.ts`: discovers plugins on disk under + the plugin directory. +- **Loader** β€” `src/lib/plugins/loader.ts`: spawns each plugin in an isolated + child process and brokers hook calls over IPC. + +## Marketplace Catalog + +`listMarketplacePlugins()` (`src/lib/plugins/marketplace.ts`) returns a list of +`MarketplaceEntry` objects: + +| Field | Type | Notes | +| ------------- | -------- | ------------------------------------ | +| `name` | string | kebab-case plugin name | +| `version` | string | semver | +| `description` | string | Short summary | +| `author` | string | Author / org | +| `license` | string | SPDX-style license id | +| `downloadUrl` | string | Source download URL (may be empty) | +| `repository` | string? | Optional repository URL | +| `tags` | string[] | Search/filter tags | +| `downloads` | number | Download count | +| `rating` | number | 0–5 | +| `verified` | boolean | Whether the entry is marked verified | +| `lastUpdated` | string | ISO-ish date string | + +When no custom registry URL is configured, the catalog is the built-in +`SEED_REGISTRY` (currently `request-logger`, `rate-limiter`, `cost-tracker`, and +`theme-manager`). The seed registry is always available β€” if a configured remote +registry is unreachable, returns a non-`200` status, or returns an unrecognized +body, `listMarketplacePlugins()` logs a warning and falls back to the seed list. + +> Note: the marketplace **catalog** (browse/search) is wired end to end, but +> one-click marketplace **install** from the catalog is not yet implemented β€” the +> dashboard's "Install" button on a marketplace entry currently shows a +> "coming soon" notice. Installation today goes through the local-path install +> flow (`POST /api/plugins`) and on-disk discovery (`POST /api/plugins/scan`). + +## REST API + +All endpoints require management auth (`requireManagementAuth`) **and** are +loopback-only β€” `/api/plugins` and `/api/plugins/` are listed in +`LOCAL_ONLY_API_PREFIXES` (`src/server/authz/routeGuard.ts`). + +| Endpoint | Method | Description | +| -------------------------------- | ------ | --------------------------------------------------- | +| `/api/plugins` | GET | List installed plugins (optional `?status=` filter) | +| `/api/plugins` | POST | Install a plugin from an absolute local path | +| `/api/plugins/scan` | POST | Scan the plugin directory and register new plugins | +| `/api/plugins/marketplace` | GET | List marketplace catalog entries | +| `/api/plugins/[name]` | GET | Get installed plugin details | +| `/api/plugins/[name]` | DELETE | Uninstall a plugin | +| `/api/plugins/[name]/activate` | POST | Activate (load + register hooks) | +| `/api/plugins/[name]/deactivate` | POST | Deactivate (fire `onDeactivate`, unregister hooks) | +| `/api/plugins/[name]/config` | GET | Get plugin config + config schema | +| `/api/plugins/[name]/config` | PUT | Update plugin config (validated against schema) | + +The `GET /api/plugins` `status` filter accepts one of +`installed` / `active` / `inactive` / `error`. An invalid value returns `400`. + +### List installed plugins + +```bash +curl http://localhost:20128/api/plugins \ + -H "Cookie: auth_token=..." +``` + +### Install from a local path + +```bash +curl -X POST http://localhost:20128/api/plugins \ + -H "Cookie: auth_token=..." \ + -H "Content-Type: application/json" \ + -d '{ "path": "/absolute/path/to/my-plugin" }' +``` + +The `path` must be **absolute** and may not contain `..` traversal segments or +null bytes (enforced by Zod). The source directory must contain a valid +`plugin.json` (or be a parent of one). On success the response is `201` with the +installed plugin row. + +### Browse the marketplace + +```bash +curl http://localhost:20128/api/plugins/marketplace \ + -H "Cookie: auth_token=..." +``` + +### Update plugin config + +```bash +curl -X PUT http://localhost:20128/api/plugins/my-plugin/config \ + -H "Cookie: auth_token=..." \ + -H "Content-Type: application/json" \ + -d '{ "config": { "level": "debug", "maxItems": 100 } }' +``` + +`PUT .../config` validates each provided value against the plugin's +`configSchema` (declared in the manifest): `number` fields honor `min`/`max`, +`select` fields must match the declared `enum`. Keys not present in the schema +are allowed through. + +## Configuration + +### Plugin directory + +Plugins live under the OmniRoute data directory: + +``` +~/.omniroute/plugins// + β”œβ”€ plugin.json + └─ index.js # (or whatever manifest.main points to) +``` + +`getDefaultPluginDir()` (`src/lib/plugins/scanner.ts`) resolves this to +`/.omniroute/plugins`, where `` is taken from the `HOME` / +`USERPROFILE` environment variables. `POST /api/plugins/scan` discovers any +subdirectory there that holds a valid `plugin.json` and registers it. + +### Custom marketplace registry URL + +The marketplace catalog source is read from the `pluginMarketplaceUrl` setting +(`src/lib/plugins/marketplace.ts` reads `settings.pluginMarketplaceUrl`). When +set to an `http(s)` URL, `listMarketplacePlugins()` fetches that URL and accepts +either a top-level JSON array of entries or an object with a `plugins` array; +entries without a string `name` are filtered out. When unset (or when the fetch +fails the SSRF guard / returns a bad response), the built-in seed registry is +used. + +The dashboard "Marketplace" tab exposes a field for this URL (read back from +`GET /api/settings`). + +> Implementation note: the dashboard "Save" action sends +> `pluginMarketplaceUrl` to `PATCH /api/settings`. At the time of writing this +> key is not declared in `updateSettingsSchema` +> (`src/shared/validation/settingsSchemas.ts`), so verify persistence in your +> release before relying on it β€” the **read** path (`getSettings()` β†’ +> `listMarketplacePlugins()`) honors the key once it is present in the settings +> store. + +## Security + +### Route tier β€” loopback only + +Plugins execute code in spawned child processes, so the entire `/api/plugins` +surface is classified `LOCAL_ONLY` (Tier 1). Loopback enforcement runs +unconditionally **before** any auth check, so a leaked management token reaching +the box over a tunnel still cannot install, activate, or uninstall a plugin. +See [`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md) and +Hard Rules #15 / #17. + +### Marketplace registry SSRF guard + +A custom registry URL is attacker-influenceable configuration, so before +fetching it `listMarketplacePlugins()` runs it through two layers: + +1. **`isSafeMarketplaceUrl(url)`** (`src/lib/plugins/marketplace.ts`): + - Rejects anything that is not `http:` / `https:`. + - Rejects literal private/loopback/link-local/ULA hosts (IPv4 **and** IPv6, + including IPv4-mapped) via the canonical `isPrivateHost` + (`src/shared/network/outboundUrlGuard.ts`). + - Resolves **both** `A` and `AAAA` records and rejects if **any** resolved + address is private β€” closing the public-hostname β†’ private-IP bypass. + - **Fails closed**: a DNS resolution failure rejects the URL. +2. **`safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 })`** + (`src/shared/network/safeOutboundFetch.ts`): re-applies the public-only URL + guard at fetch time and **blocks redirects** (no public β†’ private `30x` + pivot). + +A URL that fails either layer does not abort the request β€” the marketplace +silently falls back to the built-in seed registry and logs a warning. + +> This guard was hardened in PR #3774 specifically to resolve A + AAAA and use +> the canonical `isPrivateHost` instead of an IPv4-only check. + +### Plugin execution isolation + +- **Process isolation** β€” `loadPlugin()` (`src/lib/plugins/loader.ts`) spawns + each plugin in a separate Node.js child process and communicates over IPC. + Hook calls have a timeout with `SIGTERM` β†’ `SIGKILL` escalation. +- **Env allowlist** β€” the child receives only an allowlisted set of environment + variables; the broader set is only granted when the manifest requests the + `env` permission. +- **Path containment** β€” install/upgrade/uninstall assert that the plugin + directory and `manifest.main` resolve **within** the managed plugin root + before any copy or recursive delete (guards against tampered DB paths and + `../` traversal in `manifest.main`). Activation resolves symlinks via + `realpath` and refuses to load an entry point that escapes the plugin + directory. +- **Optional integrity pin** β€” a manifest may declare an `integrity` + (`sha256-`, SRI format) field. When present, the loader verifies the + entry file hash at load time and refuses to activate on mismatch. It is + opt-in tamper-detection, **not** a security boundary β€” loopback-only routing + and the permission model are the real boundaries. + +## Manifest (`plugin.json`) + +Validated by `PluginManifestSchema` (`src/lib/plugins/manifest.ts`): + +| Field | Type | Notes | +| ------------------ | --------- | ----------------------------------------------------------- | +| `name` | string | Required; kebab-case (`^[a-z0-9-]+$`), 1–100 chars | +| `version` | string | Required; semver (`MAJOR.MINOR.PATCH`) | +| `description` | string? | ≀ 500 chars | +| `author` | string? | ≀ 200 chars | +| `license` | string? | Defaults to `MIT` | +| `main` | string? | Entry file; defaults to `index.js` | +| `source` | enum? | `local` \| `marketplace` (defaults to `local`) | +| `tags` | string[]? | Search tags | +| `requires` | object? | `{ omniroute?, permissions[] }` | +| `hooks` | object? | Booleans declaring which hooks the plugin implements | +| `skills` | object[]? | Optional skill definitions | +| `enabledByDefault` | boolean? | Auto-activate on install | +| `configSchema` | object? | Map of config fields (`string`/`number`/`boolean`/`select`) | +| `integrity` | string? | Optional `sha256-` entry-file pin | + +Permissions are drawn from the enum +`network` / `file-read` / `file-write` / `env` / `exec`. + +## Lifecycle Flow + +``` +install (POST /api/plugins, path) + β†’ scan/validate manifest β†’ copy to staging β†’ assert main within dir + β†’ atomic rename into ~/.omniroute/plugins/ β†’ insert DB row + β†’ fire onInstall β†’ if enabledByDefault: activate + +activate (POST /api/plugins/{name}/activate) + β†’ realpath containment check β†’ loadPlugin() (spawn child process) + β†’ register declared hooks β†’ status = "active" β†’ fire onActivate + +deactivate (POST /api/plugins/{name}/deactivate) + β†’ fire onDeactivate (BEFORE unregister) β†’ unregister hooks + β†’ kill child process β†’ status = "inactive" + +uninstall (DELETE /api/plugins/{name}) + β†’ deactivate if active β†’ fire onUninstall + β†’ containment-checked recursive delete of plugin dir β†’ delete DB row +``` + +Re-running `install` against a directory whose manifest version is **strictly +newer** than the installed version auto-upgrades (clean reinstall; config resets +to defaults). A same-or-older version is rejected. + +## Database + +Table `plugins` (migration `076_create_plugins.sql`): + +| Column | Type | Notes | +| --------------- | ------- | ------------------------------------------------ | +| `id` | TEXT PK | UUID | +| `name` | TEXT | Unique | +| `version` | TEXT | semver; default `1.0.0` | +| `description` | TEXT | Optional | +| `author` | TEXT | Optional | +| `license` | TEXT | Default `MIT` | +| `main` | TEXT | Entry file; default `index.js` | +| `source` | TEXT | Default `local` | +| `tags` | TEXT | JSON array; default `[]` | +| `status` | TEXT | `installed` \| `active` \| `inactive` \| `error` | +| `enabled` | INT | 0/1; default 0 | +| `manifest` | TEXT | Full manifest JSON | +| `config` | TEXT | JSON; default `{}` | +| `config_schema` | TEXT | JSON; default `{}` | +| `hooks` | TEXT | JSON array of declared hook names; default `[]` | +| `permissions` | TEXT | JSON array; default `[]` | +| `plugin_dir` | TEXT | Absolute install directory | +| `error_message` | TEXT | Set when `status = "error"` | +| `installed_at` | TEXT | `datetime('now')` | +| `updated_at` | TEXT | `datetime('now')` | +| `activated_at` | TEXT | Set on activation | + +Plugin metrics/analytics are tracked in additional tables +(`090_plugin_metrics.sql`, `091_plugin_analytics.sql`). + +## Dashboard + +The dashboard page at `/dashboard/plugins` +(`src/app/(dashboard)/dashboard/plugins/page.tsx`) provides two tabs: + +- **Installed** β€” lists installed plugins with their declared hooks, an + activate/deactivate toggle, an uninstall button, and a "Scan for plugins" + action (`POST /api/plugins/scan`). +- **Marketplace** β€” shows the catalog from `GET /api/plugins/marketplace` with a + field to set the custom registry URL. + +A per-plugin config page lives at `/dashboard/plugins/[name]/config` +(`src/app/(dashboard)/dashboard/plugins/[name]/config/page.tsx`). + +## See Also + +- [`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md) β€” + why `/api/plugins` is loopback-only (Tier 1) +- [`docs/frameworks/SKILLS.md`](./SKILLS.md) β€” the related skills framework + (`src/lib/skills/`); plugins may declare skills in their manifest +- [`docs/frameworks/WEBHOOKS.md`](./WEBHOOKS.md) β€” event-driven outbound + integrations +- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) β€” + the `buildErrorBody()` pattern every plugin route uses for error responses