/** * @multica/plugin-sdk — what a plugin surface imports. * * A surface is an ordinary script running in a sandboxed iframe. It holds no * credential and cannot reach Multica's API directly: every call here becomes a * message to the host page, which performs the call on the signed-in user's own * session and sends the result back. That indirection is the whole security * story — a plugin can never do more than the person looking at it, and there * is no token in the frame to leak. * * Zero runtime dependencies, and deliberately no import of `@multica/core` or * `@multica/ui`: this ships to third parties. */ import { BRIDGE_PORT_GLOBAL, isBridgeEvent, isBridgeResponse, type BridgeMethod, type BridgeRequest, type ThemeTokens, } from "./protocol"; export type { ThemeTokens } from "./protocol"; export interface PluginContext { workspace: { id: string; name: string; slug: string }; user: { id: string; name: string }; issue?: { id: string; identifier: string; title: string }; /** Non-secret installation configuration. Secrets never reach the frame. */ config: Record; /** Domains this installation was granted via `net:` scopes. */ granted_net_domains: string[]; } export interface Issue { id: string; identifier: string; title: string; description?: string | null; status: string; priority: string; } export interface Comment { id: string; author_type: string; author_id: string; content: string; type: string; parent_id?: string; created_at: string; } /** One completed hook call. `status` is the host's verdict, not the endpoint's. */ export interface HookResult { status: string; output?: unknown; error?: string; latency_ms: number; hook_key: string; trigger: string; attempts: number; } export interface StorageKey { key: string; size_bytes: number; updated_at: string; } /** Thrown when the host refuses or the call fails. `status` mirrors HTTP. */ export class MulticaPluginError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = "MulticaPluginError"; this.status = status; } } const DEFAULT_TIMEOUT_MS = 15_000; type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType; }; class Bridge { private port: MessagePort | null = null; private readonly pending = new Map(); private readonly queued: BridgeRequest[] = []; private ready: Promise; private markReady!: () => void; private sequence = 0; private theme: ThemeTokens = {}; private readonly themeListeners = new Set<(theme: ThemeTokens) => void>(); constructor() { this.ready = new Promise((resolve) => { this.markReady = resolve; }); const guestPort = (globalThis as Record)[BRIDGE_PORT_GLOBAL]; if (guestPort && typeof (guestPort as MessagePort).postMessage === "function" && typeof (guestPort as MessagePort).close === "function") { // First reader wins. The port is not a credential, but leaving a second // global reference around makes accidental competing SDK instances much // harder to diagnose and weakens the one-channel invariant. delete (globalThis as Record)[BRIDGE_PORT_GLOBAL]; this.port = guestPort as MessagePort; this.port.onmessage = this.onPortMessage; this.port.start?.(); this.markReady(); } } private onPortMessage = (event: MessageEvent) => { const message = event.data; if (isBridgeEvent(message)) { this.applyTheme(message.theme); return; } if (!isBridgeResponse(message)) return; const pending = this.pending.get(message.id); if (!pending) return; this.pending.delete(message.id); clearTimeout(pending.timer); if (message.ok) pending.resolve(message.data); else pending.reject(new MulticaPluginError(message.status, message.error)); }; private applyTheme(theme: ThemeTokens) { this.theme = theme; if (typeof document !== "undefined") { for (const [name, value] of Object.entries(theme)) { document.documentElement.style.setProperty(name, value); } } for (const listener of this.themeListeners) listener(theme); } onThemeChange(listener: (theme: ThemeTokens) => void): () => void { this.themeListeners.add(listener); if (Object.keys(this.theme).length > 0) listener(this.theme); return () => this.themeListeners.delete(listener); } /** Fire-and-forget: the host never answers these. */ notify(request: BridgeRequest) { if (this.port) this.port.postMessage(request); else this.queued.push(request); } async request(method: BridgeMethod, path: string, body?: unknown): Promise { await this.ready; const id = `r${++this.sequence}`; const request: BridgeRequest = { id, kind: "action", method, path, body }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new MulticaPluginError(408, `Multica did not answer ${method} ${path} in time`)); }, DEFAULT_TIMEOUT_MS); this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); this.port?.postMessage(request); }); } } const bridge = new Bridge(); function storageApi(scope: "workspace" | "user") { return { async list(): Promise { const result = await bridge.request<{ keys: StorageKey[] }>("GET", `/storage/${scope}`); return result.keys ?? []; }, async get(key: string): Promise { try { const result = await bridge.request<{ value: string }>("GET", `/storage/${scope}/${encodeURIComponent(key)}`); return result.value; } catch (error) { // A missing key is an ordinary outcome, not an error to handle. if (error instanceof MulticaPluginError && error.status === 404) return null; throw error; } }, async set(key: string, value: string): Promise { await bridge.request("PUT", `/storage/${scope}/${encodeURIComponent(key)}`, { value }); }, async delete(key: string): Promise { await bridge.request("DELETE", `/storage/${scope}/${encodeURIComponent(key)}`); }, }; } let cachedContext: PluginContext | null = null; export const multica = { context: { /** Who is looking, where, and which issue this surface is mounted on. */ async get(force = false): Promise { if (cachedContext && !force) return cachedContext; cachedContext = await bridge.request("GET", "/context"); return cachedContext; }, }, issue: { /** Defaults to the issue this surface is mounted on. */ async get(issueId?: string): Promise { const id = issueId ?? (await requireIssueId()); return bridge.request("GET", `/issues/${encodeURIComponent(id)}`); }, async update(patch: { title?: string; description?: string }, issueId?: string): Promise { const id = issueId ?? (await requireIssueId()); return bridge.request("PATCH", `/issues/${encodeURIComponent(id)}`, patch); }, async comments(issueId?: string): Promise { const id = issueId ?? (await requireIssueId()); const result = await bridge.request<{ comments: Comment[] }>("GET", `/issues/${encodeURIComponent(id)}/comments`); return result.comments ?? []; }, async comment(input: { body: string; parentId?: string }, issueId?: string): Promise { const id = issueId ?? (await requireIssueId()); return bridge.request("POST", `/issues/${encodeURIComponent(id)}/comments`, { content: input.body, parent_id: input.parentId, }); }, }, storage: { workspace: storageApi("workspace"), user: storageApi("user"), }, hooks: { /** * Invokes one of THIS plugin's hooks — the `ui` trigger. * * The surface does not call its own backend directly, and this is not a * detour for its own sake: routing through the host is what makes the call * signed, rate limited, bounded to the granted `net:` domains, and issued a * short-lived callback token. A surface that fetched its own server would * have none of that, and the user would have no record it happened. * * The host refuses any hook this plugin's manifest did not declare with the * `ui` trigger. */ async invoke(hookKey: string, input?: unknown): Promise { const issue = (await multica.context.get()).issue; return bridge.request("POST", `/hooks/${encodeURIComponent(hookKey)}`, { trigger: "ui", issue_id: issue?.id, input, }); }, }, ui: { /** Ask the host for a different frame height, in CSS pixels. */ resize(height: number) { bridge.notify({ id: `resize${Date.now()}`, kind: "ui.resize", height: Math.max(0, Math.round(height)) }); }, /** Current design tokens, and a subscription for theme switches. */ onThemeChange(listener: (theme: ThemeTokens) => void): () => void { return bridge.onThemeChange(listener); }, }, }; async function requireIssueId(): Promise { const context = await multica.context.get(); if (!context.issue) { throw new MulticaPluginError(400, "This surface is not mounted on an issue; pass an issue id explicitly."); } return context.issue.id; } export default multica;