Ctrl K
ComunidadGuías…

docs/04-API.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 04 — API: REST + WebSocket

Eine API für Web und Android. Die Web-Oberfläche bekommt **keinen**
Sonderpfad — wenn ein Feature nur im Web geht, ist es ein Fehler in der API, nicht eine
Eigenschaft der App.

Basis: `/api/v1`. Das OpenAPI-Dokument liefert `GET /api/v1/system/openapi.json` an angemeldete
Konten mit dem Recht `system.read` — die Flutter-Modelle werden daraus generiert, nicht von Hand
geschrieben. Ohne Anmeldung gibt es die API-Doku (`/docs`, `/redoc`, `/openapi.json`) nur im
Entwicklungsmodus (`NODVARD_DECK_ENV=dev`) oder mit `NODVARD_DECK_API_DOCS=1`; sonst antworten diese
Adressen mit 404, damit eine Installation nicht jedem Besucher alle Endpunkte samt Feldern zeigt.

---

## 1. Konventionen

| Thema | Regel |
|---|---|
| Auth | `Authorization: Bearer <access_token>` — identisch für Web und App |
| Fehler | JSON `{"detail": …}`: meist ein deutscher Satz, bei `422` aus der Eingabeprüfung eine Liste `[{type, loc, msg}]` |
| Paginierung | Cursor: `?limit=50&cursor=…` → `{items, next_cursor}`. Kein Offset. |
| Filter | explizite Query-Parameter, keine generische Filtersprache |
| Zeit | ISO-8601 mit `Z`. Immer UTC. Lokalzeit ist Client-Sache. |
| IDs | UUIDv7-artige Strings |
| Idempotenz | `Idempotency-Key`-Header auf allen Ausführungs-Endpunkten |
| Korrelation | `X-Request-Id` rein, in Antwort und Audit-Log wieder raus |
| Teilantworten | `?fields=` wird **nicht** unterstützt — Mobilfunk-Sparsamkeit läuft über schmale, zweckgebundene Endpunkte |

Lehnt die Eingabeprüfung eine Anfrage ab (`422`), nennt die Antwort je Feld nur `type`, `loc` und `msg`, nie die
Eingabe selbst. `msg` ist auch bei den häufigen Standardprüfungen ein deutscher Satz (Pflichtangabe fehlt, zu kurz
oder zu lang, Zahl zu klein oder zu groß, falscher Typ bei Zahlen, Text oder ja/nein, Wert nicht erlaubt, kein
gültiges JSON, unbekannte Angabe), etwa „Mindestens 8 Zeichen.“; seltenere Prüfungen (etwa eine Liste statt eines
Objekts) behalten den englischen Text von pydantic. Eigene Prüfungen (`type` `value_error`) liefern ihren eigenen
Satz, der das Feld oft schon nennt („Benutzername: …“). Wirft ein Endpunkt einen SSH-Fehler (Server nicht erreichbar,
Anmeldung abgelehnt, Server-Schlüssel nicht bestätigt) oder `HostUnreachable` (auch aus einer Erweiterung) und fängt
ihn nicht selbst ab, kommt `502` mit dem Grund in `detail` statt `500`, ohne Traceback im Container-Protokoll. Dazu
zählt auch ein Befehl, der über 8000 Byte auf die Befehlszeile eines Linux-Servers müsste (mit eigener Eingabe, als
Live-Strom oder in einer Konsole): Er geht gar nicht erst hinaus, `detail` nennt Länge und Grund („Der Befehl ist mit
… Byte zu lang für eine SSH-Befehlszeile …“). Längere Befehle ohne eigene Eingabe kommen über die Standardeingabe
an. Ein anderer Netzfehler (etwa einer HTTP-Verbindung), den der Endpunkt nicht abfängt, bleibt `500`.

Der Body einer Anfrage ist standardmäßig auf 1 MiB begrenzt (`NODVARD_DECK_MAX_BODY_BYTES`), auch ohne Anmeldung. Ist die
`Content-Length` größer, antwortet der Server mit `413` (dazu `Connection: close`), sobald der Endpunkt den Body liest,
und liest kein Byte davon; ein Endpunkt, der ihn gar nicht liest (etwa ein Upload, dessen Anmeldung oder Berechtigung
schon scheitert), antwortet wie gewohnt. Ohne Längenangabe (`Transfer-Encoding: chunked`) zählt der Server mit und bricht
beim Überschreiten mit `413` ab. `detail` nennt die erlaubte Größe
(„Die Anfrage ist zu groß (erlaubt sind höchstens 1 MB).“). Uploads haben eigene, höhere Grenzen: Sicherung hochladen
4 GiB (`NODVARD_DECK_RESTORE_MAX_UPLOAD_BYTES`), Logo 2 MB, Dokumente (Erweiterung `documents`) 20 MB, Bilder im Inventar
(Erweiterung `inventory`) 5 MB; `POST /files/{source}/upload` ist standardmäßig unbegrenzt
(`NODVARD_DECK_FILES_MAX_UPLOAD_BYTES`, siehe „Dateien“). Erweiterungen erklären eine höhere Grenze mit `max_body_bytes`
aus dem SDK ([02 §2](02-EXTENSION-API.md#routen-und-anmeldung)).

Jede Antwort, auch eine der Notseite, trägt feste Sicherheits-Kopfzeilen, die eine Route nicht lockern kann:
`X-Frame-Options: DENY` und `frame-ancestors 'none'` in der CSP (bei JSON ohne eigene CSP dazu `default-src 'none'`),
`X-Content-Type-Options: nosniff`,
`Referrer-Policy: same-origin`, `Cross-Origin-Opener-Policy: same-origin` (wirkt nur über https oder localhost),
`Permissions-Policy` mit gesperrten Geräten (Kamera, Mikrofon, Standort, Bewegungssensoren, USB, seriell, HID,
Bluetooth, MIDI, Bildschirmaufnahme, Bezahlen; Vollbild und Zwischenablage bleiben bei der Vorgabe des Browsers) und
`Cross-Origin-Resource-Policy: same-origin`. Nur die öffentlichen Symbole (`/branding/logo`, `/branding/favicon`,
`/app/icon-<Größe>.png`) und `/health` tragen `cross-origin`, damit eine Startseite wie Homarr Deck-Symbole zeigen und
Deck anpingen kann.

---

## 2. Ohne Authentifizierung

```
GET  /api/v1/health              → {status, version, uptime_s}
                                   (Notseite statt Anwendung: 503 {"status":"rescue"} – nie "ok", siehe „Kopie vor jeder Migration“)
GET  /api/v1/branding            → Name, Logo, Farben (Login-Seite braucht es)
GET  /api/v1/edition             → welche Ausgabe läuft: {id (`deck` oder `dock`), product_name, service, image,
                                   choose_modules, repo_tools}; festgelegt beim Bauen (`EDITION`, `nodvard_deck/edition.py`)
GET  /api/v1/branding/logo       → das hochgeladene Logo (404, wenn keins da ist). Antwortet mit
                                   `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`
                                   und `X-Content-Type-Options: nosniff`: Wer die Adresse direkt öffnet, bekommt kein
                                   Skript ausgeführt; als `<img>` eingebunden ändert sich nichts.
GET  /api/v1/branding/favicon    → das hochgeladene Favicon (404, wenn keins da ist), mit denselben Kopfzeilen wie
                                   das Logo; `image/png`, `image/svg+xml` oder `image/x-icon`.
GET  /api/v1/branding/custom.css → das eigene CSS der Admins als `text/css` (leer, wenn keins da ist), mit
                                   `Cache-Control: no-cache`, `nosniff` und `ETag`; mit passendem `If-None-Match`
                                   kommt 304 ohne Inhalt. Öffentlich, weil auch die Anmeldeseite es trägt. Ein
                                   gespeicherter Wert, der die Prüfung nicht besteht, wird nicht ausgeliefert (leere
                                   Antwort); geprüft wird je Wert nur einmal.
POST /api/v1/auth/login          → {username, password}
                                   → 200 {access_token, expires_in, refresh_token?, user}
                                   → 202 {mfa_required: true, mfa_token}
                                   → 401 „Ungültiger Benutzername oder Passwort.“, auch bei deaktiviertem Konto
                                   und zu langer Eingabe (Name über 64, Passwort über 1024 Zeichen). An der
                                   Antwortzeit lässt sich nicht erkennen, ob es das Konto gibt oder ob es
                                   deaktiviert ist. Einen unbekannten Namen (eine zu lange Eingabe zählt genauso) nimmt das
                                   Protokoll (`login.failed`, `login.locked`) nicht auf, die Einträge gibt es aber: Akteur ist
                                   `anonymous`/`unbekannt`, dazu nur Kennung und Länge des Namens (siehe `/audit`).
                                   → 503 mit `Retry-After: 5`: gerade laufen zu viele Passwortprüfungen (höchstens
                                   2 zugleich, 16 wartend); zählt nicht als Fehlversuch. Wird das Konto während
                                   der Prüfung deaktiviert oder sein Passwort geändert: 401 wie bei falschem Passwort.
                                   → 429 mit `Retry-After`, geprüft vor dem Passwort und ohne Datenbank: 10
                                   Fehlversuche je Adresse und Name oder 30 je Adresse (IPv6 je /64) in 5 Minuten.
                                   Dazu die Bremse je Name: Ab 10 Fehlversuchen für einen Namen in 24 Stunden, von
                                   allen fremden Adressen zusammen, wartet jeder Versuch aus einem Netz (IPv4 je
                                   Adresse, IPv6 je /64) doppelt so lange wie der vorige aus demselben Netz (2 s,
                                   4 s, …, höchstens 5 Minuten); nach 24 Stunden ohne Versuch beginnt ein Netz von
                                   vorn, eine gelungene Anmeldung setzt es zurück (mit Zwei-Faktor, sobald auch der
                                   Code stimmt). Aus einem Netz, aus dem niemand rät, geht der erste Versuch sofort;
                                   so kann niemand ein fremdes Konto aussperren. Wer das Netz mit dem Angreifer
                                   teilt, wartet dort wie er. Wer für jeden Versuch eine neue Adresse nimmt, wird kaum
                                   aufgehalten. Dafür braucht es kein Botnetz: Ein eigenes IPv6-Präfix, wie es viele
                                   Anbieter vergeben, enthält viele /64-Netze (ein /56 256, ein /48 65 536). Dagegen
                                   helfen ein langes Passwort und Zwei-Faktor. Lokale Netze (127.0.0.0/8, 10.0.0.0/8,
                                   172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, ::1, fc00::/7,
                                   fe80::/10) und Anfragen ohne erkennbare Adresse (Unix-Socket) bremst sie nicht.
                                   Unbekannte Namen werden genauso gebremst; die Namen teilen sich 4096 Plätze.
                                   Beginnt die Bremse, steht `login.locked` im Protokoll (`scopes` enthält `name`), bei
                                   einem bestehenden Konto kommt danach, nach der Antwort, die Meldung „Passwort wird
                                   durchprobiert“. Alle Zähler liegen im Speicher und beginnen nach einem Neustart neu.
                                   Hinter einem Reverse-Proxy ohne Eintrag unter „Vertrauenswürdiger Proxy“ sieht der
                                   Server nur dessen Adresse; ist sie lokal, greift die Bremse nicht, und die Grenzen
                                   je Adresse gelten für alle zusammen.
POST /api/v1/auth/mfa            → {mfa_token, code} → wie 200 oben
                                   `code` = 6-stelliger Authenticator-Code ODER ein Wiederherstellungs-Code
                                   (`ABCDE-FGHJK`, Schreibweise egal); Letzterer wird dabei verbraucht.
                                   Falsche Codes zählen gegen das 5er-Limit je `mfa_token`. Ein `mfa_token` gilt
                                   nur für eine Anmeldung (danach 401), ein Authenticator-Code je Konto nur einmal
                                   (wiederholt: 401 „Dieser Code wurde schon benutzt. …“). Falsche 6-stellige
                                   Codes zählen außerdem je Konto, egal von welcher IP und zusammen mit den
                                   falschen Codes bei Bestätigungen (Passwort ändern, Zwei-Faktor abschalten,
                                   neue Wiederherstellungs-Codes, Sicherung laden und einspielen, Update und Rückweg;
                                   siehe §3 und „System und
                                   Sicherungen“): nach 10 in 15 Min oder 20 in 24 Std 429 mit `Retry-After` (auch
                                   mit richtigem Code), `login.locked` (beginnt die Sperre bei einer Bestätigung:
                                   `auth.password_check_locked`) im
                                   Protokoll und eine Meldung „Zwei-Faktor-Code wird durchprobiert“. Für
                                   Wiederherstellungs-Codes gilt diese Sperre nicht. Mit Wiederherstellungs-Code
                                   auch 503 wie bei `/auth/login`; der Code wird dann nicht verbraucht, der Versuch
                                   zählt nicht.
                                   Die Zählung je Konto übersteht einen Neustart: Beim Start trägt Nodvard Deck die
                                   falschen Codes der letzten 24 Stunden aus dem Protokoll wieder ein
                                   (`mfa.failed`, `auth.totp_check_failed`, ohne `via: recovery_code`; Zeitstempel
                                   bis 24 Stunden in der Zukunft zählen als „jetzt“, spätere gar nicht). Die Sperren
                                   je Adresse und Name und je `mfa_token` beginnen nach einem Neustart neu.
GET  /api/v1/auth/bootstrap      → {needed}: gibt es noch keinen Nutzer? Solange keiner existiert, steht
                                   zusätzlich `restore: {ok: false, message, at}` da, wenn eine Wiederherstellung
                                   aus dem Assistenten NICHT geklappt hat (der Assistent erklärt dann warum).
POST /api/v1/auth/bootstrap      → {username, password, setup_code} → 201 {id, username, is_owner}
                                   Erstinbetriebnahme: legt den Owner an. Der `setup_code` steht im
                                   Container-Protokoll (oder `NODVARD_DECK_SETUP_CODE`). Fehlt/falsch: 403;
                                   zu viele Fehlversuche je IP (IPv6 je /64-Netz): 429; es gibt schon einen Nutzer: 409;
                                   zu viele Passwortprüfungen gleichzeitig: 503 wie bei `/auth/login`.
                                   Nach Erfolg wird der Code gelöscht.
                                   `username` wird getrimmt und klein geschrieben, hat 3 bis 64 Zeichen,
                                   beginnt mit Buchstabe oder Ziffer und enthält nur `a-z`, `0-9`, `.`, `-`
                                   und `_`; `password` 8 bis 255 Zeichen. Sonst 422 mit deutscher Meldung
                                   („Benutzername: Nur Kleinbuchstaben, Ziffern sowie . - und _ erlaubt …“).
PUT/POST/DELETE /api/v1/auth/bootstrap/restore/…, POST /api/v1/auth/bootstrap/restart
                                 → Sicherung einspielen im Assistenten, siehe „Wiederherstellen“ unter §3
                                   „System und Sicherungen“. NUR solange es kein Konto gibt (sonst 409) und
                                   NUR mit dem Einrichtungscode im Kopf `X-Setup-Code` – bei JEDEM Aufruf,
                                   geprüft **vor** dem Lesen des Bodys, gedrosselt wie `POST /auth/bootstrap`
                                   (403 falsch/fehlt, 429 zu oft; gleicher Zähler je IP).
POST /api/v1/auth/refresh        → neuer Access-Token (+ rotierter Refresh-Token; in den 30 s nach
                                   einer Rotation liefert derselbe alte Token nur einen Access-Token,
                                   ohne Cookie und ohne `refresh_token` im Body)
POST /api/v1/auth/logout         → Refresh-Token widerrufen (bei Web: den des Cookies) und das Cookie löschen
GET  /api/v1/capabilities        → was dieser Server kann
```

Routen von Erweiterungen (`/api/v1/ext/<id>/…`) gehören nicht dazu: Sie verlangen eine Anmeldung, außer
eine Erweiterung bietet sie mit der Berechtigung `api.public` ausdrücklich ohne an
([02 §2](02-EXTENSION-API.md#routen-und-anmeldung)).

Web erhält den Refresh-Token als `HttpOnly; Secure; SameSite=Strict`-Cookie und **nicht**
im Body. Android erhält ihn im Body und legt ihn in `flutter_secure_storage`. Der
Access-Token-Pfad ist für beide identisch — das ist der Kern der „eine API"-Zusage.

**Access-Token und Anmeldung.** Jeder Access-Token trägt im Claim `sid` die Anmeldung (Refresh-Token-Zeile), zu der er
gehört. Bei jeder Anfrage prüft der Server, ob diese Anmeldung noch gilt; einer Erneuerung (`replaced_by_id`) folgt er
dabei, ein älterer Token aus einem anderen Tab bleibt also gültig. Ist die Anmeldung beendet (Abmelden, „überall
abmelden“, abgelaufen), gibt es 401, auch wenn der Token selbst noch nicht abgelaufen ist; der Client holt sich wie
gewohnt per `POST /auth/refresh` einen neuen oder meldet sich neu an. Ein Token ohne `sid` (von älteren Versionen)
gilt, solange das Konto irgendeine gültige Anmeldung hat, einer mit `sid` eines anderen Kontos nie.
`POST /auth/logout` beendet die Anmeldung am Ende der Kette, auch mit einem schon erneuerten Refresh-Token, solange
dessen Zeile noch steht: Das nächtliche Aufräumen löscht sie eine Refresh-Laufzeit nach dem Erneuern. Die Prüfung des
Access-Tokens folgt höchstens 50 Erneuerungen (dahinter 401), Abmelden und das Abmelden der anderen Anmeldungen
(Passwortwechsel, 2FA abschalten) höchstens 10 000; ist die Kette noch länger, enden zur Sicherheit alle Anmeldungen
des Kontos, auch die eigene. Die Kette wird jeweils in einer einzigen Datenbankabfrage gelesen.

**Refresh-Cookie.** Das Cookie heißt `nodvard_deck_refresh`, Pfad `/api/v1/auth`, `HttpOnly`, `SameSite=Strict`,
`Secure` über HTTPS. Gesetzt wird es beim Login, beim Abschluss der Zwei-Faktor-Anmeldung und bei jedem Refresh mit neuem
Token; in der Gnadenfrist (siehe oben) gibt es keinen neuen Token, dann kommt **kein** `Set-Cookie`, und es wird auch
nichts gelöscht. Abmelden widerruft den Token des Cookies und löscht es mit denselben Attributen. Ein `refresh_token` im
Body (Android/CLI) gilt allein, das Cookie wird dann nicht gelesen. Das alte Cookie `lattice_refresh` liest und schreibt
Nodvard Deck seit 0.12 nicht mehr; wer bis dahin nur dieses hatte, meldet sich einmal neu an.

`capabilities` listet aktivierte Extensions, verfügbare View-Typen, API-Version und
Feature-Flags. Die Android-App fragt das beim Verbinden ab und blendet aus, was der
Server nicht hat — dadurch funktioniert **eine** APK gegen unterschiedlich bestückte
Installationen: ein generischer Client, eigene Server-URL pro Nutzer.

---

## 3. Kern-Ressourcen

```
GET    /me                          PATCH /me            POST /me/password
                                     → GET /me enthält `totp_enabled`, `recovery_codes_remaining` und
                                       `timezone` (Zeitzone des Dashboards, für jeden Angemeldeten)
                                     → POST /me/password: Body {current_password, new_password, totp_code}; 204, meldet
                                       alle ANDEREN Anmeldungen ab (die aktuelle bleibt). Ist Zwei-Faktor an, ist
                                       `totp_code` Pflicht (im Schema optional, kein Bruch): der aktuelle Code aus der
                                       App oder ein Wiederherstellungs-Code, Prüfung, Antworten und Zähler wie bei
                                       DELETE /me/totp. Erst das Passwort (falsch: 400 ohne `code`), dann – nur wenn das
                                       neue Passwort sich vom alten unterscheidet (sonst 422, ohne den Code zu
                                       verbrauchen) – der Code: fehlt er 403 {detail, code: "totp_missing"}, kein
                                       Fehlversuch. Dessen `detail` ist für diesen Vorgang ein eigener, für sich
                                       verständlicher Satz („Zum Ändern des Passworts brauchst du jetzt deinen
                                       Zwei-Faktor-Code. Nutzt du die Handy-App und siehst kein Feld dafür, aktualisiere
                                       zuerst die App.“): Eine ältere App kennt das Feld nicht und zeigt nur diesen
                                       Text. Falsch 400 `totp_wrong`, App-Code schon benutzt 400 `totp_used`, zu viele
                                       429. Ohne Zwei-Faktor reicht das Passwort wie bisher (ein mitgeschickter Code
                                       wird dann nicht geprüft). Audit `auth.password_change` mit `detail.via`
                                       (`totp`/`recovery_code`, nur bei Zwei-Faktor); mit Wiederherstellungs-Code
                                       zusätzlich `mfa.recovery_used` ({remaining, aktion: "passwort_aendern"}) und
                                       eine Meldung wie bei DELETE /me/totp.
GET    /me/preferences              PATCH /me/preferences
                                     → {first_steps_dismissed, theme, design_notice_dismissed}, je Benutzer und auf jedem
                                       Gerät gleich. `theme`: `classic` (Klassisch hell), `simple` (Schlicht dunkel,
                                       Standard), `pro` (Profi dunkel), `original` (Bisher, das Aussehen bis 0.8); ein
                                       unbekannter gespeicherter Wert liefert `simple`. `design_notice_dismissed`: der
                                       Hinweis „Deck hat ein neues Aussehen.“ ist erledigt; neue Konten haben ihn gleich
                                       als erledigt, Konten von vor dem Update sehen ihn einmal. PATCH ändert nur die
                                       mitgeschickten Felder, falsche Werte 422.
GET    /me/sessions                 DELETE /me/sessions/{id}
GET    /me/tokens                   POST /me/tokens      DELETE /me/tokens/{id}
POST   /me/totp/setup               POST /me/totp/confirm   DELETE /me/totp
                                     → setup: Body {current_password} → 200 {secret, otpauth_uri}; ohne Passwort
                                       400 „Zum Einschalten der Zwei-Faktor-Anmeldung brauchst du dein aktuelles
                                       Passwort.“ (der Body ist im Schema optional, damit ältere Aufrufer diese
                                       Antwort statt 422 bekommen); falsches Passwort wie unten
                                     → confirm: 200 {recovery_codes: [10 Codes]} (einmalig sichtbar,
                                       gespeichert werden nur Argon2-Hashes); nur für eine noch nicht
                                       bestätigte Einrichtung (sonst 409 „Zwei-Faktor ist schon aktiv.“);
                                       falsche Codes: 400, gedrosselt (s. u.); der bestätigende Code gilt danach je
                                       Konto nicht noch einmal (Anmeldung, Bestätigung)
                                     → DELETE /me/totp: Body {current_password, totp_code}; 204, löscht auch die Codes
                                       und meldet alle ANDEREN Anmeldungen ab (die aktuelle bleibt). Ist Zwei-Faktor an,
                                       ist `totp_code` Pflicht: der aktuelle Code aus der App (gilt je Konto nur einmal)
                                       oder ein Wiederherstellungs-Code (`ABCDE-FGHJK`, Schreibweise egal; wird
                                       verbraucht). Erst das Passwort (falsch: 400 ohne `code`, der Code bleibt
                                       unangetastet), dann der Code: fehlt er 403 {detail, code: "totp_missing"} (kein
                                       Fehlversuch); falsch 400 {detail, code: "totp_wrong"}, App-Code schon benutzt 400
                                       {detail, code: "totp_used"}; zu viele 429 mit `Retry-After`. Falsche App-Codes
                                       zählen auf die Grenze je Konto wie bei /auth/mfa (10 in 15 Min, 20 in 24 Std,
                                       zusammen mit Anmeldung und Bestätigungen); greift sie, sagt die 429-Meldung, dass
                                       ein Wiederherstellungs-Code weiter geht. Falsche Wiederherstellungs-Codes zählen
                                       wie bei der Anmeldung auf einen eigenen Zähler (10 in 5 Min, je Konto statt je
                                       Adresse, das richtige Passwort setzt ihn nicht zurück) und auf den aller
                                       Sicherheitsabfragen des Kontos (30 in 5 Min). Ohne Zwei-Faktor reicht das Passwort.
                                       Audit `auth.2fa_disabled` mit `detail.via` (`totp`/`recovery_code`); falsche Codes
                                       `auth.totp_check_failed` (`aktion`, ggf. `via`, `replayed`), nie der Code. Mit
                                       Wiederherstellungs-Code zusätzlich `mfa.recovery_used` ({remaining, aktion}) und
                                       eine Meldung wie bei der Anmeldung.
POST   /me/recovery-codes           → {current_password, totp_code} → 200 {recovery_codes}; ersetzt alle alten Codes;
                                       409 ohne aktive 2FA; `totp_code` (App-Code oder einer der bisherigen
                                       Wiederherstellungs-Codes), Antworten, Audit (`auth.recovery_codes_generated` mit
                                       `detail.via`) und Meldung wie bei DELETE /me/totp.
                                     → Sicherheitsabfragen (POST /me/password, POST /me/totp/setup, DELETE /me/totp,
                                       POST /me/recovery-codes; mit dem Passwort des Admins: POST /users/{id}/reset-2fa,
                                       PATCH /users/{id} mit `password` oder Verwaltungsrollen, POST /users mit
                                       Verwaltungsrollen):
                                       falsches `current_password` = 400 „Das aktuelle Passwort stimmt nicht.“ + Audit
                                       `auth.password_check_failed`. Dazu zählt auch ein falscher Code bei
                                       POST /me/totp/confirm (Audit `auth.totp_confirm_failed`). Gedrosselt je
                                       Nutzer über alle zusammen: 10 Fehlversuche / 5 Min, dann 429 mit
                                       `Retry-After` (auch mit richtigem Passwort); der Beginn der Sperre steht als
                                       `auth.password_check_locked` im Protokoll.
                                     → Abmelden (Passwortwechsel, Konto deaktivieren, 2FA abschalten/zurücksetzen,
                                       Notfall-Befehl) widerruft die Refresh-Tokens sofort. Ein schon ausgestellter
                                       Access-Token gilt nur, solange seine Anmeldung (`sid`, auch nach Erneuerungen in
                                       einem anderen Tab) gilt, und bekommt danach sofort 401 (Abschnitt
                                       „Access-Token und Anmeldung“). Bei `/ws` kann sich eine beendete Anmeldung nicht
                                       neu verbinden, offene Verbindungen enden nach spätestens 30 s (siehe §4).
                                       Terminal und Konsole einer abgemeldeten Anmeldung enden dagegen nach
                                       spätestens 15 s (siehe §4, „Terminal“).
POST   /me/devices                  → Push-Registrierung der App

GET    /app/changelog               → {current, build, unreleased[], versions[]}: Änderungsprotokoll
                                       (jeder angemeldete Nutzer; Dateien unter backend/src/nodvard_deck/changelog/)
                                     → Einträge enthalten kind, text und prs. Ohne weitere Angaben ist kind weiterhin
                                       neu, verbessert, behoben oder sicherheit; Dokumentation erscheint als verbessert.
                                     → Mit include_documentation=true wird zusätzlich kind=dokumentation geliefert.
                                       Neue Leser fordern diese eigene Gruppe ausdrücklich an; ältere Aufrufe bleiben lesbar.

GET    /users        POST /users     GET/PATCH/DELETE /users/{id}
                                     → Verwaltungsrollen (`manages_users` in GET /roles: die Rolle hat `users.write` oder
                                       `*`): POST mit solchen `role_ids` und PATCH mit solchen `role_ids` verlangen wie
                                       PATCH mit `password` `current_password` und ggf. `totp_code` des Admins (Fehler
                                       wie dort: fehlt → 403 `code: "password_missing"`). Danach eine Meldung „Neues
                                       Konto mit Verwaltungsrechten: …“ bzw. „Verwaltungsrechte für …“ (nur, wenn das
                                       Konto sie vorher nicht hatte). Geprüft wird das Passwort vor dem Ziel (404, 409,
                                       403 erst danach; nur das eigene Konto bei `password` → 409 sofort), der Code
                                       zuletzt. DELETE bleibt ohne Bestätigung.
                                     → POST: `username` wie bei `POST /auth/bootstrap` (getrimmt, klein, 3 bis 64
                                       Zeichen, nur `a-z 0-9 . - _`, vorne Buchstabe oder Ziffer; sonst 422). Die Regel
                                       gilt nur für neue Konten: `/auth/login` und der Notfall-Befehl nehmen weiter
                                       jeden Namen an, ältere Konten (etwa mit Leerzeichen im Namen) bleiben nutzbar.
                                       `email` (POST und PATCH) prüft der Server nicht.
                                     → PATCH: `password` setzt nur das Passwort ANDERER Nutzer; das eigene → 409
                                       (nur über /me/password mit Altpasswort), das des Owners → 403 (nur er selbst).
                                       Mit `password` verlangt der Server zusätzlich `current_password` (Passwort des
                                       handelnden Admins; fehlt → 403 `code: "password_missing"`, falsch → 400, zu oft
                                       → 429) und, wenn der Admin
                                       Zwei-Faktor an hat, `totp_code` (sein Code aus der App, kein
                                       Wiederherstellungs-Code; fehlt → 403 `code: "totp_missing"`, falsch → 400
                                       `totp_wrong`/`totp_used`). Beide Felder sind optional im Schema (nur ergänzt);
                                       ohne `password` braucht PATCH keine Bestätigung. Danach eine Meldung
                                       „Zugang von „<name>“ geändert“ (Stufe Warnung, Pfad `/settings/users`).
                                     → PATCH `is_active: false` (Owner → 409) und ein neues `password` melden den Nutzer
                                       überall ab (alle Refresh-Tokens widerrufen, seine Access-Tokens bekommen sofort
                                       401). Nach dem Wieder-Aktivieren gilt keine alte Anmeldung mehr, er muss sich neu
                                       anmelden.
                                     → Protokoll: `user.created`, `user.updated`, `user.deleted` (wer handelt,
                                       `target_id` = Konto). `detail` nennt `username`, Rollen, bei PATCH nur die
                                       Änderungen (`roles`/`is_active` mit altem und neuem Wert,
                                       `display_name_changed`, `email_changed`, `password_reset`); nie ein Passwort
                                       oder eine E-Mail-Adresse.
POST   /users/{id}/reset-2fa        → `users.write`, Body {current_password, totp_code?}: Passwort und, wenn er
                                       Zwei-Faktor an hat, Code aus der App des handelnden Admins (Fehler wie bei PATCH
                                       mit `password`; geprüft wird das Passwort zuerst, der Code erst, wenn das Ziel
                                       passt, eine Ablehnung wegen des Ziels verbraucht also keinen Code);
                                       schaltet 2FA eines ANDEREN Nutzers ab und meldet ihn
                                       überall ab (204), danach dieselbe Meldung wie bei PATCH. Owner: 403 (nur er
                                       selbst); eigenes Konto: 409 (→ /me/totp); ohne 2FA: 409. GET /users enthält
                                       `totp_enabled`.
GET    /roles        POST /roles     GET/PATCH/DELETE /roles/{id}
GET    /permissions                  → Katalog inkl. der von Extensions beigesteuerten

GET    /hosts?tag=&group=&status=   POST /hosts
GET/PATCH/DELETE /hosts/{id}         → Server sind Kern-Daten (hosts.read / hosts.write)
                                     → PATCH mit neuer `address` und DELETE: `current_password`, `totp_code` im Body,
                                       wenn dabei gespeicherte Zugänge wegfallen (siehe „Tresor“)
GET    /hosts/{id}/status
GET    /hosts/{id}/actions           → welche Aktionen Extensions hier anbieten
POST   /hosts/{id}/actions/{action}  → geht durch das Gate, liefert ggf. 202 „proposed"
GET    /hosts/{id}/credentials       POST /hosts/{id}/credentials   DELETE /hosts/{id}/credentials/{cid}
                                     → DELETE …/credentials/{cid} nur mit Bestätigung im Body (siehe „Tresor“)
POST   /hosts/{id}/credentials/generate-key           → SSH-Schlüssel erzeugen (privater Teil nur im Tresor)
GET    /hosts/{id}/credentials/{cid}/setup            → Einrichtungsbefehl für den Server
POST   /hosts/{id}/credentials/{cid}/setup-with-password → Server mit einem einmaligen Passwort einrichten (hosts.write + hosts.execute)
POST   /hosts/{id}/credentials/{cid}/make-default     → Standard-Zugang umstellen (mit `delete_previous` und
                                                        Bestätigung im Body, siehe „Tresor“)
GET    /hosts/{id}/requirements      → was Erweiterungen auf dem Server brauchen
POST   /hosts/{id}/check             → Verbindung prüfen (erreichbar, Server-Schlüssel, Anmeldung, Root, Erweiterungen, OS)
POST   /hosts/{id}/helper-hint/dismiss → 204, Hinweis „Server neu einrichten“ in der Übersicht ausblenden (hosts.write)
GET    /hosts/{id}/known-hosts       POST /hosts/{id}/known-hosts (Schlüssel bestätigen)
DELETE /hosts/{id}/known-hosts/{key_type}
GET    /host-groups   POST /host-groups   PATCH/DELETE /host-groups/{id}
POST/DELETE /host-groups/{id}/members/{host_id}

GET    /secrets                      → NUR Metadaten. Nie Werte.
POST   /secrets      PUT /secrets/{id}/value      DELETE /secrets/{id}
                                     → PUT …/value `{value, current_password, totp_code}`, DELETE mit Body
                                       `{current_password, totp_code}`: Bestätigung wie unter „Tresor“ beschrieben
                                     → PUT …/value und DELETE: 409, wenn das Secret der Zwei-Faktor-Schlüssel eines
                                       Kontos ist (abschalten nur über DELETE /me/totp, POST /users/{id}/reset-2fa
                                       oder den Notfall-Befehl)
                                     → DELETE: 409 `secret_in_use` mit `used_by[]`, solange der Eintrag fest gebunden ist
                                       (Besitzer-Modul oder Server-Zugang; dort austauschen oder entfernen). Eine nur
                                       festgehaltene Nutzung (`last_used_at`) ist ein Hinweis und sperrt nicht
POST   /secrets/{id}/test            → Verwendbarkeit prüfen, ohne den Wert zu zeigen
PATCH  /secrets/{id}                 → Beschreibung und Ablaufdatum ändern, nie den Wert

GET    /audit?actor_type=&action=&outcome=&target_type=&target_id=&correlation_id=&since=&until=
               &exclude_action=…&limit=100&offset=0   (limit höchstens 1000)
GET    /audit/export                 → NDJSON-Stream
                                     → /audit und /audit/export: Ausgabe in `detail` (Ergebnis von `action.executed`,
                                       Felder `output`/`stdout`/`stderr`) und Befehle (Felder `command`) nur mit
                                       `hosts.execute`, auch in alten Einträgen; siehe „Aktionen“
                                     → /audit und /audit/export: eingetippte Anmeldenamen stehen in keiner Antwort.
                                       `detail.username_ref` und `detail.username_length` bekommt nur der Owner
                                       (für alle anderen, auch Admins, fehlen sie). Alte Einträge zu unbekannten
                                       Namen zeigt die Antwort für alle mit Akteur `anonymous`/`unbekannt`; bei
                                       `login.locked` fehlt `detail.username` immer, auch bei bekannten Konten.
                                       Das Antwortformat bleibt gleich.

GET    /settings     PUT /settings/{key}
GET    /branding     PUT /branding   POST /branding/logo   POST /branding/favicon
GET    /branding/custom-css         PUT /branding/custom-css
                                     → `branding.write`; Body und Antwort `{css}`. PUT prüft vor dem Speichern
                                       (sonst 422 mit einem deutschen Satz, nichts gespeichert): höchstens 64 KB
                                       (UTF-8), kein Nullzeichen, kein `@import`, kein `<!--` oder `-->`, keine
                                       Funktionen, die Zeichenketten als Adresse laden (`image()`, `image-set()`,
                                       `cross-fade()`, `src()`, auch mit Herstellerpräfix). Ziel jeder `url()` nur
                                       `data:` oder ein Pfad auf diesem Server (`/…` aus
                                       `[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]`, nicht `//`); Leer- und
                                       Steuerzeichen im Ziel werden abgelehnt, auch als Escape, nicht umgeschrieben.
                                       Gelesen wird wie im Browser (CSS Syntax Level 3): Zeilenenden werden
                                       vereinheitlicht, Kommentare, Zeichenketten, Escapes und `url()` in einem
                                       Durchgang ausgewertet; eine Zeichenkette mit Zeilenumbruch oder ein offenes
                                       `url()` gilt als Fehler. Protokoll `branding.custom_css.saved` mit `bytes`.
                                     → PUT: `support_url` braucht das Schema `http:`, `https:` oder `mailto:`
                                       (Groß-/Kleinschreibung egal, dahinter nicht leer) und keine Leer- oder
                                       Steuerzeichen mittendrin, sonst 422. Leerraum am Rand fällt weg, leer = kein
                                       Link (`null`). Ein früher gespeicherter ungültiger Wert kommt in
                                       `GET /branding` als `null` zurück.
                                     → PUT: `logo_url` und `favicon_url` nur als Pfad auf diesem Server (dieselbe
                                       Regel wie bei `url()` im eigenen CSS) oder `data:image/…`, sonst 422; leer =
                                       `null`. Grund: Die App-Seite lädt Bilder nur von dort (`img-src 'self' data:
                                       blob:`). Ein früher gespeicherter fremder Wert bleibt gespeichert und darf
                                       unverändert mitgeschickt werden, damit andere Felder speicherbar bleiben; die
                                       Oberfläche setzt ihn nicht ein und zeigt einen Hinweis zum Hochladen. Zeigt
                                       `logo_url` bzw. `favicon_url` danach nicht mehr auf die hochgeladene Datei
                                       („Entfernen“, `data:` oder ein anderer Pfad), wird sie gelöscht; `GET
                                       /branding/logo` bzw. `/favicon` antwortet dann 404.
                                     → POST /branding/logo: roher Body, `Content-Type` `image/png`, `image/jpeg`,
                                       `image/svg+xml` oder `image/webp` (sonst 415), höchstens 2 MB (sonst 413).
                                       Eine leere Datei oder ein Dateianfang, der nicht zum Typ passt (PNG, JPEG,
                                       WebP, ICO an ihren ersten Bytes), ergibt 422, gespeichert wird nichts.
                                       Ein SVG wird vor dem Speichern geprüft (Erlaubnisliste): keine Skripte,
                                       Ereignis-Attribute (`on…`), Animationen, `<foreignObject>` oder
                                       Verarbeitungsanweisungen (`<?xml-stylesheet …?>`), keine DOCTYPE-Angabe mit
                                       `[…]` (eigene Entitäten), keine fremden Namensräume (außer denen von
                                       Inkscape/Illustrator), Verweise (`href`, `url(…)`) nur auf Teile derselben
                                       Datei oder eingebettete PNG-/JPEG-/GIF-/WebP-Bilder (`data:…;base64,…`).
                                       Sonst, auch bei einer kaputten Datei, 422 „SVG-Logo abgelehnt: …“.
                                     → POST /branding/favicon: wie das Logo, aber `image/png`, `image/svg+xml`,
                                       `image/x-icon` oder `image/vnd.microsoft.icon` (sonst 415), höchstens 512 KB
                                       (sonst 413), SVG mit derselben Prüfung („SVG-Favicon abgelehnt: …“). Setzt
                                       `favicon_url` auf `/api/v1/branding/favicon`. Liegt neben dem Logo unter
                                       `branding/` und ist damit in jeder Sicherung.

GET    /extensions                   GET /extensions/{id}
POST   /extensions/{id}/enable       POST /extensions/{id}/disable
PUT    /extensions/{id}/settings     PUT /extensions/{id}/permissions
POST   /extensions/{id}/reload       DELETE /extensions/{id}
GET    /extensions/{id}/frontend/index.js     → ESM-Bundle
GET    /extensions/{id}/settings     → {schema, values, secrets: [{label, title, description, item, is_set, optional}], secrets_cleared}
PUT    /extensions/{id}/secrets      → Geheimnis eines `x-secrets`-Labels setzen/ersetzen (nie lesen); `409`, solange keines
                                       der Felder aus `x-secret-bound-to` einen gespeicherten Wert oder `default` hat,
                                       und `409`, solange der Name des Listeneintrags (`per_item`) mehrfach vorkommt.
                                       Ein schon gespeichertes nur mit `current_password`/`totp_code` im Body
DELETE /extensions/{id}/secrets?label=…  → Geheimnis entfernen (idempotent, 204); ein gespeichertes nur mit Body
                                       `{current_password, totp_code}`
                                     → PUT /extensions/{id}/settings: löscht die Änderung gespeicherte Zugangsdaten (neue
                                       Adresse, siehe `secrets_cleared`), gehören `current_password`/`totp_code` neben
                                       `values` in den Body
POST   /extensions/{id}/test         → Verbindung prüfen, Body optional {"mode": "connection"|"message"}
POST   /extensions/{id}/takeover     → {old_id}: Übernahme der Daten unter einer alten Kennung erlauben (`extensions.manage`),
                                       siehe „Erweiterungen einrichten und testen“

GET    /connectors                   POST /connectors
GET/PATCH/DELETE /connectors/{id}    POST /connectors/{id}/test

GET    /jobs         POST /jobs      GET/PATCH/DELETE /jobs/{id}
POST   /jobs/{id}/run                → sofort ausführen; ein ausgeschalteter Job (`enabled: false`) → `409`
GET    /jobs/{id}/runs               GET /runs/{id}      GET /runs/{id}/output
POST   /runs/{id}/cancel

GET    /notifications?unread=        POST /notifications/read
GET    /notifications/{id}           POST /notifications/read-all
GET    /notifications/unread-count   → {unread}
DELETE /notifications/{id}           → 204, unbekannte Kennung 404
POST   /notifications/delete-read    → {deleted}
```

**Meldungen** (`notifications.read` zum Lesen und Zählen): Der Lesestatus (`read_at`) gilt für alle Nutzer gemeinsam.
Als gelesen markieren (`POST /notifications/read` mit `{ids}`, `POST /notifications/read-all`) verlangt deshalb
zusätzlich `notifications.write` (eingebaut: Bediener, Admin, Owner), sonst `403`.
Löschen betrifft ebenfalls alle und verlangt `notifications.write`: `DELETE /notifications/{id}` entfernt eine Meldung
samt ihren Zustellprotokollen (`204`, unbekannte Kennung `404`), `POST /notifications/delete-read` alle gelesenen auf
einmal (`{"deleted": n}`, ungelesene bleiben, ohne Treffer `0`). Beides steht im Protokoll als `notifications.deleted`
(`target_type` `notification`): einzeln mit der Kennung als `target_id` und dem `title` in den Einzelheiten, gesammelt
mit `{"count": n}`; ein Aufruf ohne Treffer schreibt nichts. Gelöscht wird in Häppchen mit Commit dazwischen; scheitert
ein späteres Häppchen, steht trotzdem ein Eintrag mit dem bisherigen Stand (`outcome` `failure`,
`{"count": n, "interrupted": true}`) und der Fehler geht weiter. Ein Versand an die Kanäle, der gerade läuft, wenn seine
Meldung gelöscht wird, läuft normal zu Ende (nur das Zustellprotokoll entfällt). Außerdem räumt der Kern-Job
`notifications-retention` jede Nacht auf (Einstellung `notifications.retention_days`, siehe unten).

**Tresor** (`secrets.read` zum Lesen, `secrets.write` zum Ändern): `GET /secrets` liefert neben den Metadaten
`expires_at` und `used_by[]` (`type`: `extension` | `host` | `login`, weitere möglich; dazu `id`, `name` und
`last_used_at`, wenn eine Nutzung festgehalten ist). `bound[]` (gleicher Aufbau, auch bei `PATCH`) enthält davon nur die
festen Bindungen (Besitzer-Modul, Server-Zugang, Zwei-Faktor): Solange es nicht leer ist, lehnt `DELETE` ab. Wer einen
Eintrag nur gelesen hat, steht allein in `used_by[]`. `POST /secrets` nimmt optional `expires_at`. `PATCH /secrets/{id}`
ändert `description` und `expires_at` (`null` entfernt das Datum, eine Zeit ohne Zone gilt als UTC); den Wert ersetzt
nur `PUT /secrets/{id}/value`. Der Zwei-Faktor-Schlüssel eines Kontos ergibt `409`, ein unbekannter Eintrag `404`.
Das Ablaufdatum ist nur eine Angabe: Nach dem Ablauf wird nichts gesperrt.

Ersetzen oder Löschen eines **schon gespeicherten** Geheimnisses verlangt dieselbe Bestätigung auf diesen Wegen:
`PUT /secrets/{id}/value`, `DELETE /secrets/{id}`, `PUT`/`DELETE /extensions/{id}/secrets`, `PUT /extensions/{id}/settings`
(wenn die Änderung gespeicherte Zugangsdaten löscht), `DELETE /hosts/{id}/credentials/{cid}`, `DELETE /hosts/{id}`
(wenn der Server gespeicherte Zugänge hat), `PATCH /hosts/{id}` mit neuer `address` (wenn dabei gespeicherte
SSH-Passwörter wegfallen) und `POST /hosts/{id}/credentials/{cid}/make-default` mit `delete_previous` (wenn ein
bisheriger Zugang wegfällt). Im Body stehen `current_password` und, wenn Zwei-Faktor an ist, `totp_code` (Code aus der
App). Antworten:

- `403` mit `code: "confirm_required"` und `two_factor` (bool), wenn das Passwort fehlt. Nichts ist geändert; die
  Oberfläche fragt Passwort und gegebenenfalls Code ab und schickt den Aufruf noch einmal.
- `400` bei falschem Passwort, `403` `totp_missing` ohne Code, `400` `totp_wrong`/`totp_used` bei falschem oder schon
  benutztem Code, `429` mit `Retry-After` nach zu vielen Fehlversuchen (dieselbe Drosselung wie beim Anmelden).
- Eine Erstanlage (noch nichts gespeichert) braucht keine Bestätigung, `PATCH /secrets/{id}` (Beschreibung, Ablauf) auch
  nicht.
- **Ausnahme:** „Mit Passwort verbinden“ in Proxmox VE und das Weiterreichen an Proxmox-Backups ersetzen das
  API-Token der eigenen Verbindung ohne diese Rückfrage. Dafür braucht es das Passwort des Proxmox-Servers, Deck legt
  dort ein neues Token an, und das alte bleibt in Proxmox gültig, bis man es dort löscht (siehe
  [10-PROXMOX-TOKEN.md](10-PROXMOX-TOKEN.md)). Eine zweite Rückfrage mitten im Verbinden wäre nur Hürde ohne Schutz.

Gelöscht wird im Tresor nur, was nicht mehr fest gebunden ist (Besitzer-Modul, Server-Zugang, Zwei-Faktor): Sonst
`409` mit `code: "secret_in_use"` und `used_by[]`. Dass ein Modul einen Eintrag einmal gelesen hat, sperrt nicht. Die
Geheimnisse, die ein Modul selbst anlegt, gehören immer zu diesem Modul; sie ersetzt oder entfernt man über die
Moduleinstellungen (`/extensions/{id}/secrets`). Im Protokoll stehen `secret.created`, `secret.replaced`,
`secret.updated` und `secret.deleted` (Bezeichnung und geänderte Felder, nie ein Wert).

**Verhaltensänderung für ältere Clients** (etwa eine ältere Android-App): Wer bisher ein gespeichertes Geheimnis über
die Moduleinstellungen oder einen Server-Zugang ersetzt oder gelöscht hat, bekommt jetzt `403 confirm_required`, bis
er Passwort (und Code) mitschickt. Lesen und Erstanlage ändern sich nicht.

Der Kern-Job `secret-expiry-reminder` („Erinnerung an ablaufende Zugänge“, täglich 08:07 Uhr in der eingestellten Zone)
meldet sich zu jedem Eintrag mit `expires_at` höchstens dreimal: 14 Tage vorher (`info`), 3 Tage vorher (`warning`)
und nach dem Ablauf (`warning`, steht damit auch auf der Startseite). Gezählt werden Kalendertage in der eingestellten
Zone. Verpasste Stufen holt der Job nicht nach: Wer das Datum erst 2 Tage vorher einträgt, bekommt nur die
3-Tage-Meldung. Ändert sich `expires_at`, beginnt die Zählung neu. Die Meldung nennt Bezeichnung und Datum, nie den
Wert, und sagt, wem der Zugang gehört (Modul oder Server). `payload.path` zeigt auf `/settings/extensions/{id}` bzw.
`/settings/hosts/{id}`, `tags` sind `key` und ab 3 Tagen vorher zusätzlich `warning`. Was schon verschickt ist, steht
in `secrets.metadata.expiry_notice` (siehe docs/03-DATA-MODEL.md). Eine Pause über „Automatik“ hält wie bei den
anderen festen Kern-Jobs nur bis zum nächsten Start.

**Server, Zugänge und Gruppen** (`hosts.read` zum Lesen, `hosts.write` zum Ändern; jede Änderung steht im
Protokoll):

- `POST /hosts` und `PATCH /hosts/{id}` prüfen die Eingaben. Der Kurzname (`name`) wird getrimmt und klein
  geschrieben und muss `[a-z0-9][a-z0-9_-]{0,63}` entsprechen; die Adresse ist eine IP-Adresse (nicht `0.0.0.0`/`::`,
  keine Multicast-Adresse) oder ein Rechnername nach RFC 1123 – ohne Schema, `user@`, Pfad oder Port (ein einzelner Punkt am Ende wird entfernt); `tags` folgen
  den Markierungsregeln (höchstens 20). Ungültig → `422` mit deutscher Meldung. Der Kurzname lässt sich nicht ändern.
  `PATCH` nimmt zusätzlich `current_password` und `totp_code` (Code aus der App, wenn Zwei-Faktor an ist). Nötig sind
  sie nur, wenn eine neue `address` gespeicherte SSH-Passwörter des Servers löscht; fehlt dann das Passwort, kommt
  `403 confirm_required` und nichts ist geändert (Antworten wie unter „Tresor“).
- `HostOut` enthält außerdem `credential` (der Standard-Zugang als `{id, kind, username, port}` oder `null`,
  nie ein Geheimnis), `managed_tags` (die von einer Erweiterung verwalteten Markierungen, Teilmenge von `tags`) und
  `login_ok_at`: wann sich Nodvard Deck mit dem aktuellen Standard-Zugang an dieser Adresse und diesem Port
  nachweislich angemeldet hat. `last_seen_at` heißt dagegen nur, dass der Server geantwortet hat (dem Kern-Job
  `host-reachability` reicht dafür, dass der SSH-Port Verbindungen annimmt). `login_ok_at` setzt eine gelungene
  Anmeldung mit dem Standard-Zugang bei `POST /hosts/{id}/check` oder `GET /hosts/{id}/status` (jedes Mal neu) oder
  eine SSH-Verbindung einer Erweiterung (`ctx.exec`; nur, wenn noch kein Beleg da ist), nie der Kern-Job
  `host-reachability`. `null` heißt: nicht belegt – noch nie, oder seit der Server die Anmeldung mit dem
  Standard-Zugang abgelehnt bzw. einen anderen Server-Schlüssel gezeigt hat oder seit einer neuen Adresse oder einem
  neuen Standard-Zugang, jeweils bis zur nächsten gelungenen Anmeldung (Ausnahme: `make-default` nach frischer
  Prüfung, siehe unten).
- `HostOut.sudo_full_wanted` (Standard `false`): Die volle sudo-Regel ist auf diesem Server gewollt, etwa für eigene
  Befehle mit sudo in Skripten. „Verbindung prüfen“ und die Übersicht raten dann nicht, sie zu entfernen. `PATCH` setzt es
  mit `sudo_full_wanted`; das Protokoll `host.updated` nennt die Änderung in `changed.sudo_full_wanted`. Der Abgleich
  eines Anbieters (etwa Proxmox) überschreibt es nicht.
- Für Gruppen und Plaketten („LXC auf pve1“) hat `HostOut` außerdem `category` (`server`, `vm` oder `lxc`, aus
  `kind`; ein Hypervisor-Knoten und ein von Hand angelegter Server ohne Art sind `server`), `parent_label` (bei einem
  Gast der Host, auf dem er läuft, wie der Anbieter ihn nennt, sonst `null`) und `power_state` (`running`, `stopped`,
  `paused` oder `null`, wenn der Anbieter es nicht meldet). Ein gestoppter Gast hat weiter `status: "down"`; die
  Oberfläche zeigt ihn grau als „aus“ und zählt ihn nicht als Störung. Gemeldet werden die drei Angaben über die
  gleichnamigen Felder von `DiscoveredHost` im SDK (`parent_ref`, `parent_label`, `power_state`); der Kern legt sie bei
  jedem Abgleich neu in den Metadaten des Hosts ab.
- `gone_since` in `HostOut`: seit wann der Anbieter den Server nicht mehr kennt (etwa ein in Proxmox gelöschter Gast),
  sonst `null`. Gesetzt über `ctx.hosts.mark_gone(provider_refs)` im SDK, das eine Erweiterung nur für Bereiche aufruft,
  die sie gerade vollständig gelesen hat (Proxmox: je Verbindung die Knotenliste, je Knoten die Gäste; eine nicht
  erreichbare Verbindung markiert nichts). Meldet ein späterer Abgleich den Server wieder, verschwindet die Markierung.
  Die Oberfläche zählt solche Server nicht mit, zeigt sie grau und bietet „Entfernen“ an (`DELETE /hosts/{id}`).
- `DELETE /hosts/{id}` `{current_password, totp_code}` löscht auch die gespeicherten Zugangsdaten samt ihren
  Geheimnissen im Tresor und schließt offene SSH-Verbindungen. Hat der Server gespeicherte Zugänge, sind Passwort und,
  wenn Zwei-Faktor an ist, der Code im Body nötig (sonst `403 confirm_required`, Antworten wie unter „Tresor“); ohne
  Zugänge darf der Body fehlen. Läuft gerade eine Aktion auf dem Server (`executing`) → `409`.
- `GET /hosts/{id}/metrics` (`hosts.read`) fragt die aktuellen Messwerte beim `MetricsProvider` des Servers ab →
  `{values, sampled_at}` (unbekannter Server oder kein Provider: `404`). Scheitert die Abfrage an der Verbindung
  (SSH-Fehler wie Server nicht erreichbar, Anmeldung abgelehnt oder Schlüssel nicht bestätigt, `HostUnreachable`,
  Zeitüberschreitung oder ein anderer Netzfehler), kommt `502` mit dem Grund in `detail`. Ebenso bei einer Ausnahme,
  deren Klasse `readable = True` trägt ([02](02-EXTENSION-API.md)): Die Proxmox-Erweiterung meldet so einen
  Proxmox, der nicht oder mit einem Fehler antwortet. Jeder andere Fehler des Providers bleibt `500`, auch ein
  Dateifehler.
- `POST /hosts/{id}/credentials`: `username` `[A-Za-z0-9_][A-Za-z0-9_.@\ -]{0,63}` (auch Windows-Namen wie `Max Mustermann`, `user@domain`, `DOMAIN\user`; keine Steuerzeichen), `port` 1–65535; bei `kind = ssh_key`
  muss `secret_value` ein privater Schlüssel ohne Passphrase sein (sonst `422`: „Der Schlüssel ist mit einer Passphrase
  geschützt …“ bzw. „Das ist kein gültiger privater SSH-Schlüssel.“). Schlüssel und Passwörter kommen nie in einer
  Antwort oder im Protokoll zurück – auch `422`-Antworten enthalten die Eingabe nicht (nur `type`, `loc`, `msg`).
  Zugang oder gemerkten Schlüssel zu löschen bzw. die Adresse zu ändern schließt offene SSH-Verbindungen des Servers.
  Eine neue Adresse löscht außerdem die gespeicherten SSH-Passwörter des Servers samt Geheimnis im Tresor (Schlüssel
  bleiben): Ein Passwort gehört zu dem Server, bei dem es eingegeben wurde. Das gilt auch, wenn der Abgleich einer
  Erweiterung (z. B. Proxmox) die Adresse ändert; ausgenommen sind dort VMs und LXC-Container, deren Adresse der Gast
  selbst meldet und für die schon ein Server-Schlüssel gemerkt ist.
- `GET /hosts/{id}/known-hosts` → `[{key_type, fingerprint, first_seen_at, accepted_by_user_id, accepted_by_label}]`,
  sortiert nach `key_type`. `accepted_by_label` ist der Benutzername (`null` bei automatischem Merken beim ersten
  Kontakt); fremde Namen sehen nur Nutzer mit `users.read` oder Freigaberecht, sonst steht `user/<id>` da.
- `GET /hosts/{id}/requirements` (`hosts.read`) → `[{ext_id, id, label, check_command, ok_text, fail_hint,
  unix_group, needs_root, root_reason, order}]`: was Erweiterungen über `ctx.ui.register_host_requirement()` für diesen
  Server melden (nach `tags` und `os_families` des Servers gefiltert, ohne die mit einem Tag des Servers in `skip_tags`,
  sortiert nach `order`). `unix_group` ist `null`,
  wenn der Name nicht `^[a-z_][a-z0-9_-]{0,31}$` entspricht.
- `POST /hosts/{id}/credentials/generate-key` `{username = "lattice", port = 22}` (`hosts.write`, `201`) erzeugt einen
  ed25519-Schlüssel nur für diesen Server (Kommentar `nodvard@<Kurzname>`). Der Standard `username = "lattice"` bleibt
  aus Kompatibilitätsgründen (ältere Aufrufer schicken oft keinen Namen); die Oberfläche schlägt `nodvard` vor und
  schickt den Namen immer mit. Antwort: `{credential, public_key,
  fingerprint}` – **der private Schlüssel liegt nur im Tresor** und steht nie in einer Antwort (auch keiner `422`),
  im Protokoll oder im Log. `is_default` ist nur dann `true`, wenn der Server noch keinen SSH-Zugang (Schlüssel oder
  Passwort) hat; sonst bleibt der bisherige Standard, bis `make-default` ihn umstellt. `username` und `port` werden wie
  bei `POST .../credentials` geprüft. Höchstens 10 Schlüssel je 5 Minuten und Nutzer, sonst `429` mit `Retry-After`.
  Protokoll `host.key_generated` (`credential_id`, `username`, `port`, `fingerprint`).
- `GET /hosts/{id}/credentials/{cid}/setup?sudo=false&full_sudo=false&drop_full_rule=false&groups=` (`hosts.write`) →
  `{username, public_key, fingerprint, one_liner, script, notes, groups, sudo, full_sudo, drop_full_rule, helpers}`: der
  Befehl, den man auf dem
  Server ausführt (als root direkt, sonst über `sudo`; `script` ist dasselbe lesbar). Der öffentliche Schlüssel wird serverseitig aus dem Tresor abgeleitet, mit dem
  Kommentar `nodvard@<Kurzname>` (nie dem im Schlüssel eingetragenen). Der Befehl legt den Benutzer an (falls es ihn
  nicht gibt, ohne Passwort-Anmeldung), trägt den Schlüssel als `restrict,pty …` ein, bei einem laufenden Dropbear als
  `no-port-forwarding,no-agent-forwarding,no-X11-forwarding …` (einen schon eingetragenen erkennt er an Art und
  Schlüsseltext, auch mit älterem Kommentar `lattice@…`; bei Dropbear ersetzt er seine eigene alte Zeile
  `restrict,pty <Schlüssel>`, auch mit Kommentar `lattice@…` oder einem früheren Kurznamen, und meldet „Schlüssel für
  Dropbear angepasst.“; steht der Schlüssel ohne Optionen in der Datei, setzt er sie vor jede solche Zeile und meldet
  „Der schon eingetragene Schlüssel für Nodvard Deck ist jetzt eingeschränkt …“, im Protokoll der eigenen Einrichtung
  als `key_fixed`) und richtet auf Wunsch `sudo` ohne Passwort
  (`sudo=true`, geprüft mit `visudo`, siehe unten) und
  Gruppen (`groups=docker` oder `groups=a,b`) ein. Gruppen werden nur eingetragen, wenn Erweiterungen sie für diesen
  Server verlangen (`unix_group`; privilegierte Gruppen – `root`, `sudo`, `wheel`, `admin`, `shadow`, `disk`, `adm`,
  `staff`, `lxd`, `libvirt`, `kvm` – ignoriert der Kern und protokolliert das im Log; `docker` ist bewusst erlaubt und
  bedeutet praktisch root); für `root` gibt es weder Gruppen noch `sudo`. Mit `sudo=true` bringt der Befehl die
  Hilfsskripte der eingeschalteten Module mit, die für diesen Server root brauchen (`helpers`: `[{ext_id, module, path,
  version, sha256, text}]`), jedes mit eigener Regel `/etc/sudoers.d/nodvard_<ext_id>_<benutzer>` nur für sein Skript, mit dessen Prüfsumme
  (`NOPASSWD: sha256:<sha256> <pfad>`; kennt `visudo` die Form nicht oder zeigt `sudo -l` die Regel danach nicht mit
  ihr, ohne sie und mit einem Hinweis).
  Nur diese Regeln gibt es, wenn mindestens ein eingeschaltetes Modul auf diesem Server root braucht und jedes davon ein
  Hilfsskript hat. Braucht ein Modul root ohne Hilfsskript, oder mit `full_sudo=true`, kommt die volle Regel
  (`/etc/sudoers.d/nodvard-<benutzer>`, `NOPASSWD: ALL`) dazu; `full_sudo` in der Antwort sagt, ob sie dabei ist. Braucht
  kein Modul root, richtet `sudo=true` ohne `full_sudo` gar nichts mit sudo ein: `sudo` in der Antwort ist dann `false`,
  und `notes` sagt es. Ohne
  volle Regel ist ein Hilfsskript, das sich nicht einrichten lässt, ein `FEHLER` (der Befehl endet mit Fehler), mit
  voller Regel ein Hinweis, außer bei einem Modul, das ohne sein Skript nie die volle Regel nimmt
  (`sudo_fallback = false` im Manifest, derzeit Docker): dann auch mit voller Regel ein `FEHLER`, und `notes` sagt es.
  Eine schon vorhandene volle Regel nimmt der Befehl nur mit `drop_full_rule=true` zurück, und das geht nur ohne
  `full_sudo`, dort, wo die Regeln der Hilfsskripte reichen, und nicht bei „Volle sudo-Regel gewollt“ (`sudo_full_wanted`,
  sonst 422 mit Grund); `drop_full_rule` in der Antwort
  sagt, ob es dabei ist. Dann löscht er nach allen Regeln der Skripte `nodvard-<benutzer>` und `lattice-<benutzer>`,
  wenn sie Byte für Byte die eigene Regel sind (keine Links), und nur, wenn jedes Skript vorher über sudo seine Version
  nennt; danach prüft er `visudo -c` und fragt jedes Skript noch einmal, sonst (und bei einem Abbruch) stellt er die Regel
  wieder her und endet mit `FEHLER`. Je gelöschter Datei steht „Die volle sudo-Regel <name> wurde entfernt.“ in der Ausgabe. Bei einem
  Benutzer außer `root` läuft alles,
  was `~/.ssh` und `authorized_keys` anfasst, **als dieser Benutzer** (`runuser`, sonst `su`), nie als root mit `chown`;
  ist `~/.ssh` oder `authorized_keys` ein Link, bricht der Befehl mit `FEHLER` ab, statt ihm zu folgen. Bei `root`
  (z. B. Proxmox, wo `authorized_keys` ein Link ins Cluster-Dateisystem ist) bleibt der Link unangetastet. Eine ältere
  Regel `/etc/sudoers.d/lattice-<benutzer>` entfernt der Befehl erst, wenn die neue eingetragen und die ganze
  Konfiguration geprüft ist, und nur, wenn sie Byte für Byte der Regel entspricht, die er früher selbst angelegt hat;
  sonst (auch als Link) bleibt sie mit Hinweis stehen. Jede eigene Datei prüft `visudo -c -f` für sich; die ganze
  Konfiguration (`visudo -c`) muss nur dann auch danach gelten, wenn sie es vorher schon tat (eine fremde Datei mit
  falschen Rechten lässt sonst die Einrichtung scheitern). `messages` enthält dann bis zu fünf Zeilen `visudo: …` mit
  der Meldung von `visudo`. Lehnt `visudo` die sudo-Regel ab oder wird sie zurückgenommen,
  endet der Befehl mit Fehler statt „Fertig“, ebenso, wenn er die alte Regel wiederherstellen muss. Jeder Wert im Befehl wird streng
  geprüft bzw. mit `shlex.quote` gequotet. `422` für Passwort-Zugänge, nicht-Linux-Server, einen Benutzernamen, der kein
  üblicher Linux-Name ist (`^[a-z_][a-z0-9_-]{0,31}$`), und einen nicht lesbaren Schlüssel. Ändert nichts (kein
  Protokolleintrag).
- `POST /hosts/{id}/credentials/{cid}/setup-with-password` `{password, login_user = "root", sudo = false,
  full_sudo = false, drop_full_rule = false, groups = [], sudo_password_same = true}` (`hosts.write` **und**
  `hosts.execute`) richtet den Server
  für diesen Schlüssel-Zugang ein, ohne dass jemand den Befehl selbst einfügt: Nodvard Deck meldet sich **einmal** mit
  `login_user`/`password` an, führt genau den Befehl von `GET .../setup` aus (`sudo`, `full_sudo`, `drop_full_rule` und `groups` wie dort; als `root` direkt, sonst über
  `sudo -S`, das das Passwort über die Standardeingabe bekommt, mit `sudo_password_same = false` über `sudo -n`, das nur
  geht, wenn das Konto sudo ohne Passwort darf), schließt die Verbindung und prüft danach mit dem neuen Schlüssel wie
  `POST /hosts/{id}/check`. `login_user` ist das Konto für diese eine Anmeldung, nicht der Benutzer des Zugangs. Antwort
  `200` `{ok, check, notes, messages, made_default}`: `ok` heißt, der Befehl ist gelaufen und die Prüfung hat keinen
  Fehler gefunden (zeigt sie noch etwas, bleibt es bei `200` mit `ok: false`); `check` ist die Antwort von
  `POST /hosts/{id}/check` oder `null`, wenn gerade nicht geprüft werden konnte (dann steht ein Hinweis in `notes`);
  `messages` sind nur die eigenen Zeilen des Befehls („Benutzer … angelegt.“, Hinweise, `FEHLER: …`), nie die Ausgabe
  von sudo oder des Servers; `made_default` ist `true`, wenn der Zugang dadurch Standard wurde (nur, wenn der Server
  noch keinen hatte; sonst bleibt es beim Knopf `make-default`). Dieselben Vorbedingungen wie `GET .../setup`
  (`404` unbekannter Zugang, `422` für Passwort-Zugänge, nicht-Linux-Server und ungültige Benutzernamen); `422` mit
  festem Satz am Feld auch für ein leeres, über 512 Zeichen langes oder nicht sendbares Passwort (Zeilenumbruch,
  NUL, einzelnes Surrogat) und ein ungültiges `login_user` – nie mit dem Wert.
  **Das Passwort wird nie gespeichert, protokolliert oder zurückgegeben** (nicht in der Datenbank, im Tresor, im Log,
  in einer Antwort, auch keiner `422`; in der Befehlszeile steht es nie). Ist der Befehl länger als 8000 Byte, ist die
  Befehlszeile ein kurzer, fester Text: Das Skript kommt über die Standardeingabe zwischen zwei gleichen, zufälligen
  Marken, dahinter die Zeile, die `sudo -S` selbst als Passwort liest. Das Passwort geht nur an die gespeicherte Adresse
  und den Port des Zugangs, und nur, wenn Nodvard Deck den Server-Schlüssel schon kennt: sonst `409`
  `host_key_unconfirmed` mit `host_key` (`{status, key_type, fingerprint, expected}`), ohne dass das Passwort benutzt
  wurde und ohne dass es einen Versuch kostet; bestätigt wird mit `POST /hosts/{id}/known-hosts` und der `credential_id`
  dieses Zugangs. **Die Route geht bewusst nicht durchs Aktions-Gate** (siehe
  [01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)): ein Vorschlag stünde samt Eingaben in `actions.payload`, und dort
  darf das Passwort nie landen. Dafür verlangt sie `hosts.execute` zusätzlich zu `hosts.write` (der Befehl läuft als root
  auf dem Server, wie bei Terminal und Konsole), und jeder Versuch steht im Protokoll.
  Fehler haben die Form `{detail, code}` mit festem deutschen Satz (nie Text vom Server oder von der SSH-Bibliothek);
  je nach Fall kommt `host_key` oder `messages` dazu: `wrong_password` (`400`, Anmeldung abgelehnt),
  `sudo_failed` (`400`, der Befehl lief gar nicht erst, z. B. kein sudo-Recht oder falsches sudo-Passwort; ebenso,
  wenn die Login-Shell einen langen Befehl nicht lesen kann, etwa fish oder tcsh, dann mit eigenem Satz und als root
  mit dem Code `setup_failed`),
  `invalid_password` (`400`), `host_key_unconfirmed` und `host_key_changed` (`409`, mit `host_key`; bei `changed` wurde
  das Passwort ebenfalls nicht gesendet, `expected` ist der gemerkte Fingerabdruck; der geänderte Schlüssel wird nie zum
  Bestätigen angeboten), `helpers_blocked` (`409`, mit `messages`: nur mit den Regeln der Hilfsskripte, die sich auf dem
  Server nicht sicher ablegen lassen; der Befehl hat vor jeder Änderung abgebrochen, die Meldungen nennen Ordner, Grund
  und Abhilfe), `rate_limited` und `busy` (`429`, `rate_limited` mit `Retry-After`; `busy`: für diesen Server
  läuft schon eine Einrichtung), `unreachable` (`502`, nicht erreichbar), `connection_failed` (`502`, die Verbindung kam
  nicht zustande oder brach beim Anmelden ab; es ist nichts ausgeführt worden), `connection_lost` (`502`, die Verbindung
  brach ab, nachdem der Befehl gestartet war: die Einrichtung kann teilweise gelaufen sein, mit „Verbindung prüfen“
  nachsehen, ein neuer Versuch ist gefahrlos), `setup_failed` (`502`, der Befehl lief und scheiterte, mit
  `messages`; ebenso, wenn ein langer Befehl nicht vollständig bei der Login-Shell ankam, auch weil vorher schon etwas
  von der Standardeingabe gelesen hat: dann lief nichts, `messages` ist leer, und ein neuer Versuch ist gefahrlos) und
  `timeout` (`504`, nach 90 s ohne Ende; er kann trotzdem durchgelaufen sein). Grenzen: je Ziel (Adresse und Port) 5
  und je Nutzer 10 Versuche in 10 Minuten (als Versuch zählt jeder Lauf, bei dem das Passwort an den Server gehen
  könnte), dazu je Nutzer 30 Aufrufe in 5 Minuten, je Server nur ein Lauf gleichzeitig. Die Zähler liegen im Speicher.
  Protokoll `host.setup_with_password` (`target_type` `host`; `outcome` `success`/`failure`; `detail`: `credential_id`,
  `username`, `login_user`, `sudo`, `full_sudo`, `drop_full_rule`, `groups`, `helpers`, `result` (`ok` oder der Fehlercode), bei Erfolg
  zusätzlich `check_ok` und `made_default`; nie das Passwort), dazu `host.connection_checked` für die Prüfung und
  `host.credential_made_default`, wenn der Zugang Standard wurde. Eine Sperre (`rate_limited`) steht je Nutzer nur
  einmal im Zeitfenster im Protokoll; Fehler vor dem Lauf (`403`, `404`, `422`) gar nicht.
- `POST /hosts/{id}/credentials/{cid}/make-default` `{delete_previous = false, current_password, totp_code}`
  (`hosts.write`; Body darf fehlen) macht den Zugang zum Standard. `current_password` und, wenn Zwei-Faktor an ist,
  `totp_code` braucht es nur, wenn mit `delete_previous` wirklich ein bisheriger Zugang gelöscht wird (sonst
  `403 confirm_required`, Antworten wie unter „Tresor“; geprüft erst nach der Prüfung unten, ein `409` verbraucht also
  keinen Code). Mit `delete_previous` werden der bisherige Standard und sein Geheimnis in derselben
  Transaktion gelöscht und seine offenen SSH-Verbindungen sofort geschlossen; ohne `delete_previous` werden die
  Verbindungen nur ausgemustert (laufende Terminals laufen weiter, neue nehmen den neuen Standard). **`delete_previous`
  geht nur nach einer frischen, erfolgreichen Prüfung des NEUEN Zugangs:** `POST /hosts/{id}/check` mit dessen
  `credential_id` muss in den letzten 10 Minuten bei „Anmeldung“ `ok` ergeben haben (und Adresse und Port sind
  seitdem gleich), sonst `409` („Bitte zuerst „Verbindung prüfen“ …“), und es wird nichts gelöscht oder umgestellt.
  Gilt nur, wenn wirklich ein alter Zugang gelöscht würde. Der Zustand liegt im Speicher (nach einem Neustart einfach
  neu prüfen). Wird ein alter Zugang gelöscht, steht in `notice` ein Hinweis, sonst
  `null`: bei einem Schlüssel, dass er in `authorized_keys` auf dem Server stehen bleibt (Nodvard Deck entfernt ihn dort
  nicht), bei einem Passwort, dass sich auf dem Server nichts ändert. Hat `POST /hosts/{id}/check` mit dem neuen Zugang
  in den letzten 10 Minuten die Anmeldung geschafft (Adresse und Port seitdem gleich), übernimmt `login_ok_at` den
  Zeitpunkt dieser Prüfung; sonst ist es `null` bis zur nächsten gelungenen Anmeldung. Ist der Zugang schon Standard,
  passiert nichts. Protokoll `host.credential_made_default` (`credential_id`, `previous_credential_id`, `deleted_previous`) und
  bei `delete_previous` zusätzlich `host.credential_deleted`.
- `POST /hosts/{id}/check` `{credential_id?}` (`hosts.write`, Body darf fehlen) prüft Schritt für Schritt, ob sich
  Nodvard Deck mit dem Server verbinden kann, mit dem Standard-Zugang oder dem angegebenen (`404` bei einem Zugang eines
  anderen Servers, `422` bei `api_token`). Antwort `{ok, checked_at, items, host_key, os, credential_id}`; `ok` heißt:
  kein Punkt ist `fail` oder `confirm`. Punkte `{id, label, status, detail, hint}` mit `status` `ok` | `warn` | `fail` |
  `skipped` | `confirm`, in dieser Reihenfolge (nach einem Fehler in den ersten drei hört die Prüfung auf): `reachable`
  (TCP + SSH-Banner), `host_key`, `login`, `root` (`sudo -n true`; nur `warn`, weil „ohne root“ erlaubt ist), je Meldung einer
  Erweiterung mit `check_command` `req:<ext_id>:<id>` (Ausgang 0 → `ok`, 127 → `skipped`, sonst `warn` mit dem
  `fail_hint`, `{user}` ersetzt), `os`. Windows-Server: `root`, `os` und `req:*` werden übersprungen. `host_key`:
  `{status: known|new|changed, key_type, fingerprint, expected}`.
  **Ohne bestätigten Server-Schlüssel gehen keine Anmeldedaten an den Server:** ist der Schlüssel neu, meldet die
  Prüfung `host_key` als `confirm` (mit Fingerabdruck) und hört auf – schon der Schlüsseltausch bricht ab, bevor
  Benutzername, Passwort oder Schlüssel gesendet werden, und es wird nichts gemerkt. Ohne Zugang wird der Schlüssel nur
  gelesen (Port 22). Ein **geänderter** Schlüssel ist immer `fail` (auch wenn der Server nur einen anderen Schlüsseltyp zeigt als den
  gemerkten; bei einem Server mit mehreren Schlüsseln wird der gemerkte Typ bevorzugt) und wird nie zum Bestätigen angeboten (erst
  `DELETE .../known-hosts/{key_type}`, dann neu prüfen und bestätigen). Geprüft wird nur die gespeicherte Adresse und der Port des
  Zugangs. Die Prüfung nutzt eine eigene Verbindung (nicht aus dem Pool); nach gelungener Anmeldung werden die gepoolten
  Verbindungen des Servers ausgemustert (neue Gruppenrechte gelten, laufende Terminals nicht abgeschnitten) und
  `status`/`last_seen_at` des Servers aktualisiert (`down`: nicht erreichbar, `unknown`: Schlüsselproblem). Mit dem
  Standard-Zugang setzt eine gelungene Anmeldung `login_ok_at`, eine abgelehnte löscht es; ein geänderter
  Server-Schlüssel löscht es immer, ein nicht erreichbarer Server nie. Die Prüfung eines anderen Zugangs ändert daran
  sonst nichts. Texte sind
  feste deutsche Sätze – nie Passwort, Schlüssel, Fehlerausgaben des Servers oder Ausnahme-Texte. Zeitgrenzen: 35 s
  insgesamt, 8 s je Befehl; höchstens zwei Prüfungen gleichzeitig. `429` (mit `Retry-After`, wo es eine Wartezeit gibt)
  bei mehr als 12 Prüfungen je 5 Minuten und Nutzer, bei einer schon laufenden Prüfung desselben Servers und bei
  ausgelastetem Prüfen. Protokoll `host.connection_checked` (`outcome` `success`/`failure`, `detail`: `credential_id`
  und `items` als `{id: status}`, sonst nichts).
- `POST /hosts/{id}/known-hosts` `{key_type, fingerprint}` (`hosts.write`, `201`) merkt einen Server-Schlüssel
  (Antwort wie ein Eintrag aus `GET`, `accepted_by_user_id` ist der Bestätigende). Nur der Schlüssel, den die **letzte
  Prüfung dieses Servers** (höchstens 15 Minuten her, Adresse und Port – der des Standard-Zugangs, ohne Zugang 22 –
  seitdem gleich) gesehen hat, und nur einmal; alles andere → `409` („Bitte zuerst „Verbindung prüfen“ – der
  Fingerabdruck muss frisch vom Server kommen.“). Ist für den Server schon **irgendein** Schlüssel gemerkt (auch eines
  anderen Typs) → `409` („… Erst den alten vergessen.“): ein weiterer Schlüsseltyp wird nie per Bestätigen
  hinzugefügt. Protokoll `host.known_key_pinned`
  (`key_type`, `fingerprint`).
- `credential_id` bei `POST /hosts/{id}/known-hosts` (optional, neu; ältere Aufrufer schicken es nicht): der SSH-Zugang
  dieses Servers, mit dem die Prüfung den Schlüssel gesehen hat. Sein Port zählt als der der letzten Prüfung; ohne
  Angabe gilt wie bisher der Port des Standard-Zugangs (ohne Zugang 22). Nötig, wenn ein weiterer Zugang auf einem
  anderen Port als der Standard liegt, z. B. beim Einrichten mit Passwort für diesen Zugang. Ein Zugang eines anderen
  Servers oder eine unbekannte Kennung → `404`, ein Zugang ohne SSH (z. B. `api_token`) → `422`; alles Weitere
  (frischer Fingerabdruck, nur einmal, kein anderer Schlüssel schon gemerkt) gilt unverändert.
- `DELETE /hosts/{id}/known-hosts/{key_type}` (`hosts.write`, `204`; kein gemerkter Schlüssel dieses Typs → `404`)
  vergisst einen gemerkten Server-Schlüssel und schließt offene SSH-Verbindungen des Servers. Danach merkt Nodvard Deck
  für diesen Server keinen Schlüssel mehr von allein (Terminal, Jobs, Status), egal wie `ssh.confirm_new_host_keys`
  oder `NODVARD_DECK_SSH_CONFIRM_NEW_HOST_KEYS` steht: Erst `POST /hosts/{id}/known-hosts` nach einer neuen Prüfung
  hebt das auf (oder `DELETE /hosts/{id}`). Protokoll `host.known_key_forgotten` (`key_type`, `fingerprint`).
- `PATCH /host-groups/{id}` (`name`, `description`; Name vergeben → `409`, unbekannt → `404`) und
  `DELETE /host-groups/{id}` (löscht nur die Gruppe und ihre Zuordnungen, nie Server). `POST
  /host-groups/{id}/members/{host_id}` ist idempotent und liefert `404` für eine unbekannte Gruppe oder einen
  unbekannten Server.
- Protokoll-Einträge (`target_type` `host`, bei Gruppen `host_group`): `host.created`, `host.updated` (`changed` mit
  `from`/`to` je Feld; hat eine neue Adresse SSH-Passwörter gelöscht, zusätzlich `password_credentials_removed` mit
  deren Anzahl, auch beim Abgleich einer Erweiterung, dann als Eintrag der Erweiterung), `host.deleted`,
  `host.credential_added`, `host.credential_deleted`, `host.key_generated`,
  `host.credential_made_default`, `host.known_key_pinned`, `host.known_key_forgotten`, `host.connection_checked`,
  `host.group_changed` (`change`: `created`, `renamed`, `description_changed`, `member_added`, `member_removed`,
  `deleted`).

**Einstellungen** (`GET /settings`, `PUT /settings/{key}` mit `{"value": ...}`; beides `settings.write`). Verwaltete
Schlüssel, Wert ungültig → `422`:

| Schlüssel | Wert | Vorgabe |
|---|---|---|
| `autonomy.mode` | `propose` oder `full` | `propose` |
| `autonomy.max_risk` | `low`, `medium`, `high`, `critical` | `low` |
| `security.deny_patterns` | Liste von Regex-Strings | `[]` |
| `maintenance.windows` | Liste aus `{cron, duration_minutes, host_ids}` | `[]` |
| `system.timezone` | IANA-Name aus der Zonendatenbank (`Europe/Berlin`) | `NODVARD_DECK_TIMEZONE`, sonst `TZ`, sonst `Europe/Berlin` |
| `audit.retention_days` | ganze Zahl 7 bis 3650 | `NODVARD_DECK_AUDIT_RETENTION_DAYS` (90) |
| `jobs.run_retention_days` | ganze Zahl 1 bis 3650 | `30` |
| `notifications.retention_days` | ganze Zahl 1 bis 3650 | `90` |
| `notifications.push_types` | Objekt mit Push-Regeln je Meldungsart, siehe unten | alle eingeschaltet, bisherige Prioritäten |
| `hosts.reachability.enabled` | `true` oder `false` (nur echte Wahrheitswerte) | `true` |
| `hosts.reachability.interval_minutes` | ganze Zahl 1 bis 60 | `2` |
| `system.update_check.enabled` | `true` oder `false` | `true` |
| `system.update_check.channel` | `stable` (nur fertige Versionen) oder `beta` (auch Vorabversionen) | `stable` |
| `system.update_check.auto_security` | `true` oder `false` | `false` |
| `ssh.confirm_new_host_keys` | `true` oder `false` | `true` (ohne gespeicherten Wert, auch bei bestehenden Installationen) |
| `system.trusted_proxies` | Liste von IP-Adressen/Netzen (CIDR, IPv4/IPv6), höchstens 20, öffentliche IPv4-Netze ab `/24` (private Bereiche bis zur Größe des Bereichs, höchstens `/8`), IPv6 ab `/48` | `[]` (niemandem vertrauen); `NODVARD_DECK_TRUSTED_PROXIES` gewinnt |

- `system.timezone` ist die Zeitzone aller Zeitpläne (`Job.timezone`, auch die der Erweiterungen) und der
  Wartungsfenster. Beim Setzen bekommen alle Jobs mit der bisherigen Standardzone die neue (eine ausdrücklich andere
  bleibt) und werden neu geplant, `next_run_at` zieht sofort mit. Ein Job, der neu angemeldet wird (Erweiterung neu
  geladen), behält seine Zone. Die Uhrzeit bleibt, der Zeitpunkt wandert; rund um die Zeitumstellung fällt ein Lauf
  einmal aus oder läuft doppelt (APScheduler entscheidet). Angezeigte Zeitstempel formatiert der Server nie, die
  Oberfläche zeigt sie in der Zeit des Geräts.
- `audit.retention_days`: Der nächtliche Lauf `audit-retention-purge` löscht Protokolleinträge, die älter sind.
  Die Einstellung gewinnt, die Umgebungsvariable ist der Rückfall (auch bei einem ungültigen gespeicherten Wert).
- `jobs.run_retention_days`: Der nächtliche Kern-Job `job-runs-retention` (03:20 Uhr) löscht beendete Job-Läufe
  (`job_runs`, alles außer `running`), die vor mehr als so vielen Tagen begannen, samt ihrer Protokolldatei
  (`output_ref`). Ausnahmen: **laufende Läufe** bleiben immer, und **je Job bleiben die letzten 20 beendeten Läufe**
  stehen (ein monatlicher Job behält seinen Verlauf); Läufe ohne Job gehen nur nach Alter. Dateien werden nur
  innerhalb des `runs`-Ordners gelöscht (aufgelöster Pfad, Symlinks werden nicht verfolgt), eine fehlende Datei ist
  kein Fehler. Dazu gehen Dateien im `runs`-Ordner weg, zu denen keine Zeile gehört und die älter als die
  Aufbewahrung sind. Gelöscht wird in Häppchen zu 5000 Zeilen mit Commit dazwischen; kein `VACUUM` (siehe
  docs/03-DATA-MODEL.md §7). Das eigene, schärfere Aufräumen von `host-reachability` (24 Stunden) bleibt bestehen.
  Der Lauf meldet `{retention_days, runs_deleted, files_deleted, orphans_deleted, chunks}`. Ein gespeicherter Wert
  außerhalb von 1 bis 3650 zählt als nicht gesetzt (Vorgabe 30).
- `notifications.push_types`: Zuordnung von Meldungsart zu `{"enabled": true, "priority": null}`.
  `GET /settings/notification-types` liefert die vollständige Liste aus dem Code als `[{"id": "host.down",
  "label": "Server nicht erreichbar"}, ...]`, ebenfalls mit `settings.write`. Die Tabelle unter
  Einstellungen → Benachrichtigungen verwendet diese Liste. `enabled` ist ein echter Wahrheitswert;
  `priority` ist `min`, `low`, `default`, `high`, `urgent` oder `null`. Fehlende Arten und Felder erhalten
  `enabled: true` und `priority: null`; `GET /settings` und die PUT-Antwort liefern den vollständigen Stand.
  PUT ersetzt die Zuordnung. Unbekannte Arten, zusätzliche Felder und ungültige Werte ergeben `422`, ohne
  den gespeicherten Stand zu ändern. Beispiel:
  `PUT /settings/notifications.push_types` mit `{"value": {"host.down": {"enabled": false, "priority": "urgent"}}}`.
  Die Absender kennzeichnen ihre Art mit `payload.notification_type`. Eine ausgeschaltete Art wird am Kanal
  für Push-Nachrichten einschließlich seiner weiteren Push-Kanäle übersprungen (`skipped`), bleibt aber im Meldungsverlauf.
  Der Katalog enthält die Meldungsarten des Kerns und die Einträge der installierten Erweiterungen aus deren
  `notification-types.json` (`{"kennung": "Bezeichnung"}`). Ein Kanal berücksichtigt die Push-Regeln mit
  `uses_push_preferences = true`; die Priorität erhält er dann über `payload._push_priority`.
  Eine explizite Priorität übersteuert für ntfy die Zuordnung nach Schweregrad; sie steht als Zahl 1 bis 5
  im Header `Priority` und im JSON-Feld `priority`. `null` erhält die bisherigen Einstellungen der Erweiterung
  (`priority_info`, `priority_warning`, `priority_critical`, ohne Anpassung 3/4/5).
  Mindest-Stufen und Wartungsfenster gelten weiter. Testnachrichten umgehen die Regeln je Art.
  Meldungen ohne bekannte Art werden wie bisher versandt. Die Speicherung nutzt das vorhandene JSON-Feld
  des Settings-Modells, ohne Datenbankmigration. Änderungen stehen unter `system.settings.changed` im Protokoll.
- `notifications.retention_days`: Der nächtliche Kern-Job `notifications-retention` (03:30 Uhr in der eingestellten
  Zone, eigene Minute neben den anderen Aufräum-Jobs) löscht Meldungen, die vor mehr als so vielen Tagen entstanden
  (`notifications.ts`), gelesen oder ungelesen, samt ihren Zustellprotokollen (`notification_deliveries`). **Eine
  ungelesene kritische Meldung (`severity` `critical`, noch nicht gelesen) bleibt stehen**, bis jemand sie liest; ist sie
  gelesen, geht sie nach der Frist wie jede andere. Meldungen von heute sind nie betroffen (kleinster Wert: 1 Tag). Gelöscht wird in Häppchen zu 5000 Zeilen mit Commit dazwischen,
  kein `VACUUM` (siehe docs/03-DATA-MODEL.md §7). Der Lauf meldet `{retention_days, deleted, chunks}`. Ein
  gespeicherter Wert außerhalb von 1 bis 3650 zählt als nicht gesetzt (Vorgabe 90). Wer Meldungen länger behalten
  will, stellt die Zahl hoch; ein einmal gelöschtes lässt sich nicht zurückholen.
- `hosts.reachability.*`: Schalter und Abstand des Kern-Jobs `host-reachability` (Erreichbarkeitsprüfung, siehe
  docs/11-ERST-EINRICHTUNG.md, Anhang G.1). Jede Änderung plant den Job sofort neu (`Job.schedule` = `*/N * * * *` ab
  voller Stunde, 60 = stündlich; aus = der Job ist abgemeldet, die Zustände der Server bleiben stehen). Der Job gehört
  der Einstellung: `PATCH`/`DELETE /jobs/{id}` auf ihn antworten mit `409`. Geprüft werden nur Server, deren Zustand
  kein Modul pflegt (`provider_ext_id` leer), nie Beispiel-Server. Ein Wechsel erzeugt das Ereignis
  `host.status_changed` (`{host_id, status, previous}`, WS-Kanal `events`, sichtbar mit `hosts.read`) und, bei
  Ausfall und Rückkehr, eine Meldung mit `payload.path = /hosts/{id}`. Der interne Merker
  `hosts.reachability.state` (welche Ausfälle schon gemeldet sind) steht nicht in `GET /settings`.
- `system.update_check.*`: Schalter des täglichen Kern-Jobs `system-update-check` („Nach Updates suchen“, 04:41 Uhr in
  der eingestellten Zone) und welche Versionen zählen. Der Schalter plant den Job sofort neu; `PATCH`/`DELETE /jobs/{id}`
  und `POST /jobs/{id}/run` auf ihn → `409` (Knopf „Jetzt suchen“ = `POST /system/updates/check`, mit Minuten-Drossel; ein Weg
  über die Jobs würde sie umgehen). Datenschutz: ghcr.io und github.com (Freigabe am Release, Downloads über
  `*.githubusercontent.com`) gehören zu GitHub, das bei jeder Prüfung die IP-Adresse und den Zeitpunkt sieht, sonst nichts (der User-Agent `nodvard-deck` nennt keine Version); die tägliche Prüfung lässt sich abschalten. Siehe
  „System und Sicherungen“.
- `system.update_check.auto_security`: Zustimmung des Inhabers, dass Deck Sicherheitsupdates derselben Reihe selbst
  einspielt. In dieser Version spielt Deck noch nichts selbst ein; das kommt erst mit signierten Anforderungen an den
  Update-Helfer. Ändern darf nur der Inhaber, mit `current_password` und, wenn Zwei-Faktor an ist, `totp_code` (wie bei
  `ssh.confirm_new_host_keys`); andere bekommen `403`, der Versuch steht als `denied` im Protokoll. Gezeigt in
  `GET /system/updates` (`auto_security`).
- `ssh.confirm_new_host_keys`: `true` = Terminal, Jobs und Status merken den Schlüssel eines Servers, für den noch
  keiner gemerkt ist, nicht still; die Verbindung scheitert, bevor Anmeldedaten gesendet werden, bis der Fingerabdruck
  über `POST /hosts/{id}/check` und `POST /hosts/{id}/known-hosts` bestätigt ist. `false` = der Schlüssel wird beim
  ersten Verbinden gemerkt, nur noch als ausdrückliches Abschalten. Ohne gespeicherten Wert gilt `true`, auch bei
  Installationen, die vor der Einstellung angelegt wurden. Gemerkte Schlüssel betrifft das nie; nach `DELETE /hosts/{id}/known-hosts/{key_type}`
  ist die Bestätigung für diesen Server immer nötig. Ist `NODVARD_DECK_SSH_CONFIRM_NEW_HOST_KEYS` gesetzt
  (`true`/`false`, leer zählt als nicht gesetzt), meldet `GET /settings` deren Wert, und `PUT` antwortet mit `409`.
- `system.trusted_proxies`: Nur von diesen Gegenstellen gelten `X-Forwarded-For` (rechteste Adresse, die selbst kein
  eingetragener Proxy ist; trägt man ein ganzes Netz ein, kann jedes Gerät darin eine Adresse vorgeben) und
  `X-Forwarded-Proto`, für HTTP und WebSocket; sonst bleiben sie unbeachtet. Gilt sofort. Ändern darf nur der Inhaber
  (Owner); andere bekommen `403`, der Versuch steht als `denied` im Protokoll.
  Gespeichert wird die einheitliche Schreibweise. Ist `NODVARD_DECK_TRUSTED_PROXIES` gesetzt (Komma-getrennt), meldet
  `GET /settings` deren Liste, und `PUT` antwortet mit `409`. Anleitung: docs/12-REVERSE-PROXY.md.
- Alle Änderungen dieser Tabelle ab `system.timezone` stehen mit altem und neuem Wert im Protokoll
  (`system.settings.changed`, `target_id` = Schlüssel). Ebenso die vier Schlüssel des Freigabe-Gates
  (`autonomy.mode`, `autonomy.max_risk`, `security.deny_patterns`, `maintenance.windows`). Eine Liste, die als JSON
  länger als 8000 Zeichen ist, steht dort nur mit ihrer Länge (`{"truncated": true, "count": n}`).

### System und Sicherungen

Alle Pfade unter `/system`. **Ansehen** braucht `system.read` (Rolle `admin` hat es über `*`). Alles, was
ändert oder Daten herausgibt, darf **nur der Owner** (`require_owner`: die Rolle allein reicht nicht, auch `*`
nicht, sonst `403`). Mit **[PW]** markierte Aufrufe verlangen zusätzlich `current_password` im Body: fehlt es →
`403`, falsch → `400`, zu oft falsch → `429` (dieselbe Drosselung wie bei `/me/password`). Passwörter stehen nie
in der Adresse und nie im Protokoll. Mit **[2FA]** markierte Aufrufe verlangen bei eingeschalteter Zwei-Faktor-Anmeldung
zusätzlich `totp_code`, den aktuellen sechsstelligen Code aus der App (Wiederherstellungs-Codes gelten hier nicht): fehlt er →
`403` (`code` `totp_missing`), falsch → `400` (`code` `totp_wrong`), schon benutzt → `400` (`code` `totp_used`), zu viele falsche →
`429` mit `Retry-After` (siehe „Zwei-Faktor-Code bei Update und Rückweg“ unten). Ohne Zwei-Faktor wird `totp_code` nicht beachtet.

| Methode, Pfad | Recht | Zweck |
|---|---|---|
| `GET /system/info` | `system.read` | `version`, `build` (genaue Version des Release-Images bzw. `NODVARD_DECK_BUILD`, sonst `null`), `image` (Herkunft ohne Tag, aus der Datei `/app/image-info.json` im Image bzw. `NODVARD_DECK_IMAGE`; `null` bei einem selbst gebauten Image), `timezone`, `data_dir`, `data_free_bytes`, `database` (`sqlite`/`postgresql`), `updater_available` (ein Update-Helfer ist eingerichtet und antwortet; dieselbe Quelle wie `present` in `GET /system/updates/helper`), `pre_update_copies[]` (die neuesten, höchstens drei **Kopien der Datenbank von vor einer Migration**, neueste zuerst: `name`, `created_at`, `from_version`, `to_version`, `size`; ohne Pfade; leer, solange es noch keine gibt) |
| `GET /system/updates` | `system.read` | Stand von „Nach Updates suchen“, **ohne** Anfrage ins Netz (aus `<Datenordner>/update_check.json`): `current` (laufende Version: beim offiziellen Image dessen genaue Version aus `/app/image-info.json`, auch eine Vorabversion wie `0.6.0-rc1`; sonst `version`), `latest` (neueste Version im Kanal laut letzter gelungener Prüfung; `null`, wenn noch nie geprüft oder geprüft, aber keine passende Version gefunden; dann ist `checked_at` gesetzt), `latest_digest` (sha256 des Manifests, wenn bekannt), `available` (`latest` neuer als `current`, Semver), `auto_security` (Einstellung `system.update_check.auto_security`), `kinds` (Arten der Änderungen aller Versionen nach `current` bis `latest`, Teilmenge von `sicherheit`, `behoben`, `verbessert`, `neu`, `dokumentation` in dieser Reihenfolge; für ältere Deck-Versionen wird `dokumentation` bei der Veröffentlichung als `verbessert` in die Image-Annotation geschrieben; leer ohne Update), `kinds_complete` (`false`, wenn für mindestens eine dieser Versionen die Arten unbekannt sind: ältere Images ohne Annotation, Abruf gescheitert, mehr als 10 Versionen, oder seit der Suche läuft eine andere Version; `kinds` sagt dann nicht, dass nichts Wichtiges dabei ist), `channel` (`stable`/`beta`), `enabled` (tägliche Prüfung an), `checked_at` (letzte gelungene Prüfung), `attempted_at` (letzter Versuch), `source` (`cache`; `offline`, wenn der letzte Versuch scheiterte), `error` (dann „Konnte nicht prüfen (offline?).“; `latest`, `checked_at`, `attempted_at` und `error` gelten nur für den eingestellten Kanal), `official_image` (das Image stammt laut `image` genau aus `ghcr.io/nodvard/deck`), `image`, `official_image_name`, `helper` (wie `updater_available` in `GET /system/info`), `release_notes_url` (`https://github.com/nodvard/deck/blob/v<latest>/CHANGELOG.md`), `approved` (zu `latest` und `latest_digest` liegt eine Freigabe in gültiger Form am Release, nur dann gibt es das Update per Knopf; die Signatur prüft der Update-Helfer), `approved_key` (Kennung des Schlüssels, den sie nennt) |
| `POST /system/updates/check` | `system.read` | Jetzt nachsehen; Antwort wie `GET`, mit `source: live`. Scheitert die Abfrage (offline, `401`/`429`/`5xx` der Registry, kaputtes JSON, Zeitlimit), kommt **kein** Fehlercode, sondern der letzte Stand mit `source: offline` und `error`. Höchstens **einmal pro Minute** für alle zusammen, sonst `429` „Gerade erst nachgesehen …“ mit `Retry-After` |
| `GET /system/updates/helper` | `system.read` | Zustand des Update-Helfers, ohne Anfrage ins Netz (siehe „Update-Helfer“ unten): `present` (eingerichtet und antwortet, Herzschlag höchstens 90 s alt), `reason` (warum nicht: `missing` nicht eingerichtet, `stale` antwortet nicht, `unsafe` Kanal nicht sicher, `proto` andere Protokollversion, `invalid` unlesbarer Status; sonst `null`), `ready` (könnte jetzt ein Update einspielen), `ready_reason` (fester Bezeichner des Helfers: warum er nicht bereit ist, oder ein Hinweis wie `channel_cluttered`; `finishing`: der letzte Vorgang ist entschieden, der Helfer räumt nur noch auf, `ready` ist dann `false` und `busy` `null`), `state` (`idle`/`busy`/`unsafe`/`error`), `helper_version`, `heartbeat_at`, `target` (`current_version`, `floating_tag` = `latest` oder `X.Y`, `null` bei fester Version; `pinned`: die Version steht fest in der Compose-Datei, `X.Y.Z` oder Digest), `busy` (`id`, `action` `update`/`rollback`, `step`, `since`), `previous` (Rückweg: `version`, `until`; nur solange er gilt; dazu `data_revert`: `true` = die laufende Version hat beim Start die Datenbank umgebaut, beim Rückweg geht alles seit `data_since` verloren und `accept_data_loss` ist Pflicht, `false` = die Daten bleiben, `null` = unklar, der Rückweg wird dann mit `data_unclear` abgelehnt – dieselbe Entscheidung wie bei `POST /system/updates/rollback`; `data_since`: Unix-Zeit des Umbaus, nur bei `data_revert: true`), `last_result` (`id`, `action`, `from`, `to`, `outcome`, `code`, `finished_at`), `pending` (eigene Anforderung ohne Ergebnis, höchstens 30 Minuten alt: `id`, `action`, `to`, `at`), `request_options` (was der Helfer in einer Anforderung zusätzlich annimmt; `["digest"]`: ein Update geht nur mit `latest_digest` aus `GET /system/updates`, ohne ihn zeigt die Oberfläche keinen Knopf, sondern bittet, erneut nach Updates zu suchen), `request_versions` (Versionen der Anforderung, die der Helfer versteht; mit `2` prüft er die Freigabe samt Signatur, sonst zeigt die Oberfläche den Hinweis, den Helfer zu erneuern). Zeiten als Unix-Zeit in Sekunden, Gründe und Codes nur feste Bezeichner, nie Freitext. Ist `present` `false`, ist `ready` `false`, `request_options` und `request_versions` leer und alle übrigen Felder außer `reason` sind `null`; nur `last_result` (bei `stale`) und `pending` können trotzdem gesetzt sein. Hält nebenbei neue Ergebnisse des Helfers im Protokoll fest |
| `POST /system/updates/apply` | Owner [PW] [2FA] | `{version, current_password, totp_code?}`: Update auf `version` beim Update-Helfer anfordern → `202` `{request_id, action: "update", from, to, data_revert: false, digest, signed}` (`Cache-Control: no-store`); das Ergebnis steht danach in `GET /system/updates/helper`. `version` muss die neueste gefundene sein (`latest` aus `GET /system/updates`) und neuer als die laufende. Kann der Helfer einen Digest prüfen (sein Status nennt `request_options: ["digest"]`), schickt Deck den Digest aus der letzten Update-Suche mit. Der Helfer löst das bewegliche Tag trotzdem selbst auf und zieht nur, wenn es noch genau auf diesen Digest zeigt, sonst lehnt er mit `digest_changed` ab (erneut nach Updates suchen); so wird nie still etwas anderes eingespielt als das, was die Suche gezeigt hat. `digest` in der Antwort und im Protokoll (`system.update.requested`) nennt ihn, bei einem älteren Helfer ist er `null`. Die Freigabe aus der Update-Suche geht als Anforderung `v: 2` mit, wenn der Helfer sie kennt (`request_versions` enthält `2`); dann ist `signed` `true`. `409`: Helfer fehlt oder antwortet nicht (`helper_missing`), räumt noch auf (`helper_finishing`), ist beschäftigt (`helper_busy`) oder nicht bereit (`helper_not_ready`), Kanal nicht sicher (`channel_unsafe`), schon eine Anforderung offen (`request_pending`), nicht das offizielle Image (`not_official_image`), Version in der Compose-Datei fest eingetragen (`pinned`), kein Digest aus der Update-Suche, obwohl der Helfer einen ziehen könnte (`no_digest`, erneut nach Updates suchen), keine gültige Freigabe für diese Version am Release (`not_released`, von Hand geht das Update trotzdem); `422`: keine gültige Version (`bad_version`), Vorabversion (`prerelease`), nicht die neueste gefundene (`not_latest`), nicht neuer als die laufende (`not_newer`), passt nicht zum Tag der Compose-Datei (`tag_mismatch`); `429` (`rate_limited`) mit `Retry-After` |
| `POST /system/updates/rollback` | Owner [PW] [2FA] | `{current_password, accept_data_loss?, totp_code?}`: Rückweg auf die Version vor dem letzten Update (`previous` in `GET /system/updates/helper`) anfordern → `202` wie bei `apply`, mit `action: "rollback"`. Hat die laufende Version beim Start die Datenbank umgebaut, gehen die Daten mit zurück (`data_revert: true`: alles seit dem Update geht für das Dashboard verloren, der ersetzte Stand liegt danach im Datenordner unter `restore/replaced-…`): dann ist `accept_data_loss: true` Pflicht (sonst `422` `accept_data_loss`), und vor der Anforderung wird die Vormerkung `.boot/rollback.json` geschrieben; scheitert danach das Schreiben der Anforderung oder endet der Rückweg mit `refused` oder `aborted`, wird sie wieder gelöscht. `409` wie bei `apply` außer `not_official_image` und `pinned`, dazu `no_previous` (kein Rückweg, oder er gilt nicht mehr), `data_unclear` (die Datenbank wurde umgebaut, aber der Eintrag passt nicht genau zu diesem Rückweg oder die Kopie fehlt), `marker_failed` (die Vormerkung ließ sich nicht speichern, nichts angefordert); `429` wie bei `apply` |
| `GET /system/connection` | `system.read` | Wie diese Anfrage ankommt: `peer` (direkte Gegenstelle), `client` (verwendete Adresse), `scheme` (`http`/`https`), `via_trusted_proxy`, `forwarded_headers` (Anfrage trug `X-Forwarded-*`), `trusted_proxies`, `trusted_proxies_source` (`setting`/`env`), `untrusted_proxies` (`[{address, last_seen}]`: Gegenstellen aus dem Heimnetz, über die in den letzten 7 Tagen eine Anmeldung des Inhabers mit richtigem Passwort und `X-Forwarded-For` kam und die nicht eingetragen sind, zuletzt gesehene zuerst; nur Adresse und Zeit, höchstens 8, nur im Speicher; nur für den Inhaber gefüllt, sonst leer) |
| `GET /system/openapi.json` | `system.read` | Das vollständige OpenAPI-Dokument (alle Endpunkte samt mitgelieferter Erweiterungen); ersetzt das öffentliche `/openapi.json`, das es nur im Entwicklungsmodus oder mit `NODVARD_DECK_API_DOCS=1` gibt |
| `GET /system/backups` | `system.read` | `config`, `key` (`key_id`, `created_at` – nie Schlüssel oder Passwort), `target` (`dir`, `default_dir`, `external_root`, `external_available`, `same_storage_as_data`, `free_bytes`, `error`), `backups[]` (`name`, `size`, `created_at`, `app_version`, `key_id`, `mode`, `status` `ok`/`ungeprueft`/`beschaedigt`, `checked_at`, `check_ok`, `key_current`), `last_run`, `running`, `sqlite`, `limits` |
| `PUT /system/backups/config` | Owner, [PW] nur bei gefährlicher Änderung | `{enabled, schedule, keep, dir, include_runs, current_password?}`; `current_password` ist **optional** und nur nötig, wenn `keep` kleiner wird als bisher, `enabled` von an auf aus geht oder `dir` sich ändert (sonst `403` mit Text, welche Änderung das Passwort braucht; falsch `400`, zu oft `429` wie bei den anderen [PW]-Endpunkten; geprüft **vor** der Eingabeprüfung, ohne dass ein Ordner angelegt wird); `keep` 1–60, `schedule` Cron, `dir` nur `<Datenordner>/backups` oder `/backups` bzw. darunter (absolut, kein `..`, kein Symlink, `realpath` gleich, beschreibbar) → sonst `422`; `enabled` ohne Sicherungspasswort → `409`. Antwort wie `GET /system/backups` |
| `PUT /system/backups/key` | Owner [PW] | `{current_password, password}` (≥ 12 Zeichen, sonst `422`). Antwort **einmalig** `{key_id, recipient, recovery_key}` (`Cache-Control: no-store`) |
| `POST /system/backups/run` | Owner | Jetzt sichern, als Kern-Job `system-backup` im Hintergrund → `202`; läuft schon eine Sicherung oder fehlt der Schlüssel → `409` |
| `POST /system/backups/download` | Owner [PW] [2FA] | `{current_password, mode: "schluessel"\|"passwort", password?, totp_code?}` (der Code, weil die Datei alle Schlüssel enthält, auch den der Zwei-Faktor-Anmeldung) → `202` mit Ticket `{ticket, job_id, status, filename, size, error, expires_in, url}`; baut die Datei im Hintergrund (kein Job: dessen Protokoll enthielte die Parameter). `409` bei laufender Sicherung, fehlendem Schlüssel (`schluessel`) oder ohne SQLite-Datei; `507` bei zu wenig Platz |
| `GET /system/backups/download-jobs/{job_id}` | Owner, nur eigener Download | Stand `building`/`ready`/`failed`, Antwort wie beim Ticket (`404` für fremde, unbekannte oder schon abgeholte Downloads). `job_id` ist eine eigene Zufalls-ID und taugt **nicht** zum Herunterladen: die Oberfläche fragt sie jede Sekunde ab, sie steht also in der Adresse (und im Zugriffsprotokoll), das Ticket nicht |
| `GET /system/backups/download/{ticket}/status` | Owner, nur eigenes Ticket | **Veraltet** (`deprecated`), bleibt als Übergang: dasselbe über das Ticket in der Adresse. Neu: `download-jobs/{job_id}` |
| `GET /system/backups/download/{ticket}` | Ticket | Liefert die Datei als normalen Download (Streaming). Gilt **einmal**, 5 Minuten ab Fertigstellung, nur solange der Nutzer noch aktiver Owner ist; sonst `404`, während des Baus `409`. Eine eigens gebaute Datei wird danach gelöscht. Ein Filter am uvicorn-Zugriffsprotokoll (`core/log_filters.py`) ersetzt das Ticket in Adressen unter `…/system/backups/download/` durch `…` |
| `POST /system/backups/{name}/ticket` | Owner [PW] [2FA] | `{current_password, totp_code?}`: Ticket für eine vorhandene automatische Sicherung (die Datei bleibt liegen) |
| `POST /system/backups/{name}/verify` | Owner | sha256 der Datei gegen die Prüfsummen-Datei `<name>.json` (ohne Passwort) → `{ok, detail}` |
| `DELETE /system/backups/{name}` | Owner [PW] | Body `{current_password}`; nur `nodvard-deck-sicherung-*.ndbak` mit eigenem Kopf, die `.json` daneben verschwindet mit → `204`; unbekannt → `404` |

- **Datei** `nodvard-deck-sicherung-YYYYMMDD-HHMMSS.ndbak` (Ortszeit der eingestellten Zone): Zeile 1
  `NODVARD-DECK-BACKUP/1`, Zeile 2 JSON-Kopf (`format`, `created_at`, `app_version`, `mode`, bei `schluessel`
  zusätzlich `key_id` und `kdf` = Argon2id-Parameter mit Salz), danach eine normale age-Datei mit einem tar.gz:
  `db/lattice.db` (Online-Kopie, `integrity_check`), `files/master.key`, `files/vault_keyring.json`,
  `files/jwt_secret.key` (nur ohne `NODVARD_DECK_JWT_SECRET`), `files/ext/**`, `files/branding/**`, optional
  `files/runs/**`, zuletzt `manifest.json` (Kopie des Kopfes, Alembic-Köpfe, Erweiterungen, sha256 jeder Datei).
  Nicht enthalten: `*-wal`/`-shm`/`-journal`, `setup_code.txt`, `backups/`, `restore/`, `.boot/`, Temp-Dateien,
  Symlinks, der Verlauf `metrics.db` und Git-Einstellungen in `ext/`, `branding/`, `runs/` (`core/backup/format.py`):
  `.gitattributes`, `.gitmodules`, `.gitconfig`, eine Datei `.git` und unter `.git/` alles außer Objekten und Packs
  (`objects/`), `refs/`, `HEAD`, `index` und `packed-refs` – also weder `config` noch `hooks/`, `info/`, `logs/`. Das
  gilt unabhängig von Groß- und Kleinschreibung, auch für Windows-Formen wie `.git.` oder den Kurznamen `GIT~1`;
  `.gitignore` bleibt, der Git-Verlauf selbst ist weiter in der Sicherung. Übersprungene Symlinks werden nicht verschwiegen: `last_run.warnings`
  (Liste deutscher Sätze, älteren Einträgen fehlt das Feld) nennt Anzahl und höchstens fünf Pfade
  („2 Verknüpfungen in Erweiterungsdaten nicht gesichert: ext/a/link, …“); die Sicherung gilt trotzdem als gelungen.
- Eine neuere Formatversion wird mit Hinweis abgelehnt. Beim Lesen gelten Deckel: scrypt log2 N ≤ 18 (geschrieben
  wird mit 18; 2^20 wären 1 GiB und werfen einen Pi mit 1 GB aus dem Speicher), Argon2id m ≤ 128 MiB (geschrieben
  mit 64 MiB), t ≤ 10, p ≤ 4. Mit `age`/`rage` selbst verschlüsselte Dateien mit größerem Arbeitsfaktor werden
  abgelehnt.
- Protokoll: `system.backup.created`, `.failed`, `.config_changed`, `.key_set` (`key_id`), `.download_prepared`,
  `.downloaded`, `.verified`, `.deleted`. Eine fehlgeschlagene automatische Sicherung meldet sich zusätzlich als
  Benachrichtigung („Sicherung fehlgeschlagen“, `payload.path` = `/settings/system`). Darf Nodvard Deck in den Ordner nicht
  schreiben, nennen Protokolleintrag, Benachrichtigung und Job-Lauf (`GET /jobs/{id}/runs`, `GET /runs/{id}/output`, Kanal
  `runs.<id>`; auch für Viewer lesbar) nur den kurzen Grund; Benachrichtigung und Job-Lauf sagen dazu, dass die Hilfe
  unter Einstellungen → System bei „Sicherung“ steht. Der ganze Hinweis mit Benutzernummer und Befehl steht nur
  in `last_run.error` und `target.error` von `GET /system/backups` (`system.read`) und in den `422`-Antworten der
  Endpunkte unter `/system/backups`, die nur der Owner aufruft (Einstellungen speichern, Prüfen, Herunterladen, Löschen).
- **Nach Updates suchen** (`core/updates.py`): Quelle ist ghcr.io, dazu die Freigabe am Release auf github.com. Anonymes Token
  (`GET https://ghcr.io/token?scope=repository:nodvard/deck:pull&service=ghcr.io`, klappt nur bei einem öffentlichen Paket),
  Tags (`GET /v2/nodvard/deck/tags/list?n=100`, weitere Seiten über den `Link`-Kopf, höchstens 10; eine Folgeseite auf einem anderen
  Host oder Pfad gilt als Fehler, der Token geht nur an ghcr.io). Es zählen nur Tags `1.2.3`, im Kanal `beta` auch `1.2.3-rc1`;
  verglichen wird nach Semver mit der laufenden Version (`current`); abweichend davon werden Teile einer Vorabversion mit Zahl am Ende
  natürlich sortiert (`rc1 < rc2 < rc9 < rc10`). Danach optional das Manifest der neuesten Version (OCI-Index),
  dessen sha256 `latest_digest` ist. Je Anfrage höchstens 5 s; Token und Tags zusammen höchstens 15 s, das Manifest bekommt nur die
  restliche Zeit (reicht sie nicht, bleibt `latest_digest` `null`, die Prüfung gilt trotzdem). In derselben Zeit holt sie
  die Manifeste der übrigen neuesten Versionen im Kanal, die neueste zuerst, höchstens 10 insgesamt, und liest daraus die
  Annotation `io.nodvard.deck.release.kinds` (`kinds`, `kinds_complete`; geschrieben von `release.yml`). Gefragt werden
  immer dieselben Versionen, egal welche läuft: Sonst verriete die älteste Anfrage ghcr.io die laufende Version. In
  `kinds` zählen nur die Versionen nach `current`; eine leere Annotation oder eine unbekannte Art macht `kinds_complete`
  `false`. Ist darunter `sicherheit`, kommt nach der Suche einmal je Version die Meldung „Sicherheitsupdate verfügbar“
  (`severity` `critical`, `payload.path` `/settings/system`) über alle eingerichteten Meldungskanäle (etwa Push); welche Version
  schon gemeldet ist, merkt sich `system.update_check.security_notified` (nicht in `GET /settings`).
  Mit Digest holt die Prüfung
  in der Zeit, die dann noch bleibt, die Freigabe `freigabe.json` und ihre Signatur `freigabe.json.sig` am Release
  `v<latest>` (`https://github.com/nodvard/deck/releases/download/…`, je höchstens 4 KiB, ohne Token; Weiterleitungen nur
  per https zu `github.com`, `objects.githubusercontent.com` und `release-assets.githubusercontent.com`, höchstens 3).
  Weiterleitungen nur auf Port 443 und ohne Benutzerangabe. Sie zählt nur, wenn Form, Repository, fertige Version und
  Digest genau passen und sie gerade gilt; dann ist `approved` `true` und `approved_key` nennt den Schlüssel. Die
  Signatur selbst prüft erst der Update-Helfer mit seinen eingebauten Schlüsseln. Fehlt die Freigabe oder passt sie
  nicht, gilt die Prüfung trotzdem, nur `approved` bleibt `false`; scheitert nur ihr Abruf (Netz, Zeit), bleibt eine
  schon gefundene Freigabe für dieselbe Version und denselben Digest stehen. Jede Antwort wird ungepackt angefordert
  (`Accept-Encoding: identity`, eine gepackte gilt als Fehler) und höchstens 1 MiB gelesen (eine größere `Content-Length` bricht vor dem
  Lesen ab). User-Agent `nodvard-deck`, ohne Version. Ein Proxy aus der Umgebung (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) gilt hier, anders als bei Erweiterungen (`ctx.http`). Es läuft immer nur eine Prüfung zur Zeit. Eine Antwort ohne Tag-Liste gilt als
  Fehler (der letzte Stand bleibt); eine vollständige Antwort ohne passende Version ist ein Ergebnis (`latest: null`). Täglich als
  Kern-Job `system-update-check` (Schalter `system.update_check.enabled`), dazu der Knopf „Jetzt suchen“. Gefragt wird das Repository der laufenden Ausgabe
  (`nodvard_deck/edition.py`: Nodvard Deck `nodvard/deck`, gleich `core.updates.REPOSITORY`; Nodvard Dock `nodvard/dock`), Token,
  Tag-Liste, Folgeseiten und Manifest immer für dasselbe. Eine Freigabe holt nur die Suche für `nodvard/deck`; bei Nodvard Dock
  fragt sie nur ghcr.io, `approved` bleibt dort `false`, und ein Ergebnis im Cache gilt nur für die Ausgabe, die gesucht hat. Das Release-Image trägt Herkunft, Version und Ausgabe als Datei
  `/app/image-info.json` (Build-Argumente `IMAGE`, `VERSION` und `EDITION` in `release.yml`/`deploy/Dockerfile`,
  `nodvard_deck.image_info`; keine Umgebungsvariable, die beim Neuanlegen eines Containers mitwandern könnte; `NODVARD_DECK_EDITION`
  geht der Datei vor, ohne Angabe gilt Nodvard Deck). Daran erkennt die Oberfläche ein selbst gebautes Image. Vorrang für `image` und `build`
  in `GET /system/info`: nicht leere Variable `NODVARD_DECK_IMAGE`/`NODVARD_DECK_BUILD` (alt `LATTICE_*`), dann die Datei, sonst `null`.
  `current` der Update-Suche (und `from_version`/`to_version` der Kopien vor Updates) nimmt beim offiziellen Image dagegen zuerst die
  Version aus der Datei, dann `build` (jeweils nur, wenn es eine Version ist), sonst `version`: eine veraltete Variable verfälscht den
  Vergleich nicht.
- **Update-Helfer** (`services/update_helper.py`, `core/updater_client.py`): ein eigenes kleines Programm neben dem Dashboard, das
  Update und Rückweg selbst einspielt. Beide teilen sich nur einen Ordner (`/app/updater`, Einstellung `updater_dir`, nur für Tests
  ändern), ein eigenes Volume, das der Helfer einrichtet und das root gehört: `status.json` schreibt nur der Helfer, das Dashboard
  legt Anforderungen (Aktion, Version und, wenn der Helfer es annimmt, den Digest) unter `requests/` ab. Fehlt der Ordner, gibt es keinen Helfer (`present: false`,
  `reason: missing`). Eingeschaltet wird der Helfer mit einer eigenen Compose-Datei
  ([deploy/README „Update-Helfer“](../deploy/README.md#update-helfer)). Verbindlich entscheidet immer der Helfer;
  was das Dashboard vorher prüft, sorgt für eine klare Antwort. Gezogen wird immer der Digest, auf den das bewegliche
  Tag im offiziellen Repository gerade zeigt; ein Digest in der Anforderung muss derselbe sein (`digest_changed`), er
  wählt nichts aus. Ein Update per Knopf gibt es nur mit Freigabe aus der Update-Suche (`approved`, sonst `409`
  `not_released`); kennt der Helfer Anforderungen `v: 2` (`request_versions` in `GET /system/updates/helper`), geht sie
  mit (`signed: true` in der Antwort und im Protokoll), ein älterer Helfer bekommt `v: 1`. Das Image gilt nur, wenn sein
  Versions-Label genau die angeforderte Version ist, diese neuer als
  die laufende ist und zur Reihe im Tag passt. Wer Dashboard oder Kanal in der Hand hat, kann so keine andere Version
  aus der Registry einspielen, und ein Rückruf über das Zurücksetzen des Tags greift. Ein Update kann eine signierte
  Freigabe mitbringen (Anforderung `v: 2`): Sie nennt Repository, Version und Digest, gilt nur in ihrem Zeitfenster und
  muss mit einem Schlüssel signiert sein, der fest im Helfer steht (Ed25519); das Tag muss trotzdem auf den Digest zeigen.
  Signiert sind genau die sieben Felder der Freigabe (`fmt`, `repo`, `version`, `digest`, `key_id`, `issued_at`,
  `expires_at`) als sortiertes, kompaktes ASCII-JSON; die Signatur steht hexadezimal in `sig`, eine Freigabe gilt höchstens
  366 Tage. Der Helfer rechnet die Signatur erst nach den Grenzen nach und höchstens einmal je Runde (weitere: `busy`).
  In seinem Status nennt er `2` unter `request_versions` nur, wenn er mindestens einen Schlüssel eingebaut hat; ohne
  Schlüssel bleibt es bei `v: 1`.
  Eine abgelehnte Freigabe ändert nichts (`unknown_key`, `grant_mismatch`, `grant_expired`, `bad_signature`). Ohne
  Freigabe nimmt der Helfer ein Update noch an, Pflicht wird sie mit 1.0 (dann `signature_required`). Der Rückweg kommt
  ohne Signatur aus: Er setzt nur den Stand davor wieder ein, den der Helfer selbst gesichert hat. Eine Freigabe lässt
  sich mit einer neuen Anforderung erneut verwenden; das bringt nur dieselbe Version, und die Grenze unten gilt
  weiter. Höchstens **eine Anforderung je 10 Minuten**, Update und Rückweg
  zusammen, sonst `429` „Gerade erst angefordert …“ mit `Retry-After`; was nicht angefordert wurde (Kanal nicht sicher, Vormerkung
  nicht gespeichert) oder der Helfer abgelehnt hat (`refused`), zählt nicht. Ablehnungen von `apply`/`rollback` tragen neben
  `detail` einen festen `code` (siehe Tabelle, dazu `403` `not_owner`, `password_missing` und `totp_missing`), bei `helper_missing` und
  `channel_unsafe` auch `helper_reason`, bei `helper_not_ready`, wenn der Helfer einen Grund nennt. Ein falsches Passwort antwortet
  wie bei den anderen [PW]-Endpunkten nur mit `detail`, ein falscher oder schon benutzter Zwei-Faktor-Code mit `code` `totp_wrong`
  bzw. `totp_used`.
- **Zwei-Faktor-Code bei Update und Rückweg** ([2FA]): dieselben Bausteine wie bei `POST /auth/mfa`. Ein Code gilt je Konto nur
  einmal, auch quer zwischen Anmeldung und Bestätigung. Falsche Codes zählen gegen dieselbe Grenze je Konto (10 in 15 Minuten,
  20 in 24 Stunden, Anmeldung und Bestätigung zusammen); danach `429` auch mit richtigem Code. Beginnt die Sperre bei einer
  Bestätigung, steht sie als `auth.password_check_locked` im Protokoll, dazu kommt die Meldung „Zwei-Faktor-Code wird
  durchprobiert“. Jeder falsche Code, der nicht schon an der Sperre scheitert, steht als `auth.totp_check_failed` im
  Protokoll (`detail.aktion` `update_einspielen` bzw. `update_zurueck`, bei einem schon benutzten Code `replayed: true`; nie
  der Code). Während einer Sperre wird nicht mehr geprüft: Dann gibt es `429` und keinen `auth.totp_check_failed`-Eintrag.
  Die Ablehnung steht trotzdem als `system.update.request_refused` bzw. `system.update.rollback_refused` mit `detail.code`
  `confirm_429` im Protokoll. Die Grenze je Konto gilt auch über einen Neustart hinweg (siehe `POST /auth/mfa`), ebenso die für
  falsche Wiederherstellungs-Codes bei Bestätigungen (10 in 5 Minuten je Konto). Nach dem Einspielen einer Sicherung zählt
  deren Protokoll. Dasselbe gilt für Sicherung laden und einspielen ([2FA] in der Tabelle oben).
- Protokoll des Update-Helfers: `system.update.requested` bzw. `system.update.rollback_requested` (`request_id`, `from`, `to`, beim
  Rückweg auch `copy` und `data_revert`); jede Ablehnung als `system.update.request_refused` bzw. `system.update.rollback_refused`
  mit `detail.code` (bei der Bestätigung `outcome` `denied` und `not_owner`, `password_missing` oder `confirm_<Statuscode>`, sonst
  `failure` und der Code der Antwort). Ergebnisse des Helfers stehen **genau einmal je Anforderung** als `system.update.<outcome>`
  im Protokoll (Akteur `system`/`updater`, `target_id` = Anforderung, `outcome` `success` bei `applied` und `reverted`, sonst
  `failure`): `applied` (Update eingespielt), `reverted` (Rückweg eingespielt), `rolled_back` (hat nicht geklappt, die
  Ausgangsversion läuft weiter bzw. wieder), `refused` (vom Helfer abgelehnt), `aborted` (abgebrochen, bevor die neue Version
  gestartet wurde; schon Geändertes ist zurückgebaut), `failed_manual` (die vorige Version ließ sich nicht wieder starten, bitte
  von Hand nachsehen), `external_change` (von außen wurde etwas am Container geändert). Festgehalten wird beim Start (ist danach
  noch etwas offen, alle 30 s, höchstens 30 Minuten), bei `GET /system/updates/helper` und vor jeder neuen Anforderung; welche
  schon eingetragen sind, steht in `<Datenordner>/updater_seen.json` (außerhalb der Datenbank, übersteht also das Zurückspielen
  einer Kopie). Bei `applied`, `reverted`, `rolled_back`, `failed_manual` und `external_change` kommt zusätzlich eine Meldung
  (`payload.path` = `/settings/system`).
- Den Kern-Job `system-backup` steuert nur diese Karte: `PATCH`/`DELETE /jobs/{id}` auf ihn → `409`
  (sonst könnte ein Admin über `jobs.write` die automatischen Sicherungen still abschalten).

**Wiederherstellen** (Owner, `core/backup/restore.py`, `boot.py`): Hochladen → Prüfen → Vormerken → Neustart; eingespielt
wird beim Start, **vor** der Migration. Der Zwischenstand liegt unter `<Datenordner>/restore/<id>/`; es gibt höchstens einen.

| Endpunkt | Recht | Zweck |
|---|---|---|
| `GET /system/restore/status` | `system.read` | `{pending, staged, result, replaced, limits, busy}`: Vormerkung, Zwischenstand (**nur der Owner**), Ergebnis des letzten Einspielens (ohne IP-Adressen), alter Stand (`replaced`: Name, Größe, `expires_at` = wann er von selbst gelöscht wird), Grenzen |
| `PUT /system/restore/upload` | Owner [PW im Kopf] | Roher Strom (`Content-Type: application/octet-stream`, **kein** multipart; sonst `415`). Kopf `X-Confirm-Password`: Anmeldepasswort **prozentkodiert** (UTF-8, `encodeURIComponent`; Kopfzeilen kennen kein Unicode). Owner und Passwort werden geprüft, **bevor** der Body gelesen wird (`403` fehlt, `400` falsch, `429` zu oft). `Content-Length` über `restore_max_upload_bytes` (4 GiB, `NODVARD_DECK_RESTORE_MAX_UPLOAD_BYTES`) → `413` ohne Lesen, ebenso bei Überschreitung mitten im Strom; zu wenig Platz → `507` vor dem Lesen; läuft schon ein Upload/eine Prüfung → `409`; ist etwas vorgemerkt → `409`; keine Sicherung (Kopf) → `422`. `201` `{id, state: "uploaded", size, header: {mode, created_at, app_version}, expires_in}` |
| `POST /system/restore/{id}/inspect` | Owner | `{password}` **oder** `{recovery_key}` (genau eines, sonst `422`). Entschlüsselt in den Staging-Ordner und prüft alles. `200` `{id, state: "ready", summary}`; `summary`: `created_at`, `app_version`, `instance_id`, `mode`, `owner_name`, `users`, `hosts`, `extensions[]`, `includes`, `warnings[]` (u. a. andere Installation, ältere/neuere Version, fehlende Erweiterungen, kein Konto in der Sicherung, übersprungene Git-Einstellungen). Falsches Geheimnis `400` (Upload bleibt, noch ein Versuch), zu wenig Platz `507` (bleibt), `409` bei laufender Arbeit; jeder andere Fehler (beschädigt, zu neu, Erweiterung fehlt, zu groß `413`, nicht einspielbar) `422` und der Zwischenstand ist gelöscht; unbekannte oder fremde `id` `404`. Entschlüsselt wird immer nur **eine** Sicherung zugleich (teilt die Sperre mit den Sicherungen) |
| `POST /system/restore/{id}/schedule` | Owner [PW] [2FA] | `{current_password, sign_out_all: true, totp_code?}` → `200` Vormerkung `{id, source, scheduled_at, expires_in, sign_out_all, backup}`; `restore/pending.json`. Vorher nicht geprüft oder schon etwas vorgemerkt → `409`. Gilt eine Stunde |
| `DELETE /system/restore/pending` | Owner | Verwirft Vormerkung **und** jeden Zwischenstand → `204` |
| `DELETE /system/restore/replaced` | Owner [PW] | Body `{current_password}`; löscht den alten Stand (`restore/replaced-…`, enthält alte Konten und Schlüssel) → `204` |
| `POST /system/restart` | Owner [PW] | Body `{current_password}`. Beendet den Prozess nach der Antwort mit dem Rückgabewert **75**; der Container startet ihn neu (Regel `restart: unless-stopped`; ohne Regel bleibt der Dienst aus). → `202` `{restarting, exit_code: 75}` |
| `PUT /auth/bootstrap/restore/upload`, `POST /auth/bootstrap/restore/{id}/inspect`, `POST …/{id}/schedule`, `DELETE …/restore/pending` | Einrichtungscode | Wie oben, aber ohne Konto und ohne Passwort-Kopf: **nur ohne Konto** (sonst `409`), Kopf `X-Setup-Code` bei jedem Aufruf. `schedule` nimmt `{sign_out_all}` |
| `POST /auth/bootstrap/restart` | Einrichtungscode | wie `POST /system/restart`; nur wenn etwas vorgemerkt ist (sonst `409`) |

- **Härtung beim Entpacken:** tar nur als Strom (`tarfile.data_filter` pro Eintrag, jede Datei schreiben wir selbst mit
  `O_EXCL|O_NOFOLLOW`), Namen nur aus einer Erlaubnisliste (`manifest.json`, `db/lattice.db`,
  `files/{master.key,vault_keyring.json,jwt_secret.key}`, `files/ext/**`, `files/branding/**`, `files/runs/**`),
  nur normale Dateien und Ordner (keine Links, Hardlinks, Geräte, FIFOs), jede Datei einmal, das Manifest zuletzt.
  Git-Einstellungen darin (dieselbe Regel wie unter „Nicht enthalten“ oben, etwa aus älteren Sicherungen) werden nicht
  abgelehnt, sondern **übersprungen**: gelesen und per sha256 gegen das Manifest geprüft, aber nicht geschrieben. Ihre Zahl
  steht in `summary.warnings` („3 Git-Einstellungen aus der Sicherung wurden nicht übernommen, der Verlauf bleibt.“), bis zu
  fünf Namen im Log (`restore_skipped_vcs_entries`).
  Grenzen am **entpackten** Strom: Einträge (`…_MAX_ENTRIES`, 200 000), Einzeldatei und Gesamtmenge
  (`…_MAX_UNPACKED_BYTES`, 16 GiB, nie mehr als der freie Platz abzüglich 128 MiB) – gegen gzip-Bomben.
  Bei **jedem** Fehler wird der Staging-Ordner gelöscht. Kopf und Manifest müssen zusammenpassen, sha256 jeder Datei.
- **Die SQLite-Datei ist nicht vertrauenswürdig:** nur lesend und unveränderlich geöffnet (`mode=ro&immutable=1`),
  `trusted_schema=OFF`, ein Autorisierer, der nur Lesen zulässt, keine Trigger, Ansichten oder virtuellen Tabellen
  (die legt Nodvard Deck nie an), `integrity_check`, und `alembic_version` darf nur Revisionen enthalten, die dieses Image
  kennt (sonst `422` „aus einer neueren Version“ bzw. „Erweiterung … fehlt“). Die Angaben des Manifests zu den Köpfen müssen
  mit der Datenbank übereinstimmen. Ältere bekannte Stände sind in Ordnung (die Migration folgt beim Start).
- **Einspielen beim Start** (`python -m nodvard_deck.boot`, von `entrypoint.sh` statt `nodvard_deck.migrate`): Zwischenstand erneut prüfen (sha256
  aller Dateien, Datenbank gegen die Revisionen **dieses** Images), den aktuellen Stand nach `restore/replaced-<Zeit>/` **verschieben**
  (`rename`, auch `-wal`/`-shm`/`-journal`), den neuen an seinen Platz, bei `sign_out_all` alle Refresh-Tokens löschen,
  `setup_code.txt` löschen, wenn es Konten gibt. Ein Journal (`restore/journal.json`) macht jeden Fehler **und** jeden Absturz mitten
  drin rückgängig; **endgültig** ist es erst, wenn danach auch die Migration gelungen ist (sonst geht der alte Stand zurück).
  `restore/result.json` hält das Ergebnis fest; `main.lifespan` schreibt danach `system.restore.applied`/`.failed` in die jetzt gültige
  Datenbank. Nur noch ein `replaced-…` bleibt; **nach 30 Tagen** löscht es der Kern-Job `restore-cleanup` (täglich 03:40) und beim Start
  `restore.sweep` von selbst (`replaced.expires_at` nennt den Tag). Aufgegebene Zwischenstände und abgelaufene Vormerkungen werden nach einer Stunde gelöscht.
- **Rechte:** nur der Owner (Admin ohne Owner `403`), wo oben [PW] steht zusätzlich das aktuelle Passwort; ohne Konten nur mit Einrichtungscode.
  Eine Vormerkung aus dem Assistenten wird beim Start verworfen, wenn inzwischen ein Konto existiert.
- Protokoll: `system.restore.uploaded`/`.upload_failed`/`.inspected`/`.inspect_failed`/`.scheduled`/`.schedule_failed`/`.cancelled`/
  `.replaced_deleted`, `system.restart.requested`, nach dem Start `system.restore.applied`/`.failed`. Nie ein Passwort, Schlüssel oder Code.
- Ohne Oberfläche: `python -m nodvard_deck.admin restore-backup <datei.ndbak>` prüft und merkt vor (Passwort am Terminal oder aus der
  Standardeingabe mit `--yes`); `deploy/restore.sh sicherung.ndbak` ruft ihn auf.

#### Kopie vor jeder Migration, Sperre und Notseite (`nodvard_deck.boot`, `nodvard_deck.rescue`)

Zusätzlich zum Einspielen (siehe oben) macht `python -m nodvard_deck.boot` vor der Anwendung Folgendes (`backend/src/nodvard_deck/boot.py`,
Zustand in `<Datenordner>/.boot/`, Kopien in `<Datenordner>/backups/vor-update/`; weder `.boot/` noch `backups/` sind Teil einer Sicherung):

- **Sperre** (`.boot/app.lock`, `flock`): Die laufende Anwendung hält sie, `boot` und `admin restore-backup` prüfen sie. Hält jemand sie, endet
  `boot` mit **75** und ändert nichts (so spielt ein `compose run` ohne `--entrypoint` nie unter der laufenden Anwendung etwas ein);
  `admin restore-backup` räumt dann keinen laufenden Upload der Anwendung weg. Wo `flock` nicht geht, gibt es eine Sperre ohne Wirkung.
- **Kopie vor jeder Migration** (nur SQLite): Datenbank online kopieren (`sqlite3.backup`, danach `integrity_check`) nach
  `backups/vor-update/<Zeit>_<von>_<nach>.db` (+ `.json`), vorher mindestens das **1,2-fache** der Datenbank samt WAL frei, es bleiben **drei**.
  **Ohne Kopie keine Migration** (`no_space`, `no_copy`). Notausgang `NODVARD_DECK_SKIP_PRE_MIGRATE_BACKUP=1` (alter Name `LATTICE_…` gilt nur noch in dieser Version).
  Scheitert die Migration, geht die Datenbank auf die Kopie zurück (die Kopie wird dabei umbenannt, nicht kopiert); `state.json` hält
  `last_migration` vorher als `running` fest, ein Absturz mitten drin wird beim nächsten Start genauso behandelt. Den halben Stand verwirft der Rückweg im
  selben Start; nach einem Absturz legt er ihn nach `restore/replaced-…` (außer die Datenbank liegt auf einem anderen Laufwerk und im Datenordner fehlt der Platz).
  Neuere Daten (Downgrade, ausdrücklicher Rückweg) werden nie verworfen: fehlt der Platz, kommt `no_space`.
- **`state.json`:** `app_version`, `db_heads`, `started_ok` (setzt `main.lifespan` nach einem gelungenen Start; `boot` setzt es mit jeder Migration auf
  `false`), `last_migration` (`from_version`, `to_version`, `from_heads`, `to_heads`, `copy`, `state`), `revert` (Journal des Zurücksetzens) und
  `failure` (Art, Grund, bereinigtes Protokoll der letzten 50 Zeilen: keine Pfade, Passwörter, SQL-Parameter).
- **Downgrade-Erkennung:** Kennt das Image Revisionen der Datenbank nicht (sie ist neuer), kommt die Kopie nur automatisch zurück, wenn (1) die letzte
  Migration von Ständen ausging, die dieses Image kennt, und genau den jetzigen Stand erzeugt hat, (2) die Kopie existiert, heil ist und diese Stände
  hat und (3) die neue Version nie erfolgreich gestartet ist (`started_ok=false`). Oder eine ausdrückliche Vormerkung (`.boot/rollback.json`, 24 Stunden gültig,
  von der Notseite oder von `POST /system/updates/rollback`) liegt vor: dann auch nach einem guten Start; die neueren Daten bleiben unter `restore/replaced-…` (30 Tage). Sonst: Notseite.
- **Fehler führen zur Notseite**, nie zu einer Neustart-Schleife: `boot` gibt 1 zurück, `deploy/entrypoint.sh` startet `python -m nodvard_deck.rescue`; fällt die aus, wartet
  der Container (`exec sleep`). Das gilt auch für einen unlesbaren Journal-Eintrag oder einen gescheiterten Rückweg einer Wiederherstellung (`rollback_failed`).

**Die Notseite** (`backend/src/nodvard_deck/rescue.py`, **nur Standardbibliothek**, ein Test prüft das über den Syntaxbaum und startet sie ohne installierte Pakete) antwortet
auf dem Port der Anwendung:

| Methode, Pfad | Antwort |
|---|---|
| `GET /api/v1/health` | **`503`** `{"status":"rescue"}` (nie `ok`: Healthcheck, `scripts/deploy_pi.sh` und ein Helfer erkennen so, dass etwas nicht stimmt). Jeder andere Pfad unter `/api/` ebenso `503` mit `{"status":"rescue","detail":…}` |
| `GET <alles andere>` | Die Seite (`503`, Deutsch, ohne fremde Dateien). **Ohne Code** nur Allgemeines |
| `POST /rescue/unlock` | Notfallcode (`.boot/rescue_code.txt`, wie der Einrichtungscode; steht als Banner im Protokoll) → `303` + Cookie `rescue_session` (HttpOnly, SameSite=Strict, 15 Minuten). Je Adresse 5 Fehlversuche, insgesamt 25 in 10 Minuten, dann `429` (mit `Retry-After`, Antwort erst nach 0,5 s) für jeden weiteren **falschen** Code. Der richtige Code geht immer durch, auch während einer Sperre; Versuche während einer Sperre verlängern sie nicht |
| `GET /rescue/status`, `GET /rescue/log` | mit Cookie: Grund und bereinigtes Protokoll; ohne: nur `{"status":"rescue","unlocked":false}` bzw. `401` |
| `POST /rescue/retry` | mit Cookie: Prozessende mit 75, der Container startet neu (Restart-Regel) und `boot` läuft noch einmal |
| `POST /rescue/rollback` | mit Cookie, nur wenn `boot` eine brauchbare Kopie gefunden hat (sonst `409`): „Stand vor dem Update wiederherstellen“ vormerken, dann wie `retry`. Die Kopie kommt aus dem Zustand, nie aus der Anfrage |
| `POST /rescue/lock` | Sitzung beenden |

POST-Anfragen prüfen `Origin` gegen `Host`. Eine Aktion „Kopie herunterladen“ gibt es **bewusst nicht**: Eine unverschlüsselte Datenbank über HTTP herauszugeben ist
unverantwortlich, und die Verschlüsselung (age/`pyrage`) gehört nicht zur Standardbibliothek. Die Kopie liegt im Datenordner.

Grenzen: Inhalt höchstens 4 KiB (sonst `413`), Anfragezeile und Kopfzeilen zusammen höchstens 32 KiB, dabei unter 100 Kopfzeilen (sonst `431`); 3 s für die Kopfzeilen,
10 s für die ganze Verbindung, danach wird sie geschlossen. Höchstens 64 Verbindungen gleichzeitig, davon 8 je Absender (bei IPv6 je /64-Netz); `127.0.0.1` und `::1`
(Healthcheck im Container) haben 8 eigene Plätze, die andere nicht belegen können.

### Erweiterungen einrichten und testen

`GET`/`PUT /extensions/{id}/settings`, `PUT`/`DELETE /extensions/{id}/secrets` und `POST /extensions/{id}/test` brauchen
`extensions.manage`.

- `GET /extensions` und `GET /extensions/{id}` liefern zusätzlich (nur additiv, alte Felder
  unverändert): `needs_setup` (bool, nur bei eingeschalteten Erweiterungen: Pflichtfelder
  oder Pflicht-Geheimnisse fehlen, oder der letzte Verbindungstest ist fehlgeschlagen),
  `setup_reasons` (Liste deutscher Sätze, leer wenn alles in Ordnung) und `last_test`
  (`{ok, message, at}` oder `null`; wird nach jeder Änderung der Einstellungen oder
  Zugangsdaten gelöscht). Ohne `extensions.manage` fehlt der Text: `last_test` enthält dann nur
  `ok` und `at`, und `setup_reasons` nennt den Fehlschlag ohne Fehlergrund.
- Jeder Eintrag von `GET /extensions` enthält zusätzlich `bundled` (`true` bei Erweiterungen, die mit Nodvard Deck
  ausgeliefert werden, also `source == "bundled"`) und `display_version` (bei mitgelieferten die Programmversion,
  sonst die eigene `version`). `version` bleibt die Version aus `extension.toml`. `/capabilities` ist unverändert.
- Jeder Eintrag von `GET /extensions` (und `GET /extensions/{id}`) enthält zusätzlich `stage` (`stable` oder `beta`, aus
  `stage` im Manifest, siehe [02](02-EXTENSION-API.md#1-anatomie-einer-extension); fehlt es dort, steht `stable`). Auch jeder
  Eintrag von `GET /pages` trägt `stage` seiner Erweiterung. Die Oberfläche kennzeichnet `beta` mit einem Abzeichen; für
  Clients ist es nur eine Anzeige.
- Jeder Eintrag von `GET /extensions` (und `GET /extensions/{id}`) enthält zusätzlich `takeover_requests`: Erweiterungen,
  die die gespeicherten Daten dieser Zeile übernehmen wollen (gleiche oder frühere Kennung, siehe
  [02](02-EXTENSION-API.md#erweiterung-umbenennen-legacy_ids)), je `{id, old_id, name, source, permissions, allowed}`;
  `old_id` ist die Kennung, unter der die Daten liegen, und kann gleich `id` sein. Sie werden erst geladen, wenn das
  jemand erlaubt; `allowed: true` heißt erlaubt, gilt aber erst nach dem nächsten Start. Ohne `extensions.manage` ist die
  Liste leer.
- `POST /extensions/{id}/takeover` mit `{old_id}` erlaubt der wartenden Erweiterung `id` die Übernahme (`extensions.manage`,
  Protokoll `extension.takeover_allowed`). Antwort `{restart_needed: true, switched_off, extra_permissions}`: Die
  Übernahme gilt ab dem nächsten Start, für die Quelle und die Rechte, die `id` jetzt hat. Wollte die neue mehr Rechte,
  als die bisherige hatte, ist deren Zeile jetzt aus (`switched_off`), und `extra_permissions` nennt die zusätzlichen.
  `404`, wenn `id` nicht auf `old_id` wartet; `409`, wenn `old_id` schon einer anderen Erweiterung gehört oder eine
  andere sie schon übernehmen darf.
- `POST /extensions/{id}/test` ruft `health()` der Erweiterung auf (bei
  Benachrichtigungskanälen zusätzlich `test()`); mit `{"mode": "message"}` sendet es stattdessen
  eine Testnachricht über den Kanal (`422`, wenn die Erweiterung keinen hat). Antwort
  `{ok, message, details?}`: `message` ist ein verständlicher deutscher Satz („Keine Antwort
  von 192.168.1.20:8006 – Adresse und Port prüfen.“, „Zugangsdaten abgelehnt …“ bei 401, „Zugriff verweigert – dem
  Konto oder Token fehlt ein Recht auf dem Server …“ bei 403, „Zertifikat wird nicht vertraut …“, „Adresse nicht
  gefunden …“). Aus Fehlern von httpcore, h11, idna und websockets in der Ursachenkette übernimmt der Test keinen
  Text (sie können Teile fremder Antworten enthalten), feste Sätze der Erweiterung stehen ohne den Vorspann
  „Technische Meldung“. `details` bei mehreren Verbindungen
  `[{name, ok, message}]`. Geheimnisse stehen nie im Text. Ein fehlgeschlagener Test ist
  `200` mit `ok: false`. `409`: Erweiterung ausgeschaltet, `404` unbekannt, `429` nach mehr als
  10 Tests pro Minute und Nutzer (mit `Retry-After`). Jeder Test steht im Protokoll
  (`extension.test`, ohne Text).
- `DELETE /extensions/{id}/secrets?label=…` entfernt ein Geheimnis aus `x-secrets` (`422` für
  fremde Labels); Protokoll `extension.secret_removed`.
- `PUT /extensions/{id}/settings` `{values}` übernimmt nur Felder, die das Schema kennt und die
  nicht `x-hidden` sind (die pflegt die Erweiterung selbst). Nicht mitgeschickte Felder bleiben
  wie gespeichert, `null` entfernt einen Wert (dann gilt wieder der `default`); Pflichtfelder
  prüft der Kern am Stand danach (ein `default` zählt als gesetzt). Ändert sich ein Feld, an das
  ein Geheimnis gebunden ist (`x-secret-bound-to`, siehe [02 §1](02-EXTENSION-API.md#1-anatomie-einer-extension)),
  löscht der Kern dieses Geheimnis, auch bei der ersten Adresse (leer → Wert); eine andere
  Schreibweise derselben Adresse zählt nicht als Änderung. Die Antwort nennt die gelöschten
  Labels in `secrets_cleared` (sonst `[]`, bei `GET` immer `[]`), das Protokoll
  `extension.settings` zusätzlich zu `changed` in `detail.secrets_cleared` (nur wenn etwas
  gelöscht wurde).
  Felder mit `x-widget: "schedule"` müssen ein gültiger Cron-Ausdruck sein (fünf Angaben, dieselbe Prüfung wie beim
  Anmelden des Jobs); sonst `422` „„<Feldtitel>“: Der Zeitplan „…“ ist ungültig. Erlaubt sind fünf Angaben: …“, und
  nichts wird gespeichert. Leer oder `null` bleibt erlaubt (dann gilt der Standard der Erweiterung).
  Zahlenfelder (`integer`, `number`) prüft der Kern gegen `minimum` und `maximum` des Schemas (beide eingeschlossen,
  [02 §1](02-EXTENSION-API.md#1-anatomie-einer-extension)); sonst `422`
  „„<Feldtitel>“: Erlaubt sind Werte von 2 bis 59. Eingetragen ist -5.“, und es wird nichts gespeichert, auch kein
  anderes Feld derselben Anfrage. `NaN` und unendlich lehnt er bei jedem Zahlenfeld ab („Bitte eine Zahl eintragen.“),
  auch ohne Grenzen. Ein Wert außerhalb der Auswahl (`enum`) ergibt `422` „„<Feldtitel>“ muss einer dieser Werte sein: …“
  (mit dem Titel des Felds, nicht seinem Pfad); bei Zahlen muss auch der Typ stimmen, `"10"` statt `10` wird abgelehnt.
  Ein Wert, der schon vor einer neuen Grenze gespeichert war, bleibt stehen (keine Migration); die Erweiterung setzt ihn
  beim Lesen selbst auf die Grenze.
- `PUT /extensions/{id}/secrets` antwortet für ein gebundenes Geheimnis mit `409` („Erst die
  Adresse eintragen und die Einstellungen speichern, danach die Zugangsdaten hinterlegen.“),
  solange keines der Felder gespeichert einen Wert oder `default` hat. Ebenso mit `409` („Der Name … kommt mehrfach
  vor. Erst einen der Einträge umbenennen …“), solange der Name des Listeneintrags mehrfach vorkommt; `PUT
  /extensions/{id}/settings` löscht das Geheimnis eines doppelten Namens ohnehin (nach Rückfrage).
- Das alte `POST /ext/proxmox|backups/connections/{name}/token` legt ein Token nur an
  (`409`, wenn schon eines existiert oder der Name mehrfach vorkommt). Zum Setzen **und Ersetzen** dient
  `PUT /extensions/{id}/secrets` mit `{label: "proxmox-token:<name>", value}`; die Oberfläche
  nutzt nur noch diesen Weg. Das Token der Verbindung löschen die Routen der beiden Erweiterungen selbst:
  `POST /ext/proxmox|backups/connections` ein altes unter demselben Namen, `PUT …/connections/{name}` eines, wenn
  `base_url` auf ein anderes Ziel zeigt (Vergleich mit `nodvard_sdk.same_target`, eine andere Schreibweise derselben
  Adresse zählt nicht), und `DELETE …/connections/{name}` das der entfernten Verbindung. Gab es eines, steht
  `extension.secret_removed` im Protokoll. Ein Geheimnis unter dem Namen, das nicht von der Erweiterung stammt (etwa
  über `POST /secrets` angelegt), bleibt immer unverändert: `POST` und `PUT` antworten dann `409` mit einem Satz, der
  das Geheimnis nennt, bevor sich etwas ändert; `DELETE` entfernt die Verbindung und lässt das Geheimnis stehen.
  Allgemein antwortet eine Erweiterungs-Route, die ein fremdes Geheimnis ersetzen oder löschen wollte
  (`SecretNotOwned`), mit `409` und einem Satz dazu, nicht mit `500`.
- **Fingerabdruck des Zertifikats** (`proxmox` und `backups`, ergänzt): Die Verbindungs-Routen nehmen zusätzlich
  `tls_fingerprint_sha256` an (64 Hex-Zeichen, gern mit Doppelpunkten; gespeichert als `AB:CD:…`; ungültig → `422`;
  leer oder `null` bei `PUT` nimmt ihn weg). Ein gesetzter Fingerabdruck schaltet `tls_insecure_skip_verify` aus; zeigt
  `base_url` bei `PUT` auf ein anderes Ziel, fällt er weg. `GET …/connections` liefert dazu `tls_fingerprint_sha256`
  (`null`, wenn keiner gemerkt ist) und `tls_fingerprint_invalid` (im Einstellungsfeld steht etwas Ungültiges; die
  Verbindung ist dann gesperrt). Bestätigen nach einem Wechsel:

  ```
  GET  /ext/proxmox|backups/connections/{name}/certificate → 200 {sha256, trusted, subject, issuer, not_after,
                                                                  stored, stored_invalid, matches}
  POST /ext/proxmox|backups/connections/{name}/certificate {sha256} → 200 wie GET
  ```

  `GET` liest nur das Zertifikat (TLS-Handshake, nichts wird gesendet): `sha256` zeigt der Server jetzt, `stored` ist
  der gemerkte Fingerabdruck, `matches` ob beide gleich sind. `POST` liest es erneut und speichert nur, wenn `sha256`
  genau dem entspricht, was der Server in diesem Moment zeigt; sonst `409 {detail, code: "certificate_mismatch",
  sha256}` mit dem aktuellen Wert. Dabei geht `tls_insecure_skip_verify` aus; Protokoll
  `proxmox.certificate_confirmed` bzw. `backups.certificate_confirmed` mit Verbindung, Adresse, altem und neuem
  Fingerabdruck. Beide brauchen `secrets.write`. `400` kein Fingerabdruck, `404` unbekannte Verbindung, `502` Server
  nicht erreichbar. Eine Verbindung mit gemerktem Fingerabdruck, deren Server ein anderes Zertifikat zeigt, meldet im
  Zustand (`details[<name>]`) `code: "certificate_changed"`, eine mit ungültigem `certificate_invalid`.

### Aktionen — der Bestätigungs-Workflow

```
GET    /actions?status=proposed          → die Karten im Incident-Feed
GET    /actions/{id}                     → Stand einer (laufenden) Aktion abfragen
POST   /actions/{id}/approve?wait=0..20  → startet die Ausführung (Idempotency-Key!)
POST   /actions/{id}/reject              → {reason}
POST   /actions/{id}/dismiss
```

Ein `POST` auf eine zustandsverändernde Ressource antwortet:

- `200` — ausgeführt (Autonomie-Modus `full`, Risiko unter Schwelle)
- `202` — **als Vorschlag angelegt**, Body enthält die `action`-Ressource
- `403` — vom Gate abgelehnt, Body nennt die greifende Regel
- `409` — Vorschlag abgelaufen
- `422` — nur bei `POST /hosts/{id}/actions/{action}`: Die Angaben in `payload` passen nicht zum `params_schema` der
  Aktion (falscher Typ, Pflichtfeld fehlt, Wert außerhalb von `enum`, Muster oder Grenzen). `detail` ist ein deutscher
  Satz, der das Feld nennt und sagt, was nicht stimmt. Es entsteht dann keine Aktion und nichts geht ans Gate. Die
  Prüfung deckt `type`, `enum`, `pattern`, `minLength`/`maxLength`, `minimum`/`maximum`, `properties`, `required`,
  `additionalProperties`, `items` und `minItems`/`maxItems` ab; andere Schlüsselwörter werden nicht geprüft (nicht
  abgelehnt). Verbindlich prüft weiterhin der Ausführer der Aktion.

Dass `202` der Normalfall ist, folgt aus dem Aktions-Gate
([01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)): Vorschlagen und Ausführen sind getrennte
Schritte. Clients müssen `202` behandeln — die Flutter-App genauso wie das Web.

**Ausführung im Hintergrund.** Genehmigte Aktionen laufen nicht mehr
innerhalb der HTTP-Anfrage (ein Update dauert bis zu 45 min). `approve` und
`POST /hosts/{id}/actions/{type}` (im Modus `full`) starten die Ausführung und warten
höchstens `wait` Sekunden (Standard 20) auf das Ergebnis:

- fertig → `200` mit der `action`-Ressource wie bisher (`succeeded`/`failed`)
- noch nicht fertig → `202` mit `status = "executing"`, Header `Location:
  /api/v1/actions/{id}` und `Retry-After: 3`; der Client fragt `GET /actions/{id}` ab,
  bis ein Endstatus da ist. `wait=0` antwortet sofort (für Sammel-Freigaben).

Ein Fehler im Hintergrund endet immer in `failed` mit `result.error` (den Text sieht nur, wer
`hosts.execute` hat, siehe unten); wird das Dashboard
währenddessen beendet, vermerkt der Kern die Aktion als abgebrochen, und beim nächsten
Start setzt er übrig gebliebene `executing`-Zeilen auf `failed`. Das Ereignis
`action.executed` kommt erst, nachdem Ergebnis und Audit-Zeile gespeichert sind.

**Ausgabe nur mit `hosts.execute`.** Ausgabe und Fehlertext einer Ausführung können beliebigen
Text vom Server enthalten (z. B. das Ergebnis eines Shell-Befehls). Die `action`-Ressource
liefert sie deshalb nur an Nutzer mit `hosts.execute` (eingebaut: Bediener, Admin, Owner) – in
`GET /actions`, `GET /actions/{id}`, den Antworten von `approve`, `reject` und `dismiss` und
von `POST /hosts/{id}/actions/{action}`. Alle anderen, z. B. der Betrachter (viewer), sehen
Status, Zeiten und Beteiligte; von `result` bleiben nur `success`, `exit_code` und
`duration_ms`, die übrigen Felder, die das Ergebnis hat, bleiben stehen, aber leer (`output`
und `error` als `null`, `detail` als `{}`). Das Feld `output_hidden` ist dann `true`, wenn
tatsächlich etwas weggelassen wurde; mit `hosts.execute` ist es immer `false`.

**Befehl nur mit `hosts.execute` oder Freigaberecht.** Der `payload` einer Aktion (Shell-Befehl, Skript-Inhalt,
Parameter) kann Zugangsdaten enthalten. Vollständig liefert ihn die `action`-Ressource (in denselben Antworten wie
oben) nur an Nutzer mit `hosts.execute` oder mit `actions.approve:<risiko>` für das Risiko dieser Aktion: Wer eine
Aktion freigibt, muss ihren Befehl sehen. Alle anderen bekommen aus dem `payload` nur Kennungen einer festen Liste
(`host_id`, `vmid`, `node`, `connection`, `job_id`, `script_id`, `unit`, `container`, `project`, `service`, `image`,
`snapname`, `storage`, `minutes`), und auch die nur als Text bis 200 Zeichen, Zahl oder `true`/`false`; Befehl,
Skript-Inhalt und Parameter fehlen. Das Feld `payload_hidden` ist dann `true`, wenn tatsächlich etwas weggelassen
wurde, sonst `false`. Für den Kanal `events` gilt eine strengere Regel (§4).

Ins Protokoll (`action.executed`) schreibt der Kern vom Ergebnis unter `detail.result` nur
`success`, `exit_code`, `duration_ms` und die Länge von Ausgabe und Fehlertext
(`output_length`, `error_length`), nie den Text. Legt das Gate den Grund selbst fest (kein
Executor, Zeitüberschreitung, abgebrochen, beim Start unterbrochen oder eine Ausnahme), steht
dazu ein fester Satz in `gate_error`; bei einer Ausnahme nennt er nur deren Art, den vollen Text
hat die Aktion. `GET /audit` und `GET /audit/export` bereinigen für Nutzer ohne `hosts.execute`
auch ältere Einträge, die noch Ausgabe enthalten: Bei `action.executed` behält `detail.result`
nur die genannten Werte, alle anderen Felder sind leer (`output` und `error` stehen immer als
`null` darin, `detail` als `{}`); in allen Einträgen werden Felder `output`, `stdout`, `stderr`
und `command` (auch verschachtelt) zu `null`, denn ein Befehl kann wie der `payload` einer Aktion Zugangsdaten
enthalten. Wurde dabei etwas entfernt, setzt `GET /audit`
`output_hidden: true`; der Export hat dieses Feld nicht.

**Namen statt Kennungen.** Die `action`-Ressource trägt neben den rohen
Feldern (`proposed_by_type`/`proposed_by_id`, `approved_by_user_id`) zwei lesbare:
`proposed_by_label` (Benutzername, Name der Erweiterung, `System`, `Zeitplan` oder `KI`)
und `approved_by_label` (Benutzername des Entscheiders; `null`, solange niemand
entschieden hat). Nicht auflösbare Akteure (gelöschter Nutzer, entfernte Erweiterung)
bekommen bei `proposed_by_label` den rohen Wert `art/kennung`, bei `approved_by_label` die
Nutzer-Kennung so, wie sie gespeichert ist (`approved_by_user_id`, ohne `user/`-Vorsatz).
Aufgelöst wird nur der Name, mit je einer Abfrage für alle Zeilen einer Liste.
Benutzernamen **anderer** Nutzer bekommt nur, wer `users.read` hat oder Aktionen
entscheiden darf (irgendein `actions.approve:<risiko>`, z. B. Bediener); der Betrachter
(viewer) sieht dort weiter `user/<uuid>` (bzw. bei `approved_by_label` die nackte
`<uuid>`) -- `GET /users` bleibt ihm ja auch verwehrt. Den
eigenen Namen sieht jeder; Namen von Erweiterungen sowie `System`/`Zeitplan`/`KI` sind
keine Nutzerdaten und stehen jedem offen.
Extensions bekommen dasselbe für ihre eigenen Zeilen über `ctx.actions.proposer_labels(rows)`
(nachgeschlagen wird nach der `id`, nicht nach den übergebenen Zeilen). Dort prüft der Kern
nicht, wer die Seite aufruft -- die Route der Extension muss selbst abgesichert sein.

**Ohne Klick über eine Dauerfreigabe.** Ist eine Aktion über eine Dauerfreigabe angelaufen
([01 §4](01-ARCHITECTURE.md#4-das-aktions-gate)), ist `gate_decision.rule` `standing_approval`, `approved_by_user_id`
bzw. `approved_by_label` nennen die Person, die sie erteilt hat, und `reason` endet mit „– ohne Klick, lief mit
Dauerfreigabe vom <Datum> durch <Benutzername>“. Hat das Gate sie nicht anerkannt, ist es ein normaler Vorschlag mit
dem Grund in `reason` und in `gate_decision.standing_approval_rejected` (Felder:
[03 §5](03-DATA-MODEL.md#5-aktionen--das-gate-journal)).

### Dauerfreigabe für geplante Skripte (Erweiterung `scripts`)

```
POST   /ext/scripts/scripts/{id}/standing-approval   → 200 Skript (wie GET)   {fingerprint, targets_fingerprint}
DELETE /ext/scripts/scripts/{id}/standing-approval   → 204, Freigabe zurückziehen (wirkt sofort)
```

Beide brauchen `actions.standing_approval` (eingebaut: Admin und Owner; Bediener und Betrachter bekommen `403`).
Erteilen geht nur für aktive Skripte mit Zeitplan. Beide Felder im Body sind Pflicht und kommen aus der letzten Antwort
von `GET /ext/scripts/scripts` bzw. `GET /ext/scripts/scripts/{id}`: `fingerprint` (Inhalt, Parameter, Ziel,
Zeitplan) und `targets_fingerprint` (die Zielserver mit Konto, Adresse und SSH-Port). Hat sich seitdem etwas davon
geändert, antwortet `POST` mit `409` und gibt nichts frei; ebenso ohne Zeitplan, bei ausgeschaltetem Skript oder ohne
Server, auf dem es laufen kann. `DELETE` ohne Freigabe: `404`. Erteilen, Zurückziehen und Erlöschen stehen im
Protokoll (`scripts.standing_approval.granted`, `scripts.standing_approval.revoked`,
`scripts.standing_approval.expired`).

`PUT /ext/scripts/scripts/{id}` (Skript speichern) prüft einen Zeitplan vor dem Speichern: Ein ungültiger ergibt `422`
„Der Zeitplan „…“ ist ungültig: …“, und nichts landet im Versionsverlauf. Ein ungültiger Zeitplan aus einer älteren
Version legt die Erweiterung beim Start nicht mehr lahm: Das Skript läuft dann vorerst nicht nach Zeitplan, dazu kommt
eine Meldung. Ebenso nutzt Nodvard Shield für einen ungültigen gespeicherten Zeitplan vorerst seinen Standard und meldet
das.

`GET /ext/scripts/scripts` und `GET`/`PUT /ext/scripts/scripts/{id}` liefern zusätzlich `fingerprint`, bei aktiven
Skripten mit Zeitplan `standing_preview` (die Server, die eine Freigabe jetzt decken würde, je `id`, `name`, `account`,
`address`, `port`) und `targets_fingerprint`, dazu `standing_approval`: `null` ohne Freigabe, sonst `active`, `problem`
(warum sie nicht mehr gilt), `granted_by_label`, `granted_at`, `hosts` (wie bei `standing_preview`) und `new_hosts`
(Namen neuer Zielserver, für die sie nicht gilt). Einträge von `GET /ext/scripts/scripts/{id}/runs` tragen
zusätzlich `standing_approval` (lief ohne Klick) und `reason`.

### Update-Vorschläge für viele Server und Härtungs-Audit (Erweiterung `shield`)

```
POST /ext/shield/defender/updates/propose-all   → 200 {mode, results[], counts}   Recht: soc.manage
POST /ext/shield/defender/audits                → 200 {hosts, started, running, audits}   Recht: soc.manage
```

**`propose-all`.** Body: `{"mode": "security" | "all", "host_ids": ["…"]}`. `host_ids` ist optional; ohne Angabe gilt
jeder Server der Update-Übersicht, bei dem für `mode` etwas offen ist (`security`: Sicherheitsupdates, `all`: Updates).
Aufräumen und Neustart gibt es hier nicht (`422`); beides bleibt eine Entscheidung je Server
(`POST /ext/shield/defender/hosts/{id}/upgrade`).

Die Route legt nur Vorschläge an (Gate, Aktion „System-Updates einspielen“); freigegeben wird wie beim Einzelweg unter
„Aktionen“, gesammelt, außer bei hohem Risiko (dist-upgrade). Steht die Automatik auf „Selbstständig handeln“, laufen
freigegebene Aktionen von selbst an. Jeder Vorschlag entsteht genau wie der Einzelvorschlag (Plan aus dem gespeicherten
Update-Stand, Befehl aus den Feldern, bei der Einstellung „Proxmox: Alle Updates als dist-upgrade“ Risiko `high`). Die
Vorschläge entstehen nacheinander und ohne Warten auf freigegebene Aktionen; die Route öffnet keine SSH-Verbindung.

`results[]`: je Server `host_id`, `name`, `result` (`proposed`, `skipped`, `failed`), bei `skipped`/`failed` `reason`
(deutscher Satz; bei einem unerwarteten Fehler ein fester Satz, Einzelheiten im Protokoll des Dashboards), bei `proposed`
`action_id`, `status` (`proposed`, bei automatischer Freigabe `executing`) und `risk`. `skipped`: nichts offen, nie geprüft,
nicht unterstützt, Einspiel-Lauf aktiv oder für den Server liegt schon ein offener Update-Vorschlag vor. `failed`: Plan
oder Gate sind gescheitert; die anderen Server sind davon unberührt. `counts`: `proposed`, `skipped`, `failed`, `total`.
Läuft schon ein Sammel-Vorschlag, gibt die Route `409`. Im Protokoll steht ein Eintrag `shield.updates_proposed_all`
(Modus, Zählung, vorgeschlagene Server). Offene Vorschläge findet die Route unter den letzten 200 Aktionen der Erweiterung.

**`audits`.** Body `{"host_ids": [...]}` oder `{"host_ids": "all"}` (Standard: alle Server von Shield). Die Antwort kommt sofort:
`started` (neu gestartet), `running` (lief schon, es startet kein zweites) und `audits` (je Server `requested_at`,
`started_at`, `waiting` – es laufen schon drei Audits –, `already_running`). Lynis läuft auf dem Server vom SSH-Kanal
entkoppelt, mit niedriger Priorität und höchstens 3 Stunden; das Ergebnis kommt später in `GET /ext/shield/defender/audits`,
auch nach einem Neustart des Dashboards. `GET /ext/shield/defender/overview` nennt je Server ein laufendes Audit
(`audit_run`).

Die alten Adressen `/ext/nexus-soc/…` antworten gleich (veraltet, siehe §7).

### Proxmox mit Passwort verbinden (Erweiterung `proxmox`)

```
POST /ext/proxmox/connect/certificate            → 200 {base_url, certificate, backups_available}
POST /ext/proxmox/connect                        → 200 {status: "connected", …} oder {status: "tfa_required", tfa}
POST /ext/proxmox/connect/tfa                    → 200 wie /connect
GET  /ext/proxmox/connections/{name}/old-tokens  → 200 {connection, users[], tokens[], privsep_off[], backups_checked}
```

Alle vier brauchen `secrets.write` (eingebaut: Admin und Owner). Das Passwort geht in genau eine Anmeldung bei Proxmox,
nur an das bestätigte Zertifikat, und wird nie gespeichert; Ablauf und Rechte: [10](10-PROXMOX-TOKEN.md#schneller-weg-mit-passwort-verbinden).
Jeder Versuch, bei dem Passwort oder Code hinausging, steht im Protokoll (`proxmox.connect_with_password`, ohne
Geheimnisse).

| Endpunkt | Feld | Bedeutung |
|---|---|---|
| `connect/certificate` | `base_url` | Adresse des Knotens; ohne Schema gilt `https://`, ohne Port 8006. Liest nur das Zertifikat, sendet nichts. |
| `connect` | `base_url` | wie oben |
| | `username` | Vorgabe `root@pam`, immer mit Bereich |
| | `password` | höchstens 512 Zeichen |
| | `name` | Kurzname der Verbindung, freiwillig (leer: Name des Clusters bzw. Knotens) |
| | `fingerprint` | der verglichene SHA-256-Fingerabdruck; Pflicht, wenn `certificate.trusted` `false` ist |
| | `access` | `full` (Vorgabe, „Ansehen und steuern“) oder `read` |
| | `backups` | `true`: auch einen Zugang für Proxmox-Backups anlegen (ist das Modul aus, steht das in `notes`), `false`: nicht; ohne Angabe: wenn das Modul an ist |
| `connect/tfa` | `flow` | aus `tfa.flow` der Antwort `tfa_required` (gilt 180 s, nur für den Nutzer, der begonnen hat) |
| | `code`, `kind` | der Code; `kind` `totp` (Vorgabe), `recovery` oder `yubico` |

**Antworten.** `certificate`: `{sha256, trusted, subject, issuer, not_after, alt_names}`; `backups_available`: ob das
Modul Proxmox-Backups an ist. `tfa_required`: `tfa` = `{flow, kinds, expires_in_s}`. `connected`: `connection`
(`name`, `base_url`, `token_id`, `tls_insecure_skip_verify` – jetzt immer `false`, `tls_fingerprint_sha256` – der
bestätigte Fingerabdruck bei einem nicht anerkannten Zertifikat, sonst `null`, `tls_fingerprint_invalid`, `enabled`,
`has_token`), `created` (was in Proxmox neu
angelegt wurde, deutsche Sätze), `nodes`, `access`, `privileges` (Rechte der Rolle), `replaced_token_id` (bisheriger
Token einer vorhandenen Verbindung oder `null`), `backups` (`null` oder `{name, token_id, replaced_token_id,
privileges, storages, pool, tls_insecure_skip_verify, tls_fingerprint_sha256, enabled}`; Token von
`nodvard-backup@pve`; eine vorhandene Verbindung wird bei der Zertifikatsprüfung nie abgeschwächt, merkt sich den
bestätigten Fingerabdruck und bleibt aus, wenn sie aus war), `old_tokens` (wie bei
`old-tokens`), `discovery` (`started`: das Einlesen läuft im Hintergrund, `not_started`), `notes` (deutsche Sätze).
`old-tokens` liest mit dem gespeicherten Token der Verbindung (kein Passwort, ändert nichts): `users` die gelesenen
Benutzer (`nodvard@pve`, dazu `nodvard-backup@pve`, wenn Proxmox-Backups gefragt werden konnte), `tokens` je
`{token_id, user, name, privsep, command}` – nur Tokens dieser Benutzer mit den Namen, die Nodvard Deck vergibt
(`deck…`), die keine Verbindung mehr benutzt; `command` ist der `pveum`-Befehl zum Löschen. `privsep_off`: Tokens
dieser Benutzer ohne Privilegientrennung. `backups_checked`: ob Proxmox-Backups gefragt werden konnte (sonst zählen
dessen Tokens nie als alt). `404` unbekannte Verbindung, `502` Proxmox nicht lesbar.

**Fehler** (`connect…`): Body `{detail, code}`, dazu je nach Fall `certificate` (das Zertifikat, das der Server jetzt
zeigt), `created` (was in Proxmox angelegt blieb), `leftover_token`/`leftover_tokens` (neue Tokens, die vielleicht in
Proxmox liegen blieben – zum Selbstlöschen) und `retry_after` (auch als Header). `detail` ist immer ein fester
deutscher Satz, nie Text von Proxmox.

| `code` | Status | Bedeutung |
|---|---|---|
| `invalid_address`, `invalid_name`, `invalid_user`, `invalid_password`, `invalid_code`, `invalid_fingerprint` | 400 | Eingabe ungültig, nichts gesendet |
| `https_required` | 400 | `http://`-Adresse, nichts gesendet |
| `name_taken`, `already_connected` | 409 | Kurzname gehört zu einem anderen Server bzw. Adresse unter anderem Namen eingetragen |
| `name_twice` | 409 | Kurzname steht in den Einstellungen bei zwei Verbindungen; erst eine umbenennen. Vor der Anmeldung geprüft, sonst wird das neue Token in Proxmox wieder gelöscht |
| `certificate_unconfirmed` | 409 | Zertifikat nicht anerkannt und kein `fingerprint`; Passwort nicht gesendet |
| `certificate_changed` | 409 | anderes Zertifikat als bestätigt, vor der Anmeldung; Passwort nicht gesendet |
| `certificate_changed_after_login` | 409 | anderes Zertifikat nach der Anmeldung; das Passwort ging nur an das bestätigte, danach Abbruch |
| `wrong_password`, `login_rejected` | 400 | Anmeldung abgelehnt |
| `tfa_unsupported` | 409 | zweiter Faktor ohne eintippbaren Code (WebAuthn/U2F) |
| `tfa_wrong` | 400 | Code falsch; neu mit Passwort beginnen |
| `tfa_expired` | 409 | Vorgang abgelaufen oder fremd |
| `not_allowed` | 403 | das Proxmox-Konto darf keine Benutzer, Rollen oder Tokens anlegen |
| `redirect` | 502 | Proxmox leitet um; Nodvard Deck folgt nicht |
| `unreachable`, `no_tls`, `connection_failed` | 502 | Server nicht erreichbar, kein https, Verbindung abgebrochen |
| `proxmox_error`, `probe_failed` | 502 | Fehler beim Anlegen bzw. neuer Zugang nicht benutzbar; nichts gespeichert |
| `secret_foreign` | 409 | im Tresor liegt unter `proxmox-token:<Kurzname>` ein Geheimnis, das nicht von der Erweiterung stammt (etwa über `POST /secrets`); es bleibt unverändert, nichts gespeichert, neue Tokens wieder entfernt |
| `save_failed` | 500 | Speichern in Nodvard Deck gescheitert; neue Tokens wieder entfernt |
| `rate_limited`, `busy` | 429 | zu viele Versuche (`retry_after`) bzw. läuft schon |

### Proxmox-Backups: Wiederherstellen als Kopie und Fehlerursache (Erweiterung `backups`)

```
GET  /ext/backups/restore/backups?connection=&vmid=&kind=   → 200 {vmid, name, kind, exists, backups[], errors[]}   Recht: hosts.execute
GET  /ext/backups/restore/options?connection=&volid=        → 200 {kind, nodes[], suggested_vmid, suggested_name, blocked, refuse, warnings[], …}   Recht: hosts.execute
POST /ext/backups/restore                                   → 200 {action_id, status, risk, warnings[]}   Recht: hosts.execute
GET  /ext/backups/jobs/{job_ref}/failure?upid=              → 200 {upid, node, status, key, what, todo, detail, …}   Recht: hosts.read
```

**Wiederherstellen als Kopie.** Legt aus einer Proxmox-Sicherung einen **neuen** Gast (VM oder Container) an. Das Original
wird nie angefasst und nie überschrieben. Die Kopie bekommt eine neue Nummer und neue MAC-Adressen und entsteht im festen
Pool `nodvard-kopien`; Autostart ist aus, sie startet nicht von selbst. Alle drei Routen brauchen `hosts.execute`, weil die
Auswahl feste Adressen und durchgereichte Geräte aus der Gast-Konfiguration zeigt. Was Proxmox dafür braucht (Version, Pool,
Rechte des Tokens): [10](10-PROXMOX-TOKEN.md#wiederherstellen-als-kopie-backups). Zeiten (`ctime`, `backup_at`) sind
Unix-Sekunden und Größen Bytes, beides so, wie Proxmox sie liefert (nicht ISO-8601).

| Endpunkt | Feld | Bedeutung |
|---|---|---|
| `restore/backups` | `connection` | Name der Backups-Verbindung (Pflicht) |
| | `vmid` | Nummer des Gastes, 100 bis 999999999 (Pflicht) |
| | `kind` | `qemu` oder `lxc`, freiwillig. Ohne Angabe gilt die Art des Gastes, wie Proxmox ihn jetzt kennt; gibt es ihn nicht mehr, kommen beide Arten |
| `restore/options` | `connection` | wie oben |
| | `volid` | Kennung der Sicherung, aus `backups[].volid` (Pflicht) |
| `restore` (Body) | `connection`, `volid` | wie oben |
| | `node` | Zielknoten; muss online sein und die Sicherung sehen |
| | `storage` | Zielspeicher auf diesem Knoten; muss VM-Platten bzw. Container aufnehmen dürfen, an sein und genug Platz haben |
| | `vmid` | neue Nummer, 100 bis 999999999 (Zahl oder Text) |
| | `name` | Name der Kopie: Buchstaben ohne Umlaute, Ziffern, Bindestriche und Punkte, höchstens 63 Zeichen, nicht mit einem Bindestrich anfangen oder enden |
| | `link_down` | `true` (Vorgabe): die Netzwerkkarten der Kopie werden getrennt angelegt, weil sie im Gast dieselbe IP-Einstellung wie das Original hat; wieder verbinden geht in Proxmox. `false`: nicht trennen |

**Antworten.** `restore/backups`: `vmid`, `name` (aktueller Name des Gastes oder `null`), `kind`, `exists` (ob es den Gast in
Proxmox noch gibt), `backups[]` (neueste zuerst; je `volid`, `storage`, `kind`, `kind_label` – `VM` oder `Container` –,
`ctime`, `size`, `notes` – einzeilig, höchstens 120 Zeichen oder `null` –, `protected`) und `errors[]` (je Backup-Speicher,
der sich nicht lesen ließ, ein Satz „<Speicher>: <Grund>“; die übrigen Speicher stehen trotzdem in `backups`).

`restore/options` ändert nichts und liefert, was ein Dialog braucht: `kind`, `kind_label`, `source_vmid`, `source_name`,
`source_exists` (das Original), `backup_at`, `backup_size`, `disk_bytes` (grobe Obergrenze der Plattengröße laut Sicherung
oder `null`), `nets[]` (Netzwerkkarten laut Sicherung: `key`, `bridge`, `tag`, `addresses[]` – feste Adressen, die die Kopie
mitbekommt), `warnings[]` (Hinweise, etwa zu festen Adressen oder durchgereichten Geräten), `suggested_vmid` (eine freie
Nummer, unter der weder alte Sicherungen liegen noch eine HA-Regel steht, sonst `null`), `suggested_name`, `pool` und
`keep_hint` (Pool der Kopie und ein Satz, wie sie dauerhaft behalten wird), `default_node`, `default_storage` (`null`, wenn
nichts passt) und `nodes[]`: je Knoten, der die Sicherung sehen kann, `node` und `storages[]` mit `storage`, `type`, `avail`,
`total`, `need_bytes` (Mindestbedarf: Sicherung + 10 % + 2 GB, bei `lvm` mindestens Plattengröße + 2 GB; `null`, wenn die
Größe der Sicherung unbekannt ist), `fits` (`avail` reicht dafür) und `warning` (Satz, wenn der Platz nur mit Thin
Provisioning reicht). Passende Speicher stehen vorn, dann der mit dem meisten freien Platz. Zwei Felder sperren den Dialog,
sonst sind sie `null`: `blocked` (Satz, wenn eine Kopie schon vor jeder Eingabe nicht geht: Proxmox zu alt – VMs brauchen
VE 7.2, Container VE 6 –, oder der Pool bzw. ein Recht darauf fehlt) und `refuse` (Satz, wenn genau diese Sicherung nicht
geht, weil sie Geräte oder Ordner des Servers durchreicht).

**Vorschlag.** `POST /ext/backups/restore` prüft alles, was auch beim Ausführen geprüft wird (Knoten online, Sicherung lesbar,
Proxmox-Version, Nummer frei und nicht die des Originals, keine alten Sicherungen und keine HA-Regel unter dieser Nummer,
Zielspeicher und Platz, Rechte des Tokens), und legt dann einen Vorschlag mit **hohem Risiko** an (Aktion
`backup.restore_copy`). Passt etwas nicht, entsteht gar kein Vorschlag. Die Route antwortet auch bei einem Vorschlag mit
`200`: `action_id`, `status` (Stand der Aktion nach dem Gate, wie unter „Aktionen“; normal `proposed`, die Aktion wartet auf
Freigabe), `risk` (`high`) und `warnings[]` (Hinweise aus den Prüfungen). Freigeben dürfen nur Rollen mit `actions.approve:high`
(eingebaut: Admin und Owner), den Stand zeigt `GET /actions/{id}`.

Beim Ausführen wird alles noch einmal frisch geprüft. Haben sich Art, Nummer des Originals oder Zeitpunkt der Sicherung seit
dem Vorschlag geändert (die Sicherung wurde ersetzt), scheitert die Aktion, ohne etwas anzulegen. Nach dem Anlegen wartet sie
auf die Proxmox-Aufgabe (höchstens 45 Minuten) und richtet die Kopie fertig ein (Netzwerkkarten trennen, Name und Autostart
prüfen). Endet sie vorher (Zeitgrenze, keine Antwort von Proxmox, Neustart des Dashboards), holt der Backup-Wächter das
Fertig-Einrichten nach. Je Kopie kommt genau eine Meldung; in Proxmox wird nie etwas gelöscht. Im Protokoll steht
`backups.restore_copy` (`target_type` `restore`, `target_id` `<Verbindung>/<neue Nummer>`, `outcome` `success` oder `failure`).

**Fehler** der drei Restore-Routen: `detail` ist ein deutscher Satz. Fehlt eine Pflichtangabe ganz (Query-Parameter
oder Body nicht lesbar), kommt das übliche 422 von FastAPI, dort ist `detail` die Liste der Prüffehler.

| Status | Bedeutung |
|---|---|
| 404 | Verbindung unbekannt (bei `POST` auch: `connection` fehlt) |
| 422 | Eingabe ungültig (Nummer des Gastes, Kennung der Sicherung, Knoten, Speicher, neue Nummer, Name, `link_down`) |
| 409 | Prüfung gescheitert, es wurde nichts angelegt: Proxmox nicht erreichbar; Sicherung von diesem Knoten aus nicht lesbar oder weg; Art nicht erkennbar oder sehr altes tar-Format; Proxmox zu alt; neue Nummer schon vergeben, gleich der des Originals oder mit alten Sicherungen bzw. einer HA-Regel belegt; Zielspeicher unpassend, ausgeschaltet oder zu voll; Geräte oder Ordner des Servers durchgereicht (`refuse`); Pool oder Recht fehlt |

**Fehlerursache.** `GET /ext/backups/jobs/{job_ref}/failure` erklärt in Klartext, warum der neueste Lauf eines Backup-Jobs für
einen Gast fehlgeschlagen ist. Proxmox meldet am Ende meist nur „job errors“; der Grund steht im Aufgabenprotokoll.

| Feld | Bedeutung |
|---|---|
| `job_ref` (Pfad) | `<Verbindung>--<Job>--<VMID>`, so wie in den Zeilen von `GET /ext/backups/jobs` |
| `upid` (Query) | freiwillig: ein bestimmter fehlgeschlagener Lauf (`upid` aus `GET /ext/backups/jobs/{job_ref}/history`); ohne Angabe der neueste fehlgeschlagene Lauf dieses Jobs für den Gast |

Antwort: `upid`, `node`, `started_at`, `finished_at` (Unix-Sekunden), `status` (Ausgang laut Proxmox), `log_readable` (ob das
Aufgabenprotokoll gelesen werden konnte; ohne das Proxmox-Recht `Sys.Audit` zählt nur der Status), `key` (Art der Ursache),
`what` (was das heißt) und `todo` (was zu tun ist; beides feste deutsche Sätze) sowie `detail` (die Zeile von Proxmox, die
den Fehler am besten beschreibt, auf 300 Zeichen gekürzt, sonst der Status; `null`, wenn nichts da ist). `key` ist einer von
`no_space`, `target_lost`, `storage_offline`, `locked`, `pbs_auth`, `guest_agent`, `vm_timeout`, `missing_right`,
`permission`, `io_error`, `guest_missing`, `aborted`, `unreachable`, `other_guest` (fehlgeschlagen ist ein anderer Gast im
selben Job) oder `unknown` (keine Erklärung bekannt, es bleibt `detail`). `404`: `job_ref` ungültig oder kein
fehlgeschlagener Lauf gefunden; `409`: Verbindung nicht (mehr) eingetragen oder Proxmox nicht lesbar.

### Adress-Check für Proxy-Hosts (Erweiterung `npm`)

```
GET /ext/npm/address-check   → 200 {state, message, checks[], problems}   Recht: hosts.read
GET /ext/npm/summary         → 200 Stand des Nginx Proxy Managers (hosts[], Zertifikate)   Recht: hosts.read
```

Bis 0.8 gehörte das zum Modul „Netzwerk“. Die alten Adressen `GET /ext/network/npm/address-check` und
`GET /ext/network/npm` antworten weiter gleich (veraltet, siehe §7), solange das Modul `npm` an ist.

Zu welchem Docker-Container führt das Ziel jedes Proxy-Hosts im Nginx Proxy Manager, und läuft er? Die Route liest nur, hat
keine Parameter und antwortet immer mit `200`: Zustände wie „nicht eingerichtet“ stehen in `state`, nie als Fehlerstatus.
Die Container kommen aus dem Modul Docker (Fähigkeit `ServiceCatalog`), das dafür jeden Docker-Server per SSH fragt.

| `state` | Bedeutung |
|---|---|
| `ok` | geprüft; `checks` hat je Proxy-Host einen Eintrag. `message` ist `null` – außer wenn kein Server als Docker-Server markiert ist: dann steht dort ein Satz, und alle Einträge sind `unknown` |
| `not_configured` | Nginx Proxy Manager ist nicht (vollständig) eingetragen; `message` sagt, was fehlt |
| `no_catalog` | das Modul Docker ist ausgeschaltet oder nicht geladen, es gibt keinen Check |
| `unreachable`, `auth_failed` | der Nginx Proxy Manager antwortet nicht bzw. lehnt die Anmeldung ab |
| `docker_unreadable` | das Modul Docker lieferte gar keine Container-Liste (Fehler oder Zeitlimit); `message` nennt die Gründe. Sind einzelne oder alle Docker-Server nicht erreichbar, liefert das Modul Docker dafür Platzhalter: dann bleibt es bei `ok` mit `unreachable`-Einträgen. Das Lesen darf je Anbieter der Container-Liste höchstens 45 Sekunden dauern, danach steht dort „Zeitüberschreitung nach 45 s“. Gelingt es nur teilweise, wird mit dem Gelesenen geprüft |

Außer bei `ok` ist `checks` leer und `problems` `0`. Ein Eintrag von `checks[]` hat:

| Feld | Bedeutung |
|---|---|
| `proxy_id` | ID des Proxy-Hosts (`hosts[].id` in `GET /ext/npm/summary`) |
| `status` | mit Container: sein Zustand (`running`, `stopped`, `restarting`, `paused`, `removing`; ein anderer Docker-Zustand steht wörtlich da, `unknown`, wenn er fehlt). Ohne Container: `unknown` (kein Ziel, kein Docker-Ziel oder nichts gefunden), `ambiguous` (mehrere Container passen) oder `unreachable` (der Server war nicht erreichbar, nicht geprüft) |
| `tone` | `good` (läuft), `bad` (Container gestoppt), `warn` (startet ständig neu, ist angehalten, wird entfernt oder hat einen anderen Zustand; auch: Server nicht erreichbar), sonst `neutral` |
| `text` | fertiger deutscher Satz |
| `container` | Name des zugeordneten Containers oder `null` |
| `docker_host` | Name des Servers oder `null` |
| `docker_host_id` | Server-ID, nur wenn ein Container zugeordnet ist |
| `can_start` | `true`, wenn der zugeordnete Container gestoppt ist (`exited`/`created`) und gestartet werden kann |

`problems` zählt die Einträge mit `tone` `bad`. Ein ausgeschalteter Proxy-Host wird trotzdem zugeordnet, ist aber nie `bad`
oder `warn` und bietet kein Starten an: Dass dahinter nichts läuft, ist dann meist gewollt. Ebenso bei einem nur vermuteten
Treffer (ein Containername passt, aber der Server des Proxy Managers ist unbekannt; der Satz beginnt „Führt vermutlich …“).

**Zuordnung**, in dieser Reihenfolge: (1) Ist das Ziel (`forward_host`) Adresse oder Name eines Docker-Servers, zählt der
Container auf diesem Server, der `forward_port` veröffentlicht; läuft von mehreren genau einer, gewinnt er. (2)
`host.docker.internal` und Docker-Gateways (`172.17.0.1` usw.) gelten als der Server, auf dem der Nginx Proxy Manager läuft;
`localhost` nur, wenn er im Host-Netz läuft. (3) Sonst gilt das Ziel als Containername (oder Compose-Dienst) auf dem Server
des Proxy Managers. Mehrere Treffer ergeben `ambiguous`, keiner `unknown`; das ist kein Fehler, das Ziel kann ein Dienst
außerhalb von Docker sein. Zusätzliche Orte (`locations`) eines Proxy-Hosts werden nicht geprüft. Starten kann ein Client über
den Aktionsweg des Servers: `POST /hosts/{docker_host_id}/actions/container.start` mit `{"payload": {"container": …},
"reason": …}`, wie jede Aktion durch das Gate.

### Viren-Wächter in Echtzeit (Erweiterung `shield`)

```
GET  /ext/shield/defender/realtime/readiness?refresh=   → 200 {hosts[]}                       Recht: soc.read
POST /ext/shield/defender/realtime/{host_id}            → 200 {action_id, status, risk}      Recht: soc.manage
```

Ohne Echtzeit prüft der Viren-Wächter im Minuten-Takt die zuletzt geänderten Dateien. In Echtzeit meldet clamonacc
(fanotify) jede neue Datei sofort an clamd; der Wächter-Lauf holt dann nur noch dessen Protokoll ab und prüft jeden Fund
noch einmal über clamd. Eine Oberfläche dafür gibt es noch nicht, nur diese Schnittstelle.

**`realtime/readiness`** ändert nichts. Je Server liest es, ob Echtzeit dort möglich wäre (zehn Minuten gemerkt,
`refresh=true` fragt neu). Felder je Eintrag in `hosts[]`:

- `host_id`, `host_name`, `reachable` und `possible`.
- `reasons[]`: warum nicht möglich, deutsche Sätze.
- `notes[]`: Hinweise.
- `container`: Art der Virtualisierung, wenn der Server ein Container ist. Dann geht Echtzeit nicht.
- `mem_total_mb` und `mem_available_mb`.
- `fanotify`.
- `clamd_installed`, `clamonacc_installed`, `clamd_active` und `clamonacc_active`. `false` auch bei einem Neustart in Schleife, `null` heißt unbekannt.
- `root`: `root`, `helper`, `sudo` oder `none`.
- `apt`.
- `error`, wenn der Server nicht erreichbar ist.

Dazu der gemerkte Stand:

- `realtime_requested`: eingeschaltet.
- `realtime_down_since`: Zeitpunkt (ISO 8601), seit dem der Wächter-Lauf wieder im Minuten-Takt prüft, weil clamonacc dort nicht läuft. Sonst `null`. Eingeschaltet bleibt er dabei, und läuft clamonacc wieder, gilt wieder Echtzeit.
- `realtime_on`: eingeschaltet, `clamonacc_active` ist nicht `false`, und es gibt keinen Rückfall.

**`realtime/{host_id}`** mit Body `{"enable": true|false}` legt einen Vorschlag „Viren-Wächter in Echtzeit“ (Risiko mittel) an.
Freigegeben wird unter „Aktionen“. Vorher prüft die Route:

- Beim Einschalten verlangt sie `possible` und ein installiertes clamd, sonst `409` mit dem Grund. Nodvard Deck installiert clamd nicht selbst.
- Die Ordner kommen aus den Einstellungen `watch_paths` und `exclude_paths`.

Gemerkt wird der neue Stand erst, wenn der Server das Umstellen bestätigt. Startet clamonacc nicht, räumt der Server wieder auf, und der Wächter gilt als ausgeschaltet. `404` gibt es bei einem unbekannten Server.

### Wächter-Vorlagen für den Echtzeit-Wächter (Erweiterung `shield`)

```
GET  /ext/shield/defender/watch-templates   → 200 {suggestions[], errors[], watch_paths[]}   Recht: soc.manage
POST /ext/shield/defender/watch-paths       → 200 {added, covered_by, watch_paths[]}          Recht: settings.write
```

Der Echtzeit-Wächter prüft ohne eigene Einstellung nur Orte, an denen Schadsoftware typischerweise landet (`/tmp`,
`/var/tmp`, `/dev/shm`, `/home`, `/root`, `/opt`). Die Vorlagen finden Ordner, in denen fremde Dateien landen – Samba- und
NFS-Freigaben und der Datenordner einer Nextcloud –, und tragen sie auf Klick in die Einstellung `watch_paths` ein. Die
Einstellung gilt für alle Server; ein Ordner, den es auf einem Server nicht gibt, kostet dort nichts. Ein großer Ordner
kostet bei jedem Lauf des Wächters Zeit.

**`watch-templates`** hat keine Parameter, ändert nichts und antwortet mit `200`. Gesucht wird nur lesend auf den Servern, die
der Virenschutz prüft (verwaltete Linux-Server mit Zugangsdaten, bei gesetzter Markierung nur diese): `smb.conf`,
`/etc/exports`, die `config.php` einer Nextcloud und Docker-Volumes mit „nextcloud“ im Namen. Es läuft als root, wenn das ohne
Passwort geht, sonst als normaler Benutzer (dann fehlt meist die Nextcloud); je Server höchstens 20 Sekunden, bis zu vier
Server gleichzeitig. Aussortiert werden Samba-Drucker und `[homes]`, Pfade mit Platzhaltern (`%U`), ganze Systemordner
(`/etc`, `/usr`, `/var` und ähnliche) und alles unter `/proc`, `/sys`, `/dev`, `/run` und `/boot`.

- `suggestions[]`: je Ordner `path`, `kinds[]` (`samba`, `nfs`, `nextcloud`), `hosts[]` (Namen der Server, auf denen er
  gefunden wurde), `covered_by` (der schon überwachte Ordner, der diesen enthält, sonst `null`) und `excluded_by` (der
  „ausgenommene Ordner“, der ihn enthält – dort sucht der Wächter nicht –, sonst `null`). Zuerst kommen die Ordner, die weder
  überwacht noch ausgenommen sind, dann die übrigen, jeweils nach Pfad sortiert.
- `errors[]`: ein Satz je Server, bei dem nicht oder nur teilweise gesucht werden konnte (keine Antwort, Zeitlimit, Antwort
  unvollständig). Die übrigen Server stehen trotzdem in `suggestions`.
- `watch_paths[]`: die Ordner, die jetzt überwacht werden (die Standardorte, solange nichts eingestellt ist).

**`watch-paths`** nimmt den Body `{"path": "/srv/samba/fotos"}` (1 bis 255 Zeichen) und hängt den Ordner an `watch_paths` an;
steht dort noch nichts, werden die Standardorte mit dem neuen Ordner zusammen gespeichert. Der Pfad wird vereinfacht
(Schlussstrich, `//`, ein angehängtes `/*` fällt weg). Antwort `200`: `added` (`true`: neu eingetragen; `false`: ein schon
überwachter Ordner enthält ihn, nichts geändert), `covered_by` (dieser Ordner, sonst `null`) und `watch_paths` (die Liste
danach). Gleichzeitige Aufrufe tragen nichts doppelt ein. Nur ein neu eingetragener Ordner steht im Protokoll
(`shield.watch_path_added`, `detail.path`).

| Status | Bedeutung |
|---|---|
| 400 | kein brauchbarer Ordnerpfad: beginnt nicht mit `/`, hat `..` als Pfadteil, enthält `%` oder Steuerzeichen, ist ein ganzer Systemordner, `/` selbst oder liegt unter `/proc`, `/sys`, `/dev`, `/run` oder `/boot` |
| 409 | der Ordner liegt in einem ausgenommenen Ordner (`exclude_paths`); erst die Ausnahme in den Einstellungen entfernen |
| 422 | Body ungültig, `path` fehlt, ist leer oder länger als 255 Zeichen |

Die alten Adressen `/ext/nexus-soc/…` antworten gleich (veraltet, siehe §7).

### Abo-Link für die ntfy-App (Erweiterung `ntfy`)

```
GET /ext/ntfy/subscribe-link   → 200 {url, text, error}   Recht: extensions.manage
```

Die Erweiterung `ntfy` heißt in der Oberfläche „Push-Nachrichten“. Die Route liefert den Link, mit dem die ntfy-App auf dem
Handy das Thema des Dashboards abonniert; die Einstellungsseite macht daraus den QR-Code (`x-qr-url` in
`settings.schema.json`). Die Route hat keine Parameter und rechnet aus den **gespeicherten** Einstellungen (`server_url`, `topic`),
nicht aus ungespeicherten Eingaben. Der Link enthält nur Adresse, Port, Pfad und Thema, nie Zugangsdaten aus der Adresse, das
Zugriffstoken oder ein anderes Geheimnis. Die Antwort ist immer `200`; lässt sich kein Link berechnen, steht ein Satz in
`error` und kein Fehlerstatus.

- `url`: `ntfy://<Server>[:<Port>][/<Pfad>]/<Thema>?display=Nodvard%20Deck`, bei einem `http://`-Server zusätzlich
  `secure=false` (sonst nimmt die App https an). Beispiel: `ntfy://ntfy.example.com/deck-alarme?display=Nodvard%20Deck`.
- `text`: ein Satz, was man mit dem Code tut (Kamera darauf halten, die ntfy-App bietet das Abonnieren an).
- `error`: ein Satz, wenn kein Link möglich ist; dann sind `url` und `text` `null`. Gründe: Adresse des Servers und/oder Thema
  fehlen noch, die Adresse ist ungültig (etwa weder `http` noch `https`), oder es sind nur weitere Kanäle eingerichtet (Telegram,
  Discord usw.) und ntfy selbst nicht – der QR-Code gehört zu ntfy.

### Eigene Temperaturgrenze je Server (Erweiterung `system`)

```
GET /ext/system/hosts/{host_id}/temp-limit   → 200 {host_id, own_c, global_c, effective_c}   Recht: hosts.read
PUT /ext/system/hosts/{host_id}/temp-limit   {own_c}  → 200 wie GET                          Recht: settings.write
```

Die Erweiterung `system` heißt in der Oberfläche „Server-Zustand“. Sie warnt, wenn ein Server zu heiß wird, ab der Grenze
`warn_temp_c` aus ihren Einstellungen (Vorgabe 75 °C). Läuft ein Server immer wärmer (etwa ein Proxmox-Rechner unter
nächtlicher Last), bekommt er hier eine eigene Grenze; die übrigen Server bleiben bei der allgemeinen.

- `own_c`: die eigene Grenze in °C (ganze Zahl von 40 bis 110) oder `null`. Im PUT ist das Feld Pflicht: Nur ein
  ausdrückliches `null` nimmt die Grenze heraus, dann gilt wieder `global_c`. Fehlt das Feld (`{}`), steht ein unbekanntes
  Feld im Body (etwa ein Tippfehler im Namen), ist der Typ ein anderer (Text, Kommazahl, `true`) oder der Wert
  außerhalb der Spanne, gibt es `422` und nichts wird geändert.
- `global_c`: die allgemeine Grenze `warn_temp_c`, `effective_c`: die Grenze, die für diesen Server gerade gilt.
- Unbekannter Server: `404`. Beim Speichern fallen Grenzen gelöschter Server mit heraus.

Gespeichert wird in der Einstellung `warn_temp_by_host` (Host-ID → °C). Sie ist im Formular versteckt (`x-hidden`), ein
Speichern dort lässt sie unverändert. Dieselbe Grenze gilt für die Meldung („Warnung ab 90 °C, eigene Grenze dieses
Servers“) und für die Farbe der Temperatur auf der Seite; `GET /ext/system/hosts/{host_id}/info` liefert dazu
`temp_warn_c` (gültige Grenze) und `temp_warn_own_c` (eigene oder `null`). Eine Temperatur meldet erst die zweite Prüfung in
Folge über der Grenze, eine einzelne Spitze bleibt still; ändert sich die Grenze zwischen zwei Prüfungen, beginnt die
Zählung neu.

Jede Änderung der eigenen Grenze steht im Protokoll als `ext_system.temp_limit_changed` (Ziel: der Server, Ersteller: der
angemeldete Benutzer, `detail`: `own_c_before`, `own_c`, `effective_c`). Ein PUT mit dem schon gespeicherten Wert schreibt
nichts. Die Erweiterung braucht dafür `audit.write`; fehlt die Berechtigung oder scheitert der Eintrag, wird die Grenze
trotzdem gespeichert und eine Warnung ins Container-Protokoll geschrieben.

Auf der Seite „Server-Zustand“ stellt man die Grenze bei den Temperaturen ein (Live-Ansicht, Bereich „Sensoren“, und
„Zustand & Updates“): Feld „Warnen ab (°C)“, „Speichern“ und „Allgemeine Grenze nutzen“ (schickt `null`). Wer
`settings.write` nicht hat, sieht nur die geltende Grenze.

### Dashboard und Widgets

```
GET    /dashboard/layouts            GET/PUT /dashboard/layouts/{id}
GET    /widgets                      → Katalog aller WidgetSpecs aktivierter Extensions
GET    /ext/{ext_id}/{data_endpoint} → Widget-Daten, {data, meta}
```

Ein Layout trägt neben `items` die Liste `groups` (`[{key, title, collapsed}]`, `key` einer von `servers`, `security`,
`apps`, `backups`, `services`, `title` höchstens 60 Zeichen, leer heißt Name der Vorgabe). `PUT` nimmt `items` und
`groups` getrennt an, ein Feld ohne Angabe bleibt, wie es ist; doppelte oder unbekannte Gruppen ergeben 422. Fehlt eine
Gruppe in der Liste, steht sie in der Vorgabe-Reihenfolge dahinter. Jedes Widget aus `/widgets` hat `group`.

`GET /widgets` liefert nur Widgets, deren `permissions` die Person alle hat. Ebenso fehlen darin Knöpfe
(`view.actions`, `view.item.actions`, `view.row_actions`), deren eigene `permissions` ihr nicht alle gehören.

Ein Eintrag in `items` hat die Form `{widget_id, ext_id, x, y, w, h, config}`. Das Backend prüft nur, dass `items` eine
Liste von JSON-Objekten ist, und speichert Felder und Werte, wie sie kommen. Selbst ändert der Kern die Einträge nur,
wenn ein Modul die Widgets eines anderen übernimmt (`adopts`: neue `ext_id` und `widget_id`, doppelte fallen weg), und
mit den Beispieldaten (eigene Anordnung, beim Entfernen kommt die alte zurück). Ausgewertet werden `x`, `y`, `w`, `h`
und `config` nur in der Oberfläche:

- `x`, `y`, `w`: Lage und Breite in einem Raster aus 12 Spalten. Wo die Web-Oberfläche Lage oder Breite setzt (neue
  Widgets, „Widgets verwalten“, Ziehen, „Schmal“ und „Breit“), gilt `x + w ≤ 12`; das Backend weist einen Eintrag, der
  darüber hinausragt, nicht ab. Ist die Widget-Fläche mindestens 960 px breit, wird jede Gruppe dicht von links oben
  gepackt, in der Reihenfolge von `y`, dann `x`; `w` hebt die Oberfläche dabei auf die Mindestbreite des Anzeige-Typs
  und begrenzt es auf 12. Schmaler stehen die Karten nur in dieser Reihenfolge untereinander (auf Bildschirmen ab
  640 px in zwei Spalten), `x` und `w` zählen dort nicht.
- `h`: Höhe in Rasterzeilen; ohne `config.size` nur die Vorgabe, bis die Karte ihren Inhalt gemessen hat. Zahl und
  Tacho nehmen bis dahin immer 2 Zeilen, die anderen Typen `h`, mindestens ihre Mindesthöhe (2 oder 3) und höchstens 5.
  Die gemessene Höhe speichert die Oberfläche nie.
- `config.hidden`: `true` heißt, das Widget ist von der Startseite entfernt. Der Eintrag bleibt stehen, sonst gälte das
  Widget als neu und käme von selbst zurück; „Widgets verwalten“ nimmt den Schlüssel beim Zurückholen wieder heraus.
  Jeder andere Wert zählt wie keiner.
- `config.group`: einer der fünf Gruppen-Schlüssel oben; legt das Widget in eine andere Gruppe als die aus `/widgets`.
  Fehlt der Wert oder ist er unbekannt, gilt die Gruppe des Widgets.
- `config.size`: `"s"`, `"m"` oder `"l"` (in der Oberfläche Klein, Mittel, Groß) gibt der Karte eine feste Höhe von 2, 3
  oder 5 Rasterzeilen (eine Zeile 90 px, dazwischen 12 px). Ohne Angabe oder mit einem anderen Wert richtet sich die
  Höhe nach dem Inhalt: die kleinste Stufe aus 1, 2, 3 oder 5 Zeilen, in die er passt. Zahl und Tacho sind immer
  mindestens 2 Zeilen hoch. Listen, Tabellen und Kachelraster, die nicht hineinpassen, enden mit „+ x weitere“,
  anderer Inhalt scrollt.

Weitere Schlüssel in `config` lässt die Web-Oberfläche beim Speichern stehen.

Die Android-App holt `/widgets`, rendert die deklarativen Specs und zieht die Daten von
denselben Endpunkten wie das Web. Eine neue Extension erscheint dadurch in der App,
ohne dass die App aktualisiert wird — das ist der praktische Gewinn aus
[02 §4](02-EXTENSION-API.md#4-widgets--deklarativ-nicht-als-code).

### Eigene Apps („+ App hinzufügen“)

```
GET    /overview                     → u. a. `apps`: eigene Apps (zuerst) + erkannte Dienste, je mit `source`
GET    /apps                         → [App] (hosts.read)
POST   /apps                         → 201 App   {name, url, icon?, color?, group?, open_in_new_tab?, host_id?, sort_order?}
PATCH  /apps/{id}                    → App       nur die genannten Felder; icon/color/group/host_id mit null leeren
DELETE /apps/{id}                    → 204
PUT    /apps/order                   → [App]     {ids: [...]}: diese zuerst, in dieser Reihenfolge; Rest dahinter
                                                 (bis 50 mehr als die Obergrenze, denn das Cockpit schickt immer alle)
GET    /apps/suggestions             → {suggestions: [{name, url, source, host, service_id}], errors: [str]} (apps.write)
```

`GET /apps/suggestions` (ergänzt) sammelt Vorschläge für neue Kacheln (Seite „Apps“): von jeder Erweiterung mit
`AppSuggestionProvider` (z. B. Nginx Proxy Manager: eingeschaltete Proxy-Hosts, `https://`, wenn ein
Zertifikat dran ist) und aus erkannten Diensten (`ServiceCatalog`) mit Adresse, die gerade laufen (`source` ist dann
der Name der Erweiterung). Die erkannten Dienste kommen aus demselben Zwischenspeicher wie `GET /overview` (kein
eigener Abruf) und tragen `service_id` (ergänzt), die Kennung ihrer Kachel (`apps[].id` mit `source="detected"`); die
Apps-Seite zeigt sie an dieser Kachel („Als App übernehmen“) statt noch einmal unter „Vorschläge“. Nennt eine
Erweiterung dieselbe Adresse wie ein erkannter Dienst, bleibt der Vorschlag am Dienst. Jeder Vorschlag wird wie eine von Hand angelegte App geprüft (Name auf 60 Zeichen gekürzt;
unbrauchbare Adressen fallen still weg); Adressen, die es als App schon gibt (gleiche Adresse, Groß-/Kleinschreibung
von Schema und Rechner und ein abschließender `/` zählen nicht), und doppelte kommen nicht vor; höchstens 200. Jede
Quelle hat 12 s; antwortet eine nicht, steht ein Satz in `errors` (ohne Text aus der Quelle), die anderen bleiben.
Ließen sich die erkannten Dienste nicht lesen, steht dafür ein Satz in `errors` (ohne Text aus der Quelle); die
ausführlichere Meldung steht in `errors` von `GET /overview`.
Der Server ruft keine der Adressen auf; angelegt wird erst mit `POST /apps`.

App = `{id, name, url, icon, color, group, sort_order, open_in_new_tab, host_id, host, created_at, updated_at}`
(`host` = Anzeigename des Servers). Rechte: Lesen `hosts.read` (wie die erkannten Apps im Cockpit); Schreiben
**`apps.write`** – neu und additiv: nur Administrator und Owner haben es von selbst. Die Kacheln stehen auf der
Startseite aller Nutzer, ein Link dort ist für jeden ein Klick-Ziel; `hosts.write` (Server und SSH-Zugänge) und
`settings.write` (ganze Installation) wären dafür deutlich zu viel. Jede Änderung steht im Protokoll
(`app.created`, `app.updated`, `app.deleted`, `app.reordered`; mit Name und `https://host:port`, nie mit Pfad oder
Parametern der Adresse).

Prüfung (422 mit deutscher Meldung am Feld `loc`): `name` 1–60 Zeichen, getrimmt, ohne Steuerzeichen, Zeilen-/Absatztrenner
(U+2028/U+2029), Sonderleerzeichen (nur das normale Leerzeichen ist erlaubt) und nicht vergebene Zeichen (nur in den
Emoji-Blöcken gelten sie als neues Emoji) und mit mindestens einem sichtbaren Zeichen (Buchstabe, Ziffer, Symbol oder
Satzzeichen – Füllzeichen wie U+3164 und Verbinder allein sind kein Name); `url` nur
`http://`/`https://` mit Rechnername oder IP, höchstens 1000 Zeichen, ohne Benutzer/Passwort, Leer-/Steuerzeichen
und Rückwärts-Schrägstrich (also nie `javascript:`, `data:`, `file:` …); `group` höchstens 40 Zeichen, mit denselben Zeichenregeln wie `name` (leer = keine Gruppe); `color`
`#rrggbb`; `icon` ein Name aus der festen Liste (`services/custom_apps.APP_ICONS`, deckungsgleich mit
`frontend/src/lib/appIcons.ts`) oder ein einzelnes Emoji – **keine Bild-Adresse** (sonst würde der Browser jedes
Nutzers beim Anzeigen der Startseite einen fremden Server anfragen: Tracking, Mixed Content); `host_id` muss ein
vorhandener Server sein; höchstens 200 Apps (`409`; die Beispieldaten halten die Grenze ebenfalls ein). **Der Server ruft die Adressen nie selbst ab** – es gibt keine
Statusabfrage für eigene Apps (kein SSRF); eine solche Prüfung wäre ein eigenes Paket.

`GET /overview` ist rein additiv erweitert: `services` und `services_running` bleiben wie sie waren (nur erkannte
Dienste, die Kennzahl „Dienste“ zählt keine Links); neu ist `apps` – eigene Apps (`source: "custom"`, nach Position,
immer frisch aus der Datenbank) vor den erkannten Diensten (`source: "detected"`, aus dem 30-s-Zwischenspeicher).
Felder: `id, source, name, url, host, host_id, state, tone, image, icon, color, group, open_in_new_tab, sort_order`
(`null`, wo es für die Art nichts gibt). Wird ein Server gelöscht, bleibt seine App stehen, nur ohne `host_id`.
Ebenfalls neu: `services[].unreachable` (`true` bei der Platzhalterkachel eines Servers, dessen Container nicht gelesen werden
konnten – sie ist kein Dienst und zählt nicht als „läuft nicht“) und `services_unreachable_hosts` (Namen dieser Server, sonst leer).
Der Platzhalter kommt auch, wenn der Server antwortet, Docker aber nicht (Dienst aus, keine Rechte).
`services[].url` (und damit `url` der erkannten Apps) prüft der Server nach derselben Regel wie die Adresse einer eigenen
App: nur `http://` oder `https://` mit Rechnername oder IP-Adresse, sonst `null` (kein `javascript:`, `data:` oder `//…`).

Ebenfalls additiv: `helper_hints` – Server, die neu eingerichtet werden sollten, damit die Module mit engen Rechten
laufen: `[{host_id, host_name, modules: [Anzeigename], checked_at, without_root, still_used, no_fallback}]`. Ein Eintrag kommt nur, wenn sudo auf einem
Linux-Server Hilfsskripte ausführen lässt (volle Regel `NOPASSWD: ALL`, auch mit weiteren Angaben wie
`NOPASSWD: SETENV: ALL`, oder die eigene Regel mindestens eines Skripts) und mindestens ein Hilfsskript eines eingeschalteten
Moduls fehlt oder nicht das mitgelieferte ist (älter, verändert, unlesbar); nie für root und nie für Windows.
`without_root` ist `true`, wenn dort keine volle Regel steht: Diese Module haben dann gerade gar kein root (sonst laufen
sie über die volle Regel). Ausnahme sind die Module in `still_used` (Teil von `modules`, sonst leer): Diese Module rufen
ihr Skript in jeder Fassung auf (`calls_any_version` im Manifest, derzeit Docker) und laufen dort mit einer älteren
weiter, solange deren Version lesbar ist, nur root sie ändern kann und sudo sie erlaubt. `no_fallback` (Teil von
`modules`, sonst leer; fehlt bei älteren Kernen) nennt die Module, die ohne ihr Skript auch mit der vollen Regel kein root
haben (`sudo_fallback = false` im Manifest, derzeit Docker); außer denen in `still_used` fehlt ihnen root also auch,
wenn `without_root` `false` ist. Der Stand
stammt aus „Verbindung prüfen“ (nur mit dem Standard-Zugang) und dem Kern-Job `helper-hints` (alle 6 Stunden ein
lesender Befehl je Server, derselbe wie bei der Prüfung); `GET /overview` selbst fragt keinen Server ab.
`POST /hosts/{id}/helper-hint/dismiss` (`hosts.write`, 204, 404 ohne Server) blendet den Hinweis für diesen Server aus,
bis sich die fehlenden Skripte oder ihre mitgelieferten Versionen ändern (etwa durch ein Update).

`full_rule_removable` (fehlt bei älteren Kernen) ist `true`, wenn dort die volle Regel steht, aber nicht nötig ist: Jedes
eingeschaltete Modul, das dort root braucht, hat ein Hilfsskript, und jedes ist brauchbar und hat seine eigene Regel.
`modules` ist dann leer, wenn kein Skript fehlt oder veraltet ist. So ein Eintrag kommt nicht, wenn die Regel beim Server
gewollt ist (`HostOut.sudo_full_wanted`). Weggeklickt bleibt er weg, bis die volle Regel einmal nicht mehr da war.
Dasselbe Feld steht in der Antwort von `POST /hosts/{id}/check`, dort auch dann, wenn die Regel gewollt ist; der Punkt
`root` ist in dem Fall `ok`, sonst `warn` mit dem Befehl zum Entfernen im Hinweis.

Ebenfalls additiv: `untrusted_proxies` – wie in `GET /system/connection`: Proxys, hinter denen Nodvard Deck die echten
Adressen nicht sieht, weil sie nicht unter „Vertrauenswürdiger Proxy“ stehen. Gemerkt wird nur bei einer Anmeldung des
Inhabers (Owner) mit richtigem Passwort (auch wenn danach noch der Zwei-Faktor-Code fehlt), die `X-Forwarded-For` trägt,
und nur von einer Gegenstelle aus dem Heimnetz (privat, Loopback, Link-Local, `100.64.0.0/10`, `fc00::/7`); sonst
könnte jedes Konto oder jeder aus dem Internet eine Adresse in den Hinweis schreiben. `Forwarded` allein zählt nicht,
weil Deck nur `X-Forwarded-*` auswertet. Das Feld ist nur für den Inhaber gefüllt, der die Liste als Einziger ändern
darf, sonst leer. Eingetragen wird nie etwas von selbst.

Ebenfalls additiv: `legacy_env` (`[{old, new, new_set}]`) – alte Umgebungsvariablen (`LATTICE_<NAME>`), mit denen
Nodvard Deck gestartet wurde, und ihr neuer Name (`NODVARD_DECK_<NAME>`), nach dem alten Namen sortiert. `new_set`: Der
neue Name ist ebenfalls gesetzt und gewinnt, der alte kann einfach weg. Nur die Namen, nie die Werte. Die alten Namen
gelten in dieser Version noch, ab 1.0 nicht mehr. Nur für den Inhaber gefüllt, sonst leer.

### Beispieldaten

```
GET    /demo                         → {active, hosts, notifications, apps, layout_replaced, hosts_with_access}
POST   /demo/seed                    → wie GET, dazu {created}; legt Beispieldaten an (idempotent)
DELETE /demo                         → {removed_hosts, kept_hosts, removed_notifications, layout_restored, removed_apps, kept_apps}
```

„Mit Beispieldaten ansehen“: fünf Beispiel-Server (Adressen aus `192.0.2.0/24`, RFC 5737, ohne
Zugang, damit nie etwas angefragt wird), vier Meldungen unterschiedlicher Schwere mit `payload.path`
drei Beispiel-Apps (eigene Kacheln mit Adressen aus `192.0.2.0/24`, die mit den Beispieldaten wieder verschwinden;
eine, die jemand auf eine echte Adresse umgestellt hat, bleibt stehen: `kept_apps`; sie kommen hinter schon vorhandene eigene Apps) und, falls Module Widgets mitbringen,
ein Beispiel-Layout im Dashboard des aufrufenden Nutzers
(das bisherige wird gesichert und beim Löschen zurückgesetzt). Die IDs der angelegten Zeilen stehen in
der globalen Einstellung `demo.seeded_ids` – `DELETE /demo` entfernt genau diese und sonst nichts.
Ein Beispiel-Server, den jemand zu einem echten umgebaut hat (Adresse außerhalb von `192.0.2.0/24`
oder von einem Modul eingelesen), bleibt stehen (`kept_hosts`). Hängt an einem Beispiel-Server ein
Zugang, wird er mitgelöscht; `hosts_with_access` nennt solche Server vorab (für die Rückfrage).

Rechte: `GET` braucht `hosts.read`; `POST` und `DELETE` brauchen `hosts.write` **und**
`settings.write` (sie legen/entfernen Server, schreiben Meldungen für alle Nutzer und ändern das
Dashboard). `POST` antwortet `409`, sobald es echte Server gibt (außer Beispielen); ein zweiter
`POST` ändert nichts (`created: false`). `DELETE` ohne Beispieldaten ist ein harmloses Nichts;
läuft gerade eine Aktion auf einem Beispiel-Server, antwortet er `409` und ändert nichts.
Protokoll: `system.demo.seeded`, `system.demo.removed`. `NODVARD_DECK_DEMO_MODE=1` ruft beim Start
dieselbe Funktion auf (Akteur `system`).

### Dateien

```
GET    /files/sources                            → alle FileSources aller Extensions (ohne gesperrte, s. u.)
GET    /files/places?host_id=                     → wichtige Orte eines Servers: [{label, path, icon, hint}]
GET    /files/{source}/list?path=&cursor=
GET    /files/{source}/stat?path=
GET    /files/{source}/info                       → SourceInfo (Quota, Health, Deep-Link)
GET    /files/{source}/download?path=             → Streaming, Range-fähig
POST   /files/{source}/upload?path=               → Streaming
POST   /files/{source}/mkdir | /rename | /remove
POST   /files/transfer                            → {from:{source,path}, to:{source,path}}
                                                    → job_run-ID, Fortschritt über WS
GET    /files/search?q=&sources=                  → Fan-out, Ergebnisse gemischt
```

`/files/places` sammelt bei allen `FilePlaceProvider` ([02 §3](02-EXTENSION-API.md#3-capabilities--der-kern-der-entkopplung))
die Abkürzungen für einen Server. Jeder Anbieter hat sechs Sekunden und läuft gleichzeitig mit den anderen; scheitert
einer oder braucht zu lange, fehlen nur seine Orte (eine Zeile im Container-Protokoll, ohne Traceback). Es kommen nur
absolute Pfade ohne `..` an, geglättet und nach Pfad entdoppelt, höchstens 30. Rechte: `files.read` und `hosts.read`
(sonst `403`); ein unbekannter Server ist `404`. Wer zu diesem Server keine Dateiquelle öffnen darf (der Server hat keine
oder sie verlangt `hosts.execute`, s. u.), bekommt eine leere Liste.

`/files/transfer` ist das Drag-&-Drop zwischen Quellen im Dateimanager: der Server streamt
`open_read(A) → open_write(B)`, ohne dass die Datei über den Client läuft.

`GET /files/sources` liefert je Quelle `source_id`, `label`, `icon`, `caps` und `host_id` (nur additiv): die Kennung des
Servers, hinter dem die Quelle steht (die SSH-Quellen der Server), sonst `null`. Eine Quelle nennt sie über das
optionale Attribut `host_id` ([02 §3](02-EXTENSION-API.md#3-capabilities--der-kern-der-entkopplung)); nur ein nicht leerer
Text zählt, alles andere ergibt `null`. Der Dateimanager fragt damit `GET /hosts` ab (nur, wenn mindestens eine Quelle
einen Server hat) und zeigt bei diesen Quellen Status und Betriebssystem des Servers. Ohne Leserecht auf Server (`hosts.read`)
fehlen nur diese Angaben, die Quellen bleiben.

Meinen Quelle und Ziel dieselbe Datei, antwortet `/files/transfer` mit `409` („Quelle und Ziel sind dieselbe
Datei.“) und startet keinen Lauf. In derselben Quelle zählt der gleiche Pfad, bei einer Quelle mit
`file_identity()` ([02 §3](02-EXTENSION-API.md#3-capabilities--der-kern-der-entkopplung), bisher die
SFTP-Quellen der Server) auch der gleiche aufgelöste Pfad (Link, `..`-Umweg). Über zwei Quellen hinweg (etwa
zwei Einträge für denselben Server) nur, wenn beide `file_identity()` liefern, der aufgelöste Pfad gleich ist
und dazu Größe, Änderungszeit, Besitzer, Gruppe und Rechte übereinstimmen; zwei verschiedene Rechner-Kennungen
sind immer zwei Dateien. Im Zweifel gilt die Datei als eine andere. Nicht erkannt wird ein Ordner, der unter
zwei Pfaden eingehängt ist: eine Datei dort auf ihren eigenen zweiten Pfad zu verschieben, löscht sie.
Verschieben ist im Dateimanager ein Transfer mit anschließendem `remove` der Quelle; das Original wird nur
gelöscht, wenn genau so viele Bytes angekommen sind, wie die Quelle beim Anzeigen hatte, sonst bleibt es und
der Dateimanager sagt das.

Die SFTP-Quellen der Server (terminal-Extension) schreiben bei `upload` und `/files/transfer` erst in eine
Temp-Datei im Zielordner (`.<name>.nodvard-tmp-…`) und ersetzen das Ziel erst, wenn alles angekommen ist. Ein
abgebrochener Upload oder Transfer lässt die vorhandene Datei dann unberührt. Ersetzt die Temp-Datei eine
bestehende Datei, ist sie bis zum Ende nur für ihren Besitzer lesbar (`0600`); die neue Datei behält Besitzer,
Gruppe und Rechte der alten. Ein Link wird bis zur echten Datei verfolgt und bleibt ein Link. Direkt ins Ziel
geschrieben wird, wenn der Zugang im Zielordner nichts anlegen darf oder den Besitzer der alten Datei nicht
übernehmen kann (etwa ohne root bei einer fremden Datei). Dann liest Nodvard Deck erst die ganze Quelle (über
8 MiB in eine lokale Temp-Datei), bevor es das Ziel öffnet: bricht die Quelle ab, bleibt das Ziel unberührt,
scheitert erst das Schreiben, kann es unvollständig sein. Pipes und Geräte (etwa `/dev/null`) werden direkt
beschrieben. Weil beim Ersetzen eine neue Datei entsteht, sieht ein Container, in den die Datei einzeln per
Bind-Mount eingebunden ist, den neuen Inhalt erst nach einem Neustart des Containers; ein eingebundener Ordner
ist nicht betroffen.

`upload` hat standardmäßig keine Größengrenze, der Body geht als Strom an die Quelle. `NODVARD_DECK_FILES_MAX_UPLOAD_BYTES`
(Bytes, `0` = keine Grenze) setzt eine. Ist die `Content-Length` größer, kommt `413` mit `Connection: close`, bevor die
Quelle das Ziel öffnet. Ohne Längenangabe (`chunked`) bricht erst das Mitzählen ab (siehe §1), bei der eingestellten
Grenze, mindestens aber bei der allgemeinen (1 MiB); dann hat die Quelle schon zu schreiben begonnen (die SFTP-Quellen
lassen eine vorhandene Datei dann wie oben unberührt).

Scheitert ein Zugriff an der Quelle (Server aus, Zugang abgelehnt, Zeitüberschreitung), antworten `info`, `list`,
`stat`, `download`, `upload`, `mkdir`, `rename` und `remove` mit `502` und dem Text
`Zugriff auf die Quelle fehlgeschlagen: <Grund>`, fehlt die Datei, mit `404`. Bei Netzfehlern ist der Grund ein
deutscher Satz („Server antwortet nicht (Zeitüberschreitung …)“); die Nextcloud-Quelle nennt einen eigenen festen Satz je
Fall, nie den Antworttext des Servers (etwa „Nextcloud antwortet nicht (Zeitüberschreitung).“, „Nextcloud findet … nicht
(HTTP 404). …“, „In der Nextcloud ist kein Speicherplatz mehr frei (HTTP 507).“, „Nextcloud ist gerade nicht bereit (HTTP
503), zum Beispiel im Wartungsmodus. …“). Eine Weiterleitung (3xx) ist bei ihr nie ein Erfolg: „Nextcloud leitet auf eine
andere Adresse um (HTTP n). …“, ohne die Zieladresse. Bei einem Dateifehler (`OSError` wie `PermissionError`, kein Netzfehler) steht nur der Name des
Fehlers da, nie ein Pfad; den vollen Text schreibt der Server nur ins Container-Protokoll. Ein gescheiterter
`/files/transfer` trägt denselben Grund im Lauf (`error`).

`download` holt den ersten Teil aus der Quelle, bevor die Antwort beginnt. Scheitert die Quelle dabei (Weiterleitung, Datei
fehlt, Server nicht erreichbar), kommt ein HTTP-Fehler statt einer leeren oder abgebrochenen Datei: `404` mit
„'<pfad>' nicht gefunden.“, sonst `502` wie oben. Eine leere Datei ist weiterhin `200` ohne Inhalt. Bei Quellen mit eigener
Berechtigung steht ein gescheiterter Download im Protokoll als `files.download` mit Ergebnis `failure` und dem Status.

Rechte: `sources`, `info`, `list`, `stat`, `download` und `search` brauchen `files.read`; `upload`, `mkdir`,
`rename`, `remove` und `transfer` brauchen `files.write`. Eine Quelle kann zusätzlich eine eigene Berechtigung
verlangen (`required_permission`, [02 §3](02-EXTENSION-API.md#3-capabilities--der-kern-der-entkopplung)): Die
SSH-Quellen der terminal-Extension verlangen `hosts.execute`, weil sie mit dem Zugang des Servers arbeiten und
nicht mit dem des Nutzers. Wer sie nicht hat, sieht die Quelle in `/files/sources` und `/files/search` nicht;
alle anderen Endpunkte antworten für sie mit `403`, `/files/transfer` auch dann, wenn nur Quelle oder Ziel
gesperrt ist.

Protokoll, nur für Quellen mit eigener Berechtigung (`target_type` `file_source`, immer nur Quelle und Pfad, nie
Inhalte): `files.access` (verweigert, `outcome` `denied`), `files.download`, `files.upload`, `files.mkdir`,
`files.rename`, `files.remove` (die vier schreibenden auch, wenn sie scheitern), `files.search` (je durchsuchter
Quelle, ohne Suchbegriff), `files.transfer_start` und `files.transfer` (Ergebnis mit `bytes`, beide mit `run_id`).
`list`, `stat` und `info` stehen nicht im Protokoll.

Bei der Nextcloud-Quelle bleibt jeder Pfad im Dateibereich des eingerichteten Benutzers. Ein Pfad, der ihn
verlassen könnte (ein `..`-Teil, auch URL-kodiert wie `%2e%2e` oder mehrfach kodiert, ein Backslash, ein
NUL-Zeichen oder ein kodierter Schrägstrich wie `%2F`), wird abgelehnt, bevor eine Anfrage mit diesem Pfad
an den Nextcloud-Server geht: `list`, `stat`, `download`, `upload`, `mkdir`, `rename` und `remove` antworten
mit `404`, ein `/files/transfer` mit so einem Pfad endet als fehlgeschlagener Lauf.

---

## 4. WebSocket

Das Image startet uvicorn mit `--ws-max-size 1048576`: Eine einzelne Nachricht vom Client an einen der Sockets darf
höchstens 1 MiB groß sein, eine größere beendet die Verbindung (ohne den Schalter gilt der uvicorn-Standard von 16 MiB).

### Multiplexiert: `GET /ws`

Eine Verbindung, viele Kanäle. Auf dem Mobilgerät ist jede zusätzliche Verbindung
Akkulaufzeit — deshalb bewusst nicht ein Socket pro Feature.

Authentifizierung: erste Nachricht nach dem Verbinden ist
`{"type":"auth","token":"<access_token>"}`. Keine Tokens in der URL (sie landen in
Proxy-Logs). Der Token einer schon beendeten Anmeldung (abgemeldet, widerrufen) bekommt
`auth_error`, auch solange er noch nicht abgelaufen ist.

Eine offene Verbindung wird bei jedem `ping` (alle 30 s) erneut geprüft: Ist das Konto
deaktiviert, das Passwort geändert oder die Anmeldung beendet, kommt ein `error` mit dem
Grund in `payload.title`, danach schließt der Server mit Code `4401`. Geänderte Rollen gelten
für die Filterung nach RBAC ab dieser Prüfung, ohne neue Verbindung. Außerdem endet die Verbindung, sobald der
Access-Token abläuft, mit dem sie sich angemeldet hat (Standard 15 Minuten, `access_token_ttl_seconds`): ebenfalls
`error` mit dem Grund, dann Code `4401`. Ein Token ohne `exp` bekommt schon beim Aufbau `auth_error`. Der Client holt
sich per `POST /auth/refresh` einen neuen Access-Token, verbindet sich neu und abonniert seine Kanäle wieder. Eine so
beendete Verbindung bekommt bis zum Schließen nur noch dieses `error`, aber keine Ereignisse mehr, auch nicht auf
Kanälen ohne Rechteprüfung.

Umschlag, in beide Richtungen:

```json
{ "type": "...", "channel": "...", "id": "...", "payload": { } }
```

| type | Richtung | Zweck |
|---|---|---|
| `auth` / `auth_ok` / `auth_error` | ↔ | |
| `subscribe` / `unsubscribe` / `subscribed` | ↔ | |
| `event` | ← | Nutzlast auf einem Kanal |
| `ping` / `pong` | ↔ | 30 s, App erkennt tote Verbindungen |
| `error` | ← | `payload.title` = Grund als deutscher Satz |

Kanäle:

```
events                     alles, was der Event-Bus publiziert (gefiltert nach RBAC)
notifications              neue Benachrichtigungen
actions                    Vorschlag angelegt / bestätigt / ausgeführt
hosts                      Statuswechsel
jobs.{job_id}              Laufstatus
runs.{run_id}              Live-Ausgabe eines Laufs
files.transfer.{id}        Fortschritt
ext.{ext_id}.{beliebig}    Extension-eigene Kanäle (ctx.ws.broadcast)
```

`action.*`-Ereignisse im Kanal `events` (Recht `hosts.read`, z. B. `action.executed`) enthalten den `payload` der
Aktion (im Umschlag unter `payload.data.payload`) vollständig nur für Verbindungen mit `hosts.execute`;
`actions.approve:<risiko>` reicht hier anders als bei der REST-Ressource nicht. Alle anderen bekommen dieselbe
Nachricht mit den Kennungen wie in §3 „Aktionen“ und `payload.data.payload_hidden` (`true`, wenn etwas weggelassen
wurde). `action.executed` nennt außerdem in `payload.data.ext_id` das Modul, das die Aktion vorgeschlagen hat (ältere
Kerne ohne das Feld).

Verhalten bei Verbindungsabbruch: der Client abonniert nach dem Reconnect neu und holt
den verpassten Zustand per REST nach. Es gibt **keine** Nachrichten-Wiederholung über
WS — ein Ereignisstrom mit Zustellgarantie wäre eine Message-Queue, und die soll es laut
[00 D-09](00-DECISIONS.md#d-09-hintergrundarbeit--queue) nicht geben. Jedes Ereignis ist
ein Hinweis „schau nach", der Wahrheitsstand liegt in der REST-Ressource.

### Terminal: `GET /ws/terminal/{session_id}` — eigener Socket

Absichtlich getrennt vom Multiplex: Tastatureingaben sind latenzkritisch und Ausgaben
sind binär und voluminös; beides würde den Steuerkanal verstopfen.

```
GET  /terminal/hosts                                    → [host_id, …]   (Hosts, für die eine Sitzung möglich ist)
POST /terminal/sessions   {host_id, cols, rows, user?, scope?, ref?}  → {session_id, ws_url}
```

Die Kern-Seite `/terminal` (mehrere Tabs, je Tab eine Sitzung) baut darauf auf.
`scope` und `ref` (nur zusammen, sonst `422`) öffnen eine Shell **in** etwas auf dem Server statt auf dem Server
selbst, etwa `{"scope": "container", "ref": "nginx"}`. `scope` hat die Form `^[a-z][a-z0-9_-]{0,31}$`, `ref` beginnt
mit Buchstabe oder Ziffer und enthält sonst nur `A–Z a–z 0–9 _ . : @ -` (höchstens 255 Zeichen, sonst `422`). Bietet
kein Modul dafür eine Shell an (`ScopedTerminalTarget` mit diesem `scope`, das `ref` öffnen kann), antwortet der Server
mit `404`; fällt der Anbieter bis zum Verbinden weg, endet der Socket mit `4501` (nie eine Shell auf dem Server
selbst). Die Seite öffnet so einen Tab direkt über `/terminal?host=<id>&scope=…&ref=…` (z. B. „Konsole“ auf der
Docker-Seite). `terminal.open` und `terminal.close` tragen dann `scope` und `ref` in `detail`. Ein halber oder
ungültiger Link öffnet auf der Seite gar nichts (nie ersatzweise die Shell des Servers). Die Container-Konsole des
Moduls Docker hängt nur am Recht `hosts.execute`, gilt also auch, wenn das Modul „Terminal“ aus ist, läuft im
Container als dessen Standard-Benutzer (oft root) und öffnet sich nie im Container von Nodvard Deck selbst.
Scheitert das Öffnen, kommt vor dem Close-Frame `{"type":"error","message":…}`. `message` nennt den Grund
und ist nie leer, bei Netzfehlern ein deutscher Satz („Server antwortet nicht (Zeitüberschreitung bei …). Ist er
eingeschaltet und im Netz?“); derselbe Grund steht in `terminal.open` mit Ergebnis `failure` (in `reason`).

Auf dem Socket: **binäre** Frames = rohe PTY-Bytes in beide Richtungen.
**Text**-Frames = JSON-Steuerung:

```json
{"type":"resize","cols":120,"rows":40}
{"type":"signal","name":"SIGINT"}
{"type":"exit","code":0}
```

Sitzungen werden im Audit-Log als `terminal.open` / `terminal.close` mit Dauer vermerkt.

Eine Sitzung hängt an Konto, Recht `hosts.execute`, Passwort und genau der Anmeldung, aus der
das Ticket stammt. Der Server prüft das beim Öffnen und danach alle 15 s
(`terminal_recheck_interval_s`): Ist das Konto deaktiviert, das Recht entzogen, das Passwort
geändert oder die Anmeldung beendet (abgemeldet oder widerrufen), endet die Sitzung. Ohne jede
Eingabe **und** Ausgabe endet sie nach 30 Minuten (`terminal_idle_timeout_s`, 0 = aus); ein
Befehl, der noch Text liefert, hält sie offen. Der Grund kommt jeweils als
`{"type":"error","message":…}` vor dem Close-Frame. Im Protokoll steht er beim Öffnen in
`terminal.open` mit `denied`, bei einer laufenden Sitzung in `terminal.close` (`reason`, dazu
`detail.ended_by`: `access_ended` bzw. `idle`).

| Close-Code | Bedeutung |
|---|---|
| `1000` | normales Ende |
| `4401` | Konto, Recht, Passwort oder Anmeldung gelten nicht mehr |
| `4404` | Ticket unbekannt oder abgelaufen, Host unbekannt |
| `4408` | Leerlauf |
| `4500` | Öffnen gescheitert |
| `4501` | keine Erweiterung bietet für diesen Host ein Terminal an |

### Konsole: `GET /ws/console/{session_id}` — Bildschirm einer VM/eines Containers

```
GET  /console/hosts                  → [host_id, …]   (Hosts mit verfügbarer Konsole)
POST /console/sessions {host_id}     → {session_id, ws_url, protocol, password}
```

Anders als beim Terminal öffnet der `POST` die Konsole **sofort** (über die
`ConsoleTarget`-Capability, z. B. Proxmox' vncproxy/vncwebsocket): das Einmal-Kennwort
für die protokolleigene Authentifizierung (`protocol: "vnc"` → RFB-VNC-Auth) entsteht
erst dabei, und der Browser-Client (noVNC) braucht es vor dem WS-Aufbau. Nicht innerhalb
von `terminal_session_ttl_s` abgeholte Sitzungen werden geschlossen.

Auf dem Socket: nur **binäre** Frames, rohe Protokoll-Bytes in beide Richtungen; ein
angefragtes Subprotokoll `binary` wird bestätigt. Der Browser spricht nur mit Nodvard Deck,
nie mit dem Hypervisor — dessen Token bleibt im Vault. Jede Öffnung (auch eine
gescheiterte) steht im Audit-Log als `console.open`.

Unbekannter Host oder keine Konsole für ihn: `404`. Scheitert danach das Öffnen, antwortet der `POST`
mit `502` und `detail` „Konsole konnte nicht geöffnet werden: …“; derselbe Grund steht in
`console.open` mit Ergebnis `failure` (in `reason`). Das gilt auch, wenn der Hypervisor den
WebSocket-Aufbau mit einer Weiterleitung (3xx) beantwortet: Nodvard Deck folgt ihr nicht, es entsteht
keine zweite Verbindung, und der Grund nennt die Umleitung samt Statuscode
(z. B. „… umgeleitet (HTTP 302) …“). Einen Proxy aus der Umgebung von Nodvard Deck (`HTTP_PROXY`,
`HTTPS_PROXY`, `WSS_PROXY` usw.) benutzt die Konsole nie, ihre Verbindungen gehen immer direkt an die eingetragene
Adresse des Hypervisors.

Wie beim Terminal endet die Konsole, sobald Konto, Recht `hosts.execute`, Passwort oder
Anmeldung nicht mehr gelten (Prüfung beim Aufbau und alle 15 s): Close-Code `4401`, sonst
`1000`. Ein unbekanntes oder abgelaufenes Ticket: `4404`.

---

## 5. Was die Android-App zusätzlich braucht

Damit die App nicht auf API-Nachbesserungen warten muss, sind diese vier Dinge schon Teil
der API:

1. **`/capabilities`** — eine APK, viele unterschiedlich bestückte Server.
2. **Deklarative Widgets** — sonst ist die App bei jeder neuen Extension blind.
3. **`202`-Semantik** — der Bestätigungs-Workflow muss auf dem Telefon funktionieren,
   das ist sogar der wahrscheinlichste Ort dafür (Meldung kommt aufs Handy, Bestätigung
   mit einem Daumen).
4. **Push:** `POST /me/devices {platform, push_token}`. Der Versand läuft über einen
   `NotificationChannel`, bewusst nicht über einen zentralen Push-Relay. Praktisch heißt
   das: ntfy-Topic-Abo in der App (funktioniert ohne eigene Infrastruktur und ohne
   Google-Konto-Bindung), FCM optional für alle, die es wollen.

---

## 6. Versionierung

`/api/v1` bleibt kompatibel: nur additive Änderungen. Brechende Änderungen bekommen
`/api/v2`, beide laufen parallel, bis die App nachgezogen ist. Bis die erste APK
existiert, ist `v1` faktisch instabil — deshalb entsteht die App erst nach der
Web-Oberfläche und nicht parallel.

---

## 7. Kompatibilität

Das Dashboard heißt „Nodvard Deck“. Alle Nodvard-Produkte sollen später über einen
versionierten Vertrag („Nodvard Link“) mit Deck und der geplanten App zusammenarbeiten, und
die App spricht nur mit Deck. Deshalb gilt ab sofort für `/api/v1` (Kern **und** Extension-Routen
unter `/api/v1/ext/<id>/…`): nur abwärtskompatible Änderungen.

| Änderung | Erlaubt? |
|---|---|
| Neuer Endpunkt | ja |
| Neues optionales Feld / neuer optionaler Parameter in einer Anfrage | ja |
| Neues Feld in einer Antwort | ja |
| Endpunkt (Pfad oder Methode) entfernen oder umbenennen | **nein** |
| Antwortfeld entfernen oder umbenennen | **nein** |
| Neues Pflichtfeld / neuer Pflicht-Parameter in einer Anfrage (auch: Body, der vorher fehlte) | **nein** |
| Typ eines Feldes ändern | **nein** |
| Wert einer festen Werteliste (Enum) entfernen | **nein** |

**Bewusst strenger aus Sicherheitsgründen:** `DELETE /me/totp` und `POST /me/totp/setup` verlangen `current_password`,
das sie früher nicht brauchten (sonst könnte eine gestohlene Sitzung allein Zwei-Faktor ab- oder mit einem eigenen
Authenticator einschalten). Bei `DELETE /me/totp` ist der Body dadurch Pflicht (Eintrag in `breaking_exceptions.toml`),
bei `POST /me/totp/setup` bleibt er im Vertrag optional; ohne Passwort gibt es dort `400` statt `422` (siehe §3).
Ebenso bei Erweiterungsrouten: Was eine Erweiterung ohne `permission=` einhängt, verlangt eine Anmeldung und antwortet
ohne Token mit `401` (früher war es ohne Anmeldung erreichbar). Ohne Anmeldung geht nur, was sie ausdrücklich mit
`public=True` und der Manifest-Berechtigung `api.public` einhängt
([02 §2](02-EXTENSION-API.md#2-der-extensioncontext)). Bei hello-world verlangen `POST /notify-test` und
`POST /vault-use-and-fail` dazu `extensions.manage`. Ebenso verlangen `POST /notifications/read` und
`POST /notifications/read-all` `notifications.write` (früher genügte `notifications.read`), und wer weder
`hosts.execute` noch `actions.approve:<risiko>` für das Risiko einer Aktion hat, bekommt ihren `payload` gekürzt
(`payload_hidden`, siehe §3). Ebenso bei der Größe: Einen Body über 1 MiB (siehe §1) beantwortet ein
Endpunkt, der ihn liest, mit `413` (früher gab es keine allgemeine Grenze); Uploads haben eigene, höhere Grenzen.
Ebenso lehnt `POST /jobs/{id}/run` einen ausgeschalteten Job mit `409` ab (früher lief er trotzdem, so ließ sich etwa die
ausgeschaltete Update-Automatik von Nodvard Shield mit `jobs.run` starten); die Automatik selbst spielt ohne
`auto_updates_enabled` ohnehin nichts ein. Skripte ohne eingeschalteten Zeitplan startet man weiter über
`POST /ext/scripts/scripts/{id}/run`.
Ebenso richtet `sudo=true` bei `GET .../setup` und `POST .../setup-with-password` nur noch die Regeln der Hilfsskripte
ein, wenn die reichen; die volle sudo-Regel gibt es dann nur mit `full_sudo=true` (siehe §3).
Ebenso verlangen `POST /me/password`, `DELETE /me/totp` und `POST /me/recovery-codes` bei eingeschalteter
Zwei-Faktor-Anmeldung zusätzlich `totp_code` (App-Code oder Wiederherstellungs-Code), `POST /system/backups/download`,
`POST /system/backups/{name}/ticket` und `POST /system/restore/{id}/schedule` den App-Code. Ebenso verlangen
`PATCH /users/{id}` mit `password` oder Verwaltungsrollen und `POST /users` mit Verwaltungsrollen das eigene
`current_password` des Admins (fehlt: `403` mit `code: "password_missing"`) und `POST /users/{id}/reset-2fa` zusätzlich
den App-Code, wenn der Admin Zwei-Faktor an hat. Im Vertrag ist das Feld optional (kein Schema-Bruch). Ältere Clients,
z. B. eine ältere Android-App, bekommen `403` mit `code: "totp_missing"` und können diese Aktionen erst nach einem Update
ausführen; beim Passwort ändern erklärt das `detail` selbst, dass die App zuerst zu aktualisieren ist. Fehler beim Code
tragen zusätzlich `code` (`totp_wrong`, `totp_used`), auch bei Update und Rückweg.
Ebenso brauchen alle Wege, die ein schon gespeichertes Geheimnis ersetzen oder löschen, `current_password` und bei
Zwei-Faktor `totp_code`: `PUT /secrets/{id}/value`, `DELETE /secrets/{id}`, `PUT`/`DELETE /extensions/{id}/secrets`,
`PUT /extensions/{id}/settings` (wenn die Änderung Zugangsdaten löscht), `DELETE /hosts/{id}/credentials/{cid}`,
`DELETE /hosts/{id}` (wenn der Server Zugänge hat), `PATCH /hosts/{id}` mit neuer Adresse (wenn SSH-Passwörter
wegfallen) und `make-default` mit `delete_previous`. Ohne Passwort kommt `403` mit `code: "confirm_required"` (siehe
„Tresor“ in §3); die Erstanlage bleibt ohne, ebenso „Mit Passwort verbinden“ in Proxmox (Ausnahme in §3).
Ebenso nimmt `PUT /branding` für `logo_url` und `favicon_url` keine fremde Adresse mehr an (`422`), weil die Seite
Bilder nur noch vom eigenen Server laden soll; ein schon gespeicherter Wert darf unverändert bleiben, und für das Favicon
gibt es dafür `POST /branding/favicon` (siehe §3).
Ebenso verlangen bei Nodvard Shield `POST /ext/shield/incidents/{id}/confirm`, `…/dismiss`, `…/resolve` und
`…/reopen` `soc.manage` (früher genügte `soc.read`); Lesen bleibt bei `soc.read`.
Und bei Erweiterungen: `ctx.http` folgt keiner Weiterleitung mehr, auch nicht mit `follow_redirects=True`, und
`ctx.audit.log()` schreibt keine Kern-Einträge (`mfa.*`, `auth.*`, `login.*`, `system.*`, `user.*`); beides in
[02 §2](02-EXTENSION-API.md#2-der-extensioncontext).

**Umbenennen** geht nur so: Der alte Name bleibt mehrere Releases parallel bestehen und ist
als `deprecated` markiert (`deprecated=True` am Endpunkt bzw. Feld, dazu ein Hinweis im
Änderungsprotokoll). Erst wenn App und Extensions den neuen Namen nutzen, darf der alte
später entfallen. Wirklich unvermeidbare Brüche brauchen `/api/v2` (siehe §6).

**Umbenannte Erweiterungen (`legacy_ids`).** Bekommt eine Erweiterung eine neue Kennung
([02 §1](02-EXTENSION-API.md#erweiterung-umbenennen-legacy_ids)), gelten ihre alten Adressen weiter. Diese bleiben in allen
1.x erhalten (als veraltet markiert) und fallen frühestens mit 2.0 weg. Es sind keine Weiterleitungen (kein `3xx`, kein
eigener Kopf in der Antwort), sondern dieselben Routen unter einer zweiten Adresse:
- `/api/v1/ext/<alt>/…`: dieselben Routen wie unter `/api/v1/ext/<neu>/…`, mit derselben Anmeldeprüfung und denselben
  Antworten. Im OpenAPI-Schema sind sie `deprecated` und stehen hinter der neuen Adresse. Öffentliche Routen stehen mit
  beiden Präfixen im Protokoll.
- `/api/v1/extensions/<alt>/…` (Abruf, enable, disable, `frontend/index.js`, settings, secrets, test) meint dieselbe
  Erweiterung und dieselbe gespeicherte Zeile. Antworten nennen in `id` die neue Kennung. Ausnahme: Liegt unter der alten
  Kennung noch ein verwaister Stand (ein Zwilling, z. B. nach Neuinstallation, Rückweg und erneutem Update), antworten
  ändernde Aufrufe darüber mit `409`. Lesen geht weiter.
- Neue optionale Felder: `ExtensionOut.legacy_ids`, `PageOut.legacy_ext_ids`, `WidgetOut.legacy_ext_ids` (jeweils `[]`
  ohne Umbenennung), `ActionOut.action_label` (Name der angemeldeten Aktionsart, sonst `null`) und `ActionOut.ext_name`
  (Name der installierten Erweiterung, auch bei alter Kennung, sonst `null`).
- `GET /extensions` listet jede Erweiterung einmal unter ihrer neuen Kennung; verwaiste Zwillinge fehlen.
  `GET /jobs?ext_id=` filtert nach der Speicher-Kennung (bei Nodvard Shield auf bestehenden Installationen
  `nexus-soc`), `GET /capabilities` nennt nur die neue Kennung.

**Übernommene Teile (`adopts.routes`).** Zieht ein Teil in ein eigenes Modul
([02 §1](02-EXTENSION-API.md#teil-eines-anderen-moduls-übernehmen-adopts)), gelten seine alten Adressen
`/api/v1/ext/<quelle>/<alt>` weiter, solange das neue Modul an ist: dieselben Routen des neuen Moduls unter einer zweiten
Adresse, mit derselben Anmeldeprüfung und denselben Rechten, im OpenAPI-Schema `deprecated`. Auch sie bleiben in allen
1.x erhalten und fallen frühestens mit 2.0 weg. Bedient die Quelle eine Adresse noch selbst, antwortet weiter die Quelle.
Das gilt nur unter mitgelieferten Modulen; ein nachinstalliertes Paket bekommt keine fremden Adressen.

**Pi-hole** ist seit diesem Stand ein eigenes Modul `pihole` (Beta, vorher Teil von `network`). Neu sind
`GET /api/v1/ext/pihole/summary`, `GET …/widgets/status` und `POST …/pause`, `…/resume`, `…/gravity`, `…/domains`.
Veraltet und weiter beantwortet (solange `pihole` an ist): `GET /api/v1/ext/network/pihole`,
`GET /api/v1/ext/network/widgets/pihole`, `POST /api/v1/ext/network/pihole/pause` und `…/pihole/resume`.

**Nginx Proxy Manager** ist ebenso ein eigenes Modul `npm` (Beta). Neu sind `GET /api/v1/ext/npm/summary`,
`GET …/address-check`, `GET …/widgets/certificates` und `POST …/hosts/{host_id}/enable` bzw. `…/disable`. Veraltet und
weiter beantwortet (solange `npm` an ist): `GET /api/v1/ext/network/npm`, `GET /api/v1/ext/network/npm/address-check`,
`GET /api/v1/ext/network/widgets/certificates` und `POST /api/v1/ext/network/npm/hosts/{host_id}/enable` bzw. `…/disable`.
„Netzwerk“ selbst ist nur noch ein Wegweiser ohne eigene Adressen.

**Nodvard Shield** heißt seit 0.7 `shield` (früher `nexus-soc`). Veraltet sind damit `/api/v1/ext/nexus-soc/**` (alle
Routen der Erweiterung, heute 34 Pfade, jeder auch unter `/api/v1/ext/shield/…`) und `/api/v1/extensions/nexus-soc/**`.
Beide antworten wie die neuen. Neue Einträge im Protokoll heißen `shield.*`, ältere `nexus_soc.*` bleiben stehen; ein
Filter `GET /audit?action=shield.…` findet die älteren nicht. Die Aktionsarten bleiben `nexus_soc.*`
([01 „Alte und neue Namen“](01-ARCHITECTURE.md#alte-und-neue-namen)).

**Docker** heißt seit 0.9 `docker` (früher `service-matrix`). Veraltet sind damit `/api/v1/ext/service-matrix/**` (alle
Routen der Erweiterung, jede auch unter `/api/v1/ext/docker/…`) und `/api/v1/extensions/service-matrix/**`, beide in allen
1.x. Sie antworten wie die neuen. Die Protokoll-Namen bleiben `service_matrix.tile_url` und `service_matrix.image_update`,
die Aktionsarten `container.*` und `docker.*`. Auf bestehenden Installationen filtert
`GET /jobs?ext_id=service-matrix` den Zeitplan der Image-Prüfung ([01 „Alte und neue Namen“](01-ARCHITECTURE.md#alte-und-neue-namen)).

**Automatischer Schutz:** `backend/tests/contract/api_v1.json` ist ein eingecheckter
Schnappschuss des Vertrags (je Pfad und Methode: Parameter, Body-Felder mit Pflicht-Kennzeichen
und Typ, Felder der 2xx-Antwort samt Enum-Werten). Die Tests in `backend/tests/contract/`
vergleichen die aktuelle `app.openapi()` (mit allen mitgelieferten Extensions) damit:

- Ein **Bruch** lässt den Test fehlschlagen; die Meldung listet alle Brüche auf.
- Ein **Zuwachs** (erlaubt) lässt einen zweiten Test fehlschlagen, bis der Schnappschuss
  nachgezogen ist: `python scripts/update_api_contract.py`, danach die geänderte JSON einchecken.
  Das Skript verweigert das Schreiben, wenn dabei ein Bruch entstünde.
- **Ausnahmen** sind nur mit Begründung möglich: Eintrag in
  `backend/tests/contract/breaking_exceptions.toml` (Pfad, Methode, Art, Begründung), dann
  `python scripts/update_api_contract.py --allow-breaking`. Die Liste ist anfangs leer und soll
  es möglichst bleiben.