Skip to main content
7 min read R.S.

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:fetch permission.
  • 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.