Files
OmniRoute/src/lib/db/plugins.ts
Diego Rodrigues de Sa e Souza 20c31493af feat(plugins): plugins framework + per-API-key disable-non-public-models (#3041)
Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (#2913 — thanks @oyi77): hooks + registry unification, plugin SDK (`definePlugin`), worker-thread sandbox, per-plugin hook rate limiting, SHA-256 integrity verification, semver-gated upgrade, and execution analytics. Plugin routes are loopback-only (`isLocalOnlyPath`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
2026-06-01 15:43:55 -03:00

281 lines
7.6 KiB
TypeScript

/**
* Plugin DB module — CRUD operations for the plugins table.
*
* @module db/plugins
*/
import { getDbInstance } from "./core";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("DB_PLUGINS");
// ── Types ──
export interface PluginRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
license: string;
main: string;
source: string;
tags: string; // JSON array
status: "installed" | "active" | "inactive" | "error";
enabled: number; // 0 | 1
manifest: string; // JSON
config: string; // JSON
configSchema: string; // JSON
hooks: string; // JSON array
permissions: string; // JSON array
pluginDir: string;
errorMessage: string | null;
installedAt: string;
updatedAt: string;
activatedAt: string | null;
}
export interface PluginCreateInput {
id: string;
name: string;
version: string;
description?: string;
author?: string;
license?: string;
main: string;
source?: string;
tags?: string[];
status?: PluginRow["status"];
enabled?: boolean;
manifest: Record<string, unknown>;
config?: Record<string, unknown>;
configSchema?: Record<string, unknown>;
hooks?: string[];
permissions?: string[];
pluginDir: string;
}
// ── Helpers ──
function rowToPlugin(row: any): PluginRow {
return {
id: row.id,
name: row.name,
version: row.version,
description: row.description,
author: row.author,
license: row.license,
main: row.main,
source: row.source,
tags: row.tags,
status: row.status,
enabled: row.enabled,
manifest: row.manifest,
config: row.config,
configSchema: row.config_schema,
hooks: row.hooks,
permissions: row.permissions,
pluginDir: row.plugin_dir,
errorMessage: row.error_message,
installedAt: row.installed_at,
updatedAt: row.updated_at,
activatedAt: row.activated_at,
};
}
// ── CRUD ──
export function insertPlugin(input: PluginCreateInput): PluginRow {
const db = getDbInstance();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO plugins (
id, name, version, description, author, license, main, source, tags,
status, enabled, manifest, config, config_schema, hooks, permissions,
plugin_dir, installed_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
input.id,
input.name,
input.version,
input.description ?? null,
input.author ?? null,
input.license ?? "MIT",
input.main,
input.source ?? "local",
JSON.stringify(input.tags ?? []),
input.status ?? "installed",
input.enabled ? 1 : 0,
JSON.stringify(input.manifest),
JSON.stringify(input.config ?? {}),
JSON.stringify(input.configSchema ?? {}),
JSON.stringify(input.hooks ?? []),
JSON.stringify(input.permissions ?? []),
input.pluginDir,
now,
now
);
log.info("plugin.inserted", { id: input.id, name: input.name });
const plugin = getPluginByName(input.name);
if (!plugin) {
throw new Error(`Failed to retrieve plugin '${input.name}' after insertion`);
}
return plugin;
}
export function getPluginById(id: string): PluginRow | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM plugins WHERE id = ?").get(id);
return row ? rowToPlugin(row) : null;
}
export function getPluginByName(name: string): PluginRow | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM plugins WHERE name = ?").get(name);
return row ? rowToPlugin(row) : null;
}
export function listPlugins(status?: PluginRow["status"]): PluginRow[] {
const db = getDbInstance();
const rows = status
? db.prepare("SELECT * FROM plugins WHERE status = ? ORDER BY name").all(status)
: db.prepare("SELECT * FROM plugins ORDER BY name").all();
return rows.map(rowToPlugin);
}
export function updatePluginStatus(
name: string,
status: PluginRow["status"],
errorMessage?: string
): boolean {
const db = getDbInstance();
const now = new Date().toISOString();
const activatedAt = status === "active" ? now : null;
// `activated_at` records the most-recent activation timestamp and is intentionally
// preserved on deactivation via COALESCE (activatedAt is null when status != "active").
// Callers should treat it as "last activated at", not "currently active since".
const result = db
.prepare(
`UPDATE plugins SET status = ?, enabled = ?, error_message = ?,
updated_at = ?, activated_at = COALESCE(?, activated_at)
WHERE name = ?`
)
.run(status, status === "active" ? 1 : 0, errorMessage ?? null, now, activatedAt, name);
if (result.changes > 0) {
log.info("plugin.status_updated", { name, status });
}
return result.changes > 0;
}
export function updatePluginConfig(name: string, config: Record<string, unknown>): boolean {
const db = getDbInstance();
const now = new Date().toISOString();
const result = db
.prepare("UPDATE plugins SET config = ?, updated_at = ? WHERE name = ?")
.run(JSON.stringify(config), now, name);
return result.changes > 0;
}
export function deletePlugin(name: string): boolean {
const db = getDbInstance();
const result = db.prepare("DELETE FROM plugins WHERE name = ?").run(name);
if (result.changes > 0) {
log.info("plugin.deleted", { name });
}
return result.changes > 0;
}
export function pluginExists(name: string): boolean {
const db = getDbInstance();
const row = db.prepare("SELECT 1 FROM plugins WHERE name = ?").get(name);
return !!row;
}
// ── Analytics ──
export interface PluginExecutionRow {
pluginName: string;
hook: string;
durationMs: number;
success: boolean;
errorMessage: string | null;
createdAt: string;
}
export interface PluginAnalyticsSummary {
totalCalls: number;
successCount: number;
failureCount: number;
avgDurationMs: number;
}
/**
* Record a single plugin execution in plugin_analytics.
*/
export function recordPluginExecution(
pluginName: string,
hook: string,
durationMs: number,
success: boolean,
errorMessage?: string
): void {
const db = getDbInstance();
db.prepare(
`INSERT INTO plugin_analytics (plugin_name, hook, duration_ms, success, error_message)
VALUES (?, ?, ?, ?, ?)`
).run(pluginName, hook, durationMs, success ? 1 : 0, errorMessage ?? null);
}
/**
* Return execution rows for a given plugin (most recent first).
*/
export function getPluginAnalytics(pluginName: string): PluginExecutionRow[] {
const db = getDbInstance();
const rows = db
.prepare(
`SELECT plugin_name, hook, duration_ms, success, error_message, created_at
FROM plugin_analytics
WHERE plugin_name = ?
ORDER BY created_at DESC`
)
.all(pluginName) as any[];
return rows.map((r) => ({
pluginName: r.plugin_name,
hook: r.hook,
durationMs: r.duration_ms,
success: r.success === 1,
errorMessage: r.error_message,
createdAt: r.created_at,
}));
}
/**
* Return aggregate stats for a given plugin.
*/
export function getPluginAnalyticsSummary(pluginName: string): PluginAnalyticsSummary {
const db = getDbInstance();
const row = db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS successes,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failures,
AVG(duration_ms) AS avg_duration
FROM plugin_analytics
WHERE plugin_name = ?`
)
.get(pluginName) as any;
return {
totalCalls: row?.total ?? 0,
successCount: row?.successes ?? 0,
failureCount: row?.failures ?? 0,
avgDurationMs: row?.avg_duration ?? 0,
};
}