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); }} + > +
+ +

+ This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin. +

+ + + +
+ + +
+
+ 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; } };