Plugin Architectures: Extensible Platforms Without the Monolith
A community platform for DAOs cannot anticipate every need. One DAO needs a bounty board, another a proposal editor, a third a Snapshot widget. Building everything into the core results in a monolith that satisfies no one. The alternative: a plugin system that allows third-party developers to extend the platform without compromising the core.
This post describes the architecture of such a plugin system, as we designed it for a DAO community platform.
The Plugin Manifest
Every plugin describes itself through a manifest:
interface PluginManifest {
id: string; // e.g. "org.example.bounty-board"
name: string;
version: string; // Semver
author: string;
permissions: Permission[];
entryPoints: {
panel?: string; // URL to iframe content
command?: string; // URL to Worker script
settings?: string; // URL to settings UI
};
hooks: HookRegistration[];
slots: SlotRegistration[];
}
type Permission =
| "read:messages"
| "write:messages"
| "read:members"
| "read:channels"
| "storage:local" // Plugin-local storage (max 5 MB)
| "network:fetch"; // External HTTP access
// Example
const manifest: PluginManifest = {
id: "org.example.bounty-board",
name: "Bounty Board",
version: "1.2.0",
author: "Example DAO",
permissions: ["read:messages", "read:members", "storage:local"],
entryPoints: {
panel: "/plugins/bounty-board/panel.html",
settings: "/plugins/bounty-board/settings.html",
},
hooks: [
{ event: "message:created", handler: "onMessage" },
],
slots: [
{ location: "sidebar", priority: 50 },
],
};
Sandboxing: iframes and Web Workers
Plugins never run in the same context as the host application. We use two isolation models:
iframes for visual plugins (panels, widgets). The iframe is
loaded with restrictive sandbox attributes:
function mountPlugin(manifest: PluginManifest, container: HTMLElement) {
const iframe = document.createElement("iframe");
iframe.src = manifest.entryPoints.panel!;
iframe.sandbox.add(
"allow-scripts", // JS allowed
"allow-forms", // Forms allowed
// NO allow-same-origin → no access to host cookies/storage
// NO allow-top-navigation → cannot navigate away
);
// CSP: Only own resources and the plugin SDK API
iframe.csp = "default-src 'self'; script-src 'self' https://sdk.platform.example;";
iframe.style.cssText = "width:100%;height:100%;border:none;";
container.appendChild(iframe);
return iframe;
}
Web Workers for headless plugins (automations, bots). Workers have no DOM access and run in their own thread:
function startPluginWorker(manifest: PluginManifest): Worker {
const worker = new Worker(manifest.entryPoints.command!, {
type: "module",
name: `plugin:${manifest.id}`,
});
// Timeout: plugin may take max 5s per event
const timeouts = new Map<string, NodeJS.Timeout>();
worker.onmessage = (e) => {
const { requestId, type, payload } = e.data;
const timeout = timeouts.get(requestId);
if (timeout) clearTimeout(timeout);
handlePluginResponse(manifest.id, type, payload);
};
return worker;
}
The Plugin SDK
Plugins communicate with the host application through a typed SDK that works
via postMessage:
// Plugin side (in iframe or Worker)
import { PluginSDK } from "@platform/plugin-sdk";
const sdk = new PluginSDK();
// Read messages (requires "read:messages" permission)
const messages = await sdk.messages.list({
channelId: "ch_abc123",
limit: 50,
});
// React to events
sdk.on("message:created", async (message) => {
if (message.content.includes("!bounty")) {
await sdk.messages.send({
channelId: message.channelId,
content: "Bounty Board opened. Visit the panel.",
});
}
});
// Plugin-local storage
await sdk.storage.set("lastSync", Date.now().toString());
const last = await sdk.storage.get("lastSync");
Internally, every SDK call is serialized to a postMessage. The host
application checks permissions against the manifest before executing the call:
// Host side: message handler for plugin requests
window.addEventListener("message", async (event) => {
const { pluginId, method, params, requestId } = event.data;
const manifest = pluginRegistry.get(pluginId);
if (!manifest) return;
// Permission check
const requiredPerm = getRequiredPermission(method);
if (!manifest.permissions.includes(requiredPerm)) {
event.source?.postMessage({
requestId,
error: `Permission denied: ${requiredPerm}`,
}, event.origin);
return;
}
// Rate limiting: max 100 calls/second per plugin
if (!rateLimiter.check(pluginId)) {
event.source?.postMessage({
requestId,
error: "Rate limit exceeded",
}, event.origin);
return;
}
try {
const result = await executeMethod(method, params);
event.source?.postMessage({ requestId, result }, event.origin);
} catch (err) {
event.source?.postMessage({
requestId,
error: (err as Error).message,
}, event.origin);
}
});
Lifecycle Hooks
Plugins can hook into the platform lifecycle:
interface PluginLifecycle {
// When the plugin loads
onActivate(): Promise<void>;
// When unloaded (e.g., disabled by admin)
onDeactivate(): Promise<void>;
// On platform events
onMessage?(message: Message): Promise<void>;
onMemberJoin?(member: Member): Promise<void>;
onMemberLeave?(member: Member): Promise<void>;
onChannelCreated?(channel: Channel): Promise<void>;
// Periodic (min. every 60s)
onTick?(): Promise<void>;
}
// In the plugin:
sdk.lifecycle.register({
async onActivate() {
console.log("Bounty Board active");
await loadBounties();
},
async onDeactivate() {
await savePendingChanges();
},
async onMessage(message) {
await checkForBountyCommands(message);
},
});
Slot System for UI Integration
Plugins do not render their UI at arbitrary locations but in predefined slots. The host application defines where plugins may appear:
// Predefined slots in the host application
type SlotLocation =
| "sidebar" // Sidebar
| "message-action" // Context menu of a message
| "channel-header" // Header area of a channel
| "settings-tab" // Tab in settings
| "compose-toolbar"; // Toolbar in the message editor
// Host renders slots:
// <PluginSlot location="sidebar" />
// The plugin registers for a slot:
sdk.slots.register("sidebar", {
title: "Bounty Board",
icon: "clipboard-list",
render: (container) => {
container.innerHTML = "<bounty-board-panel />";
},
});
Through this system, the platform can guarantee that plugins do not break the layout. Each slot has maximum dimensions, and the host can disable plugins that claim too much space or render too slowly.
Security Considerations
Three measures are critical:
- Content Security Policy: Plugins may not load external
scripts unless they have the
network:fetchpermission. - Resource Limits: Each plugin has a CPU budget (measured
via
performance.now()deltas). On exceeding it, the Worker is terminated or the iframe is unloaded. - Audit Trail: Every API call from a plugin is logged. On abuse, a DAO admin can immediately disable the plugin.
Takeaways
A plugin system transforms a platform from an application into an ecosystem. The core stays lean and stable while the community adds functionality that the core developers would never have anticipated. The price is complexity in the sandboxing and permission layer — but that investment pays off as soon as the first external plugin solves a problem you would never have prioritized yourself.