Clone
1
Plugin Marketplace
github-actions[bot] edited this page 2026-06-16 04:00:53 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

🌍 View in other 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).

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.

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 layersrc/lib/plugins/marketplace.ts: lists / searches the catalog, falling back to the seed registry on any failure.
  • Lifecycle layersrc/lib/plugins/manager.ts (pluginManager singleton): install, upgrade, activate, deactivate, uninstall, scan, startup load.
  • Manifest layersrc/lib/plugins/manifest.ts: Zod schema + defaults for plugin.json.
  • Scannersrc/lib/plugins/scanner.ts: discovers plugins on disk under the plugin directory.
  • Loadersrc/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

curl http://localhost:20128/api/plugins \
  -H "Cookie: auth_token=..."

Install from a local path

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

curl http://localhost:20128/api/plugins/marketplace \
  -H "Cookie: auth_token=..."

Update plugin config

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 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 isolationloadPlugin() (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 SIGTERMSIGKILL 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