feat(plugins): sandboxed page/widget frames + trekBridge (M3)

Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.

- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
  guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
  to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
  so the frame can't read the session cookie or the parent DOM). Global CSP
  frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
  inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
  or origin; pushes context (theme/locale/tripId/userId), validates navigation,
  renders notifications as text, resizes widgets, and proxies trek:invoke to the
  plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.

Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
This commit is contained in:
mauriceboe
2026-07-03 17:12:56 +02:00
parent d823ac443b
commit 037eb8a209
38 changed files with 603 additions and 8 deletions
+4 -1
View File
@@ -123,7 +123,10 @@ export function applyGlobalMiddleware(
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
frameSrc: ["'none'"],
// 'self' so the app can embed same-origin, sandboxed plugin frames
// (/plugin-frame/*). Those frames are sandboxed WITHOUT allow-same-origin,
// so they run at an opaque origin and get their own locked-down CSP.
frameSrc: ["'self'"],
frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src
// fallback, so it must be set explicitly).
@@ -0,0 +1,85 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
import { pluginCodeDir } from './paths';
/**
* Serves a page/widget plugin's static client from /plugin-frame/:id/* (#plugins,
* M3). The document is embedded in a sandbox WITHOUT allow-same-origin, so it
* runs at an OPAQUE origin: it cannot read the trek_session cookie, cannot reach
* the parent DOM, and its only channel to TREK is the postMessage bridge.
*
* Each response gets a locked-down, per-plugin CSP (default-src none; own scripts
* only; connect-src limited to declared outbound hosts) and a strict path guard
* so a plugin can only serve files under its own client/ directory.
*/
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.ico': 'image/x-icon',
};
@Controller('plugin-frame/:pluginId')
export class PluginFrameController {
constructor(private readonly runtime: PluginRuntimeService) {}
@Get('*path')
serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): void {
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
res.status(404).send('Plugin not available');
return;
}
const rest = (req.params as Record<string, unknown>).path ?? (req.params as Record<string, unknown>)[0] ?? '';
const rel = (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, '') || 'index.html';
const clientDir = path.join(pluginCodeDir(pluginId), 'client');
const resolved = path.resolve(clientDir, rel);
// Containment: never escape the plugin's own client/ dir.
if (!resolved.startsWith(path.resolve(clientDir) + path.sep) && resolved !== path.resolve(clientDir)) {
res.status(403).send('Forbidden');
return;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
res.status(404).send('Not found');
return;
}
const ext = path.extname(resolved).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId));
res.sendFile(resolved);
}
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string): string {
const outbound = this.runtime.outboundHostsOf(pluginId);
const connect = ["'self'", ...outbound.map((h) => (h.includes('://') ? h : `https://${h}`))].join(' ');
return [
"default-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
`connect-src ${connect}`,
"frame-ancestors 'self'",
"base-uri 'none'",
"form-action 'self'",
'sandbox allow-scripts allow-forms allow-popups',
].join('; ');
}
}
@@ -74,6 +74,18 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
isActive(id: string): boolean {
return this.supervisor.isActive(id);
}
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
outboundHostsOf(id: string): string[] {
const row = db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
| { granted_permissions: string }
| undefined;
if (!row) return [];
return parseArray(row.granted_permissions)
.filter((p) => p.startsWith('http:outbound:'))
.map((p) => p.slice('http:outbound:'.length))
.filter(Boolean);
}
routesOf(id: string): PluginRouteInfo[] {
return this.supervisor.routesOf(id);
}
@@ -0,0 +1,31 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { db } from '../../db/database';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
/**
* GET /api/plugins — the authenticated feed of ACTIVE plugins the client renders
* (#plugins, M3): page plugins become nav entries, widget plugins become
* dashboard widgets. Empty when the runtime is disabled. Distinct from the
* admin surface (/api/admin/plugins) and the per-plugin proxy
* (/api/plugins/:id/*) — this is the exact /api/plugins path.
*/
interface ActivePlugin {
id: string;
name: string;
type: string;
icon: string | null;
}
@Controller('api/plugins')
@UseGuards(JwtAuthGuard)
export class PluginsFeedController {
@Get()
list(): { plugins: ActivePlugin[] } {
if (!pluginsEnabled()) return { plugins: [] };
const plugins = db
.prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
.all() as ActivePlugin[];
return { plugins };
}
}
+8 -4
View File
@@ -1,16 +1,20 @@
import { Module } from '@nestjs/common';
import { PluginsController } from './plugins.controller';
import { PluginsFeedController } from './plugins-feed.controller';
import { PluginsProxyController } from './plugins-proxy.controller';
import { PluginFrameController } from './plugin-frame.controller';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
/**
* 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.
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
* the runtime service owns the process supervisor and boots active plugins on
* startup; the proxy forwards /api/plugins/:id/* to the child; the feed lists
* active plugins for the client; the frame controller serves sandboxed page/
* widget assets at /plugin-frame/:id/*.
*/
@Module({
controllers: [PluginsController, PluginsProxyController],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
providers: [PluginsService, PluginRuntimeService],
})
export class PluginsModule {}
@@ -143,6 +143,13 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
await rt.deactivate('booter');
});
it('outboundHostsOf extracts declared http:outbound hosts', () => {
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
const rt = new PluginRuntimeService();
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(rt.outboundHostsOf('missing')).toEqual([]);
});
it('onModuleDestroy tears down cleanly', async () => {
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
});
@@ -0,0 +1,86 @@
/**
* The sandboxed plugin frame server (#plugins, M3): serves a plugin's client/
* assets with a locked-down per-frame CSP and a strict path guard, only when the
* plugin is active and the runtime is enabled.
*/
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 { pluginsEnabledMock } = vi.hoisted(() => ({ pluginsEnabledMock: vi.fn(() => true) }));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock }));
import { PluginFrameController } from '../../../src/nest/plugins/plugin-frame.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
let codeRoot: string;
beforeAll(() => {
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-frame-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
const dir = path.join(codeRoot, 'widget', 'client');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.html'), '<!doctype html><body>hi</body>');
});
afterAll(() => {
delete process.env.TREK_PLUGINS_DIR;
fs.rmSync(codeRoot, { recursive: true, force: true });
});
function fakeRes() {
const res = {
statusCode: 0,
headers: {} as Record<string, string>,
sent: undefined as unknown,
filePath: undefined as string | undefined,
status(c: number) { res.statusCode = c; return res; },
setHeader(k: string, v: string) { res.headers[k] = v; },
send(b: unknown) { res.sent = b; return res; },
sendFile(p: string) { res.filePath = p; res.statusCode ||= 200; },
};
return res;
}
const req = (p: string) => ({ params: { path: p } }) as never;
function runtime(active = true, hosts: string[] = []): PluginRuntimeService {
return { isActive: vi.fn(() => active), outboundHostsOf: vi.fn(() => hosts) } as unknown as PluginRuntimeService;
}
describe('PluginFrameController', () => {
it('serves index.html with the opaque-frame CSP + sandbox', () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("default-src 'none'");
expect(csp).toContain('sandbox allow-scripts allow-forms allow-popups');
expect(csp).not.toContain('allow-same-origin');
expect(csp).toContain('connect-src \'self\' https://api.weather.com');
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
});
it('404 when the plugin is inactive', () => {
const res = fakeRes();
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('404 when the runtime is disabled', () => {
pluginsEnabledMock.mockReturnValueOnce(false);
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('403 on a path-traversal attempt', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
expect(res.statusCode).toBe(403);
});
it('404 for a missing file', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
expect(res.statusCode).toBe(404);
});
});
@@ -18,6 +18,7 @@ vi.mock('../../../src/db/database', () => ({ db: testDb }));
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
@@ -56,6 +57,21 @@ describe('PluginsService.list', () => {
});
});
describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
const feed = new PluginsFeedController();
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]);
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(feed.list().plugins).toEqual([]);
});
});
describe('PluginsController M2 endpoints', () => {
const svc = {
getInstanceConfig: vi.fn(() => ({ a: 1 })),