From 088810bd203d7768b473ceba0af1605c36417dbc Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 18 May 2026 12:17:54 +0200
Subject: [PATCH] feat: harden plugin sandbox and migrate in-tree plugins
---
app/[locale]/layout.tsx | 4 +
components/plugins/plugin-consent-dialog.tsx | 114 +++
components/plugins/plugin-dialog-host.tsx | 113 +++
lib/__tests__/plugin-api.test.ts | 193 ----
lib/__tests__/plugin-store.test.ts | 47 +-
lib/admin/plugin-dev.ts | 15 +-
lib/plugin-api.ts | 944 -------------------
lib/plugin-sandbox/consent.ts | 88 ++
lib/plugin-sandbox/host-api.ts | 53 +-
lib/plugin-sandbox/host-bridge.ts | 76 +-
lib/plugin-sandbox/host-dialog.ts | 80 ++
lib/plugin-sandbox/loader.ts | 2 +
lib/plugin-sandbox/protocol.ts | 55 +-
lib/plugin-sandbox/runtime.tsx | 105 ++-
lib/plugin-types.ts | 8 +
stores/plugin-store.ts | 94 +-
16 files changed, 728 insertions(+), 1263 deletions(-)
create mode 100644 components/plugins/plugin-consent-dialog.tsx
create mode 100644 components/plugins/plugin-dialog-host.tsx
delete mode 100644 lib/__tests__/plugin-api.test.ts
delete mode 100644 lib/plugin-api.ts
create mode 100644 lib/plugin-sandbox/consent.ts
create mode 100644 lib/plugin-sandbox/host-dialog.ts
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index b1094bd8..238de5b7 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -6,6 +6,8 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
+import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
+import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -35,6 +37,8 @@ export default async function LocaleLayout({
{children}
+
+
diff --git a/components/plugins/plugin-consent-dialog.tsx b/components/plugins/plugin-consent-dialog.tsx
new file mode 100644
index 00000000..4194245f
--- /dev/null
+++ b/components/plugins/plugin-consent-dialog.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+// Modal shown the first time a plugin is enabled, listing every permission
+// the plugin's manifest declares. Accepting persists the grant on the
+// plugin record so future enables skip the prompt.
+
+import React, { useEffect, useSyncExternalStore } from 'react';
+import { head, resolveHead, subscribe, describePermission } from '@/lib/plugin-sandbox/consent';
+
+export function PluginConsentDialog(): React.JSX.Element | null {
+ const current = useSyncExternalStore(subscribe, head, () => null);
+
+ useEffect(() => {
+ if (!current) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ resolveHead(false);
+ }
+ }
+ document.addEventListener('keydown', onKey, true);
+ return () => document.removeEventListener('keydown', onKey, true);
+ }, [current]);
+
+ if (!current) return null;
+
+ return (
+
{ if (e.target === e.currentTarget) resolveHead(false); }}
+ >
+
+
+ Allow “{current.pluginName}” to access your data?
+
+
+ This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin.
+
+
+
+ {current.permissions.map((perm) => {
+ const desc = describePermission(perm);
+ return (
+ -
+
{desc.title}
+ {desc.body}
+ {perm}
+
+ );
+ })}
+
+
+
+
+
+
+
+ Plugin: {current.pluginId}
+
+
+
+ );
+}
diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx
new file mode 100644
index 00000000..4e9acaed
--- /dev/null
+++ b/components/plugins/plugin-dialog-host.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+// Host-rendered modal for plugin-requested confirm/alert dialogs.
+// Subscribes to the host-dialog queue and renders the head request, one at
+// a time. Closing the modal advances the queue.
+
+import React, { useEffect, useSyncExternalStore } from 'react';
+import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
+
+export function PluginDialogHost(): React.JSX.Element | null {
+ const current = useSyncExternalStore(subscribe, head, () => null);
+
+ useEffect(() => {
+ if (!current) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ resolveHead(false);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ resolveHead(true);
+ }
+ }
+ document.addEventListener('keydown', onKey, true);
+ return () => document.removeEventListener('keydown', onKey, true);
+ }, [current]);
+
+ if (!current) return null;
+
+ const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
+ const cancelLabel = current.cancelLabel ?? 'Cancel';
+
+ return (
+ {
+ if (e.target === e.currentTarget) resolveHead(false);
+ }}
+ >
+
+
+ {current.title}
+
+
+ {current.message}
+
+
+ {current.kind === 'confirm' && (
+
+ )}
+
+
+
+ From plugin: {current.pluginId}
+
+
+
+ );
+}
diff --git a/lib/__tests__/plugin-api.test.ts b/lib/__tests__/plugin-api.test.ts
deleted file mode 100644
index 74ee7d90..00000000
--- a/lib/__tests__/plugin-api.test.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
-import type { InstalledPlugin } from '../plugin-types';
-import { clearAllHooks } from '../plugin-hooks';
-
-function makePlugin(overrides: Partial = {}): InstalledPlugin {
- return {
- id: 'test-plugin',
- name: 'Test Plugin',
- version: '1.0.0',
- author: 'Test',
- description: '',
- type: 'ui-extension',
- entrypoint: 'index.js',
- permissions: [],
- enabled: true,
- status: 'running',
- settings: {},
- ...overrides,
- };
-}
-
-beforeEach(() => {
- clearAllHooks();
- localStorage.clear();
- setSlotRegistrationBridge(null);
-});
-
-describe('createPluginAPI', () => {
- it('exposes plugin info', () => {
- const plugin = makePlugin();
- const api = createPluginAPI(plugin);
- expect(api.plugin.id).toBe('test-plugin');
- expect(api.plugin.version).toBe('1.0.0');
- });
-
- it('returns a frozen copy of settings', () => {
- const plugin = makePlugin({ settings: { key: 'val' } });
- const api = createPluginAPI(plugin);
- expect(api.plugin.settings).toEqual({ key: 'val' });
- });
-});
-
-describe('plugin storage (scoped localStorage)', () => {
- it('set and get a value', () => {
- const api = createPluginAPI(makePlugin());
- api.storage.set('foo', 42);
- expect(api.storage.get('foo')).toBe(42);
- });
-
- it('scopes to plugin id', () => {
- const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
- const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
- api1.storage.set('key', 'a');
- api2.storage.set('key', 'b');
- expect(api1.storage.get('key')).toBe('a');
- expect(api2.storage.get('key')).toBe('b');
- });
-
- it('remove deletes a value', () => {
- const api = createPluginAPI(makePlugin());
- api.storage.set('x', 10);
- api.storage.remove('x');
- expect(api.storage.get('x')).toBeNull();
- });
-
- it('keys lists only plugin-scoped keys', () => {
- const api = createPluginAPI(makePlugin({ id: 'kp' }));
- api.storage.set('a', 1);
- api.storage.set('b', 2);
- localStorage.setItem('unrelated', 'val');
- expect(api.storage.keys()).toContain('a');
- expect(api.storage.keys()).toContain('b');
- expect(api.storage.keys()).not.toContain('unrelated');
- });
-});
-
-describe('plugin logger', () => {
- it('prefixes log messages with plugin id', () => {
- const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
- const api = createPluginAPI(makePlugin({ id: 'log-test' }));
- api.log.info('hello');
- expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
- infoSpy.mockRestore();
- });
-});
-
-describe('hooks permission gating', () => {
- it('returns no-op disposable without permission', () => {
- const plugin = makePlugin({ permissions: [] }); // no email:read
- const api = createPluginAPI(plugin);
- const d = api.hooks.onEmailOpen(vi.fn());
- expect(d).toBeDefined();
- expect(d.dispose).toBeInstanceOf(Function);
- });
-
- it('registers handler when permission is granted', () => {
- const plugin = makePlugin({ permissions: ['email:read'] });
- const api = createPluginAPI(plugin);
- const fn = vi.fn();
- const d = api.hooks.onEmailOpen(fn);
- expect(d).toBeDefined();
- d.dispose(); // should not throw
- });
-});
-
-describe('ui permission requirement', () => {
- it('throws without ui:toolbar permission', () => {
- const plugin = makePlugin({ permissions: [] });
- const api = createPluginAPI(plugin);
- expect(() => api.ui.registerToolbarAction({
- id: 'test',
- label: 'Test',
- onClick: () => {},
- })).toThrow('lacks permission');
- });
-
- it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- const plugin = makePlugin({ permissions: ['ui:toolbar'] });
- const api = createPluginAPI(plugin);
- const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
- expect(d.dispose).toBeInstanceOf(Function);
- consoleSpy.mockRestore();
- });
-});
-
-describe('slot registration bridge', () => {
- it('calls bridge when set', () => {
- const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
- setSlotRegistrationBridge(bridge);
-
- const plugin = makePlugin({ permissions: ['ui:email-footer'] });
- const api = createPluginAPI(plugin);
- const DummyComponent = () => null;
- api.ui.registerEmailFooter(DummyComponent);
- expect(bridge).toHaveBeenCalled();
- });
-});
-
-describe('toast bridge', () => {
- it('exposes success/error/info/warning methods', () => {
- const plugin = makePlugin();
- const api = createPluginAPI(plugin);
- expect(api.toast.success).toBeInstanceOf(Function);
- expect(api.toast.error).toBeInstanceOf(Function);
- expect(api.toast.info).toBeInstanceOf(Function);
- expect(api.toast.warning).toBeInstanceOf(Function);
- });
-});
-
-describe('http.post path validation', () => {
- function makeApi(permissions: string[] = ['http:post']) {
- return createPluginAPI(makePlugin({ permissions }));
- }
-
- it('rejects protocol-relative URLs like //evil.example', async () => {
- const api = makeApi();
- await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
- });
-
- it('rejects absolute URLs to other origins', async () => {
- const api = makeApi();
- await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
- });
-
- it('rejects paths not under /api/', async () => {
- const api = makeApi();
- await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
- });
-
- it('rejects paths that use backslash to bypass the check', async () => {
- const api = makeApi();
- await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
- });
-
- it('throws without http:post permission', async () => {
- const api = makeApi([]);
- await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
- });
-
- it('accepts a valid /api/ path', async () => {
- const api = makeApi();
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
- });
- const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
- expect(result.ok).toBe(true);
- expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
- });
-});
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index 1a65e6ec..80dad0bd 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
+import type { InstalledPlugin } from '@/lib/plugin-types';
// We test the raw store by directly invoking Zustand
// Mock the external dependencies the store imports
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
setupAutoDisable: vi.fn(),
}));
-vi.mock('@/lib/plugin-api', () => ({
- setSlotRegistrationBridge: vi.fn(),
-}));
-
vi.mock('@/lib/plugin-hooks', () => ({
removeAllPluginHooks: vi.fn(),
}));
@@ -42,22 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
function resetStore() {
usePluginStore.setState({
plugins: [],
- slots: {
- 'toolbar-actions': [],
- 'app-top-banner': [],
- 'email-banner': [],
- 'email-footer': [],
- 'composer-toolbar': [],
- 'composer-sidebar': [],
- 'composer-sidebar-right': [],
- 'sidebar-widget': [],
- 'email-detail-sidebar': [],
- 'settings-section': [],
- 'context-menu-email': [],
- 'navigation-rail-bottom': [],
- 'calendar-event-actions': [],
- 'admin-plugin-page': [],
- },
initialized: false,
});
}
@@ -85,31 +65,6 @@ beforeEach(() => {
});
describe('usePluginStore', () => {
- describe('registerSlot / dispose', () => {
- it('adds registration to slot and removes on dispose', () => {
- const { registerSlot } = usePluginStore.getState();
- const reg: SlotRegistration = {
- pluginId: 'p1',
- component: () => null,
- order: 100,
- };
- const disposable = registerSlot('toolbar-actions', reg);
- expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
- disposable.dispose();
- expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
- });
-
- it('sorts registrations by order', () => {
- const { registerSlot } = usePluginStore.getState();
- registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
- registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
- registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
-
- const regs = usePluginStore.getState().slots['email-banner'];
- expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
- });
- });
-
describe('setPluginStatus', () => {
it('updates status for existing plugin', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts
index d00b7178..93563fb0 100644
--- a/lib/admin/plugin-dev.ts
+++ b/lib/admin/plugin-dev.ts
@@ -92,14 +92,21 @@ export async function readDevBundle(entry: DevPluginEntry): Promise {
const result = await esbuild.build({
entryPoints: [entry.bundlePath],
bundle: true,
- format: 'esm',
+ // CJS format matches the sandbox runtime's evaluator
+ // (`new Function('module', 'exports', 'require', 'React', ...)`).
+ format: 'cjs',
+ platform: 'neutral',
write: false,
logLevel: 'silent',
sourcemap: 'inline',
target: ['es2020'],
- // React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
- // host, so we mark them external - the bundle won't try to ship them.
- external: ['react', 'react-dom', 'react/jsx-runtime'],
+ // The runtime's `require` shim resolves these at evaluation time:
+ // react / react-dom / react-dom/client / react/jsx-runtime → host copies
+ // @plugin-host → the per-plugin `api` object
+ external: [
+ 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
+ '@plugin-host',
+ ],
});
const out = result.outputFiles?.[0]?.text;
if (!out) throw new Error('esbuild produced no output');
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
deleted file mode 100644
index 66e43c01..00000000
--- a/lib/plugin-api.ts
+++ /dev/null
@@ -1,944 +0,0 @@
-// PluginAPI factory — builds the sandboxed API facade for each plugin
-
-import type {
- Disposable,
- InstalledPlugin,
- Permission,
- ToolbarAction,
- BannerFactory,
- SettingsSection,
- ComposerAction,
- SidebarWidget,
- ContextMenuItem,
- KeyboardShortcut,
- AdminPageSection,
- CalendarEventAction,
- SlotName,
- PluginI18n,
-} from './plugin-types';
-import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
-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,
-} from './plugin-hooks';
-import { createPluginI18n } from './plugin-i18n';
-import { toast as appToast } from '@/stores/toast-store';
-import { useAuthStore } from '@/stores/auth-store';
-import { apiFetch } from '@/lib/browser-navigation';
-
-// --- Permission helpers --------------------------------------
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function getPluginExternals(): any {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (globalThis as any).__PLUGIN_EXTERNALS__;
-}
-
-function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
- if ((IMPLICIT as readonly string[]).includes(perm)) return true;
- return plugin.permissions.includes(perm);
-}
-
-function requirePermission(plugin: InstalledPlugin, perm: Permission): void {
- if (!hasPermission(plugin, perm)) {
- throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`);
- }
-}
-
-/** Returns a no-op disposable when permission is missing (silent failure) */
-function guardedHook unknown>(
- plugin: InstalledPlugin,
- perm: Permission,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- bus: { register: (pluginId: string, handler: any, order?: number) => Disposable },
- handler: T,
- order: number = 100,
-): Disposable {
- if (!hasPermission(plugin, perm)) {
- return { dispose: () => {} };
- }
- return bus.register(plugin.id, handler, order);
-}
-
-// --- Plugin-scoped storage -----------------------------------
-
-function createPluginStorage(pluginId: string) {
- const prefix = `plugin:${pluginId}:`;
-
- return {
- get: (key: string): T | null => {
- if (typeof window === 'undefined') return null;
- const raw = localStorage.getItem(prefix + key);
- if (raw === null) return null;
- try { return JSON.parse(raw) as T; } catch { return null; }
- },
- set: (key: string, value: T): void => {
- if (typeof window === 'undefined') return;
- localStorage.setItem(prefix + key, JSON.stringify(value));
- },
- remove: (key: string): void => {
- if (typeof window === 'undefined') return;
- localStorage.removeItem(prefix + key);
- },
- keys: (): string[] => {
- if (typeof window === 'undefined') return [];
- const keys: string[] = [];
- for (let i = 0; i < localStorage.length; i++) {
- const k = localStorage.key(i);
- if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length));
- }
- return keys;
- },
- };
-}
-
-// --- Plugin-scoped logger ------------------------------------
-
-function createPluginLogger(pluginId: string) {
- const tag = `[plugin:${pluginId}]`;
- return {
- debug: (...args: unknown[]) => console.debug(tag, ...args),
- info: (...args: unknown[]) => console.info(tag, ...args),
- warn: (...args: unknown[]) => console.warn(tag, ...args),
- error: (...args: unknown[]) => console.error(tag, ...args),
- };
-}
-
-// --- Cross-origin fetch helpers ------------------------------
-
-/**
- * Returns true when `url`'s origin is allowed by one of the plugin's
- * declared `httpOrigins` patterns. Patterns are either a literal origin
- * (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`).
- *
- * Wildcards match exactly one subdomain layer above `host` - e.g.
- * `https://*.example.com` matches `https://a.example.com` but NOT
- * `https://example.com` and NOT `https://a.b.example.com`. This mirrors how
- * the CSP frame-src handles wildcards and avoids accidentally widening
- * access when the manifest only intended a single tier.
- */
-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();
- if (url.hostname.toLowerCase().endsWith(suffix)) {
- const prefix = url.hostname.slice(0, url.hostname.length - suffix.length);
- // Require exactly one non-empty subdomain label.
- if (prefix.length > 0 && !prefix.includes('.')) return true;
- }
- } else {
- if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true;
- }
- }
- return false;
-}
-
-// --- Cross-origin fetch types --------------------------------
-
-export interface PluginFetchInit {
- /** HTTP method. Defaults to GET. */
- method?: string;
- /** Request headers. Plain object only - no Headers / cookies forwarded. */
- headers?: Record;
- /** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */
- body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null;
- /** Optional AbortSignal for cancellation. */
- signal?: AbortSignal;
-}
-
-export interface PluginFetchResponse {
- ok: boolean;
- status: number;
- statusText: string;
- /** Response headers, lower-cased keys. */
- headers: Record;
- /** Resolves the body as text. */
- text: () => Promise;
- /** Resolves the body as parsed JSON, or null on parse error. */
- json: () => Promise;
- /** Resolves the body as raw bytes. */
- arrayBuffer: () => Promise;
- /** Resolves the body as a Blob. */
- blob: () => Promise;
-}
-
-// --- PluginAPI interface -------------------------------------
-
-export interface PluginAPI {
- plugin: { id: string; version: string; settings: Record };
- /** Localisation API - register translations and call t() to get strings */
- i18n: PluginI18n;
- ui: {
- registerToolbarAction: (action: ToolbarAction) => Disposable;
- /**
- * Register a banner that renders at the very top of the authenticated app
- * shell — above the navigation rail, sidebar and content panes. Used for
- * persistent global notices (impersonation, maintenance, etc.). The
- * component receives `{ username, serverUrl }` as props.
- *
- * Requires the `ui:app-top-banner` permission.
- */
- registerAppTopBanner: (component: React.ComponentType>) => Disposable;
- registerEmailBanner: (factory: BannerFactory) => Disposable;
- registerEmailFooter: (component: React.ComponentType) => Disposable;
- registerSettingsSection: (section: SettingsSection) => Disposable;
- registerComposerAction: (action: ComposerAction) => Disposable;
- registerSidebarWidget: (widget: SidebarWidget) => Disposable;
- registerComposerSidebar: (widget: SidebarWidget) => Disposable;
- registerDetailSidebar: (widget: SidebarWidget) => Disposable;
- registerContextMenuItem: (item: ContextMenuItem) => Disposable;
- registerNavigationRailItem: (component: React.ComponentType) => Disposable;
- registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
- registerAdminPage: (page: AdminPageSection) => Disposable;
- };
- hooks: PluginHooksAPI;
- toast: {
- success: (message: string) => void;
- error: (message: string) => void;
- info: (message: string) => void;
- warning: (message: string) => void;
- };
- http: {
- post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>;
- /**
- * Cross-origin fetch against an origin declared in the manifest's
- * `httpOrigins` allowlist. Requires `http:fetch` permission.
- *
- * No webmail credentials are forwarded - the plugin must supply its own
- * `Authorization` (or other auth) header. Each call is gated on origin
- * even when the URL came from plugin settings, so a user-pasted URL
- * outside the allowlist is rejected at the boundary.
- */
- fetch: (url: string, init?: PluginFetchInit) => Promise;
- };
- storage: ReturnType;
- log: ReturnType;
- admin: {
- getConfig: (key: string) => Promise;
- getAllConfig: () => Promise>;
- setConfig: (key: string, value: unknown) => Promise;
- deleteConfig: (key: string) => Promise;
- };
-}
-
-// Simplified hooks API type (all hooks return Disposable)
-export interface PluginHooksAPI {
- // Email
- onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailClose: (handler: () => void) => Disposable;
- onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
- onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Intercept - receives ComposeOptions, may mutate fields, return false to cancel */
- onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise) => Disposable;
- onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
- onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Emitted after emails are moved to the Archive mailbox */
- onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
- /** Emitted after emails are moved out of the Archive mailbox */
- onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
- onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSearch: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
- onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Intercept - receives MailtoContext, return false to prevent the system mail client */
- onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise) => Disposable;
- /** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
- onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise) => Disposable;
- /** Intercept - receives ReplyContext, return false to cancel */
- onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable;
- onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable;
- onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable;
- /** Intercept - receives AttachmentInfo, return false to refuse the upload */
- onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise) => Disposable;
- onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
- onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
- /** Transform - receives AttachmentPreview, may return a modified preview */
- onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise) => Disposable;
- /** Transform - receives ExternalSearchResult[] and returns an extended array */
- onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise) => Disposable;
- /** Observer - debounced snapshot of the composer draft */
- onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
- // Calendar
- onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
- onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Transform - receives ConflictWarning[] and returns an extended array */
- onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise) => Disposable;
- // Calendar Form
- onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Contacts
- onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Transform - receives RecipientSuggestion[] and returns an extended array */
- onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise) => Disposable;
- // Files
- onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable;
- onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Intercept - receives { file: FileResourceView, newName: string }, return false to cancel */
- onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise) => Disposable;
- onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Auth
- onLogin: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeLogout: (handler: () => void) => Disposable;
- onAfterLogout: (handler: () => void) => Disposable;
- onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTokenRefresh: (handler: () => void) => Disposable;
- onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Settings
- onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSettingsExport: (handler: () => void) => Disposable;
- onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSettingsReset: (handler: () => void) => Disposable;
- onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable;
- onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Identity
- onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
- onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Filters
- onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Tasks
- onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Templates
- onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable;
- // S/MIME
- onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Vacation
- onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
- onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
- // UI
- onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable;
- onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
- onMobileBack: (handler: () => void) => Disposable;
- onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Intercept - receives ExternalLinkContext, return false to cancel navigation */
- onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise) => Disposable;
- /** Observer - debounced text-selection change */
- onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
- // Theme
- onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Toast
- onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
- onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
- /** Observer fired when an OS-level notification is clicked */
- onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
- // Drag & Drop
- onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
- onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
- onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Keyboard
- registerShortcut: (shortcut: KeyboardShortcut) => Disposable;
- onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
- // App Lifecycle
- onAppReady: (handler: () => void) => Disposable;
- onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onBeforeUnload: (handler: () => void) => Disposable;
- onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
- onInterval: (handler: () => void, intervalMs: number) => Disposable;
- /** Observer - browser window focus / blur */
- onWindowFocus: (handler: () => void) => Disposable;
- onWindowBlur: (handler: () => void) => Disposable;
- /** Observer - network connectivity transitions */
- onOnline: (handler: () => void) => Disposable;
- onOffline: (handler: () => void) => Disposable;
- // Account Security
- onPasswordChange: (handler: () => void) => Disposable;
- onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- onEncryptionChange: (handler: () => void) => Disposable;
- onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Sidebar Apps
- onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
- onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Avatar
- onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Render - transform hook for email list row badges
- // Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
- onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
- // Router
- /** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
- onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
- onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
- onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
-}
-
-// --- Permission mapping for hooks ----------------------------
-
-const HOOK_PERMISSIONS: Record = {
- // Email
- onEmailOpen: 'email:read', onEmailClose: 'email:read',
- onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
- onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
- onDraftAutoSave: 'email:read',
- onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
- onSearch: 'email:read', onSearchResults: 'email:read',
- onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
- onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
- onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
- onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
- onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
- onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
- onDraftChange: 'email:read',
- onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
- onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
- onTransformOutgoingEmail: 'email:send',
- onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
- onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
- onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
- onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
- onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
- onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
- onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write',
- // Calendar
- onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
- onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
- onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
- onCheckEventConflicts: 'calendar:read',
- onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
- onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
- onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
- onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
- onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write',
- onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
- // Contacts
- onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
- onProvideRecipientSuggestions: 'contacts:read',
- onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
- onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
- onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
- onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write',
- onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write',
- // Files
- onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read',
- onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
- onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
- onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
- onBeforeFileRename: 'files:write',
- onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
- onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
- // Auth
- onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe',
- onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe',
- onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe',
- // Settings
- onSettingChange: 'settings:read', onSettingsExport: 'settings:read',
- onSettingsImport: 'settings:read', onSettingsReset: 'settings:read',
- onSettingsSync: 'settings:read', onKeywordChange: 'settings:read',
- onTrustedSenderChange: 'settings:read',
- // Identity
- onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read',
- onSignatureRender: 'identity:read',
- onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write',
- onIdentityDelete: 'identity:write',
- // Filters
- onFiltersLoaded: 'filters:read',
- onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write',
- onSieveScriptChange: 'filters:write',
- // Tasks
- onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read',
- onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write',
- onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write',
- // Templates
- onTemplateApply: 'templates:read', onTemplateRender: 'templates:read',
- onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write',
- onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write',
- // S/MIME
- onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read',
- onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read',
- // Vacation
- onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write',
- // UI
- onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe',
- onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
- onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
- onMobileViewSwitch: 'ui:observe',
- onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
- // Theme
- onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
- onLocaleChange: 'ui:observe',
- // Toast
- onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
- onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
- // Drag & Drop
- onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
- onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
- // Keyboard
- registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard',
- onAfterShortcut: 'ui:keyboard',
- // App Lifecycle
- onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
- onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
- onInterval: 'app:lifecycle',
- onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
- onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
- // Account Security
- onPasswordChange: 'security:read', onTotpChange: 'security:read',
- onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
- onDisplayNameChange: 'security:read',
- // Sidebar Apps
- onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
- onSidebarAppChange: 'ui:observe',
- // Avatar
- onAvatarResolve: 'email:read',
- // Router
- onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
-};
-
-// Map hook names → actual HookBus instances
-const HOOK_BUSES: Record unknown, order?: number) => Disposable }> = {
- // Email
- ...Object.fromEntries(Object.entries(emailHooks)),
- // Calendar
- ...Object.fromEntries(Object.entries(calendarHooks)),
- // Calendar Form
- ...Object.fromEntries(Object.entries(calendarFormHooks)),
- // Contacts
- ...Object.fromEntries(Object.entries(contactHooks)),
- // Files
- ...Object.fromEntries(Object.entries(fileHooks)),
- // Auth
- ...Object.fromEntries(Object.entries(authHooks)),
- // Settings
- ...Object.fromEntries(Object.entries(settingsHooks)),
- // Identity
- ...Object.fromEntries(Object.entries(identityHooks)),
- // Filters
- ...Object.fromEntries(Object.entries(filterHooks)),
- // Tasks
- ...Object.fromEntries(Object.entries(taskHooks)),
- // Templates
- ...Object.fromEntries(Object.entries(templateHooks)),
- // S/MIME
- ...Object.fromEntries(Object.entries(smimeHooks)),
- // Vacation
- ...Object.fromEntries(Object.entries(vacationHooks)),
- // UI
- ...Object.fromEntries(Object.entries(uiHooks)),
- // Theme
- ...Object.fromEntries(Object.entries(themeHooks)),
- // Toast
- ...Object.fromEntries(Object.entries(toastHooks)),
- // Drag & Drop
- ...Object.fromEntries(Object.entries(dragDropHooks)),
- // Keyboard
- ...Object.fromEntries(Object.entries(keyboardHooks)),
- // App Lifecycle
- ...Object.fromEntries(Object.entries(appLifecycleHooks)),
- // Account Security
- ...Object.fromEntries(Object.entries(accountSecurityHooks)),
- // Sidebar Apps
- ...Object.fromEntries(Object.entries(sidebarAppHooks)),
- // Avatar
- ...Object.fromEntries(Object.entries(avatarHooks)),
- // Render
- ...Object.fromEntries(Object.entries(renderHooks)),
- // Router
- ...Object.fromEntries(Object.entries(routerHooks)),
-};
-
-// --- Slot registration bridge --------------------------------
-// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
-
-let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType>; order: number }) => Disposable) | null = null;
-
-export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void {
- registerSlotFn = fn;
-}
-
-function registerSlot(
- pluginId: string,
- slotName: SlotName,
- component: React.ComponentType>,
- order: number = 100,
-): Disposable {
- if (!registerSlotFn) {
- console.warn(`[plugin:${pluginId}] Slot registration not available yet`);
- return { dispose: () => {} };
- }
- return registerSlotFn(slotName, { pluginId, component, order });
-}
-
-// --- Factory -------------------------------------------------
-
-export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
- // Build hooks proxy — each hook method checks permission and registers on the right bus
- const hooks: PluginHooksAPI = {} as PluginHooksAPI;
-
- for (const [hookName, bus] of Object.entries(HOOK_BUSES)) {
- const perm = HOOK_PERMISSIONS[hookName];
- if (!perm) continue;
-
- if (hookName === 'onInterval') {
- // Special: onInterval takes (handler, intervalMs)
- (hooks as unknown as Record)[hookName] = (handler: () => void, intervalMs: number) => {
- if (!hasPermission(plugin, perm)) return { dispose: () => {} };
- const safeMs = Math.max(intervalMs, 60_000); // min 60s
- const id = setInterval(handler, safeMs);
- return { dispose: () => clearInterval(id) };
- };
- } else if (hookName === 'registerShortcut') {
- // Special: registerShortcut takes a KeyboardShortcut object
- (hooks as unknown as Record)[hookName] = (shortcut: KeyboardShortcut) => {
- return guardedHook(plugin, perm, bus, shortcut.handler);
- };
- } else {
- (hooks as unknown as Record)[hookName] = (handler: (...args: unknown[]) => unknown) => {
- return guardedHook(plugin, perm, bus, handler);
- };
- }
- }
-
- return {
- plugin: {
- id: plugin.id,
- version: plugin.version,
- settings: { ...plugin.settings },
- },
-
- i18n: createPluginI18n(plugin.id),
-
- ui: {
- registerToolbarAction: (action: ToolbarAction) => {
- requirePermission(plugin, 'ui:toolbar');
- const Component = () => {
- const externals = getPluginExternals();
- const React = externals?.React;
- if (!React) return null;
- const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
- return createElement('button', {
- onClick: action.onClick,
- className: 'plugin-toolbar-action',
- title: action.label,
- }, action.label);
- };
- return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType>, action.order ?? 100);
- },
-
- registerEmailBanner: (factory: BannerFactory) => {
- requirePermission(plugin, 'ui:email-banner');
- return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType>, 100);
- },
-
- registerAppTopBanner: (component: React.ComponentType>) => {
- requirePermission(plugin, 'ui:app-top-banner');
- return registerSlot(plugin.id, 'app-top-banner', component, 100);
- },
-
- registerEmailFooter: (component: React.ComponentType) => {
- requirePermission(plugin, 'ui:email-footer');
- return registerSlot(plugin.id, 'email-footer', component as React.ComponentType>, 100);
- },
-
- registerSettingsSection: (section: SettingsSection) => {
- requirePermission(plugin, 'ui:settings-section');
- return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType>, 100);
- },
-
- registerComposerAction: (action: ComposerAction) => {
- requirePermission(plugin, 'ui:composer-toolbar');
- const Component = () => {
- const externals = getPluginExternals();
- const React = externals?.React;
- if (!React) return null;
- const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
- return createElement('button', {
- onClick: action.onClick,
- className: 'plugin-composer-action',
- title: action.label,
- }, action.label);
- };
- return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType>, action.order ?? 100);
- },
-
- registerSidebarWidget: (widget: SidebarWidget) => {
- requirePermission(plugin, 'ui:sidebar-widget');
- return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100);
- },
-
- registerComposerSidebar: (widget: SidebarWidget) => {
- requirePermission(plugin, 'ui:composer-sidebar');
- const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
- return registerSlot(plugin.id, slot, widget.render as React.ComponentType>, widget.order ?? 100);
- },
-
- registerDetailSidebar: (widget: SidebarWidget) => {
- requirePermission(plugin, 'ui:sidebar-widget');
- return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
- },
-
- registerContextMenuItem: (item: ContextMenuItem) => {
- requirePermission(plugin, 'ui:context-menu');
- const Component = () => {
- const externals = getPluginExternals();
- const React = externals?.React;
- if (!React) return null;
- const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
- return createElement('button', {
- onClick: () => item.onClick([]),
- className: 'plugin-context-menu-item',
- }, item.label);
- };
- return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType>, item.order ?? 100);
- },
-
- registerNavigationRailItem: (component: React.ComponentType) => {
- requirePermission(plugin, 'ui:navigation-rail');
- return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType>, 100);
- },
-
- registerCalendarEventAction: (action: CalendarEventAction) => {
- requirePermission(plugin, 'ui:calendar-action');
- const Component = (props: Record) => {
- const externals = getPluginExternals();
- const React = externals?.React;
- if (!React) return null;
- const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
- const iconSpan = createElement('span', {
- 'aria-hidden': 'true',
- style: { display: 'contents' },
- dangerouslySetInnerHTML: {
- __html: '',
- },
- });
- return createElement('button', {
- onClick: () => action.onClick(
- props.eventData as import('./plugin-types').CalendarEventFormView,
- { setVirtualLocation: props.setVirtualLocation as (url: string) => void },
- ),
- className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer',
- title: action.label,
- type: 'button',
- }, iconSpan, action.label);
- };
- return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType>, action.order ?? 100);
- },
-
- registerAdminPage: (page: AdminPageSection) => {
- requirePermission(plugin, 'ui:admin-page');
- return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType>, 100);
- },
- },
-
- hooks,
-
- toast: {
- success: (message: string) => appToast.success(message),
- error: (message: string) => appToast.error(message),
- info: (message: string) => appToast.info(message),
- warning: (message: string) => appToast.warning(message),
- },
-
- http: {
- post: async (path: string, body: Record) => {
- requirePermission(plugin, 'http:post');
- if (typeof path !== 'string' || !path.startsWith('/api/')) {
- throw new Error('path must start with /api/');
- }
- const url = new URL(path, globalThis.location.origin);
- if (url.origin !== globalThis.location.origin) {
- throw new Error('path must resolve to the same origin');
- }
- const { client } = useAuthStore.getState();
- const headers: Record = { 'Content-Type': 'application/json' };
- if (client) {
- headers['Authorization'] = client.getAuthHeader();
- headers['X-JMAP-Username'] = client.getUsername();
- }
- const res = await fetch(url.pathname + url.search, {
- method: 'POST',
- headers,
- body: JSON.stringify(body),
- });
- const data = await res.json().catch(() => null);
- return { ok: res.ok, status: res.status, data };
- },
-
- fetch: async (rawUrl: string, init?: PluginFetchInit) => {
- requirePermission(plugin, 'http:fetch');
- 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 an absolute https:// URL');
- }
- 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`);
- }
- // Defence-in-depth: don't let the plugin smuggle a header that the
- // host's same-origin /api flow uses to authenticate the user.
- const safeHeaders: Record = {};
- if (init?.headers) {
- for (const [k, v] of Object.entries(init.headers)) {
- const lower = k.toLowerCase();
- if (lower === 'cookie' || lower === 'x-jmap-username') continue;
- safeHeaders[k] = v;
- }
- }
- const res = await fetch(url.toString(), {
- method: init?.method ?? 'GET',
- headers: safeHeaders,
- // eslint-disable-next-line no-undef
- body: (init?.body ?? undefined) as BodyInit | undefined,
- signal: init?.signal,
- credentials: 'omit',
- mode: 'cors',
- redirect: 'follow',
- });
- const headersOut: Record = {};
- res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; });
- return {
- ok: res.ok,
- status: res.status,
- statusText: res.statusText,
- headers: headersOut,
- text: () => res.text(),
- json: () => res.json().catch(() => null),
- arrayBuffer: () => res.arrayBuffer(),
- blob: () => res.blob(),
- };
- },
- },
-
- storage: createPluginStorage(plugin.id),
- log: createPluginLogger(plugin.id),
-
- admin: {
- getConfig: async (key: string) => {
- requirePermission(plugin, 'admin:config');
- const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
- if (!res.ok) return null;
- const data = await res.json();
- return data[key] ?? null;
- },
- getAllConfig: async () => {
- requirePermission(plugin, 'admin:config');
- const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`);
- if (!res.ok) return {};
- return res.json();
- },
- setConfig: async (key: string, value: unknown) => {
- requirePermission(plugin, 'admin:config');
- await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value }),
- });
- },
- deleteConfig: async (key: string) => {
- requirePermission(plugin, 'admin:config');
- await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key }),
- });
- },
- },
- };
-}
diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts
new file mode 100644
index 00000000..706799e7
--- /dev/null
+++ b/lib/plugin-sandbox/consent.ts
@@ -0,0 +1,88 @@
+// Queue for plugin permission-consent requests.
+//
+// On first enable the plugin store posts a ConsentRequest here; the
+// `PluginConsentDialog` component renders the head and resolves the promise
+// when the user accepts or rejects. The store persists the granted set in
+// `plugin.grantedPermissions` so future enables don't re-prompt.
+
+import type { Permission } from '../plugin-types';
+
+export interface ConsentRequest {
+ id: string;
+ pluginId: string;
+ pluginName: string;
+ permissions: Permission[];
+ resolve: (granted: boolean) => void;
+}
+
+const queue: ConsentRequest[] = [];
+const listeners = new Set<() => void>();
+
+function notify(): void {
+ for (const l of listeners) {
+ try { l(); } catch { /* ignore */ }
+ }
+}
+
+function uid(): string {
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
+}
+
+export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise {
+ return new Promise((resolve) => {
+ queue.push({ id: uid(), pluginId, pluginName, permissions, resolve });
+ notify();
+ });
+}
+
+export function head(): ConsentRequest | null {
+ return queue[0] ?? null;
+}
+
+export function resolveHead(granted: boolean): void {
+ const entry = queue.shift();
+ if (!entry) return;
+ try { entry.resolve(granted); } catch { /* ignore */ }
+ notify();
+}
+
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
+
+// ─── Friendly labels for permission strings ───────────────────
+
+const PERMISSION_LABELS: Record = {
+ 'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
+ 'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
+ 'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
+ 'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
+ 'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
+ 'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
+ 'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' },
+ 'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' },
+ 'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' },
+ 'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' },
+ 'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' },
+ 'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' },
+ 'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' },
+ 'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' },
+ 'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' },
+ 'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' },
+ 'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' },
+ 'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' },
+ 'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' },
+ 'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' },
+ 'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' },
+ 'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
+ 'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' },
+ 'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' },
+ 'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
+ 'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
+ 'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
+};
+
+export function describePermission(perm: string): { title: string; body: string } {
+ return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' };
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index ba208fde..9084f769 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -7,6 +7,7 @@ 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';
+import { awaitDialog } from './host-dialog';
const PERM_PER_METHOD: Record = {
// storage is unscoped by the manifest - implicit.
@@ -27,11 +28,20 @@ const PERM_PER_METHOD: Record = {
'admin.getAllConfig': 'admin:config',
'admin.setConfig': 'admin:config',
'admin.deleteConfig': 'admin:config',
+ // ui — any plugin can ask the host to render a modal or open a URL.
+ 'ui.confirm': null,
+ 'ui.alert': null,
+ 'ui.openExternalUrl': null,
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
- return plugin.permissions.includes(perm);
+ if (!plugin.permissions.includes(perm)) return false;
+ // Defense-in-depth: even if the manifest declares a permission, the host
+ // refuses the API call unless an admin has marked the plugin as managed,
+ // or the user has explicitly granted it via the consent dialog.
+ if (plugin.managed) return true;
+ return (plugin.grantedPermissions ?? []).includes(perm);
}
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
@@ -228,7 +238,48 @@ export async function dispatchApiCall(
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;
+ case 'ui.confirm': {
+ const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
+ return awaitDialog({
+ pluginId: plugin.id,
+ kind: 'confirm',
+ title: String(opts.title ?? plugin.name ?? 'Confirm'),
+ message: String(opts.message ?? ''),
+ confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
+ cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
+ danger: !!opts.danger,
+ });
+ }
+ case 'ui.alert': {
+ const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string };
+ await awaitDialog({
+ pluginId: plugin.id,
+ kind: 'alert',
+ title: String(opts.title ?? plugin.name ?? 'Notice'),
+ message: String(opts.message ?? ''),
+ confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
+ });
+ return undefined;
+ }
+ case 'ui.openExternalUrl': {
+ const url = String(args[0] ?? '');
+ // Only http(s) — the sandbox should not be able to navigate the host
+ // anywhere internal, nor open javascript:/data:/file: schemes.
+ let parsed: URL;
+ try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
+ }
+ const target = typeof args[1] === 'string' ? (args[1] as string) : '_blank';
+ window.open(parsed.toString(), target, 'noopener,noreferrer');
+ return undefined;
+ }
+
default:
throw new Error(`Unhandled method "${method}"`);
}
}
+
+// ─── Cleanup hook for unloading plugins ───────────────────────
+
+export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts
index 0f052dd6..1a467dd2 100644
--- a/lib/plugin-sandbox/host-bridge.ts
+++ b/lib/plugin-sandbox/host-bridge.ts
@@ -14,6 +14,39 @@ import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';
+// ─── Callback marshalling ────────────────────────────────────
+
+/**
+ * Walks an object graph and replaces any function values with
+ * `{ __pluginCallback: id }` markers, registering each function in `table` so
+ * the iframe can call back later via 'callback-invoke'. Non-plain values
+ * (functions on prototype, DOM nodes, etc.) are dropped.
+ */
+function encodeCallbacks(
+ value: unknown,
+ table: Map unknown>,
+ depth = 0,
+): unknown {
+ if (depth > 6) return null; // hard cap to avoid pathological graphs
+ if (value === null || value === undefined) return value;
+ const t = typeof value;
+ if (t === 'function') {
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
+ table.set(id, value as (...args: unknown[]) => unknown);
+ return { __pluginCallback: id };
+ }
+ if (t !== 'object') return value;
+ if (Array.isArray(value)) {
+ return value.map((v) => encodeCallbacks(v, table, depth + 1));
+ }
+ // Plain object — copy own enumerable keys.
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ out[k] = encodeCallbacks(v, table, depth + 1);
+ }
+ return out;
+}
+
// ─── Public option types ─────────────────────────────────────
export interface BackgroundOptions {
@@ -59,6 +92,8 @@ export class SandboxInstance {
private pendingHookInvokes = new Map void; reject: (e: Error) => void }>();
private pendingShouldShow = new Map void>();
+ /** Host-side function references the sandbox can call back via 'callback-invoke'. */
+ private callbackTable = new Map unknown>();
constructor(
private plugin: InstalledPlugin,
@@ -69,6 +104,12 @@ export class SandboxInstance {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
+ // Slot iframes get `extraProps`; encode any function values now so the
+ // structured-clone send doesn't drop them.
+ if (initPayload.mode === 'slot') {
+ initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record;
+ }
+
this.readyPromise = new Promise((res) => { this.resolveReady = res; });
this.initPromise = new Promise((res, rej) => {
this.resolveInit = res;
@@ -148,6 +189,26 @@ export class SandboxInstance {
return;
}
+ case 'callback-invoke': {
+ const { id, callbackId, args } = msg;
+ const fn = this.callbackTable.get(callbackId);
+ if (!fn) {
+ this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
+ return;
+ }
+ void (async () => {
+ try {
+ const result = await Promise.resolve(fn(...(args ?? [])));
+ // Only send back primitives / plain objects; functions inside
+ // results would round-trip but we don't support that yet.
+ this.send({ type: 'callback-response', id, ok: true, result });
+ } catch (err) {
+ this.send({ type: 'callback-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;
@@ -202,7 +263,11 @@ export class SandboxInstance {
updateProps(props: Record): void {
if (this.destroyed) return;
- this.send({ type: 'props-update', props });
+ // Stale references would leak if we kept growing the table without
+ // bound; for now we let it grow until destroy(). A future refinement
+ // could diff old vs new props and drop entries no longer referenced.
+ const encoded = encodeCallbacks(props, this.callbackTable) as Record;
+ this.send({ type: 'props-update', props: encoded });
}
destroy(): void {
@@ -215,6 +280,7 @@ export class SandboxInstance {
}
this.pendingHookInvokes.clear();
this.pendingShouldShow.clear();
+ this.callbackTable.clear();
}
}
@@ -253,6 +319,14 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
pluginId: opts.plugin.id,
slot: opts.slot,
code: opts.code,
+ 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,
+ },
extraProps: opts.extraProps,
locale: opts.locale,
};
diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts
new file mode 100644
index 00000000..85789ce3
--- /dev/null
+++ b/lib/plugin-sandbox/host-dialog.ts
@@ -0,0 +1,80 @@
+// Process-wide queue for plugin-requested host dialogs (confirm / alert).
+// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
+// dialog here and resolves the awaited Promise after the user clicks. The
+// `PluginDialogHost` component subscribes and renders one dialog at a time.
+
+export type DialogKind = 'confirm' | 'alert';
+
+export interface DialogRequest {
+ id: string;
+ pluginId: string;
+ kind: DialogKind;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ /** When true, confirm button uses destructive styling. */
+ danger?: boolean;
+ /** Called when the dialog closes. `ok` is true only for confirm-accept. */
+ resolve: (ok: boolean) => void;
+}
+
+const queue: DialogRequest[] = [];
+const listeners = new Set<() => void>();
+
+function notify(): void {
+ for (const l of listeners) {
+ try { l(); } catch { /* ignore */ }
+ }
+}
+
+function uid(): string {
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
+}
+
+export function enqueueDialog(req: Omit): { id: string } {
+ const entry: DialogRequest = { ...req, id: uid() };
+ queue.push(entry);
+ notify();
+ return { id: entry.id };
+}
+
+export function head(): DialogRequest | null {
+ return queue[0] ?? null;
+}
+
+export function resolveHead(ok: boolean): void {
+ const entry = queue.shift();
+ if (!entry) return;
+ try { entry.resolve(ok); } catch { /* ignore */ }
+ notify();
+}
+
+/** Cancel every pending dialog for a plugin (called on unload). */
+export function cancelForPlugin(pluginId: string): void {
+ let changed = false;
+ for (let i = queue.length - 1; i >= 0; i--) {
+ if (queue[i].pluginId === pluginId) {
+ const entry = queue[i];
+ queue.splice(i, 1);
+ try { entry.resolve(false); } catch { /* ignore */ }
+ changed = true;
+ }
+ }
+ if (changed) notify();
+}
+
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
+
+/**
+ * Internal helper used by host-api to convert an `enqueueDialog` call into a
+ * Promise the plugin-side `await` can land on.
+ */
+export function awaitDialog(req: Omit): Promise {
+ return new Promise((resolve) => {
+ enqueueDialog({ ...req, resolve });
+ });
+}
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index b60fc8b4..7eeee9cc 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -15,6 +15,7 @@ import {
import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
import { register as registerActive, deregister as deregisterActive } from './registry';
+import { cancelPluginDialogs } from './host-api';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -125,6 +126,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
}
removeAllPluginHooks(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
+ cancelPluginDialogs(pluginId);
pluginErrorTracker.reset(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts
index 1da6741f..fd536e9e 100644
--- a/lib/plugin-sandbox/protocol.ts
+++ b/lib/plugin-sandbox/protocol.ts
@@ -38,7 +38,25 @@ export interface SlotInit {
slot: SlotName;
/** Same bundle code as the background instance. */
code: string;
- /** Initial props the host passes through from `PluginSlot` `extraProps`. */
+ /**
+ * Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
+ * same fields so `api.plugin.settings` and `httpOrigins` work identically
+ * to the background context.
+ */
+ manifest: {
+ id: string;
+ version: string;
+ permissions: string[];
+ settings: Record;
+ locales?: Record>;
+ httpOrigins?: string[];
+ };
+ /**
+ * Initial props the host passes through from `PluginSlot` `extraProps`.
+ * Function values are pre-encoded by the host as
+ * `{ __pluginCallback: '' }` markers and rehydrated to stub functions
+ * by the runtime; the stubs round-trip to the host via 'callback-invoke'.
+ */
extraProps: Record;
locale: string;
}
@@ -67,6 +85,25 @@ export interface ApiRequestMsg {
args: unknown[];
}
+/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
+export interface CallbackInvokeMsg {
+ type: 'callback-invoke';
+ /** Round-trip id so the host can return a value if the caller awaits. */
+ id: string;
+ /** The callback marker id (matches `__pluginCallback`). */
+ callbackId: string;
+ args: unknown[];
+}
+
+/** Host → sandbox: response to a callback-invoke. */
+export interface CallbackResponseMsg {
+ type: 'callback-response';
+ id: string;
+ ok: boolean;
+ result?: unknown;
+ error?: string;
+}
+
export interface HookResultMsg {
type: 'hook-result';
id: string;
@@ -91,6 +128,7 @@ export type SandboxToHost =
| InitDoneMsg
| InitErrorMsg
| ApiRequestMsg
+ | CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
| SlotShouldShowResultMsg;
@@ -128,11 +166,25 @@ export interface SlotShouldShowMsg {
export type HostToSandbox =
| InitMsg
| ApiResponseMsg
+ | CallbackResponseMsg
| HookInvokeMsg
| LocaleChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg;
+/** Marker used in extraProps for function values that the host owns. */
+export interface PluginCallbackMarker {
+ __pluginCallback: string;
+}
+
+export function isCallbackMarker(value: unknown): value is PluginCallbackMarker {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as { __pluginCallback?: unknown }).__pluginCallback === 'string'
+ );
+}
+
// ─── Type guards ─────────────────────────────────────────────
export function isSandboxMessage(value: unknown): value is SandboxToHost {
@@ -154,6 +206,7 @@ export const API_METHODS = [
'http.post', 'http.fetch',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
+ 'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index 50c503a0..d51fabf0 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -46,6 +46,7 @@ let slotName: SlotName | null = null;
let bootDone = false;
const pendingApi = new Map void; reject: (err: Error) => void }>();
+const pendingCallbacks = new Map void; reject: (err: Error) => void }>();
const hookHandlers: Record unknown> = {};
function sendToHost(msg: SandboxToHost): void {
@@ -74,7 +75,46 @@ function callApi(method: string, args: unknown[]): Promise {
});
}
-function buildPluginApi(manifest: BackgroundInit['manifest']) {
+function invokeHostCallback(callbackId: string, args: unknown[]): Promise {
+ const id = uid();
+ return new Promise((resolve, reject) => {
+ pendingCallbacks.set(id, { resolve, reject });
+ sendToHost({ type: 'callback-invoke', id, callbackId, args });
+ setTimeout(() => {
+ const entry = pendingCallbacks.get(id);
+ if (!entry) return;
+ pendingCallbacks.delete(id);
+ entry.reject(new Error('host callback timed out after 30s'));
+ }, 30_000);
+ });
+}
+
+/**
+ * Walks an object graph received from the host and rehydrates
+ * `{ __pluginCallback: id }` markers into stub functions that round-trip via
+ * the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
+ */
+function decodeCallbacks(value: unknown, depth = 0): unknown {
+ if (depth > 6) return null;
+ if (value === null || value === undefined) return value;
+ const t = typeof value;
+ if (t !== 'object') return value;
+ if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
+ const obj = value as Record;
+ if (typeof obj.__pluginCallback === 'string') {
+ const cbId = obj.__pluginCallback;
+ return (...args: unknown[]) => invokeHostCallback(cbId, args);
+ }
+ const out: Record = {};
+ for (const [k, v] of Object.entries(obj)) {
+ out[k] = decodeCallbacks(v, depth + 1);
+ }
+ return out;
+}
+
+type PluginManifest = BackgroundInit['manifest'];
+
+function buildPluginApi(manifest: PluginManifest) {
return {
plugin: {
id: manifest.id,
@@ -97,6 +137,17 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
info: (m: string) => { void callApi('toast.info', [m]); },
warning: (m: string) => { void callApi('toast.warning', [m]); },
},
+ ui: {
+ /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
+ confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
+ callApi('ui.confirm', [opts]) as Promise,
+ /** Opens a host-rendered alert (one button). Resolves once dismissed. */
+ alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
+ callApi('ui.alert', [opts]) as Promise,
+ /** Opens an http/https URL in a new tab via host `window.open`. */
+ openExternalUrl: (url: string, target?: string) =>
+ callApi('ui.openExternalUrl', [url, target]) as Promise,
+ },
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
getAllConfig: () => callApi('admin.getAllConfig', []),
@@ -119,8 +170,11 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
* 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.
+ *
+ * The host injects the per-plugin API as `@plugin-host`, so plugin code can
+ * `const api = require('@plugin-host')` in both background and slot modes.
*/
-function makePluginRequire(): (name: string) => unknown {
+function makePluginRequire(api: ReturnType | null): (name: string) => unknown {
const known: Record = {
'react': React,
'react-dom': ReactDOM,
@@ -128,15 +182,16 @@ function makePluginRequire(): (name: string) => unknown {
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
};
+ if (api) known['@plugin-host'] = api;
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 {
+function evaluateBundle(code: string, api: ReturnType | null): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
- const requireShim = makePluginRequire();
+ const requireShim = makePluginRequire(api);
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
@@ -161,7 +216,8 @@ function evaluateBundle(code: string): PluginExports {
// ─── Init flow ───────────────────────────────────────────────
async function bootBackground(payload: BackgroundInit): Promise {
- const exports = evaluateBundle(payload.code);
+ const api = buildPluginApi(payload.manifest);
+ const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
// Register hooks (each value must be a function).
@@ -189,14 +245,15 @@ async function bootBackground(payload: BackgroundInit): Promise {
// Side effects.
if (typeof exports.activate === 'function') {
- await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
+ await Promise.resolve(exports.activate(api));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
}
function bootSlot(payload: SlotInit): void {
- const exports = evaluateBundle(payload.code);
+ const api = buildPluginApi(payload.manifest);
+ const exports = evaluateBundle(payload.code, api);
pluginExports = exports;
slotName = payload.slot;
@@ -208,11 +265,26 @@ function bootSlot(payload: SlotInit): void {
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
- let currentProps: Record = payload.extraProps;
+ let currentProps = decodeCallbacks(payload.extraProps) as Record;
const Component = slotDef.component;
+ // A trivial pub/sub so host-pushed `props-update` messages re-render the
+ // slot tree without tearing down the iframe.
+ const propsListeners = new Set<(p: Record) => void>();
+ slotPropsUpdater = (next) => {
+ currentProps = decodeCallbacks(next) as Record;
+ for (const l of propsListeners) {
+ try { l(currentProps); } catch { /* ignore */ }
+ }
+ };
+
const SlotShell = () => {
const wrapRef = React.useRef(null);
+ const [props, setProps] = React.useState(currentProps);
+ React.useEffect(() => {
+ propsListeners.add(setProps);
+ return () => { propsListeners.delete(setProps); };
+ }, []);
React.useEffect(() => {
if (!wrapRef.current) return;
let lastHeight = -1;
@@ -228,7 +300,7 @@ function bootSlot(payload: SlotInit): void {
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
- return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
+ return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
};
const reactRoot = ReactDOM.createRoot(rootEl);
@@ -236,6 +308,9 @@ function bootSlot(payload: SlotInit): void {
sendToHost({ type: 'init-done', hooks: [], slots: [] });
}
+// Populated by bootSlot — receives `props-update` messages.
+let slotPropsUpdater: ((next: Record) => void) | null = null;
+
async function handleInit(payload: InitPayload): Promise {
if (bootDone) return;
bootDone = true;
@@ -280,6 +355,15 @@ function handleHostMessage(ev: MessageEvent): void {
break;
}
+ case 'callback-response': {
+ const pending = pendingCallbacks.get(msg.id);
+ if (!pending) return;
+ pendingCallbacks.delete(msg.id);
+ if (msg.ok) pending.resolve(msg.result);
+ else pending.reject(new Error(msg.error ?? 'callback error'));
+ break;
+ }
+
case 'hook-invoke': {
const handler = hookHandlers[msg.hookName];
if (!handler) {
@@ -318,8 +402,7 @@ function handleHostMessage(ev: MessageEvent): void {
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.
+ slotPropsUpdater?.(msg.props ?? {});
break;
}
}
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index abdb66ce..9c39a27b 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -224,6 +224,14 @@ export interface InstalledPlugin {
* `api.http.fetch()`. Carried over from the manifest at install time.
*/
httpOrigins?: string[];
+ /**
+ * Permissions the user has explicitly granted. Populated by the in-app
+ * consent dialog the first time the plugin is enabled. The host API gate
+ * checks this set in addition to `permissions`, so an unapproved permission
+ * cannot be exercised even if it appears in the manifest. Managed plugins
+ * skip the consent prompt (admin pre-approval).
+ */
+ grantedPermissions?: string[];
}
// ─── UI Slots ────────────────────────────────────────────────
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index 21cb655f..52c92e5a 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -1,37 +1,18 @@
-// Plugin store - manages installed plugins, slot registrations, and lifecycle
+// Plugin store - manages installed plugins and lifecycle. Slot registrations
+// are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import type {
- InstalledPlugin,
- PluginStatus,
- SlotName,
- SlotRegistration,
- Disposable,
-} from '@/lib/plugin-types';
+import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
import { pluginStorage } from '@/lib/plugin-storage';
import { extractPlugin } from '@/lib/plugin-validator';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
-import { setSlotRegistrationBridge } from '@/lib/plugin-api';
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
+import { requestConsent } from '@/lib/plugin-sandbox/consent';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
-
-// ─── Slot State ──────────────────────────────────────────────
-
-const SLOT_NAMES: SlotName[] = [
- 'toolbar-actions', 'app-top-banner', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
- 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
- 'calendar-event-actions', 'admin-plugin-page',
-];
-
-function emptySlots(): Record {
- const slots = {} as Record;
- for (const name of SLOT_NAMES) {
- slots[name] = [];
- }
- return slots;
-}
+import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
+import type { Permission } from '@/lib/plugin-types';
let pluginInitializationPromise: Promise | null = null;
@@ -39,7 +20,6 @@ let pluginInitializationPromise: Promise | null = null;
interface PluginStoreState {
plugins: InstalledPlugin[];
- slots: Record;
initialized: boolean;
// Management
@@ -49,8 +29,7 @@ interface PluginStoreState {
disablePlugin: (id: string) => void;
updatePluginSettings: (id: string, settings: Record) => void;
- // Runtime (called by plugin loader / API bridge)
- registerSlot: (slotName: SlotName, registration: SlotRegistration) => Disposable;
+ // Runtime (called by plugin loader)
setPluginStatus: (id: string, status: PluginStatus, error?: string) => void;
// Init
@@ -63,7 +42,6 @@ export const usePluginStore = create()(
persist(
(set, get) => ({
plugins: [],
- slots: emptySlots(),
initialized: false,
installPlugin: async (file: File) => {
@@ -154,15 +132,34 @@ export const usePluginStore = create()(
const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
if (requireApproval && !isApproved) return;
- // Ensure bridges are wired before loading (may not have run initializePlugins yet)
- setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
- setSlotRegistrationBridge(get().registerSlot);
+ // Per-user consent gate: prompt for any permission the user has not
+ // explicitly approved yet. Managed plugins (admin-pushed) skip this —
+ // the admin has already approved them at install time.
+ const implicit = new Set(IMPLICIT_PERMISSIONS);
+ const granted = new Set(plugin.grantedPermissions ?? []);
+ const missing = (plugin.permissions ?? [])
+ .filter((p): p is Permission => !!p)
+ .filter((p) => !implicit.has(p) && !granted.has(p));
+ if (missing.length > 0 && !plugin.managed) {
+ const accepted = await requestConsent(plugin.id, plugin.name, missing as Permission[]);
+ if (!accepted) return;
+ // Persist the grants so future enables don't re-prompt.
+ const allGranted = [...new Set([...granted, ...missing])];
+ set(state => ({
+ plugins: state.plugins.map(p =>
+ p.id === id ? { ...p, grantedPermissions: allGranted } : p
+ ),
+ }));
+ }
- set({
- plugins: plugins.map(p =>
+ // Ensure bridge is wired before loading (may not have run initializePlugins yet)
+ setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
+
+ set(state => ({
+ plugins: state.plugins.map(p =>
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
),
- });
+ }));
// Load it immediately
const updatedPlugin = get().plugins.find(p => p.id === id);
@@ -196,29 +193,6 @@ export const usePluginStore = create()(
});
},
- registerSlot: (slotName: SlotName, registration: SlotRegistration): Disposable => {
- set(state => ({
- slots: {
- ...state.slots,
- [slotName]: [
- ...state.slots[slotName],
- registration,
- ].sort((a, b) => a.order - b.order),
- },
- }));
-
- return {
- dispose: () => {
- set(state => ({
- slots: {
- ...state.slots,
- [slotName]: state.slots[slotName].filter(r => r !== registration),
- },
- }));
- },
- };
- },
-
setPluginStatus: (id: string, status: PluginStatus, error?: string) => {
set(state => ({
plugins: state.plugins.map(p =>
@@ -246,7 +220,6 @@ export const usePluginStore = create()(
setPluginStoreAccessor({
setPluginStatus: get().setPluginStatus,
});
- setSlotRegistrationBridge(get().registerSlot);
setupAutoDisable();
// Sync server-managed plugins before loading
@@ -277,15 +250,12 @@ export const usePluginStore = create()(
status: p.enabled ? 'enabled' : 'installed',
error: undefined,
})),
- // Don't persist slots - they are runtime-only, rebuilt on load
}),
onRehydrateStorage: () => {
return (state) => {
if (state) {
state.plugins = markServerManagedPlugins(state.plugins);
state.plugins = dedupeInstalledPlugins(state.plugins);
- // Ensure slots are initialized after rehydration
- state.slots = emptySlots();
state.initialized = false;
}
};