Zum Hauptinhalt springen
7 Min. Lesezeit R.S.

Plugin-Architekturen: Erweiterbare Plattformen ohne Monolith

Eine Community-Plattform für DAOs kann nicht alle Bedürfnisse voraussehen. Eine DAO braucht ein Bounty-Board, eine andere einen Proposal-Editor, eine dritte ein Snapshot-Widget. Alles in den Kern zu bauen endet in einem Monolithen, der niemanden zufriedenstellt. Die Alternative: ein Plugin-System, das Drittentwicklern erlaubt, die Plattform zu erweitern, ohne den Kern zu gefährden.

Dieser Beitrag beschreibt die Architektur eines solchen Plugin-Systems, wie wir es für eine DAO-Community-Plattform entworfen haben.

Das Plugin-Manifest

Jedes Plugin beschreibt sich durch ein Manifest:

interface PluginManifest {
  id: string;              // z.B. "org.example.bounty-board"
  name: string;
  version: string;         // Semver
  author: string;
  permissions: Permission[];
  entryPoints: {
    panel?: string;        // URL zum iframe-Content
    command?: string;       // URL zum Worker-Script
    settings?: string;     // URL zur Settings-UI
  };
  hooks: HookRegistration[];
  slots: SlotRegistration[];
}

type Permission =
  | "read:messages"
  | "write:messages"
  | "read:members"
  | "read:channels"
  | "storage:local"       // Plugin-lokaler Storage (max 5 MB)
  | "network:fetch";      // Externer HTTP-Zugriff

// Beispiel
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 und Web Workers

Plugins laufen nie im selben Kontext wie die Host-Anwendung. Wir setzen auf zwei Isolationsmodelle:

iframes für visuelle Plugins (Panels, Widgets). Das iframe wird mit restriktiven sandbox-Attributen geladen:

function mountPlugin(manifest: PluginManifest, container: HTMLElement) {
  const iframe = document.createElement("iframe");

  iframe.src = manifest.entryPoints.panel!;
  iframe.sandbox.add(
    "allow-scripts",         // JS erlaubt
    "allow-forms",           // Formulare erlaubt
    // KEIN allow-same-origin → kein Zugriff auf Host-Cookies/Storage
    // KEIN allow-top-navigation → kann nicht wegnavigieren
  );

  // CSP: Nur eigene Ressourcen und die 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 für headless Plugins (Automatisierungen, Bots). Workers haben keinen DOM-Zugriff und laufen in einem eigenen Thread:

function startPluginWorker(manifest: PluginManifest): Worker {
  const worker = new Worker(manifest.entryPoints.command!, {
    type: "module",
    name: `plugin:${manifest.id}`,
  });

  // Timeout: Plugin darf max. 5s pro Event brauchen
  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;
}

Die Plugin-SDK

Plugins kommunizieren mit der Host-Anwendung über eine typisierte SDK, die über postMessage arbeitet:

// Plugin-seitig (im iframe oder Worker)
import { PluginSDK } from "@platform/plugin-sdk";

const sdk = new PluginSDK();

// Nachrichten lesen (benötigt "read:messages" Permission)
const messages = await sdk.messages.list({
  channelId: "ch_abc123",
  limit: 50,
});

// Auf Events reagieren
sdk.on("message:created", async (message) => {
  if (message.content.includes("!bounty")) {
    await sdk.messages.send({
      channelId: message.channelId,
      content: "Bounty-Board geöffnet. Besuchen Sie das Panel.",
    });
  }
});

// Plugin-lokaler Storage
await sdk.storage.set("lastSync", Date.now().toString());
const last = await sdk.storage.get("lastSync");

Intern wird jeder SDK-Call zu einer postMessage-Nachricht serialisiert. Die Host-Anwendung prüft die Berechtigung anhand des Manifests, bevor sie den Call ausführt:

// Host-seitig: Message-Handler für 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/Sekunde pro 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 können sich in den Lebenszyklus der Plattform einhängen:

interface PluginLifecycle {
  // Beim Laden des Plugins
  onActivate(): Promise<void>;

  // Beim Entladen (z.B. Deaktivierung durch Admin)
  onDeactivate(): Promise<void>;

  // Bei Plattform-Events
  onMessage?(message: Message): Promise<void>;
  onMemberJoin?(member: Member): Promise<void>;
  onMemberLeave?(member: Member): Promise<void>;
  onChannelCreated?(channel: Channel): Promise<void>;

  // Periodisch (min. alle 60s)
  onTick?(): Promise<void>;
}

// Im Plugin:
sdk.lifecycle.register({
  async onActivate() {
    console.log("Bounty Board aktiv");
    await loadBounties();
  },

  async onDeactivate() {
    await savePendingChanges();
  },

  async onMessage(message) {
    await checkForBountyCommands(message);
  },
});

Slot-System für UI-Integration

Plugins rendern ihre UI nicht an beliebiger Stelle, sondern in vordefinierten Slots. Die Host-Anwendung gibt vor, wo Plugins erscheinen dürfen:

// Vordefinierte Slots in der Host-Anwendung
type SlotLocation =
  | "sidebar"           // Seitenleiste
  | "message-action"    // Kontextmenü einer Nachricht
  | "channel-header"    // Header-Bereich eines Channels
  | "settings-tab"      // Tab in den Einstellungen
  | "compose-toolbar";  // Toolbar im Nachrichteneditor

// Host rendert Slots:
// <PluginSlot location="sidebar" />

// Das Plugin registriert sich für einen Slot:
sdk.slots.register("sidebar", {
  title: "Bounty Board",
  icon: "clipboard-list",
  render: (container) => {
    container.innerHTML = "<bounty-board-panel />";
  },
});

Durch dieses System kann die Plattform garantieren, dass Plugins das Layout nicht zerstören. Jeder Slot hat maximale Dimensionen, und der Host kann Plugins deaktivieren, die zu viel Platz beanspruchen oder zu langsam rendern.

Sicherheitsüberlegungen

Drei Maßnahmen sind entscheidend:

  • Content Security Policy: Plugins dürfen keine externen Skripte laden, außer sie haben die network:fetch-Berechtigung.
  • Resource Limits: Jedes Plugin hat ein CPU-Budget (gemessen über performance.now()-Deltas). Bei Überschreitung wird der Worker terminiert oder das iframe entladen.
  • Audit Trail: Jeder API-Call eines Plugins wird geloggt. Bei Missbrauch kann ein DAO-Admin das Plugin sofort deaktivieren.

Fazit

Ein Plugin-System verwandelt eine Plattform von einer Anwendung in ein Ökosystem. Der Kern bleibt schlank und stabil, während die Community Funktionalität ergänzt, die die Kernentwickler nie vorhergesehen hätten. Der Preis ist Komplexität in der Sandboxing- und Permission-Schicht — aber diese Investition zahlt sich aus, sobald das erste externe Plugin ein Problem löst, das Sie selbst nie priorisiert hätten.