Files
multica/packages/plugin-sdk/index.ts
bedad9e222 MUL-6485: isolate hosted plugin surfaces (#7329)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 13:47:06 +08:00

282 lines
9.4 KiB
TypeScript

/**
* @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<string, unknown>;
/** 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<typeof setTimeout>;
};
class Bridge {
private port: MessagePort | null = null;
private readonly pending = new Map<string, Pending>();
private readonly queued: BridgeRequest[] = [];
private ready: Promise<void>;
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<string, unknown>)[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<string, unknown>)[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<T>(method: BridgeMethod, path: string, body?: unknown): Promise<T> {
await this.ready;
const id = `r${++this.sequence}`;
const request: BridgeRequest = { id, kind: "action", method, path, body };
return new Promise<T>((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<StorageKey[]> {
const result = await bridge.request<{ keys: StorageKey[] }>("GET", `/storage/${scope}`);
return result.keys ?? [];
},
async get(key: string): Promise<string | null> {
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<void> {
await bridge.request<void>("PUT", `/storage/${scope}/${encodeURIComponent(key)}`, { value });
},
async delete(key: string): Promise<void> {
await bridge.request<void>("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<PluginContext> {
if (cachedContext && !force) return cachedContext;
cachedContext = await bridge.request<PluginContext>("GET", "/context");
return cachedContext;
},
},
issue: {
/** Defaults to the issue this surface is mounted on. */
async get(issueId?: string): Promise<Issue> {
const id = issueId ?? (await requireIssueId());
return bridge.request<Issue>("GET", `/issues/${encodeURIComponent(id)}`);
},
async update(patch: { title?: string; description?: string }, issueId?: string): Promise<Issue> {
const id = issueId ?? (await requireIssueId());
return bridge.request<Issue>("PATCH", `/issues/${encodeURIComponent(id)}`, patch);
},
async comments(issueId?: string): Promise<Comment[]> {
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<Comment> {
const id = issueId ?? (await requireIssueId());
return bridge.request<Comment>("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<HookResult> {
const issue = (await multica.context.get()).issue;
return bridge.request<HookResult>("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<string> {
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;