diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index b8578337b..a7d941c4c 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -71,7 +71,9 @@ export async function POST(request: NextRequest) { }); } catch (error) { if (error instanceof ProxyError) { - const headers: Record = { 'cache-control': 'no-store' }; + // Marks the proxy's own refusals, so the plugin can tell them from the + // upstream server's errors. + const headers: Record = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code }; const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter; if (retryAfter) headers['retry-after'] = String(retryAfter); return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers }); diff --git a/components/plugins/__tests__/plugin-actions.test.tsx b/components/plugins/__tests__/plugin-actions.test.tsx new file mode 100644 index 000000000..cfd1ce469 --- /dev/null +++ b/components/plugins/__tests__/plugin-actions.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { PluginActions } from '../plugin-actions'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; + +const dispatch = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); +}); + +describe('PluginActions', () => { + it('renders nothing when no plugin declared actions', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('draws declared buttons and sends the click with the context', async () => { + render(); + act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); }); + + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['e1', 'e2'] }); + }); + + it('shows only actions meant for the folder or file type', () => { + act(() => setPluginActions('p', 'P', 'attachment-menu', [ + { id: 'pdf', label: 'Send PDF', mimeTypes: ['application/pdf'] }, + { id: 'img', label: 'Edit image', mimeTypes: ['image/'] }, + ])); + + render(); + + expect(screen.queryByRole('menuitem', { name: 'Send PDF' })).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'Edit image' })).toBeInTheDocument(); + }); +}); diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx new file mode 100644 index 000000000..7f518bb0c --- /dev/null +++ b/components/plugins/plugin-actions.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useSyncExternalStore, useState } from 'react'; +import { iconForName, Puzzle } from '@/components/icons'; +import { ContextMenuItem } from '@/components/ui/context-menu'; +import { cn } from '@/lib/utils'; +import { + actionApplies, + actionsFor, + runAction, + subscribeActions, + type RegisteredAction, +} from '@/lib/plugin-sandbox/actions'; + +const EMPTY: RegisteredAction[] = []; + +/** The actions plugins declared for a surface, filtered by folder role or MIME type. */ +export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) { + const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY); + return all.filter((a) => actionApplies(a, filter)); +} + +interface PluginActionsProps { + /** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */ + surface: string; + /** What the click is about (ids of the message, files, ...). Filtered per plugin before it is sent. */ + context: Record; + /** Buttons in a toolbar row, or entries in a menu. */ + variant?: 'buttons' | 'menu'; + mailboxRole?: string | null; + mimeType?: string | null; + className?: string; + /** Icon-only buttons with the label as tooltip (narrow toolbars). */ + compact?: boolean; + /** Called after a click, e.g. to close the menu the entry sits in. */ + onRan?: () => void; +} + +/** + * Buttons or menu entries plugins declared with api.ui.setActions. The host + * draws them so they match the app; the plugin gets the click. + */ +export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) { + const actions = usePluginActions(surface, { mailboxRole, mimeType }); + const [busy, setBusy] = useState(null); + if (actions.length === 0) return null; + + const run = async (action: RegisteredAction) => { + const key = `${action.pluginId}:${action.id}`; + setBusy(key); + try { + onRan?.(); + await runAction(action, surface, context); + } finally { + setBusy((current) => (current === key ? null : current)); + } + }; + + if (variant === 'menu') { + return ( + <> + {actions.map((action) => ( + { void run(action); }} + /> + ))} + + ); + } + + return ( +
+ {actions.map((action) => { + const Icon = iconForName(action.icon) ?? Puzzle; + const key = `${action.pluginId}:${action.id}`; + return ( + + ); + })} +
+ ); +} diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts new file mode 100644 index 000000000..e44e90071 --- /dev/null +++ b/lib/__tests__/plugin-actions-proxy-api.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); +const registryEntries = vi.hoisted(() => [] as unknown[]); +vi.mock('../plugin-sandbox/registry', async (orig) => ({ + ...(await orig()), + all: () => registryEntries, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; +import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions'; +import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels'; +import { allPages, resetPages } from '../plugin-sandbox/pages'; +import type { StreamSink } from '../plugin-sandbox/proxy-client'; +import '../plugin-sandbox/loader'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true, + ...overrides, + }; +} + +beforeEach(() => { + apiFetch.mockReset(); + resetActions(); + resetDayLabels(); + resetPages(); + registryEntries.length = 0; +}); + +describe('ui.setActions', () => { + it('puts a plugin\'s buttons on a surface it may use', async () => { + await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [ + { id: 'b', label: 'Second', order: 2 }, { id: 'a', label: 'First', order: 1, icon: 'archive' }, + ]]); + + expect(actionsFor('message-row-actions').map((a) => [a.pluginId, a.id])).toEqual([['p1', 'a'], ['p1', 'b']]); + }); + + it('refuses a surface the plugin has no permission for, and frame slots', async () => { + await expect(dispatchApiCall(plugin({ permissions: ['ui:toolbar'] }), 'ui.setActions', ['message-row-actions', []])) + .rejects.toThrow(/may not add actions/); + await expect(dispatchApiCall(plugin({ permissions: ['ui:email-banner'] }), 'ui.setActions', ['email-banner', []])) + .rejects.toThrow(/not a surface that takes actions/); + expect(actionsFor('message-row-actions')).toEqual([]); + }); + + it('checks what it is given', () => { + expect(() => checkActions([{ id: 'x', label: '' }])).toThrow(/label/); + expect(() => checkActions([{ id: 'x', label: 'X' }, { id: 'x', label: 'Y' }])).toThrow(/twice/); + expect(() => checkActions(Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A' })))).toThrow(/At most/); + expect(() => checkActions([{ id: 'x', label: 'X', icon: '' }])).toThrow(/icon/); + }); + + it('sends a click to the plugin with only the context it may see', async () => { + const invokeHook = vi.fn().mockResolvedValue(undefined); + const p = plugin({ permissions: ['ui:message-list'] }); + registryEntries.push({ plugin: p, background: { invokeHook } }); + await dispatchApiCall(p, 'ui.setActions', ['message-row-actions', [{ id: 'task', label: 'Make a task' }]]); + + await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 }); + + // emailId needs email:read, which this plugin lacks. + expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: {} }]); + + p.permissions.push('email:read'); + await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 }); + expect(invokeHook).toHaveBeenLastCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: { emailId: 'e1' } }]); + }); +}); + +describe('day labels and pages', () => { + it('stores labels by date, behind ui:calendar-action', async () => { + await expect(dispatchApiCall(plugin(), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday' }]])).rejects.toThrow(/lacks permission/); + await dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday', dot: true }]]); + + expect(dayLabelsByDate().get('2026-12-24')).toEqual([expect.objectContaining({ text: 'Holiday', dot: true, pluginId: 'p1' })]); + await expect(dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '24.12.2026', text: 'x' }]])).rejects.toThrow(/YYYY-MM-DD/); + }); + + it('registers pages behind ui:app-page', async () => { + await dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'files', title: 'Nextcloud', icon: 'cloud' }]]); + + expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]); + await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/); + }); +}); + +describe('proxy.fetch', () => { + const proxied = plugin({ + permissions: ['http:proxy'], + credentials: { cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } } }, + }); + + it('sends the request to the server proxy and returns the answer', async () => { + apiFetch.mockResolvedValue(new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } })); + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/ocs/v2.php/cloud/user', { + method: 'GET', headers: { 'OCS-APIRequest': 'true' }, responseType: 'json', + }]); + + expect(res).toMatchObject({ status: 200, ok: true, body: { ok: 1 } }); + const [url, init] = apiFetch.mock.calls[0]; + expect(url).toBe('/api/plugin-proxy?plugin=p1&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Focs%2Fv2.php%2Fcloud%2Fuser'); + expect(init.headers).toMatchObject({ 'OCS-APIRequest': 'true', 'x-bulwark-method': 'GET' }); + }); + + it('refuses a credential the manifest does not declare, and plugins without http:proxy', async () => { + await expect(dispatchApiCall(proxied, 'proxy.fetch', ['other', 'https://a.example', {}])).rejects.toThrow(/declares no credential/); + await expect(dispatchApiCall({ ...proxied, permissions: [] }, 'proxy.fetch', ['cloud', 'https://a.example', {}])).rejects.toThrow(/lacks permission/); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('tells the plugin when the proxy itself refused', async () => { + apiFetch.mockResolvedValue(new Response('{"error":"Too many requests","code":"rate_limited"}', { + status: 429, headers: { 'content-type': 'application/json', 'x-bulwark-proxy-error': 'rate_limited' }, + })); + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', {}]); + + expect(res).toMatchObject({ status: 429, ok: false, proxyError: 'rate_limited', body: 'Too many requests' }); + }); + + it('streams a large answer to the iframe in chunks', async () => { + const encoder = new TextEncoder(); + apiFetch.mockResolvedValue(new Response(new ReadableStream({ + start(c) { c.enqueue(encoder.encode('data: one\n\n')); c.enqueue(encoder.encode('data: two\n\n')); c.close(); }, + }), { status: 200, headers: { 'content-type': 'text/event-stream' } })); + const sent: Parameters[0][] = []; + const sink: StreamSink = { sendStream: (m) => { sent.push(m); } }; + + const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', { responseType: 'stream', streamId: 'abc123' }], { sink }); + await vi.waitFor(() => expect(sent.at(-1)?.type).toBe('stream-end')); + + expect(res).toMatchObject({ status: 200, ok: true }); + const text = sent.filter((m) => m.type === 'stream-chunk').map((m) => new TextDecoder().decode((m as { chunk: ArrayBuffer }).chunk)).join(''); + expect(text).toBe('data: one\n\ndata: two\n\n'); + }); +}); diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts new file mode 100644 index 000000000..0538826e6 --- /dev/null +++ b/lib/plugin-sandbox/actions.ts @@ -0,0 +1,158 @@ +// Buttons and menu entries plugins declare as data (`api.ui.setActions`). +// The host draws them in its own style on the surface named in the slot +// catalogue (kind: 'action'); a click goes back to the plugin with the +// surface's context, filtered by the plugin's permissions. + +import { slotSpec } from '../plugin-slot-catalog'; + +export interface PluginAction { + id: string; + label: string; + /** Name of a host icon (see components/icons); unknown names show none. */ + icon?: string; + /** Lower comes first; default 100. */ + order?: number; + /** Show only for these folder roles (message surfaces), e.g. ["inbox"]. */ + mailboxRoles?: string[]; + /** Show only for attachments or files of these MIME types (prefix match, "image/"). */ + mimeTypes?: string[]; + /** A danger action is drawn in red. */ + danger?: boolean; +} + +export interface RegisteredAction extends PluginAction { + pluginId: string; + pluginName: string; +} + +export const MAX_ACTIONS_PER_SURFACE = 8; +const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i; +const ICON_RE = /^[A-Za-z0-9-]{1,40}$/; +const ROLE_RE = /^[a-z]{1,20}$/; +const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i; + +/** Surfaces that take actions, from the slot catalogue. */ +export function isActionSurface(surface: string): boolean { + return slotSpec(surface)?.kind === 'action'; +} + +/** Check and copy what a plugin sent; throws with a message the plugin sees. */ +export function checkActions(value: unknown): PluginAction[] { + if (!Array.isArray(value)) throw new Error('actions must be a list'); + if (value.length > MAX_ACTIONS_PER_SURFACE) throw new Error(`At most ${MAX_ACTIONS_PER_SURFACE} actions per surface`); + const seen = new Set(); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`actions[${i}] must be an object`); + const a = raw as Record; + if (typeof a.id !== 'string' || !ID_RE.test(a.id)) throw new Error(`actions[${i}].id is invalid`); + if (seen.has(a.id)) throw new Error(`actions[${i}].id is used twice`); + seen.add(a.id); + if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`); + const out: PluginAction = { id: a.id, label: a.label.trim() }; + if (a.icon !== undefined) { + if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`); + out.icon = a.icon; + } + if (a.order !== undefined) { + if (typeof a.order !== 'number' || !Number.isFinite(a.order)) throw new Error(`actions[${i}].order must be a number`); + out.order = a.order; + } + if (a.mailboxRoles !== undefined) { + if (!Array.isArray(a.mailboxRoles) || a.mailboxRoles.length > 10 || !a.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) { + throw new Error(`actions[${i}].mailboxRoles must be a list of folder roles`); + } + out.mailboxRoles = [...a.mailboxRoles] as string[]; + } + if (a.mimeTypes !== undefined) { + if (!Array.isArray(a.mimeTypes) || a.mimeTypes.length > 20 || !a.mimeTypes.every((m) => typeof m === 'string' && MIME_RE.test(m))) { + throw new Error(`actions[${i}].mimeTypes must be a list of MIME types`); + } + out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase()); + } + if (a.danger !== undefined) out.danger = a.danger === true; + return out; + }); +} + +// pluginId → surface → actions +const registry = new Map>(); +const listeners = new Set<() => void>(); +const snapshots = new Map(); + +function changed(): void { + snapshots.clear(); + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeActions(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setPluginActions(pluginId: string, pluginName: string, surface: string, actions: PluginAction[]): void { + let surfaces = registry.get(pluginId); + if (!surfaces) { + surfaces = new Map(); + registry.set(pluginId, surfaces); + } + if (actions.length === 0) surfaces.delete(surface); + else surfaces.set(surface, actions.map((a) => ({ ...a, pluginId, pluginName }))); + changed(); +} + +/** Drop everything a plugin declared (it was turned off or removed). */ +export function clearPluginActions(pluginId: string): void { + if (registry.delete(pluginId)) changed(); +} + +/** + * The actions on a surface, in order. The same array is returned until + * something changes, so it can feed useSyncExternalStore. + */ +export function actionsFor(surface: string): RegisteredAction[] { + const cached = snapshots.get(surface); + if (cached) return cached; + const out: RegisteredAction[] = []; + for (const surfaces of registry.values()) out.push(...(surfaces.get(surface) ?? [])); + out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label)); + snapshots.set(surface, out); + return out; +} + +/** Whether an action applies to a folder role and/or a MIME type. */ +export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean { + if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false; + if (action.mimeTypes && ctx.mimeType !== undefined) { + const type = (ctx.mimeType ?? '').toLowerCase(); + if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false; + } + return true; +} + +/** Test helper. */ +export function resetActions(): void { + registry.clear(); + changed(); +} + +// ─── Clicks ─────────────────────────────────────────────────── + +/** + * Delivers a click to the plugin. Set by the loader, which owns the running + * plugins; it filters `context` by the surface's props and the plugin's + * permissions before anything crosses into the sandbox. + */ +export type ActionDispatcher = (pluginId: string, surface: string, actionId: string, context: Record) => Promise; + +let dispatcher: ActionDispatcher | null = null; + +export function setActionDispatcher(next: ActionDispatcher | null): void { + dispatcher = next; +} + +export async function runAction(action: RegisteredAction, surface: string, context: Record): Promise { + if (!dispatcher) return; + await dispatcher(action.pluginId, surface, action.id, context); +} diff --git a/lib/plugin-sandbox/day-labels.ts b/lib/plugin-sandbox/day-labels.ts new file mode 100644 index 000000000..761d8dac4 --- /dev/null +++ b/lib/plugin-sandbox/day-labels.ts @@ -0,0 +1,91 @@ +// Short labels and dots plugins put on calendar days (`api.ui.setDayLabels`): +// a holiday name under the day header, a dot in the small month calendar. +// Data only; the host draws them. + +export interface DayLabel { + /** YYYY-MM-DD */ + date: string; + /** Shown under the day's name in the day and week views (max 24 characters). */ + text?: string; + /** Tooltip (max 120 characters). */ + title?: string; + /** A dot on the day in the small month calendar. */ + dot?: boolean; +} + +export interface RegisteredDayLabel extends DayLabel { + pluginId: string; + pluginName: string; +} + +export const MAX_DAY_LABELS = 400; +const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +export function checkDayLabels(value: unknown): DayLabel[] { + if (!Array.isArray(value)) throw new Error('labels must be a list'); + if (value.length > MAX_DAY_LABELS) throw new Error(`At most ${MAX_DAY_LABELS} day labels`); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`labels[${i}] must be an object`); + const l = raw as Record; + if (typeof l.date !== 'string' || !DATE_RE.test(l.date)) throw new Error(`labels[${i}].date must be YYYY-MM-DD`); + const out: DayLabel = { date: l.date }; + if (l.text !== undefined) { + if (typeof l.text !== 'string' || l.text.length > 24) throw new Error(`labels[${i}].text must be at most 24 characters`); + if (l.text.trim()) out.text = l.text.trim(); + } + if (l.title !== undefined) { + if (typeof l.title !== 'string' || l.title.length > 120) throw new Error(`labels[${i}].title must be at most 120 characters`); + if (l.title.trim()) out.title = l.title.trim(); + } + if (l.dot !== undefined) out.dot = l.dot === true; + if (!out.text && !out.dot) throw new Error(`labels[${i}] needs text or dot`); + return out; + }); +} + +const byPlugin = new Map(); +const listeners = new Set<() => void>(); +let snapshot: Map | null = null; + +function changed(): void { + snapshot = null; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeDayLabels(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setDayLabels(pluginId: string, pluginName: string, labels: DayLabel[]): void { + if (labels.length === 0) byPlugin.delete(pluginId); + else byPlugin.set(pluginId, labels.map((l) => ({ ...l, pluginId, pluginName }))); + changed(); +} + +export function clearDayLabels(pluginId: string): void { + if (byPlugin.delete(pluginId)) changed(); +} + +/** Labels by date. Stable between changes (for useSyncExternalStore). */ +export function dayLabelsByDate(): Map { + if (snapshot) return snapshot; + const out = new Map(); + for (const labels of byPlugin.values()) { + for (const l of labels) { + const list = out.get(l.date); + if (list) list.push(l); + else out.set(l.date, [l]); + } + } + snapshot = out; + return out; +} + +/** Test helper. */ +export function resetDayLabels(): void { + byPlugin.clear(); + changed(); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index bd0db432d..628e6e704 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,7 +3,11 @@ // structured-cloneable data back to the iframe. import type { InstalledPlugin, Permission } from '../plugin-types'; -import { pluginHasPermission } from './permissions'; +import { mayOfferSlot, pluginHasPermission } from './permissions'; +import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client'; +import { checkActions, isActionSurface, setPluginActions } from './actions'; +import { checkDayLabels, setDayLabels } from './day-labels'; +import { checkPages, setPluginPages } from './pages'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; @@ -169,6 +173,17 @@ const PERM_PER_METHOD: Record = { // redirect URI the provider sends the user back to. 'oauth.createState': 'http:fetch', 'oauth.getRedirectUri': 'http:fetch', + // Requests with a credential, through the server (lib/plugin-secrets). + 'proxy.fetch': 'http:proxy', + 'proxy.cancel': 'http:proxy', + 'proxy.connect': 'http:proxy', + 'proxy.accounts': 'http:proxy', + 'proxy.disconnect': 'http:proxy', + // Buttons and menu entries drawn by the host; the surface's own + // permission is checked per call (see the slot catalogue). + 'ui.setActions': null, + 'ui.setDayLabels': 'ui:calendar-action', + 'ui.setPages': 'ui:app-page', // sieve (delivery-time classification) 'sieve.isSupported': 'filters:read', 'sieve.getActiveScript': 'filters:read', @@ -1576,7 +1591,7 @@ export async function dispatchApiCall( plugin: InstalledPlugin, method: string, args: unknown[], - opts?: { privileged?: boolean }, + opts?: { privileged?: boolean; sink?: StreamSink }, ): Promise { // Tier gate: privileged-only methods are refused for untrusted (null-origin) // instances even if the permission is somehow present. Defence-in-depth on @@ -1596,6 +1611,22 @@ export async function dispatchApiCall( } switch (method) { + case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null); + case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined; + case 'proxy.connect': return proxyConnect(plugin, args); + case 'proxy.accounts': return proxyAccounts(plugin); + case 'proxy.disconnect': return proxyDisconnect(plugin, args); + + case 'ui.setActions': { + const surface = args[0]; + if (typeof surface !== 'string' || !isActionSurface(surface)) throw new Error(`"${String(surface)}" is not a surface that takes actions`); + if (!mayOfferSlot(plugin, surface)) throw new Error(`Plugin "${plugin.id}" may not add actions to "${surface}"`); + setPluginActions(plugin.id, plugin.name, surface, checkActions(args[1])); + return undefined; + } + case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined; + case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined; + case 'storage.get': return storageGet(plugin.id, args[0] as string); case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined; case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 475c9049c..6bd6704be 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -15,7 +15,7 @@ import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - SandboxToHost, HostToSandbox, InitMsg, InitPayload, + SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg, } from './protocol'; // ─── Callback marshalling ──────────────────────────────────── @@ -249,7 +249,7 @@ export class SandboxInstance { const { id, method, args } = msg; void (async () => { try { - const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged }); + const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this }); this.send({ type: 'api-response', id, ok: true, result }); } catch (err) { this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) }); @@ -353,6 +353,17 @@ export class SandboxInstance { return p; } + /** Streamed proxy responses and upload progress (see proxy-client.ts). */ + sendStream(msg: StreamChunkMsg | StreamEndMsg | StreamProgressMsg): void { + if (this.destroyed) return; + if (msg.type === 'stream-chunk') { + // Hand the buffer over instead of copying it. + this.iframe.contentWindow?.postMessage(msg, '*', [msg.chunk]); + return; + } + this.send(msg); + } + setLocale(locale: string): void { if (this.destroyed) return; this.send({ type: 'locale-change', locale }); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 791308b74..26adc17a8 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -21,7 +21,13 @@ import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; -import { mayOfferSlot, mayRegisterHook, pluginHasPermission } from './permissions'; +import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions'; +import { clearPluginActions, setActionDispatcher } from './actions'; +import { clearDayLabels } from './day-labels'; +import { clearPluginPages } from './pages'; +import { cancelPluginProxyRequests } from './proxy-client'; +import { cancelConnectFor } from './connect-dialog'; +import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -182,6 +188,9 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { + const entry = allActiveEntries().find((e) => e.plugin.id === pluginId); + if (!entry) return; + const visible = slotPropsFor(entry.plugin, surface as SlotName, context); + try { + await entry.background.invokeHook('onAction', [{ surface, actionId, context: visible }]); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } +}); + // ─── Unload ─────────────────────────────────────────────────── export function unloadSandboxedPlugin(pluginId: string): void { @@ -249,6 +274,13 @@ export function unloadSandboxedPlugin(pluginId: string): void { // Drop any message-list category tabs the plugin registered so the strip // disappears (and the inbox unfilters) the moment the plugin is disabled. try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ } + // Same for the buttons, day labels and pages it declared, and anything + // still running through the proxy for it. + clearPluginActions(pluginId); + clearDayLabels(pluginId); + clearPluginPages(pluginId); + cancelPluginProxyRequests(pluginId); + cancelConnectFor(pluginId); try { entry.background.destroy(); } catch { /* ignore */ } cancelPluginDialogs(pluginId); pluginErrorTracker.reset(pluginId); diff --git a/lib/plugin-sandbox/pages.ts b/lib/plugin-sandbox/pages.ts new file mode 100644 index 000000000..fc375661c --- /dev/null +++ b/lib/plugin-sandbox/pages.ts @@ -0,0 +1,82 @@ +// Full pages plugins add to the app (`api.ui.setPages`): each gets an entry +// in the navigation rail and its own route, /apps//, where +// the plugin's `app-page` slot fills the content area. + +export interface PluginPage { + id: string; + title: string; + /** Name of a host icon (see components/icons). */ + icon?: string; +} + +export interface RegisteredPage extends PluginPage { + pluginId: string; + pluginName: string; +} + +export const MAX_PAGES_PER_PLUGIN = 3; +const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; +const ICON_RE = /^[A-Za-z0-9-]{1,40}$/; + +export function checkPages(value: unknown): PluginPage[] { + if (!Array.isArray(value)) throw new Error('pages must be a list'); + if (value.length > MAX_PAGES_PER_PLUGIN) throw new Error(`At most ${MAX_PAGES_PER_PLUGIN} pages`); + const seen = new Set(); + return value.map((raw, i) => { + if (!raw || typeof raw !== 'object') throw new Error(`pages[${i}] must be an object`); + const p = raw as Record; + if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`pages[${i}].id must be lowercase letters, digits and hyphens`); + if (seen.has(p.id)) throw new Error(`pages[${i}].id is used twice`); + seen.add(p.id); + if (typeof p.title !== 'string' || !p.title.trim() || p.title.length > 40) throw new Error(`pages[${i}].title is required (max 40 characters)`); + const out: PluginPage = { id: p.id, title: p.title.trim() }; + if (p.icon !== undefined) { + if (typeof p.icon !== 'string' || !ICON_RE.test(p.icon)) throw new Error(`pages[${i}].icon is invalid`); + out.icon = p.icon; + } + return out; + }); +} + +const byPlugin = new Map(); +const listeners = new Set<() => void>(); +let snapshot: RegisteredPage[] | null = null; + +function changed(): void { + snapshot = null; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribePages(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function setPluginPages(pluginId: string, pluginName: string, pages: PluginPage[]): void { + if (pages.length === 0) byPlugin.delete(pluginId); + else byPlugin.set(pluginId, pages.map((p) => ({ ...p, pluginId, pluginName }))); + changed(); +} + +export function clearPluginPages(pluginId: string): void { + if (byPlugin.delete(pluginId)) changed(); +} + +/** Every page, plugins in install order. Stable between changes. */ +export function allPages(): RegisteredPage[] { + if (snapshot) return snapshot; + snapshot = [...byPlugin.values()].flat(); + return snapshot; +} + +export function findPage(pluginId: string, pageId: string): RegisteredPage | null { + return byPlugin.get(pluginId)?.find((p) => p.id === pageId) ?? null; +} + +/** Test helper. */ +export function resetPages(): void { + byPlugin.clear(); + changed(); +} diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index fda559e9a..fce78e31c 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -191,8 +191,18 @@ export interface SlotShouldShowMsg { context: unknown; } +/** Host → sandbox: a piece of a streamed api.proxy.fetch response. */ +export interface StreamChunkMsg { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer; } +/** Host → sandbox: a streamed response ended (error set when it broke off). */ +export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: string; } +/** Host → sandbox: upload progress of an api.proxy.fetch request. */ +export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; } + export type HostToSandbox = | InitMsg + | StreamChunkMsg + | StreamEndMsg + | StreamProgressMsg | ApiResponseMsg | CallbackResponseMsg | HookInvokeMsg diff --git a/lib/plugin-sandbox/proxy-client.ts b/lib/plugin-sandbox/proxy-client.ts new file mode 100644 index 000000000..2ab623782 --- /dev/null +++ b/lib/plugin-sandbox/proxy-client.ts @@ -0,0 +1,292 @@ +// Host side of `api.proxy`: a plugin's requests that need one of its +// credentials go to /api/plugin-proxy, which adds the secret on the server. +// Responses come back whole (text, JSON, bytes) or as a stream of chunks for +// large downloads and AI answers; uploads report progress. + +import type { InstalledPlugin } from '../plugin-types'; +import { apiFetch, withBasePath } from '../browser-navigation'; +import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog'; + +/** Where streamed chunks and upload progress go: the iframe that asked. */ +export interface StreamSink { + sendStream(msg: + | { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer } + | { type: 'stream-end'; streamId: string; error?: string } + | { type: 'stream-progress'; streamId: string; loaded: number; total: number }): void; +} + +/** Largest response returned in one piece; bigger ones need responseType "stream". */ +export const MAX_PROXY_BUFFERED_BYTES = 25 * 1024 * 1024; +/** Largest request body; matches MAX_PROXY_REQUEST_BYTES on the server. */ +export const MAX_PROXY_UPLOAD_BYTES = 100 * 1024 * 1024; +export const MAX_STREAMS_PER_PLUGIN = 8; + +type ResponseType = 'text' | 'json' | 'arraybuffer' | 'stream'; + +export interface ProxyInit { + method?: string; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array | Blob | null; + responseType?: ResponseType; + /** Set by the runtime: ties chunks and progress to this request. */ + streamId?: string; + /** Store these JSON fields of the response as the credential (see the proxy route). */ + capture?: { secret: string; username?: string }; +} + +export interface ProxyResult { + status: number; + ok: boolean; + headers: Record; + body?: unknown; + /** Set when the proxy itself refused, e.g. "rate_limited", "wrong_origin". */ + proxyError?: string; +} + +const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/; +const STREAM_ID_RE = /^[a-z0-9]{6,40}$/; +const running = new Map>(); + +function credentialSpec(plugin: InstalledPlugin, credentialId: unknown) { + if (typeof credentialId !== 'string') throw new Error('credentialId must be a string'); + const spec = plugin.credentials?.[credentialId]; + if (!spec) throw new Error(`The manifest declares no credential "${credentialId}"`); + return { id: credentialId, spec }; +} + +function checkInit(raw: unknown): ProxyInit { + if (raw === undefined || raw === null) return {}; + if (typeof raw !== 'object') throw new Error('init must be an object'); + const init = raw as Record; + const out: ProxyInit = {}; + if (init.method !== undefined) { + if (typeof init.method !== 'string' || !/^[A-Z]{3,10}$/i.test(init.method)) throw new Error('Invalid method'); + out.method = init.method.toUpperCase(); + } + if (init.headers !== undefined) { + if (!init.headers || typeof init.headers !== 'object') throw new Error('headers must be an object'); + const entries = Object.entries(init.headers as Record); + if (entries.length > 50) throw new Error('Too many headers'); + out.headers = {}; + for (const [name, value] of entries) { + if (!HEADER_NAME_RE.test(name) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) { + throw new Error(`Invalid header "${name}"`); + } + out.headers[name] = value; + } + } + if (init.body !== undefined && init.body !== null) { + const b = init.body; + const size = typeof b === 'string' ? b.length : b instanceof Blob ? b.size : (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) ? (b as ArrayBuffer).byteLength : -1; + if (size < 0) throw new Error('body must be a string, ArrayBuffer, Uint8Array or Blob'); + if (size > MAX_PROXY_UPLOAD_BYTES) throw new Error(`body is larger than ${MAX_PROXY_UPLOAD_BYTES} bytes; upload it in chunks`); + out.body = b as ProxyInit['body']; + } + if (init.responseType !== undefined) { + if (!['text', 'json', 'arraybuffer', 'stream'].includes(init.responseType as string)) throw new Error('Invalid responseType'); + out.responseType = init.responseType as ResponseType; + } + if (init.streamId !== undefined) { + if (typeof init.streamId !== 'string' || !STREAM_ID_RE.test(init.streamId)) throw new Error('Invalid streamId'); + out.streamId = init.streamId; + } + if (init.capture !== undefined) { + const c = init.capture as Record; + if (!c || typeof c.secret !== 'string' || (c.username !== undefined && typeof c.username !== 'string')) throw new Error('Invalid capture'); + out.capture = { secret: c.secret, ...(typeof c.username === 'string' ? { username: c.username } : {}) }; + } + return out; +} + +function headersOf(res: { headers: Headers }): Record { + const out: Record = {}; + res.headers.forEach((value, name) => { out[name] = value; }); + return out; +} + +async function readCapped(res: Response, limit: number): Promise { + const length = Number(res.headers.get('content-length') ?? '0'); + if (length > limit) { + await res.body?.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`); + } + if (!res.body) return new ArrayBuffer(0); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel().catch(() => {}); + throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } + return out.buffer; +} + +function track(pluginId: string, streamId: string, controller: AbortController): () => void { + let streams = running.get(pluginId); + if (!streams) { + streams = new Map(); + running.set(pluginId, streams); + } + if (streams.size >= MAX_STREAMS_PER_PLUGIN) throw new Error(`At most ${MAX_STREAMS_PER_PLUGIN} proxy requests at once`); + streams.set(streamId, controller); + return () => { + streams!.delete(streamId); + if (streams!.size === 0) running.delete(pluginId); + }; +} + +/** Upload with progress reports; resolves with a fetch-like Response. */ +function sendWithProgress(url: string, method: string, headers: Record, body: XMLHttpRequestBodyInit, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.responseType = 'arraybuffer'; + for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value); + xhr.setRequestHeader('x-bulwark-method', method); + xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0); + xhr.onload = () => { + const h = new Headers(); + for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) { + const i = line.indexOf(':'); + if (i > 0) h.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); + } + resolve(new Response(xhr.status === 204 || xhr.status === 304 ? null : xhr.response as ArrayBuffer, { status: xhr.status, headers: h })); + }; + xhr.onerror = () => reject(new Error('Network error')); + xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', () => xhr.abort(), { once: true }); + xhr.send(body); + }); +} + +/** `api.proxy.fetch(credentialId, url, init)`. */ +export async function proxyFetch(plugin: InstalledPlugin, args: unknown[], sink: StreamSink | null): Promise { + const { id: credentialId } = credentialSpec(plugin, args[0]); + if (typeof args[1] !== 'string' || args[1].length > 8192) throw new Error('url must be a string'); + const target = args[1]; + const init = checkInit(args[2]); + const method = init.method ?? (init.body ? 'POST' : 'GET'); + const responseType = init.responseType ?? 'text'; + const streamId = init.streamId ?? Math.random().toString(36).slice(2, 12); + if (init.capture && plugin.credentials?.[credentialId]?.scope !== 'user') throw new Error('capture needs a user credential'); + + const query = new URLSearchParams({ plugin: plugin.id, credential: credentialId, url: target }); + const url = withBasePath(`/api/plugin-proxy?${query}`); + const headers: Record = { ...(init.headers ?? {}) }; + if (init.capture) headers['x-bulwark-capture'] = JSON.stringify(init.capture); + + const controller = new AbortController(); + const untrack = track(plugin.id, streamId, controller); + let streaming = false; + try { + let res: Response; + if (init.body && sink && typeof XMLHttpRequest !== 'undefined') { + res = await sendWithProgress(url, method, headers, init.body as XMLHttpRequestBodyInit, controller.signal, + (loaded, total) => sink.sendStream({ type: 'stream-progress', streamId, loaded, total })); + } else { + res = await apiFetch(`/api/plugin-proxy?${query}`, { + method: 'POST', + headers: { ...headers, 'x-bulwark-method': method }, + body: (init.body ?? undefined) as BodyInit | undefined, + signal: controller.signal, + }); + } + + const result: ProxyResult = { status: res.status, ok: res.ok, headers: headersOf(res) }; + const proxyRefused = !!res.headers.get('x-bulwark-proxy-error'); + + if (responseType === 'stream' && sink && res.body && res.ok) { + streaming = true; + const reader = res.body.getReader(); + void (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const copy = value.slice().buffer; + sink.sendStream({ type: 'stream-chunk', streamId, chunk: copy }); + } + sink.sendStream({ type: 'stream-end', streamId }); + } catch (err) { + sink.sendStream({ type: 'stream-end', streamId, error: controller.signal.aborted ? 'cancelled' : (err as Error).message }); + } finally { + untrack(); + } + })(); + return result; + } + + const bytes = await readCapped(res, MAX_PROXY_BUFFERED_BYTES); + if (proxyRefused) { + result.proxyError = res.headers.get('x-bulwark-proxy-error') ?? 'refused'; + try { + result.body = (JSON.parse(new TextDecoder().decode(bytes)) as { error?: string }).error ?? ''; + } catch { + result.body = ''; + } + return result; + } + if (responseType === 'arraybuffer') result.body = bytes; + else { + const text = new TextDecoder().decode(bytes); + if (responseType === 'json') { + try { result.body = text ? JSON.parse(text) : null; } catch { throw new Error(`Response is not JSON (HTTP ${res.status})`); } + } else { + result.body = text; + } + } + return result; + } finally { + if (!streaming) untrack(); + } +} + +/** `api.proxy.cancel(streamId)`: stop a running request or stream. */ +export function proxyCancel(plugin: InstalledPlugin, streamId: unknown): void { + if (typeof streamId !== 'string') return; + running.get(plugin.id)?.get(streamId)?.abort(); +} + +/** Stop everything a plugin has running (it was turned off). */ +export function cancelPluginProxyRequests(pluginId: string): void { + for (const controller of running.get(pluginId)?.values() ?? []) controller.abort(); + running.delete(pluginId); +} + +/** `api.proxy.connect(credentialId, { mode, server, username })`. */ +export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Promise<{ connected: boolean; origin?: string }> { + const { id, spec } = credentialSpec(plugin, args[0]); + if (spec.scope !== 'user') throw new Error(`"${id}" is set up by the administrator`); + const opts = (args[1] && typeof args[1] === 'object' ? args[1] : {}) as Record; + const mode = opts.mode === 'signin' ? 'signin' : 'password'; + return requestConnect({ + pluginId: plugin.id, + pluginName: plugin.name, + credentialId: id, + spec, + mode, + ...(typeof opts.server === 'string' && opts.server.length < 2048 ? { suggestedOrigin: opts.server } : {}), + ...(typeof opts.username === 'string' && opts.username.length < 256 ? { suggestedUsername: opts.username } : {}), + }); +} + +/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */ +export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> { + const list = await listConnectedAccounts(plugin.id); + return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected })); +} + +/** `api.proxy.disconnect(credentialId)`. */ +export async function proxyDisconnect(plugin: InstalledPlugin, args: unknown[]): Promise { + const { id } = credentialSpec(plugin, args[0]); + return forgetCredentials(plugin.id, id); +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 557c896fa..4067e4380 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -190,6 +190,61 @@ function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_AP }); } +// ─── api.proxy streams ─────────────────────────────────────── + +interface ProxyStream { + controller?: ReadableStreamDefaultController; + onProgress?: (loaded: number, total: number) => void; +} +const proxyStreams = new Map(); + +interface ProxyFetchInit { + method?: string; + headers?: Record; + body?: string | ArrayBuffer | Uint8Array | Blob | null; + /** "text" (default), "json", "arraybuffer", or "stream" for a ReadableStream body. */ + responseType?: 'text' | 'json' | 'arraybuffer' | 'stream'; + /** Upload progress, in bytes. */ + onProgress?: (loaded: number, total: number) => void; + /** Store these JSON fields of the response as the credential instead of returning them. */ + capture?: { secret: string; username?: string }; + /** Stops the request (and a streamed body). */ + signal?: AbortSignal; +} + +async function proxyFetch(credentialId: string, url: string, init: ProxyFetchInit = {}): Promise { + const streamId = uid().replace(/[^a-z0-9]/g, '').slice(0, 24); + const entry: ProxyStream = { onProgress: init.onProgress }; + proxyStreams.set(streamId, entry); + const stream = init.responseType === 'stream' + ? new ReadableStream({ + start(controller) { entry.controller = controller; }, + cancel() { + proxyStreams.delete(streamId); + void callApi('proxy.cancel', [streamId]).catch(() => {}); + }, + }) + : null; + const abort = () => { + void callApi('proxy.cancel', [streamId]).catch(() => {}); + try { entry.controller?.error(new Error('cancelled')); } catch { /* closed */ } + proxyStreams.delete(streamId); + }; + init.signal?.addEventListener('abort', abort, { once: true }); + const { onProgress: _p, signal: _s, ...plain } = init; + void _p; void _s; + try { + // No overall timeout: a big upload or a long AI answer takes as long as + // it takes, and api.proxy.cancel / the signal stop it. + const res = await callApi('proxy.fetch', [credentialId, url, { ...plain, streamId }], 0) as Record; + if (!stream || !res.ok) proxyStreams.delete(streamId); + return stream && res.ok ? { ...res, body: stream } : res; + } catch (err) { + proxyStreams.delete(streamId); + throw err; + } +} + function invokeHostCallback(callbackId: string, args: unknown[]): Promise { const id = uid(); return new Promise((resolve, reject) => { @@ -421,6 +476,31 @@ function buildPluginApi(manifest: PluginManifest) { /** Downloads a file generated by the plugin. Not a user's file or attachment. */ downloadFile: (opts: { content: string; filename: string; contentType?: string }) => callApi('ui.downloadFile', [opts]) as Promise, + /** + * Buttons or menu entries on an action surface (see the slot catalogue: + * message rows, the selection toolbar, attachment menus, the command + * palette, ...). Replaces what this plugin set there before; [] removes + * them. Clicks arrive in the `onAction({ surface, actionId, context })` hook. + */ + setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) => + callApi('ui.setActions', [surface, actions]) as Promise, + /** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */ + setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) => + callApi('ui.setDayLabels', [labels]) as Promise, + /** Full pages with a button in the navigation bar; the `app-page` slot renders them. */ + setPages: (pages: Array<{ id: string; title: string; icon?: string }>) => + callApi('ui.setPages', [pages]) as Promise, + }, + // Requests with an account or key the manifest declares under + // `credentials`; the server adds the secret (permission http:proxy). + proxy: { + fetch: proxyFetch, + /** Show the host's dialog to connect a user credential. */ + connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) => + callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>, + /** Which credentials are connected (no secrets). */ + accounts: () => callApi('proxy.accounts', []) as Promise>, + disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise, }, // Email keyword mutations (permission: email:write). Keywords follow JMAP // syntax, e.g. '$category-promotions' or '$label:'. @@ -840,6 +920,26 @@ function handleHostMessage(ev: MessageEvent): void { case 'props-update': slotPropsUpdater?.(msg.props ?? {}); break; + + case 'stream-chunk': { + const entry = proxyStreams.get(msg.streamId); + try { entry?.controller?.enqueue(new Uint8Array(msg.chunk)); } catch { /* cancelled */ } + break; + } + + case 'stream-end': { + const entry = proxyStreams.get(msg.streamId); + proxyStreams.delete(msg.streamId); + try { + if (msg.error) entry?.controller?.error(new Error(msg.error)); + else entry?.controller?.close(); + } catch { /* already closed */ } + break; + } + + case 'stream-progress': + try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ } + break; } }