From 02c52e63a0ab8ac28052f4d10a81c7df710121f1 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Fri, 3 Jul 2026 17:12:56 +0200 Subject: [PATCH] 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. --- client/src/App.tsx | 12 ++ client/src/api/client.ts | 8 ++ client/src/components/Layout/Navbar.tsx | 9 +- .../components/Plugins/PluginFrame.test.tsx | 67 ++++++++++ client/src/components/Plugins/PluginFrame.tsx | 115 ++++++++++++++++++ client/src/pages/PluginPage.tsx | 34 ++++++ client/src/store/pluginStore.test.ts | 48 ++++++++ client/src/store/pluginStore.ts | 42 +++++++ server/src/middleware/globalMiddleware.ts | 5 +- .../nest/plugins/plugin-frame.controller.ts | 85 +++++++++++++ .../nest/plugins/plugin-runtime.service.ts | 12 ++ .../nest/plugins/plugins-feed.controller.ts | 31 +++++ server/src/nest/plugins/plugins.module.ts | 12 +- .../plugins/plugin-runtime.test.ts | 7 ++ .../tests/unit/plugins/plugin-frame.test.ts | 86 +++++++++++++ .../unit/plugins/plugins-service.test.ts | 16 +++ shared/src/i18n/ar/admin.ts | 1 + shared/src/i18n/br/admin.ts | 1 + shared/src/i18n/cs/admin.ts | 1 + shared/src/i18n/de/admin.ts | 1 + shared/src/i18n/en/admin.ts | 1 + shared/src/i18n/es/admin.ts | 1 + shared/src/i18n/fr/admin.ts | 1 + shared/src/i18n/gr/admin.ts | 1 + shared/src/i18n/hu/admin.ts | 1 + shared/src/i18n/id/admin.ts | 1 + shared/src/i18n/it/admin.ts | 1 + shared/src/i18n/ja/admin.ts | 1 + shared/src/i18n/ko/admin.ts | 1 + shared/src/i18n/nl/admin.ts | 1 + shared/src/i18n/pl/admin.ts | 1 + shared/src/i18n/ru/admin.ts | 1 + shared/src/i18n/sv/admin.ts | 1 + shared/src/i18n/tr/admin.ts | 1 + shared/src/i18n/uk/admin.ts | 1 + shared/src/i18n/vi/admin.ts | 1 + shared/src/i18n/zh-TW/admin.ts | 1 + shared/src/i18n/zh/admin.ts | 1 + 38 files changed, 603 insertions(+), 8 deletions(-) create mode 100644 client/src/components/Plugins/PluginFrame.test.tsx create mode 100644 client/src/components/Plugins/PluginFrame.tsx create mode 100644 client/src/pages/PluginPage.tsx create mode 100644 client/src/store/pluginStore.test.ts create mode 100644 client/src/store/pluginStore.ts create mode 100644 server/src/nest/plugins/plugin-frame.controller.ts create mode 100644 server/src/nest/plugins/plugins-feed.controller.ts create mode 100644 server/tests/unit/plugins/plugin-frame.test.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index bac8356f7..6b621dbbf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' import { applyAppearance } from './theme/applyAppearance' import { useAddonStore } from './store/addonStore' +import { usePluginStore } from './store/pluginStore' +import PluginPage from './pages/PluginPage' import LoginPage from './pages/LoginPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage' @@ -111,6 +113,7 @@ export default function App() { const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() + const { loadPlugins } = usePluginStore() useEffect(() => { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { @@ -168,6 +171,7 @@ export default function App() { if (isAuthenticated) { loadSettings() loadAddons() + loadPlugins() } }, [isAuthenticated]) @@ -284,6 +288,14 @@ export default function App() { } /> + + + + } + /> apiClient.get('/addons').then(r => r.data), } +export const pluginsApi = { + // Active plugins the client renders (page nav entries, dashboard widgets). + active: () => apiClient.get('/plugins').then(r => r.data), + // Call one of a plugin's own declared routes through the host proxy. + invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => + apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data), +} + export const airtrailApi = { getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data), saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) => diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 5df0d25de..2490722ef 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -4,8 +4,9 @@ import { Link, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' +import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' -import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' +import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' @@ -52,6 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: // Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled) + const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page') useEffect(() => { if (user) loadAddons() @@ -135,7 +137,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so the left brand block and the right action cluster keep their layout. */} - {globalAddons.length > 0 && !tripTitle && ( + {(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase }, - ...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })) + ...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })), + ...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks })) ].map(tab => { const isActive = location.pathname === tab.path return ( diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx new file mode 100644 index 000000000..a86713e0c --- /dev/null +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -0,0 +1,67 @@ +// FE-PLUGINS-FRAME-001 to 004 +import { render, cleanup, waitFor } from '@testing-library/react'; +import PluginFrame from './PluginFrame'; + +const navigate = vi.fn(); +const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() }; +const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true })); + +vi.mock('react-router-dom', () => ({ useNavigate: () => navigate })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) })); +vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7 } }) })); +vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } })); + +function fromFrame(frame: HTMLIFrameElement, data: unknown) { + window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit)); +} + +afterEach(() => { + cleanup(); + navigate.mockClear(); + Object.values(toast).forEach((f) => f.mockClear()); + invoke.mockClear(); +}); + +describe('PluginFrame', () => { + it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html'); + const sandbox = iframe.getAttribute('sandbox') || ''; + expect(sandbox).toContain('allow-scripts'); + expect(sandbox).not.toContain('allow-same-origin'); + }); + + it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + // message NOT from our iframe -> ignored + window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } })); + expect(navigate).not.toHaveBeenCalled(); + // message from our iframe -> handled + fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' }); + expect(navigate).toHaveBeenCalledWith('/dashboard'); + }); + + it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative + expect(navigate).not.toHaveBeenCalled(); + fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' }); + expect(toast.success).toHaveBeenCalledWith('saved'); + }); + + it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: unknown[] = []; + // capture host->frame messages + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m); + + fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' }); + await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined })); + await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true)); + }); +}); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx new file mode 100644 index 000000000..74edeaf03 --- /dev/null +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from '../../i18n' +import { useAuthStore } from '../../store/authStore' +import { useToast } from '../shared/Toast' +import { pluginsApi } from '../../api/client' + +/** + * Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge + * (#plugins, M3). + * + * The frame is served same-origin from /plugin-frame/:id but sandboxed WITHOUT + * allow-same-origin, so it runs at an OPAQUE origin: no access to the trek_session + * cookie, no parent DOM, no credentialed fetch. Its only channel is postMessage, + * and we authenticate every inbound message by the SENDER WINDOW IDENTITY + * (event.source === our iframe), never by a claimed id or by origin (which is + * "null" for opaque frames). Data reads go through the host (app-origin, session + * cookie) so the plugin never handles credentials. + */ + +interface PluginFrameProps { + pluginId: string + tripId?: string | null + className?: string + title?: string +} + +type Inbound = + | { type: 'trek:ready' } + | { type: 'trek:context:request' } + | { type: 'trek:navigate'; to: string } + | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string } + | { type: 'trek:resize'; height?: number } + | { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown } + +export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) { + const frameRef = useRef(null) + const { locale } = useTranslation() + const navigate = useNavigate() + const toast = useToast() + const userId = useAuthStore((s) => s.user?.id) + const [height, setHeight] = useState(null) + + useEffect(() => { + const frame = frameRef.current + if (!frame) return + + const post = (msg: unknown) => frame.contentWindow?.postMessage(msg, '*') // opaque frame -> targetOrigin must be '*' + + const context = () => ({ + type: 'trek:context', + tripId, + userId: userId != null ? String(userId) : null, + theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', + locale, + hostOrigin: window.location.origin, + }) + + const onMessage = async (ev: MessageEvent) => { + // The ONLY trusted identity: the message came from OUR iframe's window. + if (ev.source !== frame.contentWindow) return + const msg = ev.data as Inbound + if (!msg || typeof msg !== 'object') return + + switch (msg.type) { + case 'trek:ready': + case 'trek:context:request': + post(context()) + break + case 'trek:navigate': { + const to = typeof msg.to === 'string' ? msg.to : '' + // In-app paths only; block protocol-relative and admin unless allowed by the app itself. + if (/^\/[a-zA-Z0-9/_?=&%.-]*$/.test(to) && !to.startsWith('//')) navigate(to) + break + } + case 'trek:notify': { + const text = String(msg.message ?? '').slice(0, 200) + const level = msg.level ?? 'info' + if (text) (toast[level] ?? toast.info)(text) + break + } + case 'trek:resize': + if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000)) + break + case 'trek:invoke': { + // The plugin's own route, called host-side with the user's session. + try { + const data = await pluginsApi.invoke(pluginId, msg.sub, { method: msg.method, body: msg.body }) + post({ type: 'trek:response', requestId: msg.requestId, data }) + } catch (e) { + const err = e as { response?: { status?: number }; message?: string } + post({ type: 'trek:error', requestId: msg.requestId, code: err.response?.status ?? 'error', message: err.message ?? 'invoke failed' }) + } + break + } + } + } + + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }, [pluginId, tripId, userId, locale, navigate, toast]) + + return ( +