mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +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:
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user