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
@@ -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 })),