Zum Inhalt springen

Das Plugin-Manifest

Die manifest.json ist der ganze Vertrag mit dem Host. Sie liegt als genau eine Datei in der Wurzel des Archivs. Der Host prüft sie, bevor irgendetwas geladen wird; ein Fehler darin ist eine Ablehnung, kein Absturz.

Prüfen Sie sie mit Ihrem eigenen Übersetzer, statt es beim Installieren zu erfahren:

import { readFileSync } from 'node:fs';
import { parseManifest } from '@prodlog/sdk';
const manifest = parseManifest(JSON.parse(readFileSync('manifest.json', 'utf8')));

parseManifest wirft einen ZodError mit genauem Pfad zur Fundstelle. Es gibt daneben parseUploadedManifest — das ist der Vorfilter des Hochlade-Weges, kein zweiter Prüfweg; Sie brauchen ihn beim Bauen nicht.

{
"id": "acme.tcp", // klein geschrieben, mit Punkt oder Bindestrich getrennt
"version": "0.1.0", // semver
"sdk": "^0.9.0", // der Vertragsbereich, gegen den Sie gebaut haben
"system": false, // immer false; true gehört der Plattform und wird abgelehnt
"permissions": ["data:write"], // was Ihr CODE erreichen darf
"rbacPermissions": [], // was ein MENSCH darf, in Ihrem Namensraum
"requiresLicense": false,
"backend": { "entry": "backend.js" },
"provides": [],
"tables": []
}

Klein geschrieben, Segmente mit . oder - getrennt: ^[a-z0-9]+(?:[-.][a-z0-9]+)*$. Diese Id ist Ihr Namensraum, und alles Weitere hängt daran.

Was der Host ablehnt:

  • shell — gehört der Schale selbst.
  • Eine Id, deren erstes Segment ein reservierter Namensraum ist: plugin, audit, user, data, tenant, system, capability. Also auch tenant-x oder data.foo. Nur ein System-Plugin des Herstellers darf dort sitzen.
  • Eine Id, die mit einer bereits geladenen verschachtelt ist (foo neben foo-bar) — die Eigentümerschaft an einer Tabelle wäre sonst nicht entscheidbar.

version ist strenges semver (major.minor.patch, Vorabkennung erlaubt). Eine Aktualisierung muss echt höher sein; ein Rückschritt wird mit version_not_higher abgelehnt.

sdk ist ein Bereich. Siehe „Was ein Plugin ist” für die Tabelle, welche Vertragsversionen ein Host bereitstellt.

Eine geschlossene Aufzählung:

Recht Wofür
data:read Lesende Abfragen über die vermittelte Oberfläche
data:write Schreibende Abfragen — und die Fähigkeiten, die darunter hängen
plugin:manage Plugins verwalten (praktisch nur für System-Plugins)
capability:invoke Der Rückkanal: eine Fähigkeit des Hosts aufrufen
audit:read Den Audit-Verlauf lesen
net:request Eine einzelne ausgehende HTTP(S)-Anfrage, vom Host vermittelt
net:socket Eine gehaltene, zweiseitige Verbindung — was ein Konnektor braucht

net:request und net:socket sind getrennt, weil sie getrennte Risiken sind: ein REST-Aufruf hinterlässt eine Spur einzelner, begrenzter Anfragen, ein gehaltener Socket ist ein offener Kanal, solange das Plugin lebt.

Der Fehler, der übersehen wird. capability:invoke öffnet den Rückkanal, aber die Fähigkeit dahinter hat ihr eigenes Tor. tag.write hängt unter data:write. Wer nur capability:invoke deklariert, bekommt bei jedem Schreiben permission_denied — und die Meldung nennt das fehlende Recht nicht.

Offene Liste im Format namensraum:verb, also ^[a-z0-9]+(?:[-.][a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$. Der Namensraum muss Ihre Plugin-Id sein. Ein reservierter Namensraum wird nur auf einem System-Plugin akzeptiert, und diese Prüfung geht der Eigentümerprüfung vor — auch dann, wenn Ihre Id zufällig ein reserviertes Wort ist.

"rbacPermissions": ["acme.tcp:configure", "acme.tcp:reset-counter"]

Zwei Arten, ein Feld:

// in-process: ein TypeScript-/JavaScript-Modul in einer abgeschotteten Laufzeit
"backend": { "entry": "backend.js" }
// Sidecar: ein eigener Prozess, eigene Sprache, eigene Bibliotheken
"backend": {
"entry": "runtime/main.py",
"runtime": "sidecar",
"requires": { "language": "python", "minVersion": "3.11" }
}

Die beiden unterscheiden sich in genau zwei Punkten, und beide werden mit einer eigenen Meldung geprüft:

  • entry. In-process ist es ein Dateiname in der Archivwurzel — kein /, kein führender Punkt. Ein Sidecar-entry ist ein relativer Pfad in den Paketbaum hinein; jedes Segment muss mit Buchstabe oder Ziffer beginnen, wodurch . und .. als Segment bauartbedingt unmöglich sind.
  • requires. Für einen Sidecar Pflicht („a sidecar backend must declare requires“), für in-process verboten („requires is only meaningful for a sidecar backend”). Der Host prüft den Interpreter, bevor er einen Prozess startet — eine klare Ablehnung statt eines Tracebacks tief in fremdem Code.

Ein reiner "backend": true aus der Zeit vor SDK 0.6.0 wird noch angenommen und heißt „kein ausführbarer Teil”. Nutzen Sie ihn nicht für Neues.

"frontend": { "entry": "remoteEntry.js" }

Ein Dateiname, kein Pfad. Die Datei muss unter dist/ im Archiv liegen. Ein reines Backend-Paket lässt das Feld weg und liefert kein dist/ — das ist richtig so.

Eine Liste. Jeder Eintrag:

{
"category": "route",
"id": "acme.tcp",
"order": 10,
"label": "TCP",
"paletteGroup": "pages",
"meta": { "path": "/acme.tcp" }
}
  • category ist bewusst frei. Der Host zeichnet ein paar bekannte Kategorien: route, nav, slot, command, settings, ai-tool, connector, pageType, app.
  • id muss Ihnen gehören: gleich der Plugin-Id oder ein mit -/. abgetrenntes Kind davon. foo passt nicht auf foobar.
  • order und label steuern die Reihenfolge und Beschriftung; fehlendes order zählt als 0, Gleichstand wird nach id aufgelöst.
  • paletteGroup ist die Rubrik in der Befehlspalette: quick-actions, pages oder settings. Ohne Angabe landet ein Befehl unter pages.

Zwei Kategorien haben eigene, harte Regeln:

route — meta.path muss /<id> oder /<id>/<slug>[/<slug>…] sein, jedes Segment ein einfacher Kleinbuchstaben-Slug. Damit sind Traversal, Punktsegmente und kodierte Tricks an dieser Stelle ausgeschlossen.

pageType — die Contribution-Id wird ein Routensegment und muss deshalb zusätzlich zur Eigentümerregel eine saubere Segmentform haben. meta wird gegen den Seitentyp-Vertrag geprüft; ein fehlerhafter macht das ganze Manifest ungültig, damit kein Seitentyp im Anlegedialog steht, aus dem sich nichts erzeugen lässt.

{
"category": "pageType",
"id": "acme.report",
"label": "Report",
"meta": {
"label": "Report",
"defaultIcon": "clipboard-list",
"create": { "path": "/reports", "nameField": "name", "refField": "id" }
}
}

Sie beschreiben Ihre Tabellen; der Host baut sie. Aus einem hochgeladenen Paket wird niemals Text als SQL ausgeführt — Bezeichner werden gegen ein strenges Muster geprüft, Typen gegen einen geschlossenen Katalog aufgelöst, und das DDL setzt ausschließlich das System zusammen.

"tables": ["acme_tcp_log"],
"tableSchemas": [
{
"table": "acme_tcp_log",
"columns": [
{ "name": "telegram", "type": "text", "required": true },
{ "name": "received_at", "type": "timestamptz" }
]
}
]

Die Regeln:

  • Der Tabellenname muss mit Ihrer normalisierten Id beginnen (. und - werden zu _): aus acme.tcp wird der Präfix acme_tcp.
  • Spaltentypen: text, integer, numeric, timestamptz, boolean. Mehr gibt es nicht.
  • id, tenant_id und created_at legt die Plattform selbst an. Sie zu deklarieren ist ein Fehler.
  • tables muss genau die Namen enthalten, die tableSchemas beschreibt.
  • Ein hochgeladenes Paket mit tables, aber ohne tableSchemas, wird abgelehnt — es könnte Tabellen sonst nie bauen und beim Entfernen Tabellen aufräumen wollen, die nie entstanden sind.

Row Level Security und die Mandantenspalte werden beim Anlegen erzwungen, unabhängig davon, was Sie deklarieren. Ein Plugin kann sich davon nicht abmelden.

system ist für Ihr Paket immer false. Ein hochgeladenes Manifest mit system: true wird ganz abgelehnt, nicht stillschweigend zurückgestuft.

requiresLicense: true hängt Ihr Plugin an die Lizenzprüfung des Betreibers. Ohne Berechtigung lädt es nicht (plugin ... is not entitled). Freie Plugins setzen false.

Ein Konnektor deklariert sein eigenes Formular, in beiden Sprachen, und der Host zeichnet es, ohne Ihr Protokoll zu kennen. Die Id steht nicht in meta — sie ist die Contribution-Id, und die ist bauartbedingt schon Ihre.

import { connectorContribution, type ConnectorType } from '@prodlog/sdk';
const tcp: ConnectorType = {
id: 'acme.tcp',
label: { de: 'TCP', en: 'TCP' },
connectionFields: [
{ name: 'host', label: { de: 'Host', en: 'Host' }, type: 'text', required: true },
{ name: 'port', label: { de: 'Port', en: 'Port' }, type: 'number', required: true, default: 5000 }
],
tagFields: [
{
name: 'channel',
label: { de: 'Kanal', en: 'Channel' },
type: 'select',
required: true,
options: [{ value: 'data', label: { de: 'Data', en: 'Data' } }]
}
],
canWrite: true
};
// direkt nach `provides` hinein
const contribution = connectorContribution(tcp);

Feldarten: text, password, number, boolean, select. Ein Feld kann über showWhen an den Wert eines anderen gekoppelt werden — ein verstecktes Feld ist nicht pflichtig und wird nicht gespeichert, denn ein unsichtbares Bedienelement darf das Speichern nicht blockieren.

Der Preis dieser Bauart, ehrlich: der Typ kann nicht berechnet werden. Es gibt keine Feldliste, die sich der Konnektor beim Gerät abholt. Dafür lässt sich das Formular zeichnen, während der Prozess kaputt ist — genau die Maske, mit der ein Betreiber es repariert.