Capabilities — die Befehlsschicht
Eine Capability ist eine Aktion, die einmal beschrieben wird und dann von überall denselben Weg nimmt: der Knopf in der Oberfläche, die KI und ein anderes Plugin rufen dieselbe Fähigkeit auf. Sie hat einen Namen, ein Eingabeschema, ein Recht, eine Beschreibung und einen Handler.
Das ist der Grund, warum es die Schicht überhaupt gibt: die KI-Werkzeuge werden aus denselben Registrierungen abgeleitet. Was Sie einmal registrieren, ist gleichzeitig die Werkzeugbeschreibung — nichts wird doppelt geschrieben.
Lesen Sie das zuerst, es spart Ihnen einen Umweg. Die hier beschriebene Naht —
Plugin,register(host),registerCapability— wird heute vom fest eingebauten Ladeweg bedient. Ein Paket, das ein Betreiber hochlädt, bringt sein Backend als Modul mit Routen mit; siehe „Backend und Sidecar”. Beide Wege sind im SDK beschrieben, aber nur dieser hier registriert Fähigkeiten. Wenn Sie ein hochladbares Paket bauen, ist die Fähigkeitsschicht für Sie die Gegenrichtung: Sie rufen Fähigkeiten des Hosts auf, statt eigene anzubieten.
Wo Ihr Code beginnt
Abschnitt betitelt „Wo Ihr Code beginnt“Ein Plugin ist ein Objekt mit einem Manifest und einer register-Funktion:
import type { Plugin, PluginHost } from '@prodlog/sdk';
export const plugin: Plugin = { manifest,
// Optional: einmalige, idempotente Schema-Einrichtung. Läuft NUR für fest // eingebaute Plugins beim Start der Instanz - ein hochgeladenes Paket führt // dies nie aus, seine Tabellen kommen aus der Manifest-Beschreibung. async migrate(context) { await context.createTenantTable('acme_tcp_log', 'telegram text not null'); },
register(host: PluginHost) { // hier registrieren Sie Contributions und Capabilities }};
export default plugin;register wird genau einmal je Prozess aufgerufen, beim Laden des Plugins, bevor die
erste Anfrage bedient wird. Das ist Ihre Startstelle. Ein Wurf hier bricht das Laden dieses
Plugins ab und lässt Host und andere Plugins laufen; eine halb fertige Registrierung wird
zurückgerollt, damit nichts Verwaistes aufrufbar bleibt.
Was host bietet:
interface PluginHost { readonly log: Logger; registerContribution(contribution: Contribution): void; registerCapability<Input, Output>(definition: CapabilityDefinition<Input, Output>): void;}Eine Capability registrieren
Abschnitt betitelt „Eine Capability registrieren“import { z } from 'zod';import type { PluginHost, InvocationContext } from '@prodlog/sdk';
const deactivateInput = z.object({ userId: z.string().min(1) });
export function registerCapabilities(host: PluginHost): void { host.registerCapability({ name: 'acme.tcp.resetCounter', description: 'Reset the telegram counter of one connection.', permission: 'data:write', rbacPermission: 'acme.tcp:configure', input: deactivateInput, handler: async (context: InvocationContext, input): Promise<{ ok: true }> => { await context.data.query('UPDATE acme_tcp_log SET counter = 0 WHERE id = $1', [input.userId]); await context.audit.record({ action: 'acme.tcp.resetCounter', target: input.userId }); return { ok: true }; } });}Die Felder von CapabilityDefinition:
| Feld | Bedeutung |
|---|---|
name |
Muss Ihre Plugin-Id sein oder <id>.<verb>. Ein fremder Name wird abgelehnt, ein doppelter auch. |
description |
Ein Satz. Er wird zur Werkzeugbeschreibung für die KI. |
permission |
Das Plattformrecht aus der geschlossenen Aufzählung — muss im Manifest stehen. |
rbacPermission |
Optional. Das Recht eines Menschen, das der Host zusätzlich prüft. Fehlt es, ist die Fähigkeit RBAC-frei. |
input |
Ein Zod-Schema. Bei ungültiger Eingabe wirft der Aufruf, bevor Ihr Handler läuft. |
handler |
(context, input) => Promise<Output> |
Der Aufrufkontext
Abschnitt betitelt „Der Aufrufkontext“Jeder Aufruf bekommt einen eigenen, an einen Mandanten gebundenen Kontext:
interface InvocationContext { readonly tenant: { readonly id: string }; readonly caller: CallerIdentity; readonly log: Logger; readonly data: DataApi; // query<T>(sql, params) - unter dem RLS-Kontext der Anfrage readonly audit: AuditApi; // record({ action, target?, detail? }) readonly events: EventsApi; // publish(type, payload?) - über den vermittelten Bus}data.query ist der einzige Weg zu Daten. Kein rohes Datenbank-Handle wird je gereicht; die
Abfrage läuft parametrisiert unter der Mandantensitzung. Ein Plugin sendet auch nie direkt
an ein anderes Plugin — Ereignisse gehen über den vermittelten Bus.
Wer ruft — context.caller
Abschnitt betitelt „Wer ruft — context.caller“interface CallerIdentity { readonly appUserId: string | null; readonly roles: readonly string[]; readonly kind: 'user' | 'operator' | 'ai' | 'machine';}Drei Dinge, die dabei wichtig sind:
- Der Host setzt das aus der Sitzung, nie aus dem Anfragerumpf. Ein Plugin kann hier nichts setzen, erweitern oder fälschen.
appUserIdist die mandantenlokaleapp_user.id, ausdrücklich nicht dassubdes Identitätsanbieters.nullheißt „kein Mensch dahinter” oder „nicht auflösbar” — nie eine Ersatzidentität.kind: 'machine'ist ein vom Host beaufsichtigter Prozess, etwa ein Konnektor, der einen Socket hält. Wer auf einen Menschen prüft, muss das wie „niemand angemeldet” lesen — nie als privilegierten internen Aufrufer.
roles sind die groben Sitzungsrollen (tenant-admin, user, display). Die feine
Prüfung bleibt beim Host über rbacPermission. Ihre eigene Rollenprüfung kommt immer
obendrauf, nie stattdessen.
Ein Konflikt ist kein Serverfehler
Abschnitt betitelt „Ein Konflikt ist kein Serverfehler“Wenn die Daten des Mandanten in einem Zustand sind, der die Änderung verbietet — „diese
Kennung ist schon vergeben”, „der letzte aktive Eintrag darf nicht deaktiviert werden” —,
dann hat der Server richtig gearbeitet. Werfen Sie dafür einen CapabilityConflictError:
import { CapabilityConflictError } from '@prodlog/sdk';
if (existing) { throw new CapabilityConflictError('A connection with this name already exists.', 'duplicate_name');}Der Host bildet das auf HTTP 409 ab, und die Meldung wird an den Aufrufer durchgereicht — das ist der Sinn eines 409. Schreiben Sie deshalb einen fachlichen Satz hinein, nie einen Datenbankfehler und nie eine interne Kennung. Alles andere, was ein Handler wirft, bleibt ein 500 mit einem geschlossenen Text.
Wie ein Aufruf ankommt
Abschnitt betitelt „Wie ein Aufruf ankommt“Die Oberfläche ruft eine Fähigkeit über eine Route des Hosts auf:
POST /api/plugins/<pluginId>/capabilities/<name>Der Rumpf ist die Eingabe. Der Host arbeitet in dieser Reihenfolge:
- Angemeldet? Sonst
401. - Plugin für diesen Mandanten eingeschaltet? Sonst
404 plugin not enabled. - RBAC-Recht, falls die Fähigkeit eines deklariert. Sonst
403 permission denied. - Plattformrecht des Aufrufers gegen
permission. Sonst ebenfalls403. - Eingabe gegen Ihr Zod-Schema. Sonst
400 invalid input. - Ihr Handler.
Fehlerabbildung, damit Sie beim Bau nicht raten müssen:
| Lage | Status |
|---|---|
| Unbekannter Fähigkeitsname | 404 unknown capability |
| Recht fehlt (Plattform oder RBAC) | 403 permission denied |
| Eingabe passt nicht zum Schema | 400 invalid input |
CapabilityConflictError |
409 mit Ihrer Meldung und optionalem code |
| Alles andere | 500 internal error |
Ein vollständiges Beispiel im Repo
Abschnitt betitelt „Ein vollständiges Beispiel im Repo“plugins/tenant-user registriert elf Fähigkeiten und ist die beste Vorlage:
| Name | Recht | RBAC-Recht |
|---|---|---|
tenant-user.list |
data:read |
tenant-user:read |
tenant-user.get |
data:read |
tenant-user:read |
tenant-user.listRoles |
data:read |
tenant-user:read |
tenant-user.directoryMode |
data:read |
tenant-user:read |
tenant-user.create |
data:write |
tenant-user:create |
tenant-user.deactivate |
data:write |
tenant-user:deactivate |
tenant-user.reactivate |
data:write |
tenant-user:deactivate |
tenant-user.assignRole |
data:write |
tenant-user:assign-role |
tenant-user.unassignRole |
data:write |
tenant-user:assign-role |
tenant-user.setSecurityRole |
data:write |
tenant-user:assign-role |
tenant-user.delete |
data:write |
tenant-user:delete |
Zwei Muster daraus sind übernehmenswert: die Eingabeschemata liegen gebündelt in einer
eigenen Datei, und die Registrierung ist in Lese-, Schreib-, Rollen- und Löschteile
zerlegt — vier kleine Funktionen statt einer langen register.
Was heute nicht geht
Abschnitt betitelt „Was heute nicht geht“- Eine Fähigkeit kann kein Ereignis abonnieren. Es gibt keinen
onEnable-Haken und keinen Lebenszyklus-Rückruf außerregisterundmigrate. - Ein hochgeladenes Paket führt
migrate()nicht aus. Seine Tabellen entstehen ausschließlich aus der Manifest-Beschreibung. - Nur ein fest eingebautes Plugin registriert heute Fähigkeiten. Der Ladeweg, der
register(host)aufruft, wird beim Start der Instanz für die mitgelieferten Plugins durchlaufen. Ein hochgeladenes Paket geht den Backend-Weg mit Routen. Ein Sidecar erreicht die Fähigkeitsschicht nur in der Gegenrichtung, übercapability/invokeauf dem Rückkanal.