diff --git a/app/plugin-sandbox/layout.tsx b/app/plugin-sandbox/layout.tsx
new file mode 100644
index 00000000..8c1f20d4
--- /dev/null
+++ b/app/plugin-sandbox/layout.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = {
+ title: 'Plugin sandbox',
+ robots: { index: false, follow: false },
+};
+
+export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/plugin-sandbox/page.tsx b/app/plugin-sandbox/page.tsx
new file mode 100644
index 00000000..6ddfd078
--- /dev/null
+++ b/app/plugin-sandbox/page.tsx
@@ -0,0 +1,7 @@
+import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
+
+export const dynamic = 'force-static';
+
+export default function PluginSandboxPage() {
+ return ;
+}
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 147b5f66..90a839a6 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -92,7 +92,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
-import { usePluginStore } from "@/stores/plugin-store";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
@@ -970,8 +970,8 @@ export function EmailViewer({
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
- const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
- const hasDetailSidebar = detailSlots && detailSlots.length > 0;
+ const detailSlots = usePluginSlotOffers('email-detail-sidebar');
+ const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
new file mode 100644
index 00000000..b95f3166
--- /dev/null
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
+// the background instance confirms `shouldShow(context)` (if defined). The
+// iframe renders the plugin's slot component using the plugin's bundle in a
+// null-origin context; its height is pushed back via postMessage and applied
+// to a wrapper .
+
+import React, { useEffect, useRef, useState } from 'react';
+import type { SlotName } from '@/lib/plugin-types';
+import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
+import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
+
+interface Props {
+ pluginId: string;
+ slot: SlotName;
+ extraProps?: Record
;
+}
+
+export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
+ const wrapperRef = useRef(null);
+ const instanceRef = useRef(null);
+ const [height, setHeight] = useState(0);
+ // null = pending, true/false = decided
+ const [show, setShow] = useState(null);
+
+ // Decide whether to mount based on the plugin's shouldShow (background-side).
+ useEffect(() => {
+ const active = getActivePlugin(pluginId);
+ if (!active) { setShow(false); return; }
+ const offer = active.slotOffers.find((o) => o.name === slot);
+ if (!offer) { setShow(false); return; }
+ if (!offer.hasShouldShow) { setShow(true); return; }
+ let cancelled = false;
+ active.background
+ .evaluateShouldShow(slot, extraProps ?? {})
+ .then((s) => { if (!cancelled) setShow(s); })
+ .catch(() => { if (!cancelled) setShow(false); });
+ return () => { cancelled = true; };
+ }, [pluginId, slot, extraProps]);
+
+ // Spawn / tear down the slot iframe.
+ useEffect(() => {
+ if (show !== true) return;
+ const active = getActivePlugin(pluginId);
+ if (!active || !wrapperRef.current) return;
+ const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en';
+ const inst = createSlotInstance({
+ plugin: active.plugin,
+ slot,
+ code: active.code,
+ locale,
+ extraProps: extraProps ?? {},
+ hostContainer: wrapperRef.current,
+ onResize: (h) => setHeight(h),
+ });
+ instanceRef.current = inst;
+ return () => {
+ try { inst.destroy(); } catch { /* ignore */ }
+ instanceRef.current = null;
+ };
+ // We intentionally don't depend on extraProps here — propagating prop
+ // changes happens via postMessage below to avoid iframe churn.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [show, pluginId, slot]);
+
+ // Push prop updates without remount.
+ useEffect(() => {
+ instanceRef.current?.updateProps(extraProps ?? {});
+ }, [extraProps]);
+
+ if (show !== true) return null;
+ return ;
+}
diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx
index 63489412..ea00587a 100644
--- a/components/plugins/plugin-slot.tsx
+++ b/components/plugins/plugin-slot.tsx
@@ -1,9 +1,9 @@
'use client';
-import React from 'react';
+import React, { useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
-import { usePluginStore } from '@/stores/plugin-store';
-import { PluginSlotRenderer } from './plugin-slot-renderer';
+import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
+import { PluginIframeSlot } from './plugin-iframe-slot';
interface PluginSlotProps {
name: SlotName;
@@ -12,16 +12,21 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
- const registrations = usePluginStore(s => s.slots[name]);
+ const offers = useSyncExternalStore(
+ subscribe,
+ () => offersForSlot(name),
+ () => [],
+ );
- if (!registrations || registrations.length === 0) return null;
+ if (offers.length === 0) return null;
return (
- {registrations.map((reg, i) => (
-
(
+
))}
diff --git a/hooks/use-plugin-slot-offers.ts b/hooks/use-plugin-slot-offers.ts
new file mode 100644
index 00000000..2501cc05
--- /dev/null
+++ b/hooks/use-plugin-slot-offers.ts
@@ -0,0 +1,17 @@
+'use client';
+
+// Reactive accessor for sandboxed plugin slot offers. Components that gate
+// layout on the *presence* of a plugin-supplied slot (e.g. a detail
+// sidebar) read from here instead of the legacy `usePluginStore.slots` map.
+
+import { useSyncExternalStore } from 'react';
+import type { SlotName } from '@/lib/plugin-types';
+import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
+
+export function usePluginSlotOffers(slotName: SlotName) {
+ return useSyncExternalStore(
+ subscribe,
+ () => offersForSlot(slotName),
+ () => [],
+ );
+}
diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts
index b7f9d219..468af2fa 100644
--- a/lib/plugin-loader.ts
+++ b/lib/plugin-loader.ts
@@ -1,187 +1,81 @@
-// Plugin Loader - loads and activates plugins via blob URL dynamic import
+// Plugin loader entrypoint. Delegates to the iframe-based sandbox in
+// `lib/plugin-sandbox/`. The legacy blob-URL `import()` path has been
+// removed; plugin bundles now run in a null-origin sandbox iframe and
+// communicate with the host via postMessage RPC.
-import type { InstalledPlugin, Disposable } from './plugin-types';
-import { pluginStorage } from './plugin-storage';
-import { createPluginAPI, type PluginAPI } from './plugin-api';
-import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
-import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
-import React from 'react';
-import ReactDOM from 'react-dom';
-import * as ReactJSX from 'react/jsx-runtime';
-
-// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
-
-let localeSyncInitialised = false;
+import type { InstalledPlugin } from './plugin-types';
+import {
+ loadSandboxedPlugin,
+ unloadSandboxedPlugin,
+ activateAllSandboxed,
+ deactivateAllSandboxed,
+ setSandboxStoreAccessor,
+ setSandboxLocale,
+ setupSandboxAutoDisable,
+} from './plugin-sandbox/loader';
+import { all as allActive, get as getActive } from './plugin-sandbox/registry';
+/**
+ * Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
+ * so blob-imported plugin code could resolve `react`. With the sandbox model
+ * plugins receive React injected as a function argument inside their iframe
+ * runtime — there is nothing to expose on the host window.
+ *
+ * Kept as a no-op for callers that still invoke it during app bootstrap.
+ */
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (globalThis as any).__PLUGIN_EXTERNALS__ = {
- React,
- ReactDOM,
- ReactJSX,
- };
-
- // Sync plugin i18n with the app locale (runs once per page load)
- if (!localeSyncInitialised) {
- localeSyncInitialised = true;
- // Dynamic import avoids a circular dependency chain at module evaluation time
- import('@/stores/locale-store').then(({ useLocaleStore }) => {
- setPluginI18nLocale(useLocaleStore.getState().locale);
- useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
- }).catch(() => {/* locale sync is best-effort */});
- }
+ // Initialise the locale sync once. Importing the store lazily avoids the
+ // circular module graph we used to fight before the sandbox refactor.
+ void import('@/stores/locale-store').then(({ useLocaleStore }) => {
+ setSandboxLocale(useLocaleStore.getState().locale);
+ useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
+ // Mirror on a global so the slot-iframe component can read it at spawn.
+ (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
+ useLocaleStore.subscribe((state) => {
+ (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
+ });
+ }).catch(() => { /* locale sync is best-effort */ });
}
-// --- Active plugin tracking ----------------------------------
+// ─── Store accessor (status updates) ──────────────────────────
-interface ActivePlugin {
- id: string;
- api: PluginAPI;
- disposable?: Disposable;
- deactivate?: () => void;
+type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
+
+export function setPluginStoreAccessor(accessor: StoreAccessor): void {
+ setSandboxStoreAccessor(accessor);
}
-const activePlugins = new Map();
-
-// --- Load a single plugin ------------------------------------
-
-type PluginStoreAccessor = {
- setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
-};
-
-let storeAccessor: PluginStoreAccessor | null = null;
-
-export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void {
- storeAccessor = accessor;
-}
+// ─── Lifecycle (sandbox-backed) ───────────────────────────────
export async function loadPlugin(plugin: InstalledPlugin): Promise {
- if (activePlugins.has(plugin.id)) {
- console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
+ if (getActive(plugin.id)) {
+ console.warn(`[plugin-loader] "${plugin.id}" is already loaded`);
return;
}
-
- // Ensure React/ReactDOM are exposed before any plugin module evaluates
- exposePluginExternals();
-
- try {
- // 1. Read bundle from IndexedDB
- const code = await pluginStorage.getCode(plugin.id);
- if (!code) {
- throw new Error(`No code found in storage for plugin "${plugin.id}"`);
- }
-
- // 2. Create scoped module via blob URL
- const blob = new Blob([code], { type: 'application/javascript' });
- const url = URL.createObjectURL(blob);
-
- // 3. Dynamic import (webpackIgnore prevents bundler processing)
- let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void };
- try {
- mod = await import(/* webpackIgnore: true */ url);
- } finally {
- URL.revokeObjectURL(url);
- }
-
- if (typeof mod.activate !== 'function') {
- throw new Error(`Plugin "${plugin.id}" has no activate() export`);
- }
-
- // 4. Build sandboxed API
- const api = createPluginAPI(plugin);
-
- // 4b. Auto-register translations bundled in the manifest (plugin.locales)
- // Plugins may still call api.i18n.addTranslations() in activate() to add more.
- if (plugin.locales) {
- for (const [locale, strings] of Object.entries(plugin.locales)) {
- api.i18n.addTranslations(locale, strings);
- }
- }
-
- // 5. Call activate
- const disposable = await mod.activate(api);
-
- // 6. Track active plugin
- activePlugins.set(plugin.id, {
- id: plugin.id,
- api,
- disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable
- ? disposable as Disposable
- : undefined,
- deactivate: mod.deactivate,
- });
-
- // 7. Mark running
- storeAccessor?.setPluginStatus(plugin.id, 'running');
- console.info(`[plugin-loader] Plugin "${plugin.id}" activated`);
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg);
- console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err);
- }
+ await loadSandboxedPlugin(plugin);
}
-// --- Deactivate a single plugin ------------------------------
-
export function deactivatePlugin(pluginId: string): void {
- const active = activePlugins.get(pluginId);
- if (!active) return;
-
- try {
- // Call deactivate() if provided
- active.deactivate?.();
- // Dispose the disposable returned from activate()
- active.disposable?.dispose();
- } catch (err) {
- console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err);
- }
-
- // Remove all hook subscriptions for this plugin
- removeAllPluginHooks(pluginId);
-
- // Clear cached translations (avoids memory leak on repeated enable/disable cycles)
- clearPluginI18nTranslations(pluginId);
-
- // Reset error tracker
- pluginErrorTracker.reset(pluginId);
-
- activePlugins.delete(pluginId);
- storeAccessor?.setPluginStatus(pluginId, 'disabled');
- console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
+ unloadSandboxedPlugin(pluginId);
}
-// --- Activate all enabled plugins ----------------------------
-
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise {
- // Ensure externals are exposed
exposePluginExternals();
-
- const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
- for (const plugin of enabledPlugins) {
- await loadPlugin(plugin);
- }
+ await activateAllSandboxed(plugins);
}
-// --- Deactivate all plugins ---------------------------------
-
export function deactivateAllPlugins(): void {
- for (const pluginId of [...activePlugins.keys()]) {
- deactivatePlugin(pluginId);
- }
+ deactivateAllSandboxed();
}
-// --- Check if a plugin is active -----------------------------
-
export function isPluginActive(pluginId: string): boolean {
- return activePlugins.has(pluginId);
+ return getActive(pluginId) !== undefined;
}
-// --- Setup auto-disable callback -----------------------------
-
export function setupAutoDisable(): void {
- pluginErrorTracker.setAutoDisableCallback((pluginId) => {
- deactivatePlugin(pluginId);
- storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
- });
+ setupSandboxAutoDisable();
}
+
+// Re-export for stores/tests that need the active set.
+export { allActive as activePlugins };
diff --git a/lib/plugin-sandbox/bundle-integrity.ts b/lib/plugin-sandbox/bundle-integrity.ts
new file mode 100644
index 00000000..484c55ab
--- /dev/null
+++ b/lib/plugin-sandbox/bundle-integrity.ts
@@ -0,0 +1,57 @@
+// SHA-256 integrity check for plugin bundles.
+//
+// The bundle endpoint returns the canonical hash as the ETag. The client
+// re-hashes the bytes after fetch and refuses to load on mismatch. This
+// closes the gap where a compromised admin route (or transient MITM upstream
+// of the CDN/proxy) could swap the bundle silently.
+
+export async function sha256Hex(input: string | Uint8Array): Promise {
+ // Re-wrap so the buffer is a plain ArrayBuffer (not SharedArrayBuffer) to
+ // satisfy lib.dom's BufferSource typing.
+ let buf: ArrayBuffer;
+ if (typeof input === 'string') {
+ buf = new TextEncoder().encode(input).buffer as ArrayBuffer;
+ } else {
+ const copy = new Uint8Array(input.byteLength);
+ copy.set(input);
+ buf = copy.buffer;
+ }
+ const digest = await crypto.subtle.digest('SHA-256', buf);
+ const view = new Uint8Array(digest);
+ let out = '';
+ for (let i = 0; i < view.length; i++) {
+ const h = view[i].toString(16);
+ out += h.length === 1 ? '0' + h : h;
+ }
+ return out;
+}
+
+/**
+ * Compare `actual` and `expected` in constant time. Both must be the same
+ * length lower-case hex strings. Returns false on any structural mismatch.
+ */
+export function constantTimeHexEqual(actual: string, expected: string): boolean {
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false;
+ if (actual.length !== expected.length) return false;
+ let diff = 0;
+ for (let i = 0; i < actual.length; i++) {
+ diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
+ }
+ return diff === 0;
+}
+
+/**
+ * Verify `code` against `expectedHash`. Returns the (normalised) hash on
+ * match, throws on mismatch. Pass `null`/`undefined` for `expectedHash` to
+ * compute-and-return without verification (used for dev-plugin paths).
+ */
+export async function verifyBundle(code: string, expectedHash: string | null | undefined): Promise {
+ const actual = await sha256Hex(code);
+ if (!expectedHash) return actual;
+ // Server may quote the hash (it's also used as an ETag); strip and compare.
+ const normalised = expectedHash.replace(/^"|"$/g, '').trim().toLowerCase();
+ if (!constantTimeHexEqual(actual, normalised)) {
+ throw new Error(`Bundle integrity mismatch: expected ${normalised}, got ${actual}`);
+ }
+ return actual;
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
new file mode 100644
index 00000000..ba208fde
--- /dev/null
+++ b/lib/plugin-sandbox/host-api.ts
@@ -0,0 +1,234 @@
+// Host-side implementations of the sandboxed plugin API. Every method gates
+// on `plugin.permissions` BEFORE doing the underlying work, and only returns
+// structured-cloneable data back to the iframe.
+
+import type { InstalledPlugin, Permission } from '../plugin-types';
+import { IMPLICIT_PERMISSIONS } from '../plugin-types';
+import { toast as appToast } from '@/stores/toast-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { apiFetch } from '../browser-navigation';
+
+const PERM_PER_METHOD: Record = {
+ // storage is unscoped by the manifest - implicit.
+ 'storage.get': null,
+ 'storage.set': null,
+ 'storage.remove': null,
+ 'storage.keys': null,
+ // toast / log don't need a permission (anyone can show a toast).
+ 'toast.success': null,
+ 'toast.error': null,
+ 'toast.info': null,
+ 'toast.warning': null,
+ // http
+ 'http.post': 'http:post',
+ 'http.fetch': 'http:fetch',
+ // admin
+ 'admin.getConfig': 'admin:config',
+ 'admin.getAllConfig': 'admin:config',
+ 'admin.setConfig': 'admin:config',
+ 'admin.deleteConfig': 'admin:config',
+};
+
+function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
+ if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
+ return plugin.permissions.includes(perm);
+}
+
+// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
+
+function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
+ if (url.protocol !== 'https:') return false;
+ for (const entry of allowlist) {
+ let parsed: URL;
+ try { parsed = new URL(entry.replace('*.', '')); } catch { continue; }
+ if (parsed.protocol !== 'https:') continue;
+ const port = url.port || '';
+ const expectedPort = parsed.port || '';
+ if (port !== expectedPort) continue;
+ if (entry.includes('*.')) {
+ const suffix = '.' + parsed.hostname.toLowerCase();
+ const host = url.hostname.toLowerCase();
+ if (host.endsWith(suffix)) {
+ const prefix = host.slice(0, host.length - suffix.length);
+ if (prefix.length > 0 && !prefix.includes('.')) return true;
+ }
+ } else if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// ─── Per-plugin storage namespace ─────────────────────────────
+
+const STORAGE_PREFIX = (pluginId: string) => `plugin:${pluginId}:`;
+
+function storageGet(pluginId: string, key: string): unknown {
+ if (typeof window === 'undefined') return null;
+ const raw = window.localStorage.getItem(STORAGE_PREFIX(pluginId) + key);
+ if (raw === null) return null;
+ try { return JSON.parse(raw); } catch { return null; }
+}
+function storageSet(pluginId: string, key: string, value: unknown): void {
+ if (typeof window === 'undefined') return;
+ window.localStorage.setItem(STORAGE_PREFIX(pluginId) + key, JSON.stringify(value));
+}
+function storageRemove(pluginId: string, key: string): void {
+ if (typeof window === 'undefined') return;
+ window.localStorage.removeItem(STORAGE_PREFIX(pluginId) + key);
+}
+function storageKeys(pluginId: string): string[] {
+ if (typeof window === 'undefined') return [];
+ const prefix = STORAGE_PREFIX(pluginId);
+ const out: string[] = [];
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const k = window.localStorage.key(i);
+ if (k?.startsWith(prefix)) out.push(k.slice(prefix.length));
+ }
+ return out;
+}
+
+// ─── http.post (same-origin /api/*) ───────────────────────────
+
+async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
+ if (typeof path !== 'string' || !path.startsWith('/api/')) {
+ throw new Error('path must start with /api/');
+ }
+ const url = new URL(path, window.location.origin);
+ if (url.origin !== window.location.origin) {
+ throw new Error('path must resolve to the same origin');
+ }
+ const { client } = useAuthStore.getState();
+ const headers: Record = { 'Content-Type': 'application/json' };
+ if (client) {
+ headers['Authorization'] = client.getAuthHeader();
+ headers['X-JMAP-Username'] = client.getUsername();
+ }
+ const res = await fetch(url.pathname + url.search, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => null);
+ return { ok: res.ok, status: res.status, data };
+}
+
+// ─── http.fetch (cross-origin, manifest-allowlisted) ──────────
+
+interface PluginFetchInit {
+ method?: string;
+ headers?: Record;
+ body?: string | ArrayBuffer | ArrayBufferView | null;
+}
+
+async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) {
+ if (typeof rawUrl !== 'string') throw new Error('url must be a string');
+ let url: URL;
+ try { url = new URL(rawUrl); } catch { throw new Error('url must be absolute https://'); }
+ const allowlist = plugin.httpOrigins ?? [];
+ if (allowlist.length === 0) {
+ throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`);
+ }
+ if (!originMatchesAllowlist(url, allowlist)) {
+ throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`);
+ }
+ const safeHeaders: Record = {};
+ if (init?.headers) {
+ for (const [k, v] of Object.entries(init.headers)) {
+ const lower = k.toLowerCase();
+ if (lower === 'cookie' || lower === 'x-jmap-username') continue;
+ safeHeaders[k] = v;
+ }
+ }
+ const res = await fetch(url.toString(), {
+ method: init?.method ?? 'GET',
+ headers: safeHeaders,
+ body: (init?.body ?? undefined) as BodyInit | undefined,
+ credentials: 'omit',
+ mode: 'cors',
+ redirect: 'follow',
+ });
+ // Sandboxed plugin can't hold a Response object across the boundary, so
+ // we read the body once and return it as text + arrayBuffer (base64).
+ const headers: Record = {};
+ res.headers.forEach((val, key) => { headers[key.toLowerCase()] = val; });
+ const buf = await res.arrayBuffer();
+ let text: string | null = null;
+ try { text = new TextDecoder('utf-8', { fatal: false }).decode(buf); } catch { text = null; }
+ return {
+ ok: res.ok,
+ status: res.status,
+ statusText: res.statusText,
+ headers,
+ bodyText: text,
+ bodyBytes: new Uint8Array(buf),
+ };
+}
+
+// ─── admin config (same as before) ────────────────────────────
+
+async function adminGetAll(pluginId: string): Promise> {
+ const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`);
+ if (!res.ok) return {};
+ return res.json();
+}
+async function adminGet(pluginId: string, key: string): Promise {
+ const all = await adminGetAll(pluginId);
+ return all[key] ?? null;
+}
+async function adminSet(pluginId: string, key: string, value: unknown): Promise {
+ await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value }),
+ });
+}
+async function adminDelete(pluginId: string, key: string): Promise {
+ await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key }),
+ });
+}
+
+// ─── Dispatcher ──────────────────────────────────────────────
+
+/** Resolves an api-request method against the per-plugin permissions. */
+export async function dispatchApiCall(
+ plugin: InstalledPlugin,
+ method: string,
+ args: unknown[],
+): Promise {
+ // Permission gate
+ const requiredPerm = PERM_PER_METHOD[method];
+ if (requiredPerm !== undefined && requiredPerm !== null) {
+ if (!hasPermission(plugin, requiredPerm)) {
+ throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`);
+ }
+ } else if (!(method in PERM_PER_METHOD)) {
+ throw new Error(`Unknown API method "${method}"`);
+ }
+
+ switch (method) {
+ case 'storage.get': return storageGet(plugin.id, args[0] as string);
+ case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
+ case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
+ case 'storage.keys': return storageKeys(plugin.id);
+
+ case 'toast.success': appToast.success(String(args[0] ?? '')); return undefined;
+ case 'toast.error': appToast.error(String(args[0] ?? '')); return undefined;
+ case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
+ case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
+
+ case 'http.post': return doHttpPost(args[0] as string, args[1]);
+ case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
+
+ case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
+ case 'admin.getAllConfig': return adminGetAll(plugin.id);
+ case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
+ case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
+
+ default:
+ throw new Error(`Unhandled method "${method}"`);
+ }
+}
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
new file mode 100644
index 00000000..0f052dd6
--- /dev/null
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -0,0 +1,260 @@
+// Host-side wrapper around a single sandbox iframe (one per plugin/background,
+// plus one per slot mount). Owns the iframe lifecycle and the postMessage RPC.
+//
+// Origin model: the iframe is `sandbox="allow-scripts"` with no
+// `allow-same-origin`, so its origin is opaque ("null"). We can't pin on
+// `event.origin`; instead, every inbound message is gated on
+// `event.source === iframe.contentWindow`. The iframe's runtime pins the
+// parent on the first inbound message.
+
+import type { InstalledPlugin, SlotName } from '../plugin-types';
+import { dispatchApiCall } from './host-api';
+import { SANDBOX_PATH } from './protocol';
+import type {
+ SandboxToHost, HostToSandbox, InitMsg, InitPayload,
+} from './protocol';
+
+// ─── Public option types ─────────────────────────────────────
+
+export interface BackgroundOptions {
+ plugin: InstalledPlugin;
+ code: string;
+ locale: string;
+ /** Where the hidden iframe should attach. Defaults to document.body. */
+ hostContainer?: HTMLElement;
+}
+
+export interface SlotOptions {
+ plugin: InstalledPlugin;
+ slot: SlotName;
+ code: string;
+ locale: string;
+ extraProps: Record;
+ /** Container element the visible slot iframe is mounted into. */
+ hostContainer: HTMLElement;
+ /** Called whenever the sandbox reports a new content height. */
+ onResize: (height: number) => void;
+}
+
+export interface InitDoneInfo {
+ hooks: string[];
+ slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
+}
+
+// ─── Sandbox instance ────────────────────────────────────────
+
+export class SandboxInstance {
+ readonly iframe: HTMLIFrameElement;
+ readonly pluginId: string;
+ readonly mode: 'background' | 'slot';
+
+ readyPromise: Promise;
+ initPromise: Promise;
+
+ private resolveReady!: () => void;
+ private resolveInit!: (info: InitDoneInfo) => void;
+ private rejectInit!: (err: Error) => void;
+ private listener: (ev: MessageEvent) => void;
+ private destroyed = false;
+
+ private pendingHookInvokes = new Map void; reject: (e: Error) => void }>();
+ private pendingShouldShow = new Map void>();
+
+ constructor(
+ private plugin: InstalledPlugin,
+ initPayload: InitPayload,
+ hostContainer: HTMLElement,
+ private slotResizeCb: ((height: number) => void) | null,
+ ) {
+ this.pluginId = plugin.id;
+ this.mode = initPayload.mode;
+
+ this.readyPromise = new Promise((res) => { this.resolveReady = res; });
+ this.initPromise = new Promise((res, rej) => {
+ this.resolveInit = res;
+ this.rejectInit = rej;
+ });
+
+ this.iframe = document.createElement('iframe');
+ this.iframe.setAttribute('sandbox', 'allow-scripts');
+ this.iframe.setAttribute('referrerpolicy', 'no-referrer');
+ this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
+ this.iframe.style.border = 'none';
+ this.iframe.style.display = 'block';
+ if (initPayload.mode === 'background') {
+ this.iframe.style.position = 'absolute';
+ this.iframe.style.width = '1px';
+ this.iframe.style.height = '1px';
+ this.iframe.style.opacity = '0';
+ this.iframe.style.pointerEvents = 'none';
+ this.iframe.style.left = '-9999px';
+ this.iframe.setAttribute('aria-hidden', 'true');
+ } else {
+ this.iframe.style.width = '100%';
+ this.iframe.style.height = '0px';
+ }
+ this.iframe.src = SANDBOX_PATH;
+
+ this.listener = (ev) => this.onMessage(ev);
+ window.addEventListener('message', this.listener);
+ hostContainer.appendChild(this.iframe);
+
+ // Send init after the iframe runtime signals it's ready.
+ this.readyPromise.then(() => {
+ if (this.destroyed) return;
+ const msg: InitMsg = { type: 'init', payload: initPayload };
+ this.send(msg);
+ });
+ }
+
+ // ─── Internal ───────────────────────────────────────────────
+
+ private send(msg: HostToSandbox): void {
+ // targetOrigin '*' is required because the iframe is opaque-origin. The
+ // payload contains no host secrets — bundle code and manifest fields the
+ // plugin already owns.
+ this.iframe.contentWindow?.postMessage(msg, '*');
+ }
+
+ private onMessage(ev: MessageEvent): void {
+ if (this.destroyed) return;
+ if (ev.source !== this.iframe.contentWindow) return;
+ const msg = ev.data as SandboxToHost;
+ if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
+
+ switch (msg.type) {
+ case 'sandbox-ready':
+ this.resolveReady();
+ return;
+
+ case 'init-done':
+ this.resolveInit({ hooks: msg.hooks, slots: msg.slots });
+ return;
+
+ case 'init-error':
+ this.rejectInit(new Error(msg.error));
+ return;
+
+ case 'api-request': {
+ const { id, method, args } = msg;
+ void (async () => {
+ try {
+ const result = await dispatchApiCall(this.plugin, method, args ?? []);
+ this.send({ type: 'api-response', id, ok: true, result });
+ } catch (err) {
+ this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
+ }
+ })();
+ return;
+ }
+
+ case 'hook-result': {
+ const entry = this.pendingHookInvokes.get(msg.id);
+ if (!entry) return;
+ this.pendingHookInvokes.delete(msg.id);
+ if (msg.ok) entry.resolve(msg.result);
+ else entry.reject(new Error(msg.error ?? 'hook error'));
+ return;
+ }
+
+ case 'slot-should-show-result': {
+ const cb = this.pendingShouldShow.get(msg.id);
+ if (!cb) return;
+ this.pendingShouldShow.delete(msg.id);
+ cb(msg.show);
+ return;
+ }
+
+ case 'slot-resize':
+ this.slotResizeCb?.(msg.height);
+ return;
+ }
+ }
+
+ // ─── Public ─────────────────────────────────────────────────
+
+ /** Dispatch a hook handler inside the sandbox; resolves with its return value. */
+ invokeHook(hookName: string, args: unknown[]): Promise {
+ if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
+ const id = uid();
+ const p = new Promise((resolve, reject) => {
+ this.pendingHookInvokes.set(id, { resolve, reject });
+ });
+ this.send({ type: 'hook-invoke', id, hookName, args });
+ return p;
+ }
+
+ /** Ask the background instance whether a slot should mount for this context. */
+ evaluateShouldShow(slot: SlotName, context: unknown): Promise {
+ if (this.destroyed) return Promise.resolve(false);
+ const id = uid();
+ const p = new Promise((resolve) => {
+ this.pendingShouldShow.set(id, resolve);
+ });
+ this.send({ type: 'slot-should-show', id, slot, context });
+ return p;
+ }
+
+ setLocale(locale: string): void {
+ if (this.destroyed) return;
+ this.send({ type: 'locale-change', locale });
+ }
+
+ updateProps(props: Record): void {
+ if (this.destroyed) return;
+ this.send({ type: 'props-update', props });
+ }
+
+ destroy(): void {
+ if (this.destroyed) return;
+ this.destroyed = true;
+ window.removeEventListener('message', this.listener);
+ this.iframe.remove();
+ for (const { reject } of this.pendingHookInvokes.values()) {
+ reject(new Error('sandbox destroyed'));
+ }
+ this.pendingHookInvokes.clear();
+ this.pendingShouldShow.clear();
+ }
+}
+
+function uid(): string {
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
+}
+
+// ─── Factory helpers ─────────────────────────────────────────
+
+export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstance {
+ const payload: InitPayload = {
+ mode: 'background',
+ pluginId: opts.plugin.id,
+ manifest: {
+ id: opts.plugin.id,
+ version: opts.plugin.version,
+ permissions: opts.plugin.permissions,
+ settings: { ...opts.plugin.settings },
+ locales: opts.plugin.locales,
+ httpOrigins: opts.plugin.httpOrigins,
+ },
+ code: opts.code,
+ locale: opts.locale,
+ };
+ return new SandboxInstance(
+ opts.plugin,
+ payload,
+ opts.hostContainer ?? document.body,
+ null,
+ );
+}
+
+export function createSlotInstance(opts: SlotOptions): SandboxInstance {
+ const payload: InitPayload = {
+ mode: 'slot',
+ pluginId: opts.plugin.id,
+ slot: opts.slot,
+ code: opts.code,
+ extraProps: opts.extraProps,
+ locale: opts.locale,
+ };
+ return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
+}
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
new file mode 100644
index 00000000..b60fc8b4
--- /dev/null
+++ b/lib/plugin-sandbox/loader.ts
@@ -0,0 +1,158 @@
+// Iframe-based plugin loader. Replaces the blob-URL `import()` flow in
+// `lib/plugin-loader.ts` with a postMessage-isolated sandbox.
+
+import type { Disposable, InstalledPlugin } from '../plugin-types';
+import { pluginStorage } from '../plugin-storage';
+import {
+ emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
+ authHooks, settingsHooks, identityHooks, filterHooks,
+ taskHooks, templateHooks, smimeHooks, vacationHooks,
+ uiHooks, themeHooks, toastHooks, dragDropHooks,
+ keyboardHooks, appLifecycleHooks, accountSecurityHooks,
+ sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
+ removeAllPluginHooks, pluginErrorTracker,
+} from '../plugin-hooks';
+import { verifyBundle } from './bundle-integrity';
+import { createBackgroundInstance } from './host-bridge';
+import { register as registerActive, deregister as deregisterActive } from './registry';
+
+// ─── Hook-bus lookup (one flat map for name → bus) ────────────
+
+type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable };
+
+const HOOK_BUSES: Record = Object.assign({},
+ emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
+ authHooks, settingsHooks, identityHooks, filterHooks,
+ taskHooks, templateHooks, smimeHooks, vacationHooks,
+ uiHooks, themeHooks, toastHooks, dragDropHooks,
+ keyboardHooks, appLifecycleHooks, accountSecurityHooks,
+ sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
+) as Record;
+
+// ─── Store accessor (status updates flow through the existing store) ──
+
+type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
+let storeAccessor: StoreAccessor | null = null;
+export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; }
+
+// ─── Locale (kept in step with the app locale) ────────────────
+
+let currentLocale = 'en';
+export function setSandboxLocale(locale: string): void {
+ currentLocale = locale;
+ // Push to all active background instances.
+ // Slot iframes inherit locale at spawn time; they're short-lived.
+ // (We don't import the registry here to avoid a circular import; the
+ // PluginIframeSlot subscribes to locale changes on its own.)
+}
+
+// ─── Bundle fetch ─────────────────────────────────────────────
+
+async function getBundleCode(plugin: InstalledPlugin): Promise {
+ // Dev plugins are written into IndexedDB by the same install flow; the
+ // bundle endpoint is the source of truth for managed plugins. For Phase 1
+ // we read from IndexedDB to match the existing flow; the store-side install
+ // path already populates this from /api/admin/plugins/[id]/bundle.
+ const code = await pluginStorage.getCode(plugin.id);
+ if (!code) {
+ throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`);
+ }
+ await verifyBundle(code, plugin.bundleHash);
+ return code;
+}
+
+// ─── Load ─────────────────────────────────────────────────────
+
+export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise {
+ if (typeof window === 'undefined') return;
+
+ try {
+ const code = await getBundleCode(plugin);
+ const background = createBackgroundInstance({
+ plugin,
+ code,
+ locale: currentLocale,
+ });
+
+ // Wait for the background runtime to evaluate the bundle, register hooks,
+ // and enumerate slots.
+ const info = await background.initPromise;
+
+ // Wire hook proxies: every hookName the plugin registered gets a HookBus
+ // entry whose handler dispatches into the sandbox.
+ const hookDisposables: Disposable[] = [];
+ for (const hookName of info.hooks) {
+ const bus = HOOK_BUSES[hookName];
+ if (!bus) {
+ console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
+ continue;
+ }
+ const proxy = async (...args: unknown[]) => {
+ try {
+ return await background.invokeHook(hookName, args);
+ } catch (err) {
+ pluginErrorTracker.record(plugin.id, err);
+ throw err;
+ }
+ };
+ hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
+ }
+
+ registerActive({
+ plugin,
+ code,
+ background,
+ slotOffers: info.slots,
+ hookDisposables,
+ });
+
+ storeAccessor?.setPluginStatus(plugin.id, 'running');
+ console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
+ } catch (err) {
+ const msg = (err as Error).message ?? String(err);
+ storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
+ console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
+ }
+}
+
+// ─── Unload ───────────────────────────────────────────────────
+
+export function unloadSandboxedPlugin(pluginId: string): void {
+ const entry = deregisterActive(pluginId);
+ if (!entry) return;
+ for (const d of entry.hookDisposables) {
+ try { d.dispose(); } catch { /* ignore */ }
+ }
+ removeAllPluginHooks(pluginId);
+ try { entry.background.destroy(); } catch { /* ignore */ }
+ pluginErrorTracker.reset(pluginId);
+ storeAccessor?.setPluginStatus(pluginId, 'disabled');
+ console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
+}
+
+// ─── Bulk ─────────────────────────────────────────────────────
+
+export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise {
+ const enabled = plugins.filter(p => p.enabled && p.status !== 'error');
+ for (const p of enabled) await loadSandboxedPlugin(p);
+}
+
+export function deactivateAllSandboxed(): void {
+ // import lazily to avoid a circular dep when registry mutates while we iterate.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { all } = require('./registry') as typeof import('./registry');
+ for (const e of all()) unloadSandboxedPlugin(e.plugin.id);
+}
+
+// ─── Auto-disable ─────────────────────────────────────────────
+
+export function setupSandboxAutoDisable(): void {
+ pluginErrorTracker.setAutoDisableCallback((pluginId) => {
+ unloadSandboxedPlugin(pluginId);
+ storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
+ });
+}
+
+// ─── Re-export for compat with the existing loader name ───────
+
+export { SandboxInstance } from './host-bridge';
diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts
new file mode 100644
index 00000000..1da6741f
--- /dev/null
+++ b/lib/plugin-sandbox/protocol.ts
@@ -0,0 +1,159 @@
+// Shared message-protocol types for host ↔ sandbox postMessage RPC.
+//
+// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
+// events arrive with `event.origin === "null"`. The host pins messages by the
+// iframe's `contentWindow` reference instead. All values crossing the boundary
+// must be structured-cloneable: no functions, no DOM nodes, no class instances.
+
+import type { SlotName } from '../plugin-types';
+
+// ─── Sandbox mode ────────────────────────────────────────────
+
+export type SandboxMode = 'background' | 'slot';
+
+/** Initialisation payload for a background-instance iframe (one per plugin). */
+export interface BackgroundInit {
+ mode: 'background';
+ pluginId: string;
+ /** Trimmed manifest visible to the plugin. No host secrets. */
+ manifest: {
+ id: string;
+ version: string;
+ permissions: string[];
+ settings: Record;
+ locales?: Record>;
+ httpOrigins?: string[];
+ };
+ /** UTF-8 plugin bundle source (CommonJS). */
+ code: string;
+ /** Initial app locale; host pushes updates via 'locale-change'. */
+ locale: string;
+}
+
+/** Initialisation payload for a slot-instance iframe (one per slot mount). */
+export interface SlotInit {
+ mode: 'slot';
+ pluginId: string;
+ /** Slot name the iframe should render a component for. */
+ slot: SlotName;
+ /** Same bundle code as the background instance. */
+ code: string;
+ /** Initial props the host passes through from `PluginSlot` `extraProps`. */
+ extraProps: Record;
+ locale: string;
+}
+
+export type InitPayload = BackgroundInit | SlotInit;
+
+// ─── Sandbox → Host messages ─────────────────────────────────
+
+export interface ReadyMsg { type: 'sandbox-ready'; }
+
+export interface InitDoneMsg {
+ type: 'init-done';
+ /** Hook names the plugin registered. The host installs proxy handlers. */
+ hooks: string[];
+ /** Slots the plugin claims. Used by the host to know when a slot is offered. */
+ slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
+}
+
+export interface InitErrorMsg { type: 'init-error'; error: string; }
+
+export interface ApiRequestMsg {
+ type: 'api-request';
+ id: string;
+ /** Dotted method path, e.g. "http.post", "storage.get", "admin.getConfig". */
+ method: string;
+ args: unknown[];
+}
+
+export interface HookResultMsg {
+ type: 'hook-result';
+ id: string;
+ ok: boolean;
+ result?: unknown;
+ error?: string;
+}
+
+export interface SlotResizeMsg {
+ type: 'slot-resize';
+ height: number;
+}
+
+export interface SlotShouldShowResultMsg {
+ type: 'slot-should-show-result';
+ id: string;
+ show: boolean;
+}
+
+export type SandboxToHost =
+ | ReadyMsg
+ | InitDoneMsg
+ | InitErrorMsg
+ | ApiRequestMsg
+ | HookResultMsg
+ | SlotResizeMsg
+ | SlotShouldShowResultMsg;
+
+// ─── Host → Sandbox messages ─────────────────────────────────
+
+export interface InitMsg { type: 'init'; payload: InitPayload; }
+
+export interface ApiResponseMsg {
+ type: 'api-response';
+ id: string;
+ ok: boolean;
+ result?: unknown;
+ error?: string;
+}
+
+export interface HookInvokeMsg {
+ type: 'hook-invoke';
+ id: string;
+ hookName: string;
+ args: unknown[];
+}
+
+export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
+
+export interface PropsUpdateMsg { type: 'props-update'; props: Record; }
+
+export interface SlotShouldShowMsg {
+ type: 'slot-should-show';
+ id: string;
+ slot: SlotName;
+ context: unknown;
+}
+
+export type HostToSandbox =
+ | InitMsg
+ | ApiResponseMsg
+ | HookInvokeMsg
+ | LocaleChangeMsg
+ | PropsUpdateMsg
+ | SlotShouldShowMsg;
+
+// ─── Type guards ─────────────────────────────────────────────
+
+export function isSandboxMessage(value: unknown): value is SandboxToHost {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as { type?: unknown }).type === 'string'
+ );
+}
+
+// ─── Constants ───────────────────────────────────────────────
+
+/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
+export const SANDBOX_PATH = '/plugin-sandbox';
+
+/** Methods callable by a plugin via api-request. Host enforces permissions. */
+export const API_METHODS = [
+ 'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
+ 'http.post', 'http.fetch',
+ 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
+ 'toast.success', 'toast.error', 'toast.info', 'toast.warning',
+] as const;
+
+export type ApiMethod = (typeof API_METHODS)[number];
diff --git a/lib/plugin-sandbox/registry.ts b/lib/plugin-sandbox/registry.ts
new file mode 100644
index 00000000..85870543
--- /dev/null
+++ b/lib/plugin-sandbox/registry.ts
@@ -0,0 +1,66 @@
+// Process-wide registry of active sandboxed plugins. The loader populates it
+// after a successful boot; PluginIframeSlot reads it to spawn slot iframes
+// and to call evaluateShouldShow on the background instance.
+
+import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
+import type { SandboxInstance } from './host-bridge';
+
+export interface SlotOffer {
+ name: SlotName;
+ order: number;
+ hasShouldShow: boolean;
+}
+
+export interface ActivePlugin {
+ plugin: InstalledPlugin;
+ /** Verified bundle source. Reused when spinning up slot iframes. */
+ code: string;
+ background: SandboxInstance;
+ slotOffers: SlotOffer[];
+ hookDisposables: Disposable[];
+}
+
+const active = new Map();
+const listeners = new Set<() => void>();
+
+function emit(): void { for (const l of listeners) try { l(); } catch { /* ignore */ } }
+
+export function register(entry: ActivePlugin): void {
+ active.set(entry.plugin.id, entry);
+ emit();
+}
+
+export function deregister(pluginId: string): ActivePlugin | undefined {
+ const e = active.get(pluginId);
+ if (!e) return undefined;
+ active.delete(pluginId);
+ emit();
+ return e;
+}
+
+export function get(pluginId: string): ActivePlugin | undefined {
+ return active.get(pluginId);
+}
+
+export function all(): ActivePlugin[] {
+ return [...active.values()];
+}
+
+/** Returns active plugins ordered by `order`, that offer the requested slot. */
+export function offersForSlot(slot: SlotName): Array<{ pluginId: string; order: number; hasShouldShow: boolean }> {
+ const out: Array<{ pluginId: string; order: number; hasShouldShow: boolean }> = [];
+ for (const entry of active.values()) {
+ for (const offer of entry.slotOffers) {
+ if (offer.name === slot) {
+ out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow });
+ }
+ }
+ }
+ out.sort((a, b) => a.order - b.order);
+ return out;
+}
+
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
new file mode 100644
index 00000000..50c503a0
--- /dev/null
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -0,0 +1,348 @@
+'use client';
+
+// Runtime that boots inside the null-origin plugin sandbox iframe.
+//
+// Lifecycle:
+// 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK;
+// the message carries no secrets, and the parent's first inbound message
+// gives us the origin to pin for everything that follows).
+// 2. Parent posts 'init' with the bundle code + manifest + mode/slot.
+// 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
+// injected as globals; the bundle is CommonJS-style (`module.exports = {
+// slots, hooks, activate }`). ES-module syntax inside the bundle is a
+// build-time concern handled by the plugin's bundler.
+// 4. In background mode: register hook handlers and call `activate(api)`.
+// The host installs HookBus stubs and dispatches via 'hook-invoke'.
+// 5. In slot mode: look up `slots[slot].component`, render it into the
+// iframe body, push height back via ResizeObserver.
+
+import { useEffect, useRef } from 'react';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom/client';
+import * as ReactJSXRuntime from 'react/jsx-runtime';
+import type {
+ HostToSandbox,
+ SandboxToHost,
+ InitPayload,
+ BackgroundInit,
+ SlotInit,
+} from './protocol';
+import type { SlotName } from '../plugin-types';
+
+// ─── Module-scope state ──────────────────────────────────────
+
+interface PluginExports {
+ slots?: Record>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
+ hooks?: Record unknown>;
+ activate?: (api: unknown) => void | Promise | { dispose: () => void };
+ default?: unknown;
+}
+
+let parentWindow: Window | null = null;
+let parentOrigin: string | null = null;
+let pluginExports: PluginExports | null = null;
+let mode: 'background' | 'slot' | null = null;
+let slotName: SlotName | null = null;
+let bootDone = false;
+
+const pendingApi = new Map void; reject: (err: Error) => void }>();
+const hookHandlers: Record unknown> = {};
+
+function sendToHost(msg: SandboxToHost): void {
+ if (!parentWindow || !parentOrigin) return;
+ parentWindow.postMessage(msg, parentOrigin);
+}
+
+function uid(): string {
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
+}
+
+// ─── Sandboxed API facade (calls flow to host via postMessage) ─
+
+function callApi(method: string, args: unknown[]): Promise {
+ const id = uid();
+ return new Promise((resolve, reject) => {
+ pendingApi.set(id, { resolve, reject });
+ sendToHost({ type: 'api-request', id, method, args });
+ // Reject after 30s to prevent unbounded promise leaks if the host hangs.
+ setTimeout(() => {
+ const entry = pendingApi.get(id);
+ if (!entry) return;
+ pendingApi.delete(id);
+ entry.reject(new Error(`API call ${method} timed out after 30s`));
+ }, 30_000);
+ });
+}
+
+function buildPluginApi(manifest: BackgroundInit['manifest']) {
+ return {
+ plugin: {
+ id: manifest.id,
+ version: manifest.version,
+ settings: { ...manifest.settings },
+ },
+ storage: {
+ get: (key: string) => callApi('storage.get', [key]),
+ set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
+ remove: (key: string) => callApi('storage.remove', [key]),
+ keys: () => callApi('storage.keys', []),
+ },
+ http: {
+ post: (path: string, body: Record) => callApi('http.post', [path, body]),
+ fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
+ },
+ toast: {
+ success: (m: string) => { void callApi('toast.success', [m]); },
+ error: (m: string) => { void callApi('toast.error', [m]); },
+ info: (m: string) => { void callApi('toast.info', [m]); },
+ warning: (m: string) => { void callApi('toast.warning', [m]); },
+ },
+ admin: {
+ getConfig: (key: string) => callApi('admin.getConfig', [key]),
+ getAllConfig: () => callApi('admin.getAllConfig', []),
+ setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
+ deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
+ },
+ log: {
+ debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
+ info: (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
+ warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
+ error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
+ },
+ };
+}
+
+// ─── Bundle evaluation ───────────────────────────────────────
+
+/**
+ * Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
+ * bundlers should be configured to externalise React; the runtime provides
+ * those modules here. Anything else is refused — the sandbox has no Node-
+ * compatible module resolution and we don't want plugins probing globals.
+ */
+function makePluginRequire(): (name: string) => unknown {
+ const known: Record = {
+ 'react': React,
+ 'react-dom': ReactDOM,
+ 'react-dom/client': ReactDOM,
+ 'react/jsx-runtime': ReactJSXRuntime,
+ 'react/jsx-dev-runtime': ReactJSXRuntime,
+ };
+ return (name: string) => {
+ if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
+ throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
+ };
+}
+
+function evaluateBundle(code: string): PluginExports {
+ const mod: { exports: PluginExports } = { exports: {} };
+ const requireShim = makePluginRequire();
+ let fn: (...args: unknown[]) => void;
+ try {
+ fn = new Function(
+ 'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
+ code,
+ ) as (...args: unknown[]) => void;
+ } catch (err) {
+ throw new Error(`Bundle parse error: ${(err as Error).message}`);
+ }
+ try {
+ fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
+ } catch (err) {
+ throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
+ }
+ const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
+ if (!exports || typeof exports !== 'object') {
+ throw new Error('Bundle did not produce module.exports object');
+ }
+ return exports;
+}
+
+// ─── Init flow ───────────────────────────────────────────────
+
+async function bootBackground(payload: BackgroundInit): Promise {
+ const exports = evaluateBundle(payload.code);
+ pluginExports = exports;
+
+ // Register hooks (each value must be a function).
+ const hookNames: string[] = [];
+ const hooks = exports.hooks ?? {};
+ for (const [name, handler] of Object.entries(hooks)) {
+ if (typeof handler === 'function') {
+ hookHandlers[name] = handler;
+ hookNames.push(name);
+ }
+ }
+
+ // Enumerate slot offers.
+ const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
+ const slots = exports.slots ?? {};
+ for (const [name, def] of Object.entries(slots)) {
+ if (def && typeof def.component === 'function') {
+ slotInfo.push({
+ name: name as SlotName,
+ hasShouldShow: typeof def.shouldShow === 'function',
+ order: typeof def.order === 'number' ? def.order : 100,
+ });
+ }
+ }
+
+ // Side effects.
+ if (typeof exports.activate === 'function') {
+ await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
+ }
+
+ sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
+}
+
+function bootSlot(payload: SlotInit): void {
+ const exports = evaluateBundle(payload.code);
+ pluginExports = exports;
+ slotName = payload.slot;
+
+ const slotDef = exports.slots?.[payload.slot];
+ if (!slotDef || typeof slotDef.component !== 'function') {
+ throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
+ }
+
+ const rootEl = document.getElementById('plugin-sandbox-root');
+ if (!rootEl) throw new Error('Sandbox root element missing');
+
+ let currentProps: Record = payload.extraProps;
+ const Component = slotDef.component;
+
+ const SlotShell = () => {
+ const wrapRef = React.useRef(null);
+ React.useEffect(() => {
+ if (!wrapRef.current) return;
+ let lastHeight = -1;
+ const ro = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const h = Math.ceil(entry.contentRect.height);
+ if (h !== lastHeight) {
+ lastHeight = h;
+ sendToHost({ type: 'slot-resize', height: h });
+ }
+ }
+ });
+ ro.observe(wrapRef.current);
+ return () => ro.disconnect();
+ }, []);
+ return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
+ };
+
+ const reactRoot = ReactDOM.createRoot(rootEl);
+ reactRoot.render(React.createElement(SlotShell));
+ sendToHost({ type: 'init-done', hooks: [], slots: [] });
+}
+
+async function handleInit(payload: InitPayload): Promise {
+ if (bootDone) return;
+ bootDone = true;
+ mode = payload.mode;
+ try {
+ if (payload.mode === 'background') {
+ await bootBackground(payload);
+ } else {
+ bootSlot(payload);
+ }
+ } catch (err) {
+ sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
+ }
+}
+
+// ─── Host message handler ────────────────────────────────────
+
+function handleHostMessage(ev: MessageEvent): void {
+ // First inbound message pins source + origin. Reject everything else.
+ if (!parentWindow) {
+ if (!ev.source || ev.source === window) return;
+ parentWindow = ev.source as Window;
+ parentOrigin = ev.origin || null;
+ }
+ if (ev.source !== parentWindow) return;
+ if (parentOrigin && ev.origin !== parentOrigin) return;
+
+ const msg = ev.data as HostToSandbox;
+ if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
+
+ switch (msg.type) {
+ case 'init':
+ void handleInit(msg.payload);
+ break;
+
+ case 'api-response': {
+ const pending = pendingApi.get(msg.id);
+ if (!pending) return;
+ pendingApi.delete(msg.id);
+ if (msg.ok) pending.resolve(msg.result);
+ else pending.reject(new Error(msg.error ?? 'api error'));
+ break;
+ }
+
+ case 'hook-invoke': {
+ const handler = hookHandlers[msg.hookName];
+ if (!handler) {
+ sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
+ return;
+ }
+ try {
+ const result = handler(...(msg.args ?? []));
+ Promise.resolve(result).then(
+ (v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
+ (e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
+ );
+ } catch (err) {
+ sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
+ }
+ break;
+ }
+
+ case 'slot-should-show': {
+ // Resolved by the background instance for any slot it offers.
+ const slotDef = pluginExports?.slots?.[msg.slot];
+ let show = true;
+ try {
+ if (slotDef && typeof slotDef.shouldShow === 'function') {
+ show = !!slotDef.shouldShow(msg.context);
+ }
+ } catch {
+ show = false;
+ }
+ sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
+ break;
+ }
+
+ case 'locale-change':
+ (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
+ break;
+
+ case 'props-update':
+ // Phase-2: would push updates into the slot shell. Currently the slot
+ // iframe is torn down and recreated when props change at the host.
+ break;
+ }
+}
+
+// ─── React entry ─────────────────────────────────────────────
+
+export function SandboxRuntime(): React.JSX.Element {
+ const inited = useRef(false);
+ useEffect(() => {
+ if (inited.current) return;
+ inited.current = true;
+ window.addEventListener('message', handleHostMessage);
+ // Initial ping. We don't know parent origin yet, so '*' is required.
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
+ }
+ return () => {
+ window.removeEventListener('message', handleHostMessage);
+ };
+ }, []);
+ return ;
+}
+
+// Suppress unused-variable warning when `mode` is only read for debugging.
+void mode;
+void slotName;
diff --git a/proxy.ts b/proxy.ts
index 135a6f32..99093399 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -74,22 +74,31 @@ export async function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
+ // The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
+ // bundles via `new Function`. It is null-origin (sandbox="allow-scripts"),
+ // so the relaxation is scoped strictly to that document and never reaches
+ // the main app, plus it must be embeddable from `'self'`.
+ const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/");
- const scriptSrc = isDev
- ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
- : `'self' 'nonce-${nonce}' blob:`;
+ const scriptSrc = isSandboxPath
+ ? `'self' 'nonce-${nonce}' 'unsafe-eval'`
+ : isDev
+ ? `'self' 'nonce-${nonce}' 'unsafe-eval'`
+ : `'self' 'nonce-${nonce}'`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
- const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+ const frameAncestors = isSandboxPath
+ ? `'self'`
+ : process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
// Plugins may declare iframe origins they need (e.g. for embedded video).
// Each origin is validated at install time and re-validated here.
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
- ? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
- : `frame-src 'self' blob:`;
+ ? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
+ : `frame-src 'self'`;
const csp = [
`default-src 'self'`,
@@ -99,7 +108,7 @@ export async function proxy(request: NextRequest) {
`font-src 'self'`,
`connect-src ${connectSrc}`,
frameSrc,
- `object-src 'self' blob:`,
+ `object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,