mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): activation, HTTP route proxy + instance settings (M2)
Wires the isolated runtime into TREK. Admins can now activate a plugin from the panel and its HTTP routes work end to end, still behind the kill switch. - PluginRuntimeService owns the supervisor: activate spawns the child with its granted permissions + decrypted instance config, deactivate kills it, status and errors are persisted to the plugins / plugin_error_log tables, and active plugins are booted on startup (OnModuleInit). - Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and reports its declared routes on load; the supervisor gained invoke()/routesOf(). - /api/plugins/:id/* proxy controller — a single static route that matches the plugin's declared routes, enforces per-route auth (auth:false routes are public for OAuth callbacks/webhooks), forwards only a whitelisted request view (never the session cookie), and strips unsafe response headers. - Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config — instance settings with secret fields encrypted (apiKeyCrypto) and masked. - Kill switch moved to its own module so it never collides with test config mocks. Photo/calendar hook consumers are deferred to a later slice.
This commit is contained in:
@@ -174,14 +174,3 @@ export const SESSION_DURATION_REMEMBER =
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
|
||||
|
||||
/**
|
||||
* Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk
|
||||
* surface an instance owner must deliberately opt into. When false, the plugin
|
||||
* module serves its admin listing (so the tab isn't a dead end) but never spawns
|
||||
* a plugin process and refuses install/activate. Read at call time so tests and
|
||||
* runtime env changes take effect without re-import.
|
||||
*/
|
||||
export function pluginsEnabled(): boolean {
|
||||
return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk
|
||||
* surface an instance owner must deliberately opt into. Lives in its own module
|
||||
* (not config.ts) so the many tests that mock config with a partial export set
|
||||
* don't have to know about it: the plugin runtime reads the env directly here.
|
||||
* Read at call time so tests and runtime env changes take effect immediately.
|
||||
*/
|
||||
export function pluginsEnabled(): boolean {
|
||||
return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
|
||||
import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host';
|
||||
import { isKnownPermission } from './protocol/envelope';
|
||||
|
||||
/**
|
||||
* Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2).
|
||||
* Bridges the DB registry (`plugins` rows) to the process supervisor: activate
|
||||
* spawns the child with its granted permissions + decrypted instance config,
|
||||
* deactivate kills it, and status/errors are persisted back to the DB. Boots all
|
||||
* `active` plugins on startup when the runtime is enabled.
|
||||
*/
|
||||
|
||||
interface PluginRow {
|
||||
id: string;
|
||||
status: string;
|
||||
permissions: string;
|
||||
granted_permissions: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly supervisor = new PluginSupervisor(createRealRpcHost, {
|
||||
onStatus: (id, status, error) => {
|
||||
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
status,
|
||||
error ?? null,
|
||||
id,
|
||||
);
|
||||
},
|
||||
onLog: (id, level, msg) => {
|
||||
if (level === 'error' || level === 'warn') {
|
||||
db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!pluginsEnabled()) return;
|
||||
const active = db.prepare("SELECT id FROM plugins WHERE status = 'active'").all() as Array<{ id: string }>;
|
||||
for (const { id } of active) {
|
||||
this.activate(id).catch(() => {
|
||||
/* status is persisted as error by the supervisor hook */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.supervisor.shutdownAll();
|
||||
}
|
||||
|
||||
/** Spawn a plugin from its DB row (granted permissions + decrypted config). */
|
||||
async activate(id: string): Promise<void> {
|
||||
const row = db.prepare('SELECT id, status, permissions, granted_permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
| PluginRow
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
const granted = new Set(parseArray(row.granted_permissions).filter(isKnownPermission));
|
||||
const config = decryptConfig(parseObject(row.config));
|
||||
await this.supervisor.activate(id, granted, config);
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.supervisor.disable(id);
|
||||
closePluginDataDb(id);
|
||||
db.prepare("UPDATE plugins SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.supervisor.isActive(id);
|
||||
}
|
||||
routesOf(id: string): PluginRouteInfo[] {
|
||||
return this.supervisor.routesOf(id);
|
||||
}
|
||||
invoke(id: string, method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray(json: string): string[] {
|
||||
try {
|
||||
const v = JSON.parse(json || '[]');
|
||||
return Array.isArray(v) ? v.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function parseObject(json: string): Record<string, unknown> {
|
||||
try {
|
||||
const v = JSON.parse(json || '{}');
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
/** Decrypt secret config values transparently (decrypt_api_key passes plaintext through). */
|
||||
function decryptConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
out[k] = typeof v === 'string' ? decrypt_api_key(v) : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { All, Controller, Param, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
|
||||
/**
|
||||
* Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2).
|
||||
*
|
||||
* This is a SINGLE static Nest route — it never registers per-plugin routes at
|
||||
* runtime. It matches the request against the plugin's declared routes, enforces
|
||||
* per-route auth (routes with `auth:false` — OAuth callbacks/webhooks — skip the
|
||||
* session check), and forwards a minimal, whitelisted request view to the
|
||||
* isolated child over RPC. The plugin never sees raw headers or the session
|
||||
* cookie, so it cannot replay the user's credentials.
|
||||
*/
|
||||
const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control', 'content-disposition', 'location']);
|
||||
|
||||
@Controller('api/plugins/:pluginId')
|
||||
export class PluginsProxyController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@All('*path')
|
||||
async proxy(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
|
||||
res.status(404).json({ error: 'Plugin not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = (req.params as Record<string, unknown>).path ?? (req.params as Record<string, unknown>)[0] ?? '';
|
||||
const sub = '/' + (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, '');
|
||||
const route = this.runtime.routesOf(pluginId).find((r) => r.method === req.method && r.path === sub);
|
||||
if (!route) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-route auth: default-on; `auth:false` routes are public (OAuth cb/webhook).
|
||||
let user: { id: number; username: string; is_admin?: boolean } | null = null;
|
||||
if (route.auth) {
|
||||
const token = extractToken(req);
|
||||
const loaded = token ? verifyJwtAndLoadUser(token) : null;
|
||||
if (!loaded) {
|
||||
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
user = loaded;
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = (await this.runtime.invoke(pluginId, 'invoke.route', {
|
||||
routeId: route.i,
|
||||
req: {
|
||||
method: req.method,
|
||||
path: sub,
|
||||
query: req.query,
|
||||
body: req.body ?? null,
|
||||
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
|
||||
},
|
||||
})) as { status?: number; headers?: Record<string, string>; body?: unknown };
|
||||
|
||||
res.status(reply?.status ?? 200);
|
||||
for (const [k, v] of Object.entries(reply?.headers ?? {})) {
|
||||
if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) res.setHeader(k, v);
|
||||
}
|
||||
res.send(reply?.body ?? '');
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'Plugin error', detail: e instanceof Error ? e.message : 'unknown' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,57 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
|
||||
/**
|
||||
* /api/admin/plugins — admin-only plugin control surface (#plugins).
|
||||
*
|
||||
* M0 scaffold: read-only listing + the runtime-enabled flag. Install, activate,
|
||||
* disable, uninstall and the registry browser are added in later milestones.
|
||||
* M0: read-only listing + the runtime-enabled flag.
|
||||
* M2: activate / deactivate (spawns/kills the isolated child) + instance config.
|
||||
* Admin-gated like the rest of /api/admin. The proxy namespace /api/plugins/:id
|
||||
* is reserved for the (future) runtime and is intentionally NOT part of this
|
||||
* admin controller.
|
||||
* is a separate controller.
|
||||
*/
|
||||
@Controller('api/admin/plugins')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class PluginsController {
|
||||
constructor(private readonly plugins: PluginsService) {}
|
||||
constructor(
|
||||
private readonly plugins: PluginsService,
|
||||
private readonly runtime: PluginRuntimeService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.plugins.list();
|
||||
}
|
||||
|
||||
@Get(':id/config')
|
||||
getConfig(@Param('id') id: string) {
|
||||
return { config: this.plugins.getInstanceConfig(id) };
|
||||
}
|
||||
|
||||
@Put(':id/config')
|
||||
updateConfig(@Param('id') id: string, @Body() body: Record<string, unknown>) {
|
||||
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@HttpCode(200)
|
||||
async activate(@Param('id') id: string) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
try {
|
||||
await this.runtime.activate(id);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
|
||||
}
|
||||
return { status: this.runtime.isActive(id) ? 'active' : 'error' };
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@HttpCode(200)
|
||||
async deactivate(@Param('id') id: string) {
|
||||
await this.runtime.deactivate(id);
|
||||
return { status: 'inactive' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginsController } from './plugins.controller';
|
||||
import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 registers only the read side; the isolated
|
||||
* runtime supervisor, install pipeline and registry service are added in later
|
||||
* milestones under this same module.
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime: the runtime
|
||||
* service owns the process supervisor and boots active plugins on startup; the
|
||||
* proxy controller forwards /api/plugins/:id/* to the isolated child.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController],
|
||||
providers: [PluginsService],
|
||||
controllers: [PluginsController, PluginsProxyController],
|
||||
providers: [PluginsService, PluginRuntimeService],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from '../../config';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
|
||||
const SECRET_MASK = '••••••••';
|
||||
|
||||
/**
|
||||
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
|
||||
@@ -33,4 +36,64 @@ export class PluginsService {
|
||||
.all() as PluginListItem[];
|
||||
return { enabled: pluginsEnabled(), plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge instance-scope settings into the plugin's config, encrypting fields
|
||||
* declared secret (unless the value is the unchanged mask sentinel). Returns
|
||||
* the config with secrets masked for the client.
|
||||
*/
|
||||
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
|
||||
const config = safeParse(row.config);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (secretKeys.has(k)) {
|
||||
if (v === SECRET_MASK) continue; // unchanged secret — keep stored ciphertext
|
||||
config[k] = maybe_encrypt_api_key(v);
|
||||
} else {
|
||||
config[k] = v;
|
||||
}
|
||||
}
|
||||
db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
/** Read the instance config with secret fields masked. */
|
||||
getInstanceConfig(id: string): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
return maskSecrets(safeParse(row.config), secretKeys);
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(json: string): Record<string, unknown> {
|
||||
try {
|
||||
const v = JSON.parse(json || '{}');
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function maskSecrets(config: Record<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
out[k] = secretKeys.has(k) && v ? SECRET_MASK : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,11 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition);
|
||||
ctx = createPluginContext(pluginId, config, transport);
|
||||
if (typeof def.onLoad === 'function') await def.onLoad(ctx);
|
||||
send({ k: 'evt', topic: 'loaded', data: {} });
|
||||
// Report the declared routes (with their index = routeId) and job ids so the
|
||||
// host can proxy HTTP and schedule jobs without re-parsing the manifest.
|
||||
const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false }));
|
||||
const jobs = (def.jobs ?? []).map((j) => j.id);
|
||||
send({ k: 'evt', topic: 'loaded', data: { routes, jobs } });
|
||||
// An immediate first heartbeat confirms liveness without waiting a full interval.
|
||||
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
|
||||
} catch (e) {
|
||||
@@ -59,6 +63,37 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a host→child request: run a declared route or job with the plugin ctx. */
|
||||
async function handleInvoke(req: { id: string; method: string; params: Record<string, unknown> }): Promise<void> {
|
||||
const respond = (ok: boolean, payload: unknown) =>
|
||||
send(
|
||||
ok
|
||||
? { k: 'res', id: req.id, ok: true, result: payload }
|
||||
: { k: 'res', id: req.id, ok: false, error: { code: 'PLUGIN_ERROR', message: String(payload) } },
|
||||
);
|
||||
try {
|
||||
if (!def || !ctx) throw new Error('plugin not loaded');
|
||||
if (req.method === 'invoke.route') {
|
||||
const routeId = req.params.routeId as number;
|
||||
const route = def.routes?.[routeId];
|
||||
if (!route) throw new Error(`no route ${routeId}`);
|
||||
const pluginReq = req.params.req as Parameters<NonNullable<typeof route.handler>>[0];
|
||||
const result = await route.handler(pluginReq, ctx);
|
||||
respond(true, result);
|
||||
} else if (req.method === 'invoke.job') {
|
||||
const jobId = req.params.jobId as string;
|
||||
const job = def.jobs?.find((j) => j.id === jobId);
|
||||
if (!job) throw new Error(`no job ${jobId}`);
|
||||
await job.handler(ctx);
|
||||
respond(true, { ok: true });
|
||||
} else {
|
||||
respond(false, `unknown invoke ${req.method}`);
|
||||
}
|
||||
} catch (e) {
|
||||
respond(false, errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
try {
|
||||
if (def && typeof def.onUnload === 'function' && ctx) await def.onUnload(ctx);
|
||||
@@ -72,6 +107,11 @@ async function shutdown(): Promise<void> {
|
||||
process.on('message', (raw: unknown) => {
|
||||
const msg = raw as Envelope;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.k === 'req') {
|
||||
// A host→child invoke (route / job).
|
||||
void handleInvoke({ id: msg.id, method: msg.method, params: (msg.params ?? {}) as Record<string, unknown> });
|
||||
return;
|
||||
}
|
||||
if (msg.k === 'res') {
|
||||
const p = pending.get(msg.id);
|
||||
if (!p) return;
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fork, type ChildProcess } from 'node:child_process';
|
||||
import { resolveChildEntry, pluginCodeDir } from '../paths';
|
||||
import type { Envelope, RpcRequest } from '../protocol/envelope';
|
||||
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope';
|
||||
import type { PluginRpcHost } from '../host/rpc-host';
|
||||
|
||||
export interface PluginRouteInfo {
|
||||
i: number;
|
||||
method: string;
|
||||
path: string;
|
||||
auth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of every running plugin child (#plugins, M1): spawn on
|
||||
* activate, route RPC between the child and its capability host, watch
|
||||
@@ -21,6 +29,12 @@ export interface SupervisorHooks {
|
||||
onEvent?(id: string, topic: string, data: unknown): void;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface Supervised {
|
||||
id: string;
|
||||
granted: ReadonlySet<string>;
|
||||
@@ -30,6 +44,9 @@ interface Supervised {
|
||||
status: PluginStatus;
|
||||
crashes: number[]; // crash timestamps (ms)
|
||||
lastBeat: number;
|
||||
routes: PluginRouteInfo[];
|
||||
jobs: string[];
|
||||
pending: Map<string, Pending>; // host→child invokes awaiting a response
|
||||
activation?: { resolve: () => void; reject: (e: Error) => void };
|
||||
}
|
||||
|
||||
@@ -74,6 +91,9 @@ export class PluginSupervisor {
|
||||
status: 'starting',
|
||||
crashes: [],
|
||||
lastBeat: Date.now(),
|
||||
routes: [],
|
||||
jobs: [],
|
||||
pending: new Map(),
|
||||
};
|
||||
this.running.set(id, sup);
|
||||
this.ensureSweep();
|
||||
@@ -99,6 +119,28 @@ export class PluginSupervisor {
|
||||
statusOf(id: string): PluginStatus | null {
|
||||
return this.running.get(id)?.status ?? null;
|
||||
}
|
||||
/** The plugin's declared HTTP routes (populated once it reports `loaded`). */
|
||||
routesOf(id: string): PluginRouteInfo[] {
|
||||
return this.running.get(id)?.routes ?? [];
|
||||
}
|
||||
|
||||
/** Send a host→child request (invoke a route or job) and await its response. */
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, timeoutMs = 30_000): Promise<unknown> {
|
||||
const sup = this.running.get(id);
|
||||
if (!sup || sup.status !== 'active' || !sup.child) {
|
||||
return Promise.reject(new Error(`plugin ${id} is not active`));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
sup.pending.delete(reqId);
|
||||
reject(new Error('plugin invoke timed out'));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
sup.pending.set(reqId, { resolve, reject, timer });
|
||||
sup.child!.send({ k: 'req', id: reqId, method, params } satisfies Envelope);
|
||||
});
|
||||
}
|
||||
|
||||
async shutdownAll(): Promise<void> {
|
||||
if (this.sweep) clearInterval(this.sweep);
|
||||
@@ -145,6 +187,17 @@ export class PluginSupervisor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.k === 'res') {
|
||||
// A response to a host→child invoke (route/job).
|
||||
const p = sup.pending.get(msg.id);
|
||||
if (!p) return;
|
||||
sup.pending.delete(msg.id);
|
||||
clearTimeout(p.timer);
|
||||
if (msg.ok) p.resolve(msg.result);
|
||||
else p.reject(new Error((msg as RpcError).error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.k === 'evt') {
|
||||
switch (msg.topic) {
|
||||
case 'hello':
|
||||
@@ -153,12 +206,16 @@ export class PluginSupervisor {
|
||||
case 'heartbeat':
|
||||
sup.lastBeat = Date.now();
|
||||
break;
|
||||
case 'loaded':
|
||||
case 'loaded': {
|
||||
sup.lastBeat = Date.now();
|
||||
const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] };
|
||||
sup.routes = d.routes ?? [];
|
||||
sup.jobs = d.jobs ?? [];
|
||||
this.setStatus(sup, 'active');
|
||||
sup.activation?.resolve();
|
||||
sup.activation = undefined;
|
||||
break;
|
||||
}
|
||||
case 'load-error': {
|
||||
const message = (msg.data as { message?: string })?.message || 'plugin load failed';
|
||||
this.setStatus(sup, 'error', message);
|
||||
@@ -179,9 +236,18 @@ export class PluginSupervisor {
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(sup: Supervised, reason: string): void {
|
||||
for (const p of sup.pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error(reason));
|
||||
}
|
||||
sup.pending.clear();
|
||||
}
|
||||
|
||||
private onExit(sup: Supervised, code: number | null, signal: string | null): void {
|
||||
// Reject any pending ctx calls implicitly by killing the child; the child is gone.
|
||||
sup.child = null;
|
||||
// In-flight host→child invokes can never complete now.
|
||||
this.rejectPending(sup, 'plugin exited');
|
||||
// A clean stop we asked for isn't a crash.
|
||||
if (sup.status === 'stopped' || sup.status === 'error') return;
|
||||
if (!this.running.has(sup.id)) return;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* End-to-end M2: the runtime service activates a plugin from its DB row and its
|
||||
* HTTP route works through the host→child invoke path, using its own isolated
|
||||
* db. Proves the full activate → route → deactivate loop.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, status TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]',
|
||||
config TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
let runtime: PluginRuntimeService;
|
||||
|
||||
beforeAll(() => {
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-data-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
const dir = path.join(codeRoot, 'counter', 'server');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'index.js'),
|
||||
`module.exports = {
|
||||
async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE hits (n INTEGER)'); },
|
||||
routes: [
|
||||
{ method: 'GET', path: '/count', auth: true, async handler(req, ctx) {
|
||||
await ctx.db.exec('INSERT INTO hits (n) VALUES (1)');
|
||||
const rows = await ctx.db.query('SELECT COUNT(*) AS c FROM hits');
|
||||
return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ count: rows[0].c, user: req.user }) };
|
||||
}},
|
||||
{ method: 'GET', path: '/boom', auth: false, async handler() { throw new Error('route fail'); } },
|
||||
]
|
||||
};`,
|
||||
);
|
||||
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
|
||||
runtime = new PluginRuntimeService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime?.deactivate('counter').catch(() => {});
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
it('activates a plugin and serves its route through the isolated child', async () => {
|
||||
await runtime.activate('counter');
|
||||
expect(runtime.isActive('counter')).toBe(true);
|
||||
expect(runtime.routesOf('counter')).toEqual([
|
||||
{ i: 0, method: 'GET', path: '/count', auth: true },
|
||||
{ i: 1, method: 'GET', path: '/boom', auth: false },
|
||||
]);
|
||||
|
||||
const r1 = (await runtime.invoke('counter', 'invoke.route', {
|
||||
routeId: 0,
|
||||
req: { method: 'GET', path: '/count', query: {}, body: null, user: { id: 5, username: 'ada', isAdmin: false } },
|
||||
})) as { status: number; body: string };
|
||||
expect(r1.status).toBe(200);
|
||||
const parsed = JSON.parse(r1.body);
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.user).toEqual({ id: 5, username: 'ada', isAdmin: false });
|
||||
|
||||
// its own db persists across invokes
|
||||
const r2 = (await runtime.invoke('counter', 'invoke.route', {
|
||||
routeId: 0,
|
||||
req: { method: 'GET', path: '/count', query: {}, body: null, user: { id: 5, username: 'ada', isAdmin: false } },
|
||||
})) as { body: string };
|
||||
expect(JSON.parse(r2.body).count).toBe(2);
|
||||
|
||||
// DB status was persisted active by the supervisor hook
|
||||
const row = testDb.prepare("SELECT status FROM plugins WHERE id = 'counter'").get() as { status: string };
|
||||
expect(row.status).toBe('active');
|
||||
});
|
||||
|
||||
it('invoke on a plugin that is not running rejects', async () => {
|
||||
await expect(runtime.invoke('never-activated', 'invoke.route', { routeId: 0, req: {} })).rejects.toThrow(/not active/);
|
||||
});
|
||||
|
||||
it('a route that throws surfaces as a rejected invoke', async () => {
|
||||
await expect(
|
||||
runtime.invoke('counter', 'invoke.route', { routeId: 1, req: { method: 'GET', path: '/boom', query: {}, body: null, user: null } }),
|
||||
).rejects.toThrow(/route fail/);
|
||||
});
|
||||
|
||||
it('activate throws for an unknown plugin id', async () => {
|
||||
await expect(runtime.activate('ghost')).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('onModuleInit is a no-op when the runtime is disabled', () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
});
|
||||
|
||||
it('deactivate stops the plugin and marks it inactive', async () => {
|
||||
await runtime.deactivate('counter');
|
||||
expect(runtime.isActive('counter')).toBe(false);
|
||||
const row = testDb.prepare("SELECT status FROM plugins WHERE id = 'counter'").get() as { status: string };
|
||||
expect(row.status).toBe('inactive');
|
||||
});
|
||||
|
||||
it('tolerates malformed granted_permissions / config JSON on activate', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('messy','inactive','not-json','not-json')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
await rt.activate('messy'); // must not throw despite the garbage JSON
|
||||
expect(rt.isActive('messy')).toBe(true);
|
||||
await rt.deactivate('messy');
|
||||
});
|
||||
|
||||
it('onModuleInit boots plugins that are active in the DB', async () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'booter', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'booter', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('booter','active','[]','{}')").run();
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
rt.onModuleInit(); // fire-and-forget spawn
|
||||
for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50));
|
||||
expect(rt.isActive('booter')).toBe(true);
|
||||
await rt.deactivate('booter');
|
||||
});
|
||||
|
||||
it('onModuleDestroy tears down cleanly', async () => {
|
||||
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The plugin HTTP proxy (#plugins, M2): route matching, per-route auth (auth:false
|
||||
* routes are public), the whitelisted request view forwarded to the child, and
|
||||
* error/404 handling — all without a real fork (the runtime is faked).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { pluginsEnabledMock, extractTokenMock, verifyMock } = vi.hoisted(() => ({
|
||||
pluginsEnabledMock: vi.fn(() => true),
|
||||
extractTokenMock: vi.fn(() => 'tok'),
|
||||
verifyMock: vi.fn(() => ({ id: 5, username: 'ada', is_admin: false })),
|
||||
}));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock }));
|
||||
vi.mock('../../../src/middleware/auth', () => ({ extractToken: extractTokenMock, verifyJwtAndLoadUser: verifyMock }));
|
||||
|
||||
import { PluginsProxyController } from '../../../src/nest/plugins/plugins-proxy.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
function fakeRes() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: undefined as unknown,
|
||||
status(c: number) { res.statusCode = c; return res; },
|
||||
setHeader(k: string, v: string) { res.headers[k] = v; },
|
||||
json(b: unknown) { res.body = b; return res; },
|
||||
send(b: unknown) { res.body = b; return res; },
|
||||
};
|
||||
return res;
|
||||
}
|
||||
function fakeReq(method: string, sub: string, extra: Record<string, unknown> = {}) {
|
||||
return { method, params: { 0: sub.replace(/^\//, '') }, query: {}, body: null, ...extra } as never;
|
||||
}
|
||||
|
||||
function makeRuntime(over: Partial<PluginRuntimeService> = {}): PluginRuntimeService {
|
||||
return {
|
||||
isActive: vi.fn(() => true),
|
||||
routesOf: vi.fn(() => [{ i: 0, method: 'GET', path: '/status', auth: true }]),
|
||||
invoke: vi.fn(async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' })),
|
||||
...over,
|
||||
} as unknown as PluginRuntimeService;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pluginsEnabledMock.mockClear().mockReturnValue(true);
|
||||
verifyMock.mockClear().mockReturnValue({ id: 5, username: 'ada', is_admin: false });
|
||||
extractTokenMock.mockClear().mockReturnValue('tok');
|
||||
});
|
||||
|
||||
describe('PluginsProxyController', () => {
|
||||
it('404 when the runtime is disabled', async () => {
|
||||
pluginsEnabledMock.mockReturnValue(false);
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the plugin is not active', async () => {
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(makeRuntime({ isActive: vi.fn(() => false) } as never)).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when no declared route matches', async () => {
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/nope'), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('401 on an auth route without a valid session', async () => {
|
||||
verifyMock.mockReturnValue(null as never);
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('forwards an authenticated request and returns the child response', async () => {
|
||||
const runtime = makeRuntime();
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/json');
|
||||
expect(res.body).toBe('{"ok":true}');
|
||||
// the child receives only a whitelisted user view, never the token/cookie
|
||||
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
|
||||
routeId: 0,
|
||||
req: expect.objectContaining({ user: { id: 5, username: 'ada', isAdmin: false } }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('a public (auth:false) route skips the session check', async () => {
|
||||
const runtime = makeRuntime({ routesOf: vi.fn(() => [{ i: 1, method: 'POST', path: '/webhook', auth: false }]) } as never);
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(runtime).proxy('p', fakeReq('POST', '/webhook'), res as never);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(extractTokenMock).not.toHaveBeenCalled();
|
||||
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
|
||||
req: expect.objectContaining({ user: null }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('strips unsafe response headers from the child', async () => {
|
||||
const runtime = makeRuntime({
|
||||
invoke: vi.fn(async () => ({ status: 200, headers: { 'content-type': 'text/plain', 'set-cookie': 'evil=1' }, body: 'ok' })),
|
||||
} as never);
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.headers['content-type']).toBe('text/plain');
|
||||
expect(res.headers['set-cookie']).toBeUndefined(); // a plugin cannot set cookies
|
||||
});
|
||||
|
||||
it('502 when the plugin invoke throws', async () => {
|
||||
const runtime = makeRuntime({ invoke: vi.fn(async () => { throw new Error('down'); }) } as never);
|
||||
const res = fakeRes();
|
||||
await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never);
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
status TEXT, reviewed_at TEXT, source_repo TEXT, sort_order INTEGER DEFAULT 0)`);
|
||||
status TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
|
||||
sort_order INTEGER DEFAULT 0);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
||||
@@ -19,6 +21,7 @@ import { PluginsController } from '../../../src/nest/plugins/plugins.controller'
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins');
|
||||
testDb.exec('DELETE FROM plugin_settings_fields');
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -46,8 +49,82 @@ describe('PluginsService.list', () => {
|
||||
|
||||
it('controller delegates to the service', () => {
|
||||
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
|
||||
const res = new PluginsController(svc).list();
|
||||
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
|
||||
const res = new PluginsController(svc, runtime).list();
|
||||
expect(svc.list).toHaveBeenCalled();
|
||||
expect(res).toEqual({ enabled: false, plugins: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsController M2 endpoints', () => {
|
||||
const svc = {
|
||||
getInstanceConfig: vi.fn(() => ({ a: 1 })),
|
||||
updateInstanceConfig: vi.fn(() => ({ a: 2 })),
|
||||
} as unknown as PluginsService;
|
||||
|
||||
beforeEach(() => {
|
||||
(svc.getInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
||||
(svc.updateInstanceConfig as ReturnType<typeof vi.fn>).mockClear();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
});
|
||||
|
||||
it('get/update config delegate to the service', () => {
|
||||
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
|
||||
const c = new PluginsController(svc, rt);
|
||||
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
|
||||
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
|
||||
});
|
||||
|
||||
it('activate spawns via the runtime when enabled', async () => {
|
||||
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
|
||||
const out = await new PluginsController(svc, rt).activate('x');
|
||||
expect(out).toEqual({ status: 'active' });
|
||||
});
|
||||
|
||||
it('activate is 503 when the runtime is disabled', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
|
||||
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('activate surfaces an activation error as 400', async () => {
|
||||
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
|
||||
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('deactivate stops the plugin', async () => {
|
||||
const rt = { deactivate: vi.fn(async () => {}) } as never;
|
||||
expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsService instance config', () => {
|
||||
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
|
||||
|
||||
const svc = new PluginsService();
|
||||
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
|
||||
// client gets the masked view
|
||||
expect(masked.api_key).toBe('••••••••');
|
||||
expect(masked.server).toBe('https://h');
|
||||
|
||||
// stored value is encrypted, not plaintext
|
||||
const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config);
|
||||
expect(stored.api_key).not.toBe('super-secret');
|
||||
expect(String(stored.api_key)).toMatch(/^enc:/);
|
||||
expect(stored.server).toBe('https://h');
|
||||
|
||||
// an unchanged mask does not overwrite the stored secret
|
||||
svc.updateInstanceConfig('x', { api_key: '••••••••' });
|
||||
const still = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config);
|
||||
expect(still.api_key).toBe(stored.api_key);
|
||||
|
||||
expect(svc.getInstanceConfig('x').api_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin', () => {
|
||||
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
|
||||
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,4 +151,24 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
expect((res as RpcError).error.code).toBe('HOST_ERROR');
|
||||
expect((res as RpcError).error.message).toBe('disk gone');
|
||||
});
|
||||
|
||||
it('a non-Error thrown by a handler still maps to HOST_ERROR', async () => {
|
||||
(deps.data.query as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw 'raw string';
|
||||
});
|
||||
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
|
||||
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1' }));
|
||||
expect((res as RpcError).error.code).toBe('HOST_ERROR');
|
||||
expect((res as RpcError).error.message).toBe('internal error');
|
||||
});
|
||||
|
||||
it('coerces numeric string params and tolerates a missing params object', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:own']), deps);
|
||||
// tripId/asUserId as strings -> coerced to numbers
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: '1', asUserId: '42' }));
|
||||
expect(ok(res)).toBe(true);
|
||||
// a request with no params object at all -> BAD_PARAMS (sql missing), not a crash
|
||||
const noParams = await host.dispatch({ k: 'req', id: 'y', method: 'db.query', params: undefined });
|
||||
expect((noParams as RpcError).error.code).toBe('BAD_PARAMS');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user