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.