Zum Inhalt springen

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.

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;
}
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>

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.

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.
  • appUserId ist die mandantenlokale app_user.id, ausdrücklich nicht das sub des Identitätsanbieters. null heiß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.

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.

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:

  1. Angemeldet? Sonst 401.
  2. Plugin für diesen Mandanten eingeschaltet? Sonst 404 plugin not enabled.
  3. RBAC-Recht, falls die Fähigkeit eines deklariert. Sonst 403 permission denied.
  4. Plattformrecht des Aufrufers gegen permission. Sonst ebenfalls 403.
  5. Eingabe gegen Ihr Zod-Schema. Sonst 400 invalid input.
  6. 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

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.

  • Eine Fähigkeit kann kein Ereignis abonnieren. Es gibt keinen onEnable-Haken und keinen Lebenszyklus-Rückruf außer register und migrate.
  • 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, über capability/invoke auf dem Rückkanal.