# 02 — Die Extension-Schnittstelle Der Kern kennt keine Extension. Er kennt **Fähigkeiten**. Eine Extension meldet an, welche davon sie erfüllt, und der Kern benutzt sie generisch. Dieses Dokument ist die Spezifikation. Der zugehörige, importierbare Vertrag liegt in `sdk/python/nodvard_sdk/`. --- ## 1. Anatomie einer Extension ``` extensions/shield/ ├─ extension.toml ← Manifest, ohne Code-Import lesbar ├─ settings.schema.json ← Einstellungen als JSON-Schema ├─ root-helper/ ← Hilfsskript für Befehle als root ├─ migrations/ ← eigener Alembic-Branch ├─ src/nodvard_deck_ext_shield/ │ ├─ __init__.py ← enthält class Extension(NodvardExtension) │ ├─ defender_api.py ← APIRouter, Zeitpläne und ActionExecutor des Virenschutzes │ ├─ ollama.py ← Connector-Typ und AIProvider für Ollama │ ├─ incident_queue.py ← Vorfall-Warteschlange, übersteht einen Neustart │ └─ models.py ← eigene Tabellen (Präfix ext_shield_; ältere: ext_nexus_soc_) └─ frontend/ ├─ build.mjs ← baut src/SocPage.tsx zum ESM-Bundle dist/index.js └─ src/SocPage.tsx ← die Seite, benannter Export des Bundles ``` Eine Extension, die als eigenes Paket installiert wird, hat zusätzlich eine `pyproject.toml`. ### Manifest (`extension.toml`) Das Beispiel ist an das Manifest von Nodvard Shield angelehnt (`extensions/shield/extension.toml`). `requires`, `enable_on` und `stage` setzt Shield selbst nicht; sie zeigen nur die Schreibweise. ```toml [extension] id = "shield" # stabil, kleinschreibung, Namensraum für alles legacy_ids = ["nexus-soc"] # optional: frühere Kennungen, siehe „Erweiterung umbenennen“ name = "Nodvard Shield" version = "0.1.0" api_version = "0.1.0" # SemVer gegen nodvard_sdk.API_VERSION author = "…" description = "Virenschutz für alle Server, dazu die KI-Container-Wache." icon = "shield-alert" entrypoint = "nodvard_deck_ext_shield:Extension" frontend = "frontend/dist/index.js" # optional requires = ["terminal>=0.1"] # andere Extensions, optional enable_on = ["host_credential"] # optional: siehe unten category = "security" # optional: Gruppe in der Modul-Auswahl des Einrichtungsassistenten sort_order = 10 # optional: Reihenfolge in der Gruppe (Standard 100) stage = "beta" # optional: Reifegrad, "stable" (Standard) oder "beta" # Was die Extension vom Kern verlangt. Der Admin bestätigt das bei der Installation. # Der Kern erzwingt es bei JEDEM ctx-Aufruf. permissions = [ "hosts.read", "hosts.execute", # Befehle auf Hosts ausführen (nur über das Gate) "secrets.read:nexus-soc-*", "audit.write", "notify.send", "schedule.register", "net.outbound", ] [settings] # JSON-Schema; der Kern rendert daraus die Settings-Seite schema = "settings.schema.json" ``` **Paketname (`entrypoint`):** Die Python-Pakete der mitgelieferten Erweiterungen heißen `nodvard_deck_ext_` (Bindestriche der ID werden zu `_`, aus `hello-world` wird `nodvard_deck_ext_hello_world`) und liegen unter `extensions//src/`. Das ist die Konvention für neue Erweiterungen, der Kern erzwingt sie nicht: Er importiert genau das Modul, das `entrypoint` nennt, und liest das Manifest bei jedem Start frisch von der Platte. **Erweiterungen von Dritten mit der alten Konvention `lattice_ext_` (oder mit einem ganz anderen Paketnamen) laden deshalb unverändert weiter**; sie müssen nichts umbenennen. Die mitgelieferten Erweiterungen gibt es unter dem alten Namen nicht mehr (auch nicht als Alias): Erweiterungen importieren einander ohnehin nicht (andere Erweiterungen erreicht man nur über `ctx.events` und `requires` + `ctx.capabilities.query()`, siehe „Nicht am Kontext“ weiter unten). Wer doch einmal ein Modul einer mitgelieferten Erweiterung direkt importiert hat, nimmt den neuen Namen. **SDK-Abhängigkeit (pip-installierbare Erweiterungen):** Die SDK-Distribution heißt jetzt `nodvard-sdk` (Importname `nodvard_sdk`), die Entry-Point-Gruppe `nodvard_deck.extensions`. Den alten Importnamen `lattice_sdk` (samt `LatticeExtension` und `LatticeError`), das alte Kern-Paket `lattice` und die alte Gruppe `lattice.extensions` gibt es seit 0.12 nicht mehr: Eine Erweiterung, die sie noch nutzt, wird nicht geladen, und die Erweiterungsliste nennt den Grund und den neuen Namen. Wer in seiner `pyproject.toml` `lattice-sdk` als Abhängigkeit einträgt, stellt sie auf `nodvard-sdk` um (oder lässt sie weg: das SDK bringt der Kern ohnehin mit). **`category` und `sort_order` (optional):** Der Einrichtungsassistent zeigt die installierten Module als Kacheln, gruppiert nach `category` (bekannt: `servers`, `security`, `tools`, `connections`, `example`; alles andere und „fehlt“ landet unter „Weitere Module“) und innerhalb der Gruppe nach `sort_order` (kleiner = weiter oben, bei Gleichstand nach Name). Die Werte stehen auch in `GET /extensions`. Module mit `example` (Beispiele für Entwickler wie hello-world) zeigt der Assistent nicht; sie stehen nur unter Einstellungen → Erweiterungen. **`stage` (optional):** Reifegrad der Erweiterung, `stable` (Standard, wenn das Feld fehlt) oder `beta`. Bei `beta` zeigt die Oberfläche ein ruhiges Abzeichen „Beta“ (Einstellungen → Erweiterungen, Assistent, Menü und über der Seite der Erweiterung) mit dem Hinweis, dass das Modul funktioniert, aber noch nicht so gründlich erprobt ist wie der Rest. Die Erweiterung selbst muss dafür nichts tun. Ein unbekannter Wert (Tippfehler oder ein Reifegrad, den erst ein neuerer Kern kennt) gilt als `beta`; die Erweiterung lädt trotzdem. **`enable_on` (optional):** Anlässe, bei denen der Kern die Erweiterung von selbst einschaltet, damit sie für Einsteiger nicht erst in den Einstellungen gesucht werden muss. Bekannt ist `host_credential` (ein Server hat einen SSH-Zugang bekommen; so schaltet sich Terminal ein). Der Kern kennt dafür keine Erweiterung beim Namen, er liest nur diese Kennzeichnung. Eingeschaltet wird **nur, wenn niemand die Erweiterung je bewusst ein- oder ausgeschaltet hat** – ein Nutzer, der sie ausgeschaltet hat, bleibt unangetastet. Das Protokoll hält `extension.auto_enabled` fest. Bei Installationen, die es schon vor dieser Funktion gab, bleiben bereits angelegte Erweiterungen unberührt; nur neu hinzugekommene zählen als „unberührt“. **Gesperrte Kennungen:** `core` gehört dem Kern, er trägt sie als `ext_id` der Aktionen, die jemand in der Oberfläche startet. Steht sie als `id`, in `legacy_ids` oder in `adopts.from`, lädt die Erweiterung nicht (Liste: `RESERVED_EXTENSION_IDS` im SDK): Sonst läse sie diese Aktionen samt Befehl über `ctx.actions.list()` als eigene. **Warum das Manifest eine Datei und keine Python-Konstante ist:** Der Kern muss Manifeste aller — auch der deaktivierten und der kaputten — Extensions lesen können, ohne deren Code zu importieren. Ein Import ist Codeausführung; eine defekte oder abgeschaltete Extension darf nicht laufen, nur weil sie in der Registry angezeigt wird. ### Erweiterung umbenennen: `legacy_ids` Die Kennung (`id`) ist der Namensraum für alles. Soll eine Erweiterung trotzdem eine neue Kennung bekommen, trägt sie die frühere in `legacy_ids` ein (so ist Nodvard Shield seit 0.7 von `nexus-soc` zu `shield` umgezogen): ```toml [extension] id = "neuer-name" legacy_ids = ["alter-name"] ``` Nach mehreren Umbenennungen stehen alle früheren Kennungen darin, die jüngste zuerst (`["mittlerer-name", "alter-name"]`). Ohne `legacy_ids` (der Normalfall) ändert sich nichts. Nur wenn unter der eigenen `id` noch Daten einer anderen Erweiterung liegen, wartet die neue auf die Erlaubnis eines Admins (siehe „Konflikte“ unten). **Grundidee: Die Kennung wechselt, die gespeicherten Daten nicht.** Nichts wird kopiert, umbenannt oder migriert. Ein Rückweg aufs alte Image findet seine Daten unverändert vor. **Regeln im Manifest:** Jede alte Kennung ist eine gültige Kennung (`a-z`, `0-9`, `-`), keine kommt doppelt vor, und die eigene `id` steht nicht darin; gesperrte Kennungen wie `core` sind ebenfalls nicht erlaubt. Sonst lädt die Erweiterung nicht. **Speicher-Kennung.** Der Kern sucht die Registry-Zeile so: 1. Gibt es eine Zeile mit der neuen Kennung, gilt sie. Gehört sie einer anderen Erweiterung, wartet die neue erst auf die Erlaubnis eines Admins (siehe „Konflikte“ unten). 2. Sonst gilt die erste vorhandene Zeile in der Reihenfolge von `legacy_ids`. Das Protokoll meldet „Erweiterung 'neuer-name' nutzt den gespeicherten Stand von 'alter-name'.“ 3. Gibt es keine, entsteht wie bei jeder neuen Erweiterung eine Zeile mit der neuen Kennung. Die `id` dieser Zeile ist die **Speicher-Kennung**. Daran hängt alles, was dauerhaft gespeichert ist und nach einem Rückweg gelesen wird: - Einstellungen, Zustand und Rechte (die Registry-Zeile selbst), - die Merker `extension.untouched.` und `extension.test.`, - die Zeitpläne (`jobs.ext_id` und damit der Laufverlauf); es entstehen keine doppelten Jobs, - der Datenordner (siehe unten). Alles andere läuft unter der **neuen** Kennung: Adressen `/api/v1/ext//…`, Seiten, Kacheln, Fähigkeiten, neue Aktionen, Meldungen, Protokolleinträge, Logger und Live-Kanäle. **Was mit dem alten Namen weiter gilt:** - **Adressen:** Der Kern hängt die Routen zusätzlich unter jeder alten Kennung ein (`/api/v1/ext//…`, gleiche Anmeldeprüfung, im OpenAPI-Schema als veraltet markiert, in allen 1.x). Die Kern-Adressen `/api/v1/extensions//…` und das Bundle lösen die alte Kennung auf. Gespeicherte Links, offene Tabs mit altem Bundle und die App laufen so ohne Änderung weiter. `request.url_for` baut immer die neue Adresse. Neuer Code nutzt nur die neue Kennung; die alten nennt die API in `legacy_ids` bzw. `legacy_ext_ids` ([04 §7](04-API.md#7-kompatibilität)). - **Oberfläche:** Die Web-Oberfläche folgt der Umbenennung selbst. `/ext//` und `/settings/extensions/` leiten ersetzend auf die neue Kennung weiter (Abfrage und Anker bleiben). Dashboard-Kacheln behalten Platz, Größe und Sichtbarkeit. Meldungen, Protokoll und Aktionen zeigen den Namen der Erweiterung, auch zu Einträgen unter der alten Kennung. - **Tabellen:** Erlaubt sind die Präfixe aller Kennungen (`ext_neuer_name_*`, `ext_alter_name_*`). Bestehende Tabellen behalten ihren Namen, neue bekommen den neuen Präfix. Die Tabellen gehören allen Kennungen gemeinsam. - **Migrationen:** Ausgelieferte Revisionen, ihr Zweigname und ihre Dateinamen bleiben unverändert, nur der Ordner zieht mit um. - **Aktionen:** `ctx.actions.list()`, `result()` und `proposer_labels()` sehen auch Vorschläge unter einer alten Kennung. Aktionsarten ändern sich nicht; ausgeführt wird über die Art. - **Live-Nachrichten:** `ctx.ws.broadcast` sendet zusätzlich auf `ext..`, damit offene Seiten mit altem Stand weiter Nachrichten bekommen. - **`requires`:** Nennt eine andere Erweiterung noch die alte Kennung, sieht sie die Fähigkeiten der umbenannten weiter. - **Datenordner:** der erste nicht leere Ordner unter `data/ext/` in der Reihenfolge Speicher-Kennung, neue Kennung, alte Kennungen. Ein leerer Ordner, den etwa ein früherer Start angelegt hat, verdeckt also keinen gefüllten. Sind alle leer, gilt der erste vorhandene; gibt es keinen, wird `data/ext/` angelegt. Ein Ordner, der sich nicht lesen lässt, zählt als gefüllt. Verschoben wird nichts. - **Sicherungen:** Erweiterungen werden auch unter ihren alten Kennungen erkannt. Eine Sicherung mit der alten Zeile ergibt beim Einspielen keine Warnung „gibt es hier nicht“. - **Geheimnisse:** Gefunden werden sie über ihr Label. Labels und Rechte-Muster wie `secrets.read:alter-name-*` bleiben stehen. **Konflikte:** - **Alte Erweiterung noch installiert:** Liegt noch ein Ordner mit der alten Kennung da (auch mit kaputtem Manifest) oder ist die alte Erweiterung als Paket mit lesbarem Manifest installiert, wird die neue nicht geladen. Im Protokoll steht „Erweiterung '…' wird nicht geladen: Die alte Kennung „…“ gehört noch zu einer installierten Erweiterung …“. Die alte lädt weiter, solange ihr Manifest lesbar ist. Lösung: alten Ordner bzw. altes Paket entfernen. - **Doppelte Migrationen:** Liegen dieselben Migrationen in zwei Erweiterungsordnern (etwa nach dem Auspacken über einen alten Stand), bricht der Start mit „Die Migrationen der Erweiterungsordner „…“ und „…“ sind doppelt …“ ab. Die Meldung steht auch auf der Notseite. Vor dem Auspacken über einen alten Stand deshalb `extensions/` leeren. - **Gemeinsame alte Kennung:** Nennen zwei Erweiterungen dieselbe alte Kennung, werden beide nicht geladen. - **Daten einer anderen Erweiterung:** Liegen unter einer der Kennungen der neuen Erweiterung (ihrer `id` oder einer aus `legacy_ids`) schon Daten einer anderen, übernimmt die neue sie erst, wenn ein Admin (`extensions.manage`) es erlaubt. - „Daten einer anderen“ heißt: eine Registry-Zeile, die die Kennung als `id`, im gespeicherten Manifest als `id` oder in dessen `legacy_ids` trägt und einer anderen Erweiterung gehört (andere `id` im gespeicherten Manifest, oder ein Paket will ein mitgeliefertes Modul ersetzen). Bei einer früheren Kennung zählen auch Aktionen und Tresor-Einträge unter ihr, für die es keine Zeile mehr gibt. - Bis dahin wird sie nicht geladen. Im Protokoll steht „Unter „…“ liegen noch gespeicherte Daten einer anderen Erweiterung. Die neue übernimmt sie erst, wenn ein Admin das unter Einstellungen → Erweiterungen erlaubt.“ - Erlaubt wird auf der Karte der bisherigen Erweiterung („Übernahme erlauben“, `POST /api/v1/extensions//takeover`), im Protokoll steht dann `extension.takeover_allowed`. Die Übernahme gilt ab dem nächsten Start; bis dahin zeigt die Karte „Übernahme erlaubt“, und keine der beiden läuft. - Die Erlaubnis gilt für Kennung, Quelle (Paket oder Ordner) und die Rechte, die die neue beim Erlauben hatte, nicht für ein bestimmtes Paket. Will sie später mehr Rechte oder kommt sie aus einer anderen Quelle, fragt Deck neu. Fehlt sie bei einem Start, verfällt die Erlaubnis. - Will die neue mehr Rechte, als die bisherige hatte, schaltet das Erlauben deren Zeile aus, und kein Anlass aus `enable_on` schaltet sie selbst ein; eingeschaltet wird die neue dann bewusst, wie bei `adopts`. - Ein Rückweg auf die frühere Erweiterung nach einer Übernahme fragt ebenfalls, denn die Zeile trägt dann das Manifest der neuen. - Ohne Rückfrage gilt eine Umbenennung, wenn unter der Kennung nichts liegt, oder wenn die neue Erweiterung mitgeliefert ist und alle betroffenen Zeilen ebenfalls (so wie `docker` mit `service-matrix`). Eine Zeile, die schon das Manifest der neuen trägt, ist ihre eigene; was vor diesem Stand übernommen wurde, bleibt übernommen. - Grund: Sonst bekäme ein nachinstalliertes Paket mit der Kennung eines entfernten Moduls dessen Zeile samt Zustand, Einstellungen, Aktionen und Tresor-Einträgen. War das Modul eingeschaltet, liefe das Paket beim nächsten Start mit allen Rechten aus seinem eigenen Manifest, ohne dass es jemand eingeschaltet hat. - Eine wartende Erweiterung zählt nicht als Anspruch: Ein Paket mit einer früheren Kennung eines mitgelieferten Moduls (etwa `service-matrix`) verdrängt dieses nicht. Wollen zwei Pakete dieselben Daten, warten beide. - Nicht erkannt werden Daten unter einer Kennung, zu der es weder eine Zeile noch Aktionen oder Tresor-Einträge gibt (etwa nur ein Datenordner); das kommt nur vor, wenn jemand Zeilen von Hand löscht. **Verwaister Zwilling.** Nach „Neuinstallation mit neuer Kennung → Rückweg aufs alte Image → wieder neu“ oder bei mehreren alten Zeilen gilt nur eine Zeile (Reihenfolge wie oben). Jede weitere bleibt unverändert liegen und wird nie geladen, nie automatisch eingeschaltet, nie geändert und nicht in `GET /extensions` gelistet. Ändernde Aufrufe über die alte Kern-Adresse (`/api/v1/extensions//…`) antworten dann mit `409`, lesen geht weiter. Das Protokoll meldet: „Erweiterung 'neuer-name' nutzt die Zeile '…'; die Zeile der alten Kennung '…' bleibt unverändert liegen und wird nicht geladen.“ Was in der Rückweg-Zeit unter der alten Kennung eingestellt wurde (Einstellungen, Zeitpläne), kommt nicht mit. **Grenzen:** - **Server-Anbieter:** Eine Erweiterung, die Server anlegt (`hosts.write`, `ctx.hosts.upsert_discovered()`), kann noch nicht umbenannt werden: Die Server und ihre Markierungen tragen die Kennung des Anbieters (`provider_ext_id`, `managed_by_ext_id`). Der Kern lehnt `legacy_ids` zusammen mit `hosts.write` beim Entdecken ab („Erweiterungen, die Server anlegen, können noch nicht umbenannt werden (legacy_ids zusammen mit hosts.write).“); die Erweiterung wird nicht geladen, der Grund steht im Protokoll und in der Zeile der Erweiterung. Alle anderen Berechtigungen gehen. - `GET /jobs?ext_id=` filtert nach der Speicher-Kennung, `GET /capabilities` nennt nur die neue Kennung. ### Teil eines anderen Moduls übernehmen: `adopts` Zieht ein Teil von einem Modul in ein eigenes (so ist Pi-hole aus „Netzwerk“ zum Modul `pihole` geworden), nennt das neue Modul die Quelle in `[[extension.adopts]]`: ```toml [[extension.adopts]] from = "network" # Kennung des Moduls, das den Teil bisher hatte settings = "pihole" # dieser Abschnitt seiner Einstellungen wird zu den eigenen widgets = { pihole = "status" } # Kacheln im Cockpit: alte Kennung = eigene routes = { "/pihole" = "/summary" } # alte Adresse unter /api/v1/ext/network = eigene Route secrets = ["network-pihole-password"] # Tresor-Einträge, die zu eigenen werden ``` Außer `from` ist alles optional; ein unbekannter Schlüssel ist ein Fehler im Manifest. **Wann übernommen wird:** einmal, beim allerersten Entdecken des neuen Moduls, und nur aus einer Zeile, die es schon vor diesem Start gab. Bei einer frischen Installation sind beide Module einfach neu. Die Zeile der Quelle bleibt unverändert, ein Rückweg aufs alte Image findet sie, wie sie war. Das Protokoll hält `extension.adopted` fest (`from`, `settings`, `enabled`, `widgets`, `secrets`, `extra_permissions`). - **Einstellungen:** der Abschnitt `settings` der Quelle, als Kopie. - **Ein/Aus:** eingeschaltet, wenn die Quelle an war, der Abschnitt wirklich ausgefüllt ist (nicht nur Vorgaben) **und** die Quelle schon alle Rechte des neuen Moduls hatte (ihre `granted_permissions`). Sonst bleibt es aus, bis es jemand bewusst einschaltet; fehlende Rechte stehen in `extra_permissions` und im Log. Das Ein/Aus gilt in beiden Fällen als gewählt, `enable_on` schaltet das Modul also nicht von selbst ein. - **Kacheln:** in jedem Cockpit an derselben Stelle, mit Größe und Sichtbarkeit. Steht die neue Kachel schon da, kommt keine zweite dazu. - **Tresor:** Einträge, die der Quelle gehören und die das neue Modul über `secrets.read:` lesen darf, gehören danach dem neuen Modul. - **Alte Adressen (`routes`):** Solange das neue Modul an ist, antworten `/api/v1/ext//` wie die eigene Route (dieselbe Anmeldeprüfung, dieselben Rechte, im OpenAPI-Schema als veraltet markiert). Bedient die geladene Quelle eine alte Adresse noch selbst, bleibt sie bei der Quelle (Log: „… bedient … selbst, keine Weiterleitung.“). Eine alte Adresse ohne passende eigene Route meldet das Log beim Einschalten. **Nur unter mitgelieferten Modulen:** Übernommen wird nur, wenn das neue Modul **und** die Quelle mitgeliefert sind (`source = "bundled"`, aus dem Ordner `extensions/`). Ein nachinstalliertes Paket (pip) mit `adopts` übernimmt nichts und hängt sich unter keine fremde Adresse. Im Log steht dann „Erweiterung '…' (pip) übernimmt nichts von '…': nur mitgelieferte Module übernehmen voneinander.“ **Jede Kennung nur einmal:** Gibt es eine Kennung doppelt (etwa ein Paket mit der Kennung eines mitgelieferten Moduls), gilt der erste Fund, und der Ordner `extensions/` wird vor den Paketen durchsucht. Jeder weitere Fund wird nicht geladen, im Log steht „Erweiterung '…' (pip, …) wird nicht geladen: Die Kennung gibt es schon (bundled, …).“ So kann ein Paket ein mitgeliefertes Modul nicht samt Einstellungen, Rechten und Übernahme ersetzen. In `adopts.routes` sind `.` und `..` als Abschnitt nicht erlaubt. Die alten Adressen bleiben in allen 1.x erhalten (als veraltet markiert) und fallen frühestens mit 2.0 weg ([04 §7](04-API.md#7-kompatibilität)). ### Einstellungs-Schema: `settings.schema.json` und die `x-*`-Zusätze Der Kern baut aus dem JSON-Schema die Einstellungsseite der Extension (Einstellungen → Erweiterungen). Unterstützt sind `type` (`string`, `integer`, `number`, `boolean`, `array`, `object`), `title`, `description`, `default`, `enum`, `required`, `properties`, `items`, `pattern` und `maxItems` (Listen; mehr Einträge lehnt das Speichern mit `422` ab). Dazu diese Zusätze (alle optional, unbekannte werden ignoriert): | Zusatz | Wo | Wirkung | |---|---|---| | `x-advanced` | Feld | Steht unter „Erweitert“, eingeklappt. | | `x-hidden` | Feld | Wird nicht angezeigt (interner Zustand). Auf der obersten Ebene übernimmt `PUT /extensions/{id}/settings` das Feld nie; es gehört den eigenen Routen der Extension (`ctx.settings.set()`). | | `x-item-title` | Liste von Objekten | Wie ein Eintrag heißt („Server hinzufügen“). | | `x-enum-labels` | Feld mit `enum` | Lesbare Texte je Wert: `{"all": "Alle Updates"}`. | | `x-warning` | Feld | Gelber Hinweis unter der Überschrift, gleich beim Eintragen, etwa wenn das Feld einen Schutz abschwächt. Kurz: Grund und was man tun sollte. | | `x-widget: "schedule"` | Text (Cron) | Zeitplan-Wähler statt Cron-Feld. `PUT /extensions/{id}/settings` prüft den Ausdruck wie beim Anmelden des Jobs und lehnt einen ungültigen mit `422` ab; leer bleibt erlaubt (dann gilt der Standard der Erweiterung). | | `x-widget: "host"` | Text oder Liste von Texten | Auswahl aus den Servern (Wert = Servername, aus `GET /hosts`). Liste: Chips mit „Server hinzufügen“. Ohne Serverliste: Textfeld bzw. Wortliste. | | `x-widget: "host-tag"` | Text oder Liste von Texten | Wie `host`, aber Auswahl aus den Markierungen (Tags) der Server, mit Anzahl. Eine gespeicherte Markierung, die es nicht mehr gibt, bleibt sichtbar. | | `x-widget: "remote-select"` | Text | Auswahlliste, die die Extension selbst liefert (z. B. die Modelle eines KI-Servers). Braucht `x-options-url`. Schlägt die Abfrage fehl oder ist die Liste leer, erscheint ein Textfeld und der Grund. | | `x-options-url` | `remote-select` | Pfad unter `/api/v1`, z. B. `/ext/shield/ai/models?which=primary`. Die Oberfläche schickt keine Formularwerte mit; die Extension fragt nur, was gespeichert ist (kein Adress-Parameter – sonst wäre die Route ein Weg für Anfragen an beliebige Adressen, samt Schlüssel). Nach einer neuen Adresse: speichern, dann „Neu laden“. Antwort: `{"options": [{"value": "…", "label": "…"}], "error": null}`; bei Problemen `options: []` und `error` als deutscher Satz (kein Fehlerstatus). | | `x-empty-label` | Auswahl | Text für „nichts gewählt“ (Standard „– nicht gesetzt –“), z. B. „alle Server“. | | `x-generate` | Text | Knopf neben dem Feld (Beschriftung `label`, Standard „Zufällig wählen“), der `prefix` plus `length` zufällige Zeichen aus `a-z0-9` einsetzt: `{"label": "Zufällig wählen", "prefix": "nodvard-", "length": 20}`. Der Zufall kommt von `crypto.getRandomValues` des Browsers. Das Ergebnis muss zum `pattern` des Felds passen (und in `maxLength`), sonst setzt die Oberfläche nichts ein; ein Test prüft das für die mitgelieferten Erweiterungen. | | `x-qr-url` | Kopfebene | Pfad unter `/api/v1`, z. B. `/ext/ntfy/subscribe-link`: Unter dem Formular erscheint eine Karte mit QR-Code und dem Link als Text. Antwort: `{"url": "…", "text": "…", "error": null}` (`url` steckt im Code, `text` sagt, was man damit tut); lässt sich kein Link berechnen, ist `url` leer und `error` ein deutscher Satz (kein Fehlerstatus). Wie bei `x-options-url` ohne Formularwerte: Die Extension rechnet aus den gespeicherten Einstellungen, die Oberfläche lädt nach jedem Speichern neu. Der Link darf kein Geheimnis enthalten. | | `pattern` | Text, Wörter einer Liste | Regulärer Ausdruck (wie JSON-Schema, nicht verankert). Die Oberfläche zeigt die Meldung am Feld und sperrt „Speichern“; das Backend prüft dasselbe (`422`). Leere Werte sind immer erlaubt. | | `x-pattern-message` | neben `pattern` | Deutsche Meldung, wenn das Muster nicht passt (Standard: „Das Format stimmt nicht.“). | | `minimum`, `maximum` | Zahl (`integer`, `number`) | Kleinster und größter erlaubter Wert, beide eingeschlossen. Die Oberfläche begrenzt das Feld und zeigt am Feld „Erlaubt sind Werte von 2 bis 59.“; `PUT /extensions/{id}/settings` prüft dasselbe (`422`). `NaN` und unendlich lehnt das Backend immer ab. Leer bleibt erlaubt (dann gilt `default`). Ein Wert, der vor der Grenze gespeichert wurde, bleibt stehen: Die Erweiterung sollte ihn beim Lesen selbst auf die Grenzen setzen. | | `enum` mit Zahlen | Zahl (`integer`, `number`) | Auswahlliste, die eine **Zahl** speichert, keinen Text; `x-enum-labels` nennt die Werte lesbar (`{"10": "alle 10 Minuten"}`). Ein gespeicherter Wert, den die Liste nicht mehr anbietet, bleibt sichtbar („7 (nicht mehr wählbar)“); das Feld meldet es und sperrt „Speichern“. Das Backend lehnt so einen Wert mit `422` ab und nennt dabei den **Titel** des Felds statt seines Pfads. Für Abstände in Minuten, die als Zeitplan `*/N` laufen, nur Teiler von 60 anbieten (z. B. 5, 6, 10, 12, 15, 20, 30): `*/7` liefe um :49, um :56 und gleich wieder um :00. Ein früher gespeicherter Wert läuft mit dem nächstkleineren Teiler (7 → 6, 25 → 20); das setzt die Erweiterung beim Lesen selbst (Nodvard Shield `steps.minute_step`, System `warn_interval_min`). | | `x-test-message` | Kopfebene | `true`, wenn die Extension einen `NotificationChannel` bereitstellt: die Verbindungskarte zeigt „Testnachricht senden“. | | `x-required-any` | Kopfebene | „Eines von beidem“: Liste von Feldgruppen, z. B. `[["server_url", "topic"], ["channels"]]`. Ist keine Gruppe ganz ausgefüllt (bei Listen: mindestens ein Eintrag), meldet `GET /extensions` `needs_setup` mit einem Grund, der alle Gruppen nennt. Für Pflichtfelder, die nur in einem von mehreren Wegen nötig sind (`required` verlangte sie immer, auch beim Speichern). | | `x-secrets` | Kopfebene | Liste der Geheimnisse (Tresor-Labels): `{"label": "proxmox-token:{name}", "title": "…", "description": "…", "per_item": "connections", "optional": true}`. `per_item` erzeugt je Eintrag der genannten Liste ein Geheimnis (über dessen `name`); fällt ein Eintrag beim Speichern über `PUT /extensions/{id}/settings` weg oder wird umbenannt, löscht der Kern sein Geheimnis, und ein neu angelegter Eintrag startet ohne (ein altes Geheimnis unter demselben Namen wird gelöscht). `optional: true`: die Extension arbeitet auch ohne; fehlt ein nicht-optionales Geheimnis oder ein `required`-Feld, meldet `GET /extensions` `needs_setup`. | | `x-secret-bound-to` | Eintrag in `x-secrets` | Felder, an die das Geheimnis gebunden ist: bei `per_item` Feldnamen des Eintrags (`["base_url"]`), sonst Pfade wie `pihole.url`. Ändert sich eines davon über `PUT /extensions/{id}/settings`, löscht der Kern das Geheimnis (Antwort `secrets_cleared`, Protokoll `detail.secrets_cleared`), auch bei der ersten Adresse (leer → Wert); ohne gespeicherten Wert zählt der `default` des Felds. Keine Änderung sind andere Schreibweisen derselben Adresse: Groß-/Kleinschreibung von Schema und Rechnername, Standardport (`:80` bei http, `:443` bei https), `/` am Ende, Fragment (`#…`), fehlendes Schema (= `http://`). Ein anderer Pfad oder eine andere Abfrage (`?…`) zählt als Änderung. `PUT /extensions/{id}/secrets` nimmt das Geheimnis erst an, wenn eines der Felder gespeichert einen Wert oder `default` hat, sonst `409` („Erst die Adresse eintragen und die Einstellungen speichern, danach die Zugangsdaten hinterlegen.“). | **Eigene Routen für Verbindungen.** Speichert eine Extension Einstellungen selbst (`ctx.settings.set()`, etwa über eigene `/connections`-Routen), löscht der Kern keine Geheimnisse. Dann ruft sie `ctx.secrets.delete(label)` selbst auf, wenn eine Verbindung neu angelegt oder entfernt wird oder ihre Adresse wechselt. Für den Vergleich gibt es `nodvard_sdk.same_target(alt, neu)` (`True` = dasselbe Ziel, nach denselben Regeln wie bei `x-secret-bound-to`; die Oberfläche rechnet dasselbe in `frontend/src/lib/targetAddress.ts`). So machen es Proxmox VE und Proxmox-Backups. **Verbindung testen.** Jede Extension mit Einstellungen bekommt in der Oberfläche den Knopf „Verbindung testen“ (`POST /extensions/{id}/test`). Der Kern ruft dafür `health()` der Extension auf (und `test()` ihres `NotificationChannel`, falls sie einen bereitstellt) und übersetzt technische Fehler in verständliche deutsche Sätze (keine Antwort, Zugangsdaten abgelehnt, Zertifikat, Adresse nicht gefunden, Weiterleitung (3xx) …). Für gute Meldungen genügt es, in `HealthReport.message` den Fehlertext des HTTP-Clients zu lassen; bei mehreren Verbindungen liefert `HealthReport.details` je Verbindung `{"name": {"healthy": bool, "error": "…"}}` – daraus entsteht die Liste „Ergebnis je Verbindung“. Ein gesunder Eintrag darf `"note": "…"` tragen (ein fertiger Satz, etwa was sich ohne Senden nicht prüfen ließ); er steht dann statt „Verbindung funktioniert.“ in der Liste, und die Zusammenfassung lautet „Keine Fehler gefunden – Hinweise unten.“. Ein Eintrag mit `"verbatim": true` zeigt seinen `error` unverändert (nur geschwärzt), statt ihn zu übersetzen. Kennzeichnet sich eine Ausnahme aus `send()` oder `test()` des `NotificationChannel` mit `readable = True` und `verbatim = True`, zeigt der Kern ihren Satz unverändert (für Sätze, die mehrere Ziele zusammenfassen; die Übersetzung nähme sonst den ersten HTTP-Code darin). Geheimnisse gehören nie in `message` oder `details`; der Kern schwärzt bekannte Werte trotzdem. ### Einstiegsklasse ```python from nodvard_sdk import NodvardExtension, ExtensionContext class Extension(NodvardExtension): async def setup(self, ctx: ExtensionContext) -> None: """Nur registrieren. Kein I/O, keine Netzwerkzugriffe, kein DB-Schreiben.""" async def on_start(self, ctx: ExtensionContext) -> None: """Hintergrundtasks starten. ctx.spawn() statt asyncio.create_task().""" async def on_stop(self, ctx: ExtensionContext) -> None: """Aufräumen. Über ctx.spawn gestartete Tasks werden automatisch beendet.""" async def health(self, ctx: ExtensionContext) -> HealthReport: """Optional. Speist die Registry-UI und das Notification-Center.""" async def on_settings_changed(self, ctx: ExtensionContext, values: dict) -> None: """Optional. Nach jedem ctx.settings.set() -- nach dem Speichern, get() liefert schon die neuen Werte. Ein set() hier drin loest den Hook nicht erneut aus; eine Ausnahme wird geloggt und macht das Speichern nicht rueckgaengig.""" async def on_secret_changed(self, ctx: ExtensionContext, label: str) -> None: """Optional. Ein Geheimnis, das die Extension lesen darf (`secrets.read: