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
@@ -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();
});