feat: sandbox plugins in null-origin iframes with postMessage RPC
This commit is contained in:
+51
-157
@@ -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<string, ActivePlugin>();
|
||||
|
||||
// --- 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<void> {
|
||||
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<void> {
|
||||
// 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 };
|
||||
|
||||
@@ -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<string> {
|
||||
// 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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, Permission | null> = {
|
||||
// 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<string, string> = { '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<string, string>;
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<Record<string, unknown>> {
|
||||
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<unknown> {
|
||||
const all = await adminGetAll(pluginId);
|
||||
return all[key] ?? null;
|
||||
}
|
||||
async function adminSet(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||
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<void> {
|
||||
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<unknown> {
|
||||
// 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}"`);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<void>;
|
||||
initPromise: Promise<InitDoneInfo>;
|
||||
|
||||
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<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
private pendingShouldShow = new Map<string, (show: boolean) => 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<void>((res) => { this.resolveReady = res; });
|
||||
this.initPromise = new Promise<InitDoneInfo>((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<unknown> {
|
||||
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
|
||||
const id = uid();
|
||||
const p = new Promise<unknown>((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<boolean> {
|
||||
if (this.destroyed) return Promise.resolve(false);
|
||||
const id = uid();
|
||||
const p = new Promise<boolean>((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<string, unknown>): 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);
|
||||
}
|
||||
@@ -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<string, AnyBus> = 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<string, AnyBus>;
|
||||
|
||||
// ─── 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<string> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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';
|
||||
@@ -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<string, unknown>;
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>; }
|
||||
|
||||
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];
|
||||
@@ -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<string, ActivePlugin>();
|
||||
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); };
|
||||
}
|
||||
@@ -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<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
|
||||
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
||||
activate?: (api: unknown) => void | Promise<void> | { 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<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const hookHandlers: Record<string, (...args: unknown[]) => 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<unknown> {
|
||||
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<string, unknown>) => 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<string, unknown> = {
|
||||
'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<void> {
|
||||
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<string, unknown> = payload.extraProps;
|
||||
const Component = slotDef.component;
|
||||
|
||||
const SlotShell = () => {
|
||||
const wrapRef = React.useRef<HTMLDivElement>(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<void> {
|
||||
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 <div id="plugin-sandbox-root" />;
|
||||
}
|
||||
|
||||
// Suppress unused-variable warning when `mode` is only read for debugging.
|
||||
void mode;
|
||||
void slotName;
|
||||
Reference in New Issue
Block a user