feat: sandbox plugins in null-origin iframes with postMessage RPC
This commit is contained in:
@@ -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 (
|
||||||
|
<html lang="en">
|
||||||
|
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default function PluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import type { TnefAttachment } from "@/lib/tnef";
|
import type { TnefAttachment } from "@/lib/tnef";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
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 { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
@@ -970,8 +970,8 @@ export function EmailViewer({
|
|||||||
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
||||||
|
|
||||||
// Plugin detail sidebar state
|
// Plugin detail sidebar state
|
||||||
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
|
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
|
||||||
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
|
const hasDetailSidebar = detailSlots.length > 0;
|
||||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
||||||
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
||||||
const detailSidebarWidthRef = useRef(280);
|
const detailSidebarWidthRef = useRef(280);
|
||||||
|
|||||||
@@ -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 <div>.
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const instanceRef = useRef<SandboxInstance | null>(null);
|
||||||
|
const [height, setHeight] = useState<number>(0);
|
||||||
|
// null = pending, true/false = decided
|
||||||
|
const [show, setShow] = useState<boolean | null>(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 <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useSyncExternalStore } from 'react';
|
||||||
import type { SlotName } from '@/lib/plugin-types';
|
import type { SlotName } from '@/lib/plugin-types';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
|
||||||
import { PluginSlotRenderer } from './plugin-slot-renderer';
|
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||||
|
|
||||||
interface PluginSlotProps {
|
interface PluginSlotProps {
|
||||||
name: SlotName;
|
name: SlotName;
|
||||||
@@ -12,16 +12,21 @@ interface PluginSlotProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PluginSlot({ name, className, extraProps }: 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 (
|
return (
|
||||||
<div className={className} data-plugin-slot={name}>
|
<div className={className} data-plugin-slot={name}>
|
||||||
{registrations.map((reg, i) => (
|
{offers.map((offer) => (
|
||||||
<PluginSlotRenderer
|
<PluginIframeSlot
|
||||||
key={`${reg.pluginId}-${i}`}
|
key={offer.pluginId}
|
||||||
registration={reg}
|
pluginId={offer.pluginId}
|
||||||
|
slot={name}
|
||||||
extraProps={extraProps}
|
extraProps={extraProps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
() => [],
|
||||||
|
);
|
||||||
|
}
|
||||||
+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 type { InstalledPlugin } from './plugin-types';
|
||||||
import { pluginStorage } from './plugin-storage';
|
import {
|
||||||
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
loadSandboxedPlugin,
|
||||||
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
unloadSandboxedPlugin,
|
||||||
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
|
activateAllSandboxed,
|
||||||
import React from 'react';
|
deactivateAllSandboxed,
|
||||||
import ReactDOM from 'react-dom';
|
setSandboxStoreAccessor,
|
||||||
import * as ReactJSX from 'react/jsx-runtime';
|
setSandboxLocale,
|
||||||
|
setupSandboxAutoDisable,
|
||||||
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
} from './plugin-sandbox/loader';
|
||||||
|
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||||
let localeSyncInitialised = false;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// Initialise the locale sync once. Importing the store lazily avoids the
|
||||||
(globalThis as any).__PLUGIN_EXTERNALS__ = {
|
// circular module graph we used to fight before the sandbox refactor.
|
||||||
React,
|
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||||
ReactDOM,
|
setSandboxLocale(useLocaleStore.getState().locale);
|
||||||
ReactJSX,
|
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;
|
||||||
// Sync plugin i18n with the app locale (runs once per page load)
|
useLocaleStore.subscribe((state) => {
|
||||||
if (!localeSyncInitialised) {
|
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
|
||||||
localeSyncInitialised = true;
|
});
|
||||||
// Dynamic import avoids a circular dependency chain at module evaluation time
|
}).catch(() => { /* locale sync is best-effort */ });
|
||||||
import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
|
||||||
setPluginI18nLocale(useLocaleStore.getState().locale);
|
|
||||||
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
|
|
||||||
}).catch(() => {/* locale sync is best-effort */});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Active plugin tracking ----------------------------------
|
// ─── Store accessor (status updates) ──────────────────────────
|
||||||
|
|
||||||
interface ActivePlugin {
|
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
|
||||||
id: string;
|
|
||||||
api: PluginAPI;
|
export function setPluginStoreAccessor(accessor: StoreAccessor): void {
|
||||||
disposable?: Disposable;
|
setSandboxStoreAccessor(accessor);
|
||||||
deactivate?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePlugins = new Map<string, ActivePlugin>();
|
// ─── Lifecycle (sandbox-backed) ───────────────────────────────
|
||||||
|
|
||||||
// --- 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||||
if (activePlugins.has(plugin.id)) {
|
if (getActive(plugin.id)) {
|
||||||
console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
|
console.warn(`[plugin-loader] "${plugin.id}" is already loaded`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await loadSandboxedPlugin(plugin);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deactivate a single plugin ------------------------------
|
|
||||||
|
|
||||||
export function deactivatePlugin(pluginId: string): void {
|
export function deactivatePlugin(pluginId: string): void {
|
||||||
const active = activePlugins.get(pluginId);
|
unloadSandboxedPlugin(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`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Activate all enabled plugins ----------------------------
|
|
||||||
|
|
||||||
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
||||||
// Ensure externals are exposed
|
|
||||||
exposePluginExternals();
|
exposePluginExternals();
|
||||||
|
await activateAllSandboxed(plugins);
|
||||||
const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
|
|
||||||
for (const plugin of enabledPlugins) {
|
|
||||||
await loadPlugin(plugin);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deactivate all plugins ---------------------------------
|
|
||||||
|
|
||||||
export function deactivateAllPlugins(): void {
|
export function deactivateAllPlugins(): void {
|
||||||
for (const pluginId of [...activePlugins.keys()]) {
|
deactivateAllSandboxed();
|
||||||
deactivatePlugin(pluginId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Check if a plugin is active -----------------------------
|
|
||||||
|
|
||||||
export function isPluginActive(pluginId: string): boolean {
|
export function isPluginActive(pluginId: string): boolean {
|
||||||
return activePlugins.has(pluginId);
|
return getActive(pluginId) !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setup auto-disable callback -----------------------------
|
|
||||||
|
|
||||||
export function setupAutoDisable(): void {
|
export function setupAutoDisable(): void {
|
||||||
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
setupSandboxAutoDisable();
|
||||||
deactivatePlugin(pluginId);
|
|
||||||
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -74,22 +74,31 @@ export async function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
const nonce = crypto.randomUUID();
|
const nonce = crypto.randomUUID();
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
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
|
const scriptSrc = isSandboxPath
|
||||||
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
||||||
: `'self' 'nonce-${nonce}' blob:`;
|
: isDev
|
||||||
|
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
||||||
|
: `'self' 'nonce-${nonce}'`;
|
||||||
|
|
||||||
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
|
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).
|
// Plugins may declare iframe origins they need (e.g. for embedded video).
|
||||||
// Each origin is validated at install time and re-validated here.
|
// Each origin is validated at install time and re-validated here.
|
||||||
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
||||||
const frameSrc =
|
const frameSrc =
|
||||||
pluginFrameOrigins.length > 0
|
pluginFrameOrigins.length > 0
|
||||||
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
|
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
|
||||||
: `frame-src 'self' blob:`;
|
: `frame-src 'self'`;
|
||||||
|
|
||||||
const csp = [
|
const csp = [
|
||||||
`default-src 'self'`,
|
`default-src 'self'`,
|
||||||
@@ -99,7 +108,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
`font-src 'self'`,
|
`font-src 'self'`,
|
||||||
`connect-src ${connectSrc}`,
|
`connect-src ${connectSrc}`,
|
||||||
frameSrc,
|
frameSrc,
|
||||||
`object-src 'self' blob:`,
|
`object-src 'none'`,
|
||||||
`base-uri 'self'`,
|
`base-uri 'self'`,
|
||||||
`form-action 'self'`,
|
`form-action 'self'`,
|
||||||
`frame-ancestors ${frameAncestors}`,
|
`frame-ancestors ${frameAncestors}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user