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.
Die kleinste nützliche Form
Abschnitt betitelt „Die kleinste nützliche Form“{ "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": []}Die Felder, Feld für Feld
Abschnitt betitelt „Die Felder, Feld für Feld“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 auchtenant-xoderdata.foo. Nur ein System-Plugin des Herstellers darf dort sitzen. - Eine Id, die mit einer bereits geladenen verschachtelt ist (
foonebenfoo-bar) — die Eigentümerschaft an einer Tabelle wäre sonst nicht entscheidbar.
version und sdk
Abschnitt betitelt „version und sdk“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.
permissions — was Ihr Code darf
Abschnitt betitelt „permissions — was Ihr Code darf“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.writehängt unterdata:write. Wer nurcapability:invokedeklariert, bekommt bei jedem Schreibenpermission_denied— und die Meldung nennt das fehlende Recht nicht.
rbacPermissions — was ein Mensch darf
Abschnitt betitelt „rbacPermissions — was ein Mensch darf“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"]backend
Abschnitt betitelt „backend“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-entryist 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 declarerequires“), für in-process verboten („requiresis 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
Abschnitt betitelt „frontend“"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.
provides — die Contributions
Abschnitt betitelt „provides — die Contributions“Eine Liste. Jeder Eintrag:
{ "category": "route", "id": "acme.tcp", "order": 10, "label": "TCP", "paletteGroup": "pages", "meta": { "path": "/acme.tcp" }}categoryist bewusst frei. Der Host zeichnet ein paar bekannte Kategorien:route,nav,slot,command,settings,ai-tool,connector,pageType,app.idmuss Ihnen gehören: gleich der Plugin-Id oder ein mit-/.abgetrenntes Kind davon.foopasst nicht auffoobar.orderundlabelsteuern die Reihenfolge und Beschriftung; fehlendesorderzählt als 0, Gleichstand wird nachidaufgelöst.paletteGroupist die Rubrik in der Befehlspalette:quick-actions,pagesodersettings. Ohne Angabe landet ein Befehl unterpages.
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" } }}tables und tableSchemas
Abschnitt betitelt „tables und tableSchemas“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_): ausacme.tcpwird der Präfixacme_tcp. - Spaltentypen:
text,integer,numeric,timestamptz,boolean. Mehr gibt es nicht. id,tenant_idundcreated_atlegt die Plattform selbst an. Sie zu deklarieren ist ein Fehler.tablesmuss genau die Namen enthalten, dietableSchemasbeschreibt.- Ein hochgeladenes Paket mit
tables, aber ohnetableSchemas, 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 und requiresLicense
Abschnitt betitelt „system und requiresLicense“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.
Der Konnektor-Typ als Daten
Abschnitt betitelt „Der Konnektor-Typ als Daten“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` hineinconst 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.