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
+12
View File
@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/plugins/:pluginId"
element={
<ProtectedRoute>
<PluginPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
+8
View File
@@ -543,6 +543,14 @@ export const addonsApi = {
enabled: () => 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 }) =>
+6 -3
View File
@@ -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 && (
<div
className="trek-nav-pill"
style={{
@@ -147,7 +149,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
}}
>
{[{ 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 (
@@ -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(<PluginFrame pluginId="demo" />);
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(<PluginFrame pluginId="demo" />);
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(<PluginFrame pluginId="demo" />);
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(<PluginFrame pluginId="demo" />);
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));
});
});
@@ -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<HTMLIFrameElement | null>(null)
const { locale } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
const userId = useAuthStore((s) => s.user?.id)
const [height, setHeight] = useState<number | null>(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 (
<iframe
ref={frameRef}
src={`/plugin-frame/${pluginId}/index.html`}
sandbox="allow-scripts allow-forms allow-popups"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
className={className}
style={{ width: '100%', height: height ?? '100%', border: 0 }}
/>
)
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import PluginFrame from '../components/Plugins/PluginFrame'
import { usePluginStore } from '../store/pluginStore'
import { useTranslation } from '../i18n'
/**
* Full-page host for a `page` plugin (#plugins, M3). Thin by design (no state /
* effects — the page-pattern rule): it resolves the plugin from the store and
* renders its sandboxed iframe; PluginFrame owns the bridge.
*/
export default function PluginPage(): React.ReactElement {
const { pluginId = '' } = useParams()
const { t } = useTranslation()
const plugin = usePluginStore((s) => s.getById(pluginId))
const loaded = usePluginStore((s) => s.loaded)
if (loaded && (!plugin || plugin.type !== 'page')) {
return (
<PageShell background="var(--bg-secondary)">
<div className="w-full px-6 py-16 text-center text-sm text-content-faint">{t('plugins.notFound')}</div>
</PageShell>
)
}
return (
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
<PluginFrame pluginId={pluginId} title={plugin?.name} className="w-full h-full" />
</div>
</PageShell>
)
}
+48
View File
@@ -0,0 +1,48 @@
// FE-STORE-PLUGIN-001 to 004
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { usePluginStore } from './pluginStore';
const initial = usePluginStore.getState();
beforeEach(() => {
usePluginStore.setState(initial, true);
});
describe('pluginStore', () => {
it('FE-STORE-PLUGIN-001: loads active plugins and splits pages/widgets', async () => {
server.use(
http.get('/api/plugins', () =>
HttpResponse.json({
plugins: [
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
],
}),
),
);
await usePluginStore.getState().loadPlugins();
const s = usePluginStore.getState();
expect(s.loaded).toBe(true);
expect(s.plugins).toHaveLength(2);
expect(s.pages().map((p) => p.id)).toEqual(['report']);
expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
expect(s.getById('flights')?.name).toBe('Flights');
expect(s.getById('nope')).toBeUndefined();
});
it('FE-STORE-PLUGIN-002: a failed fetch still marks the store loaded (no crash)', async () => {
server.use(http.get('/api/plugins', () => HttpResponse.error()));
await usePluginStore.getState().loadPlugins();
expect(usePluginStore.getState().loaded).toBe(true);
expect(usePluginStore.getState().plugins).toEqual([]);
});
it('FE-STORE-PLUGIN-003: tolerates a missing plugins array', async () => {
server.use(http.get('/api/plugins', () => HttpResponse.json({})));
await usePluginStore.getState().loadPlugins();
expect(usePluginStore.getState().plugins).toEqual([]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand'
import { pluginsApi } from '../api/client'
/**
* Active plugins the client renders (#plugins, M3). Page plugins become nav
* entries + a full-page iframe route; widget plugins mount on the dashboard.
* Cloned from addonStore — plugins have their own feed and lifecycle, so they
* don't overload the addon store.
*/
export interface ActivePlugin {
id: string
name: string
type: 'integration' | 'page' | 'widget'
icon: string | null
}
interface PluginState {
plugins: ActivePlugin[]
loaded: boolean
loadPlugins: () => Promise<void>
getById: (id: string) => ActivePlugin | undefined
pages: () => ActivePlugin[]
widgets: () => ActivePlugin[]
}
export const usePluginStore = create<PluginState>((set, get) => ({
plugins: [],
loaded: false,
loadPlugins: async () => {
try {
const data = await pluginsApi.active()
set({ plugins: (data.plugins as ActivePlugin[]) || [], loaded: true })
} catch {
set({ loaded: true })
}
},
getById: (id) => get().plugins.find((p) => p.id === id),
pages: () => get().plugins.filter((p) => p.type === 'page'),
widgets: () => get().plugins.filter((p) => p.type === 'widget'),
}))