docs(wiki): auto-sync pages + cover counts with docs

github-actions[bot]
2026-06-16 04:00:53 +00:00
parent 164c04ef9f
commit 885a8cc97d
7 changed files with 1648 additions and 0 deletions

258
Cost-Tracking.md Normal file

@@ -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 <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 <id>] # per-provider cost summary
omniroute usage logs [--limit 100] [--follow] [--api-key <k>] [--search <q>]
omniroute usage quota [--provider <id>] [--check]
omniroute usage utilization [--api-key <k>]
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 <amount> [--scope global] [--period monthly]
omniroute usage budget reset [scope]
```
### `omniroute pricing`
```bash
omniroute pricing list [--provider <p>] [--model <m>] [--limit 200]
omniroute pricing get <model>
omniroute pricing sync [--provider <p>] [--force] # POST /api/pricing/sync
omniroute pricing diff [--model <m>]
omniroute pricing defaults show
omniroute pricing defaults set [--input <p>] [--output <p>] [--cache-read <p>] [--cache-write <p>]
```
> `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).

247
Egress-Policy.md Normal file

@@ -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<string, unknown> {
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

232
Feature-Flags.md Normal file

@@ -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[<KEY>]`, 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: <count>, 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.

250
Free-Provider-Rankings.md Normal file

@@ -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 `1100`. |
Response shape:
```json
{
"rankings": [
{
"id": "<provider-id>",
"name": "<provider name>",
"icon": "<icon>",
"color": "<hex color>",
"textIcon": "<short label>",
"category": "noauth | oauth | apikey",
"topModel": {
"modelId": "<registry model id>",
"modelName": "<model display name>",
"score": 0.0,
"eloRaw": 0,
"confidence": "high | medium | low",
"category": "<task 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.

128
Notion-Context.md Normal file

@@ -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
> <https://www.notion.com/my-integrations>, 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` (1500 chars), `pageSize` (1100, default 20),
`startCursor` (optional).
- `notion_get_page``pageId` (32-char hex or UUID).
- `notion_list_block_children``blockId`, `pageSize` (1100, default 50),
`startCursor` (optional).
- `notion_query_database``databaseId`, `filter` (optional, Notion filter format),
`sorts` (optional array), `pageSize` (1100, 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).

188
Obsidian-Context.md Normal file

@@ -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://<tailscale-ip>: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":"<obsidian-rest-api-key>","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).

345
Plugin-Marketplace.md Normal file

@@ -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 | 05 |
| `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-name>/
├─ plugin.json
└─ index.js # (or whatever manifest.main points to)
```
`getDefaultPluginDir()` (`src/lib/plugins/scanner.ts`) resolves this to
`<home>/.omniroute/plugins`, where `<home>` 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-<base64>`, 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-]+$`), 1100 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-<base64>` 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/<name> → 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