Zum Inhalt

Aufbau

shared/           Fachmodell, Prüfregeln, Hilfe-Tabelle – von Server und Oberfläche genutzt
server/           Fastify-API, PostgreSQL (postgres.js), SQL-Migrationen, Tests
web/              React-Oberfläche (Vite)
deploy/           Compose-Vorlagen für NAS und Server, Start-Skript und Probe für dms-komplett
docs/             Handbücher (MkDocs), Screenshot-Skript, Release-Notizen
docs/glossar.yml  Glossar – die eine Quelle für alle Begriffe (daraus entsteht die Seite „Begriffe“)
.github/          Workflows (Prüfen, Images, Release, Handbuch …), Dependabot, Vorlagen für Issues und PRs

Ablago ist ein modularer Monolith: ein Prozess, eine Datenbank. Server und Oberfläche stecken zusammen in einem Image – als dms oder, mit PostgreSQL im selben Container, als dms-komplett. Wichtige Muster:

  • Repos doppelt: Jede Datenzugriffs-Schnittstelle gibt es für PostgreSQL (repo-pg.ts, pg…-Funktionen) und für den Arbeitsspeicher (repo-speicher.ts, speicher…). Die meisten Tests laufen schnell im Arbeitsspeicher, die Tests mit TEST_DATABASE_URL decken PostgreSQL ab. Neue Funktionen brauchen beides, und beide müssen sich gleich verhalten.
  • Routen stehen gesammelt in server/src/routen.ts, je mit zugang: 'offen' | 'angemeldet' | 'admin'. Zwei Merkmale kommen dazu: api: true macht eine Route zum Teil der öffentlichen API (Anmeldung auch mit API-Schlüssel); konsole: true erlaubt sie nur über den Befehl dms im Container. Die öffentliche API für Programme liegt unter /api/v1 (api-v1.ts, OpenAPI).
  • Öffentliche Links ohne Anmeldung gibt es bisher nur für die Abrufseite einer Auskunft (#455, server/src/abruf.ts, web/src/abruf.tsx): Schlüssel nur im Fragment (/abruf#…), in der Datenbank nur Hashes (SHA-256 für den Schlüssel, scrypt für den Code), Fehlversuche im Anmeldeschutz je IP und am Link, Download über ein Einmal-Ticket. Weitere Freigabelinks sollten diesem Muster folgen.
  • Migrationen sind nummerierte SQL-Dateien in server/migrations/. Sie laufen beim Start in einer Transaktion und werden nie nachträglich geändert, sondern immer durch eine neue ergänzt.
  • Einstellungen stehen als JSON je Schlüssel in der Tabelle einstellungen. null löscht einen Eintrag.
  • Heimnetz erkennen (#595): Ob eine Adresse im Heimnetz liegt, entscheidet nur shared/src/heimnetz.ts (imHeimnetz, Liste HEIMNETZ_ENDUNGEN mit .local, .lan, .internal, .home.arpa). Art ausnahme (streng) erlaubt http:// ohne TLS – Anmeldung über einen Anbieter, Endpunkte eigener Postfach-Anbieter; Art hinweis (weiter, z. B. auch .fritz.box, 100.64/10) dient nur Vorschlägen wie „Abruf-Link oder E-Mail“, nie einer Sicherheitsausnahme. Neue Stellen nutzen diese Funktion statt einer eigenen Liste. „Gleicher Rechner“ (nur localhost) ist eine andere Frage und bleibt bei ihren Stellen.
  • Hilfe: Jedes ?-Symbol in der Oberfläche zeigt auf eine Stelle im Handbuch. Die Tabelle dazu steht in shared/src/hilfe.ts, und die CI prüft, dass jeder Anker existiert.
  • Gemeinsam beschriebene Zeilen und Verklemmungen (#575): Trigger, die viele Transaktionen in dieselben Zeilen schreiben lassen (z. B. Zähler je Wort mit INSERT … ON CONFLICT DO UPDATE), verklemmen sich unter Last – jede Transaktion sperrt ihre Zeilen in anderer Reihenfolge, PostgreSQL bricht eine ab (Deadlock). Muster: Der Trigger hängt nur an einen Eingang ohne eindeutigen Schlüssel an, ein Hintergrundlauf übernimmt ihn unter einer Sperre und in fester Reihenfolge (Wörterverzeichnis: suchwoerter_eingang, dms_woerter_einarbeiten, Dienst.woerterEinarbeiten, Migration 052). Was trotzdem abgebrochen werden kann und nicht verloren gehen darf, läuft über beiVerklemmungWiederholen (server/src/db.ts, wiederholt bei 40P01/40001) – als eigene Transaktion bzw. sql.begin (in einer Transaktion ein Sicherungspunkt). Test: server/test/verklemmung.test.ts.
  • Sicherung und Wiederherstellung sichern alle Tabellen außer dem Inhalt von sitzungen und schema_migrationen (OHNE_INHALT in server/src/datenbankabzug.ts). Eine neue Tabelle ist automatisch dabei. Daten außerhalb der Datenbank brauchen einen Gedanken: Wo liegen sie, und kommen sie mit?

Abhängigkeiten

Ablago kommt mit wenig fremdem Code aus: 8 npm-Pakete im Server, 3 in der Oberfläche, dazu eine Handvoll Alpine-Pakete im Image. Das ist der beste Schutz gegen Angriffe über die Lieferkette und hält Sicherheitsupdates selten und überschaubar – und einer der Hauptgründe, warum Node.js hier gut passt. So soll es bleiben.

Regel (#543): Eine neue Laufzeit-Abhängigkeit – npm unter dependencies oder ein Alpine-Paket per apk add im Dockerfile – kommt nur mit Begründung im Pull Request (Abschnitt „Neue Abhängigkeiten“ der Vorlage):

  • Wozu wird sie gebraucht?
  • Warum nicht mit Node.js-Bordmitteln oder den vorhandenen Abhängigkeiten und Werkzeugen? Oft reicht eine kleine eigene Umsetzung, wie bei den Sprachdateien statt i18next.
  • Lizenz: verträglich mit Apache-2.0? Bewusst nicht im Image sind z. B. Ghostscript und MuPDF (AGPL).
  • Pflegezustand: letzte Version, Zahl der Betreuer.
  • Eigene Abhängigkeiten: was sie mit ins Image bringt.

Die Prüfung Abhängigkeiten (.github/workflows/abhaengigkeiten.yml) meldet im PR neue Laufzeit-Abhängigkeiten als Warnung, entfernte und neue Werkzeuge für Bau und Tests als Notiz und geänderte Versionen in der Zusammenfassung des Laufs – gesperrt wird nichts. Das erledigt das Skript .github/werkzeuge/abhaengigkeiten-hinweis.py (nur Python und git); lokal: python3 .github/werkzeuge/abhaengigkeiten-hinweis.py main HEAD. Bekannte Sicherheitslücken und unverträgliche Lizenzen prüft im selben Lauf die Dependency Review von GitHub.

Heute im Image (Stand 0.122):

Paket Wo Lizenz Wozu
fastify Server MIT HTTP-Server, Routen, Prüfung der Anfragen
@fastify/static Server MIT die Oberfläche als Dateien ausliefern
postgres (postgres.js) Server Unlicense Zugriff auf PostgreSQL
nodemailer Server MIT-0 E-Mails über SMTP senden (Einladungen, Hinweise, Mappen versenden)
imapflow Server MIT Post aus Postfächern über IMAP abholen
mailparser Server MIT abgeholte E-Mails in Text und Anhänge zerlegen
pdf-lib Server MIT PDFs zusammenstellen, verkleinern, schwärzen, stempeln – nur über die PDF-Stelle
yaml Server ISC Pakete im YAML-Format lesen und schreiben
react, react-dom Oberfläche MIT Oberfläche
qrcode Oberfläche MIT QR-Code für die Zwei-Faktor-Anmeldung
poppler-utils Image (Alpine) GPL-2.0-or-later, als eigenes Programm aufgerufen pdftotext, pdftoppm: Text und Bilder aus PDFs
qpdf Image (Alpine) Apache-2.0 Struktur von PDFs beim Verkleinern optimieren
postgresql17, postgresql17-contrib nur dms-komplett PostgreSQL Datenbank im selben Container; contrib für pg_trgm
tini, su-exec nur dms-komplett MIT Prozesse starten und sauber beenden, PostgreSQL ohne root

Nur für Bau und Tests (nicht im Image): typescript, tsx, esbuild, vite mit @vitejs/plugin-react und die Typen unter @types/…. Auch hier gilt: lieber Bordmittel – die Tests laufen mit dem Testläufer von Node.js (node --test), ohne eigenes Test-Framework.

PDF-Stelle

Alle Arbeiten an PDFs mit pdf-lib liegen in einem Ordner: server/src/pdf/ (#541). Nur dort wird pdf-lib eingebunden; die Prüfung server/test/pdf-stelle.test.ts (läuft mit npm test in der CI) schlägt fehl, sobald Server, Oberfläche oder shared pdf-lib anderswo importieren – auch per import() oder require(). Tests dürfen pdf-lib weiter nutzen, um erzeugte PDFs zu untersuchen.

server/src/pdf/index.ts          öffentliche Schnittstelle – alle Module importieren von './pdf'
server/src/pdf/seiten.ts         Seiten zählen, drehen, löschen, umsortieren, teilen, zusammenfügen, Fotos als Seiten
server/src/pdf/stempel.ts        Stempel und Fußzeile beim Weitergeben, Bild als A4-Seite
server/src/pdf/verkleinern.ts    Bilder herunterrechnen (über pdftoppm), Struktur aufräumen (qpdf)
server/src/pdf/schwaerzen.ts     Seiten neu aufbauen, schwärzen, Textebene ohne geschwärzte Wörter
server/src/pdf/schreiber.ts      Textdokumente nach Plan (Auskunft, Löschkonzept, Löschprotokoll)
server/src/pdf/export.ts         PDF mehrerer Mappen
server/src/pdf/aufkleber.ts      Aufkleberbogen mit Barcode
server/src/pdf/demo-dokumente.ts Demo-Dokumente
server/src/pdf/einfach.ts        kleiner Schreiber für Textseiten ganz ohne Bibliothek (Notfallblatt)
server/src/pdf/bild.ts, puffer.ts, text.ts   Bildart, JPEG-Maße, EXIF; Umgehung #456; WinAnsi

Regeln für neuen Code

  • Fachliches bleibt draußen: Was auf ein PDF kommt (Texte, Felder ohne Passwörter, Rechte, Platzhalter), entscheidet das jeweilige Modul (export.ts, auskunft.ts, ausgabe.ts …). In die PDF-Stelle geht nur, wie gezeichnet oder umgebaut wird.
  • Die Vorgänge nehmen und liefern einfache Daten – Bytes (Buffer/Uint8Array), Zahlen, Texte, Listen –, nie Objekte von pdf-lib. Textdokumente beschreibt der Aufrufer als Plan (Anweisung[]: Text, Linie, Abstand, Tabelle, neue Seite) und lässt ihn mit textdokumentPdf() zeichnen. So laufen alle Vorgänge im Arbeiter-Pool (#542): pdf/index.ts gibt sie dorthin; ein neuer Vorgang kommt zusätzlich in arbeit/vorgaenge.ts.
  • Bilder gehen nur über eigenerPuffer() an pdf-lib (#456: pdf-lib übersieht den Versatz eines Buffers im gemeinsamen Speicher und meldet bei JPEGs „SOI not found“).
  • Die PDF-Stelle schreibt nichts ins Protokoll und liest keine Einstellungen. Was der Aufrufer wissen soll, steht im Ergebnis (z. B. hinweise beim Verkleinern).
  • Fachfehler mit verständlicher Meldung (Fachfehler(422, …)) dürfen aus der PDF-Stelle kommen, etwa für verschlüsselte oder kaputte PDFs.

Bewertung der Alternativen zu pdf-lib

pdf-lib (MIT) ist die einzige verbreitete PDF-Bibliothek in reinem JavaScript. Die letzte Version 1.17.1 stammt von Ende 2021; seitdem gibt es keine Weiterentwicklung. Einen Fehler darin umgehen wir schon (#456). Ein Austausch ist nicht Teil von #541 – die Kapselung macht ihn aber überschaubar. Stand November 2026:

Weg Was dafür spricht Was dagegen spricht
pdf-lib bleibt (heute) Läuft stabil, alle Funktionen und Tests bauen darauf; MIT; vier kleine eigene Abhängigkeiten (pako, tslib, Standardschriften, upng) Keine Fehlerbehebungen und keine Sicherheitsupdates mehr; Umgehungen wie #456 bleiben unsere Sache
@cantoo/pdf-lib (gepflegte Abspaltung) Gleiche Schnittstelle, regelmäßige Versionen (2.x, zuletzt 2026), mehrere Betreuer, MIT; kann zusätzlich verschlüsselte PDFs Bringt mehr eigene Abhängigkeiten mit (u. a. einen HTML-Parser, den wir nicht brauchen); Unterschiede zur 1.17 wären mit allen Tests zu prüfen
@pdfme/pdf-lib (Abspaltung des Projekts pdfme) Gleiche Schnittstelle, gepflegt (6.x, zuletzt 2026), MIT Ein einzelner Betreuer; ebenfalls zusätzliche Abhängigkeiten (HTML-Parser, Farben)
Mehr über qpdf (schon im Image, Apache-2.0) Robust und gepflegt (C++); kann Seiten drehen, auswählen, umsortieren, teilen, zusammenfügen, Seiten überlagern und Strukturen optimieren – ohne neue Abhängigkeit; läuft als eigener Prozess und belastet den Server-Faden nicht Zeichnet keine Texte und bettet keine Bilder ein: Stempel, Export-Seiten, Aufkleber, Textdokumente und der Neuaufbau beim Schwärzen bräuchten einen eigenen Schreiber (Ansatz in einfach.ts) und qpdf zum Überlagern; jeder Aufruf schreibt Dateien
Anderes Werkzeug (MuPDF, Ghostscript, PDFium …) Sehr leistungsfähig MuPDF und Ghostscript sind AGPL und bewusst nicht im Image; native Module widersprechen der schlanken Abhängigkeitsliste

Empfehlung: pdf-lib vorerst behalten – gekapselt in der PDF-Stelle ist das Risiko beherrschbar. Erster Kandidat für einen Austausch ist @cantoo/pdf-lib (gleiche Schnittstelle, mehrere Betreuer); vor einem Wechsel prüfen: ob #456 dort behoben ist, welche Abhängigkeiten mitkommen (Regel aus Abhängigkeiten) und ob alle PDF-Tests unverändert grün sind. Wo qpdf eine Aufgabe ganz übernehmen kann – vor allem bei den Seiten-Vorgängen (seiten.ts) –, ist es der zweite Weg, weil keine neue Abhängigkeit dazukommt. Anlass zum Handeln: eine Sicherheitslücke in pdf-lib, ein Fehler, den wir nicht umgehen können, oder eine neue Node.js-Version, mit der pdf-lib nicht mehr läuft.

Arbeiter-Pool

Der Server beantwortet alle Anfragen in einem Faden. Schwere Rechenarbeit läuft darum in einem kleinen Pool von Arbeiter-Fäden (node:worker_threads, #542), ohne neue Abhängigkeit:

server/src/arbeit/index.ts        arbeite(vorgang, ...args), Einstellung (DMS_ARBEITER), Stand für den Systemzustand
server/src/arbeit/pool.ts         Arbeiterpool: Warteschlange, Zeitgrenze, Speichergrenze, Absturz, Leerlauf
server/src/arbeit/vorgaenge.ts    was ein Arbeiter kann: alle Vorgänge der PDF-Stelle, Tabellen großer Exporte, Demo-Dokumente
server/src/arbeit/arbeitsfaden.ts Einstieg des Arbeiters (gebaut als dist/arbeitsfaden.mjs)
server/src/arbeit/nachricht.ts    Nachrichten, Buffer und Fehler über die Grenze
  • Aufrufen: Die PDF-Stelle gibt ihre Vorgänge selbst an den Pool (pdf/index.ts); für alles andere await arbeite('exportTabellen', …). Die Typen der Parameter und Ergebnisse kommen aus VORGAENGE.
  • Neuer Vorgang: Funktion mit einfachen Daten als Parameter und Ergebnis (Bytes, Zahlen, Texte, Listen, einfache Objekte, Map – keine Klassen, keine Funktionen, kein Repo, keine Datenbank) in vorgaenge.ts eintragen. Ein Buffer kommt im Arbeiter als Uint8Array an und wird von alsBuffer wieder zum Buffer – auch in Listen und einfachen Objekten. Fachfehler kommen mit Status und Details beim Aufrufer an. Der Arbeiter schreibt nichts ins Protokoll; Hinweise stehen im Ergebnis.
  • Was nicht hineingehört: kleine Anfragen (die Übergabe kostet selbst etwas: Daten werden kopiert, bei 20.000 Tabellenzeilen etwa 0,1–0,3 s) und alles, was Datenbank oder Dateien braucht – die Daten holt der Hauptfaden vorher.
  • Einstellung: DMS_ARBEITER (0–16; 0 = aus, alles im Hauptfaden), Vorgabe die Hälfte der Kerne aus cpu.max der cgroup bzw. availableParallelism(), 1 bis 4. Zeitgrenze 10 Minuten, Warteschlange höchstens 50 (darüber 503), Heap je Arbeiter 1,5 GB, Leerlauf 2 Minuten. Ohne Aufruf von richteArbeiterEin() (Tests, Befehl dms) entsteht der Pool beim ersten Auftrag.
  • Tests: server/test/arbeiter.test.ts prüft Pool (Ergebnis, Fehler, Zeitgrenze, volle Warteschlange, Absturz), Einstellung, die Warnung bei hoher Wartezeit und dass die Ablage während eines großen PDF-Exports schnell lädt (Werte ohne und mit Pool stehen in der Ausgabe). In Tests läuft der Arbeiter mit tsx (Lade-Argumente aus ladeArgumente).
  • Der Paket-Läufer (laeufer/) ist ein eigener Prozess mit Berechtigungsmodell und bleibt davon getrennt.

Warnung bei hoher Wartezeit: Auslastung (messung.ts) merkt das p99 der Verzögerung der Ereignisschleife je Minutenfenster. Liegt es in 5 Fenstern hintereinander über 100 ms (SCHLEIFE_GRENZE_MS, SCHLEIFE_MINUTEN in shared/src/systemlast.ts), schreibt sie eine Warnung ins Protokoll, und Systemlast.warnung() meldet schleife an Systemzustand, Übersicht und Leiste der Werkbank.

Texte und Sprachdateien

Texte für Benutzer stehen nicht im Code, sondern in Sprachdateien (#473, Teil von #357). Sichtbar ist bisher nur Deutsch mit du; die Technik für weitere Sprachen ist da.

Regel: Wer einen Text neu schreibt oder ändert, legt ihn in der Sprachdatei ab – auch wenn der Rest der Datei noch Texte im Code hat. Ganze Bereiche ziehen nach und nach in eigenen Issues um. Umgestellt sind bisher die Anmeldung (Weboberfläche, Fehlermeldungen der API, E-Mails zum Zugang), die Texte für Dritte und die Leiste der Werkbank (Gruppen, Bereiche, Vorlagen; werkbank.json, auch für werkbank_bereiche.py).

shared/src/sprachen/de/<bereich>.json         Ausgangssprache Deutsch (du), immer vollständig
shared/src/sprachen/de-formal/dritte.json     Texte für Dritte mit „Sie“ (siehe unten)
shared/src/sprache.ts                         uebersetzer(), Platzhalter, Mehrzahl
web/src/sprache.ts, server/src/sprache.ts     t() mit den Bereichen, die Oberfläche bzw. Server brauchen

Text anlegen

  1. Passenden Bereich wählen oder einen neuen anlegen (anmeldung, mail, werkbank …; Dateiname = Bereich, klein, ohne Punkt). Ein neuer Bereich wird in web/src/sprache.ts bzw. server/src/sprache.ts eingetragen.
  2. Schlüssel in die JSON-Datei schreiben – flach, Teile mit Punkt, klein beginnend: "vergessen.knopf": "Link anfordern".
  3. Im Code abrufen: t('anmeldung.vergessen.knopf'). Die Typprüfung kennt alle Schlüssel und meldet Tippfehler.

Benennung: vom Allgemeinen zum Besonderen, nach Ort und Zweck statt nach Wortlaut – vergessen.titel, vergessen.knopf, fehler.link.abgelaufen. Fehlermeldungen unter fehler.*, Rückfragen als ….frage, ….frage.text, ….frage.ja. Wiederkehrende Wörter („Speichern“, „Abbrechen“) bekommen keinen gemeinsamen Schlüssel, solange der Bereich sie nicht braucht – so bleibt jede Übersetzung im Zusammenhang.

Platzhalter stehen in einfachen geschweiften Klammern: "passwort.zugang": "Für den Zugang {email}" → t('anmeldung.passwort.zugang', { email }). Namen nur aus Buchstaben, Ziffern und _. Fehlt ein Wert, bleibt {email} stehen und fällt auf.

Mehrzahl über zwei Schlüssel mit _one und _other und den Wert anzahl:

"dauer.stunden_one": "{anzahl} Stunde",
"dauer.stunden_other": "{anzahl} Stunden"

t('mail.dauer.stunden', { anzahl: 2 }) ergibt „2 Stunden“. Die Formen richten sich nach Intl.PluralRules; fehlt eine, gilt _other.

Zusammengesetzte Schlüssel (werkbank.gruppe.${g}) gehen mit t.frei(…) statt t(…). Sie entgehen der Typprüfung; die Prüfung in der CI zählt alle Schlüssel mit dem festen Anfang (werkbank.gruppe.) als benutzt. Wo es geht, ganze Schlüssel ausschreiben.

Prüfung in der CI (server/test/sprachdateien.test.ts, läuft mit npm test): Sie schlägt fehl, wenn der Code einen Schlüssel abruft, den es nicht gibt, wenn ein Schlüssel nirgends benutzt wird, wenn eine andere Sprache Schlüssel auslässt oder zusätzliche hat, wenn die Platzhalter zwischen den Sprachen abweichen, bei Mehrzahl ohne _other und bei leeren Texten. Die Symbol-Prüfung (keine bunten Emoji, keine Textzeichen als Symbol) gilt auch für die Sprachdateien. Lokal: node --import tsx --test test/sprachdateien.test.ts im Ordner server.

Bibliothek: eine eigene, kleine Umsetzung in shared/src/sprache.ts statt i18next – keine neue Abhängigkeit, offline, dieselben Regeln in Oberfläche und Server. Das Format (flaches JSON, _one/_other) entspricht i18next; ein späterer Wechsel bleibt möglich.

Nicht Teil von Stufe 1: weitere Sprachen, „Sie“ in der Oberfläche, Sprachwahl im Profil (#357, spätere Stufen).

Unterlagen für Dritte: Anrede du oder Sie

Die Oberfläche duzt immer. Texte, die an Personen außerhalb von Ablago gehen, folgen dagegen der Einstellung Anrede in Unterlagen für Dritte (#474, Werkbank → Datenschutz, Datenschutz.anrede, Vorgabe du). Alle diese Texte stehen im Bereich dritte der Sprachdateien: die du-Fassung in shared/src/sprachen/de/dritte.json, die Sie-Fassung in shared/src/sprachen/de-formal/dritte.json, je mit denselben Schlüsseln und Platzhaltern. Abgerufen werden sie mit textDritte(anrede, schluessel, werte) aus shared/src/anrede.ts. Die Prüfung der Sprachdateien vergleicht beide Fassungen, ein Test prüft zusätzlich, dass die Sie-Fassung kein „du“ enthält.

Neue Texte für Dritte kommen als Schlüssel in beide dritte.json – keine anrede === 'sie' ? … : … im Code – und in diese Liste:

Ausgabe Code Schlüssel
Auskunft: Deckblatt.pdf, Uebersicht.html, LIESMICH.md (#396) server/src/auskunft.ts auskunft.* (Rechte-Vorschlag: rechteStandard)
E-Mail mit der Auskunft als ZIP, E-Mail mit dem Abruf-Link (#455) auskunftMail in shared/src/auskunft.ts mail.*
Abrufseite /abruf und ihre Meldungen (#455) web/src/abruf.tsx, GET /api/abruf/anrede, /api/abruf in routen.ts abruf.*
E-Mails mit Mappen und Dokumenten (#58, #151) – keine: Text schreibt der Benutzer, vorgegeben ist nur der Betreff ohne Anrede

Baum über Verweise

Alles, was über Verweise an einer Mappe hängt, sammelt ein Modul – genutzt von der Auskunft nach Art. 15 DSGVO (#396) und von „Mappe mit allen verknüpften Mappen löschen“ (#424). Neue Funktionen, die „alles zu dieser Mappe“ brauchen, nehmen es statt eigener Schleifen.

  • Typen und Optionen: shared/src/baum.ts – BaumOptionen (richtungen: verwiesenVon = Mappen, die auf den Knoten verweisen, „nach unten“; verweistAuf = Mappen, auf die er verweist; tiefe 1–10; max bis 5000; seitwaerts; ohneTypen; nurZugehoerigkeit), bereinigeBaumOptionen(eingabe, standard), Baum, BaumKnoten, Hilfen kinderVon, haengtAuchAnderswo, wirdAnderswoGebraucht, baumZahlen.
  • Algorithmus: server/src/baum.ts – sammleBaum(quelle, startId, optionen): Breitensuche, je Ebene ein Zugriff auf Verweise (Repo.kantenVon), Mappen und Lesbarkeit. Ohne seitwaerts geht ein Knoten nur in der Richtung weiter, über die er erreicht wurde (die Start-Mappe in alle gewählten). Kreise und mehrfache Wege landen in auchUeber und kreise, jeder Knoten steht einmal da. Über max hinaus: abgeschnitten. Mit nurZugehoerigkeit geht es nur über Verweisfelder der Art Zugehörigkeit weiter (verweisartVon(typ, feldKey), Standard Bezug); Knoten, die nur über Bezug dranhängen, sind Blätter mit nurBezug – erreicht die Suche sie später über eine Zugehörigkeit, werden sie dort eingehängt und weiterverfolgt (innerhalb einer Ebene haben Zugehörigkeiten Vorrang).
  • Rechte: Die BaumQuelle baut Dienst.baumQuelle(zugriff); nur lesbare Mappen werden aufgenommen und weiterverfolgt, nicht lesbare zählen nur in verborgen. Zum Aufrufen: await dienst.baum(startId, eingabe, zugriff) (Benutzer und Rollen sind ohne ohneTypen ausgenommen), über HTTP GET /api/v1/mappen/{id}/baum.
  • Je Knoten: typ, titel, ebene, ueber (Elternknoten, Richtung, Feld, verweisart), nurBezug, dokumente, aussen (Verweise zu Mappen außerhalb des Baums je Richtung, nicht lesbare ohne Titel), aufbewahrenBis/gesperrt (Archivmodus #212) und rechte (CRUDP des Aufrufers).

Die Auskunft (Dienst.auskunftVorschau) und das Löschen sammeln beide mit nurZugehoerigkeit (#472, #424): Bezug-Knoten gehen bei der Auskunft nur als Erwähnung hinaus (AuskunftAuswahl.erwaehnungen, erwaehnungText in shared/src/auskunft.ts), beim Löschen wird nur ihr Verweis entfernt. Volltext-Vorschläge gibt es bei der Auskunft nicht (mehr).

Tests: server/test/auskunft.test.ts (Kreise, Rechte, Obergrenze, seitwärts, Zugehörigkeit/Bezug).

Mit verknüpften Mappen löschen (#424)

  • Vorschau und Plan (reine Funktionen, Server und Oberfläche): shared/src/loeschbaum.ts. Dienst.loeschVorschau(startId, { tiefe, max }, zugriff) sammelt den Baum nur verwiesenVon mit nurZugehoerigkeit (Standardtiefe 5) und ergänzt kanten (alle Verweise zwischen Knoten, nicht nur die Baumwege), geteilt und nichtLoeschbar. Kennzeichen (kennzeichen(v, k)): 🔒 = gesperrt, 📎 = nurBezug (wird nie gelöscht, loeschen gilt als entkoppeln), ⛔ = !rechte.D oder nichtLoeschbar, 🔗 = geteilt – enger als haengtAuchAnderswo(k): zählt nur Verweise der Art Zugehörigkeit nach außen über ein Feld, über das der Knoten im Baum hängt, oder auf eine Mappe desselben Typs wie im Baum; Verweise auf Stammdaten (Ablageort, Kategorie) und Benutzer zählen nicht, sonst wäre fast alles 🔗.
  • Plan: planeLoeschung(vorschau, auswahl) – Auswahl je Id loeschen | entkoppeln | behalten (fehlende Ids bleiben). Fixpunkt: Eine Mappe bleibt, wenn wirdAnderswoGebraucht(k) oder eine bleibende Mappe im Baum ohne entkoppeln auf sie verweist. Gesperrte Mappen werden nie entkoppelt.
  • Ausführen: Dienst.loescheMitVerknuepften sammelt neu, plant, prüft jede Mappe wie beim einzelnen Löschen (loeschPruefung mit allen Rückverweisen), kappt die Verweise (entkoppeln, Recht U) und legt alles mit Repo.legeVorgangInPapierkorb in einer Transaktion in den Papierkorb (Verweise untereinander zuerst – so gehen auch Kreise). Spalten papierkorb.vorgang und papierkorb.entkoppelt (Migration 042). stelleVorgangWiederHer bzw. Repo.stelleMehrereWiederHer legen erst alle Mappen, dann die Verweise an und setzen die entkoppelten Verweise wieder. Tests: server/test/loeschbaum.test.ts.

Mappen schreiben, auch gleichzeitig (#609)

Wer zuletzt speichert, darf keine Felder zurücksetzen, die er nicht angefasst hat. Deshalb gilt für jeden Schreiber von Mappenwerten:

  • Nur Geändertes schreiben. Repo.aendereMappe(id, werte, [], von, { basis, verweise }) legt nur die Felder auf den aktuellen Stand, die sich gegenüber basis (den gelesenen Werten) ändern – unter Sperre der Zeile (SELECT … FOR UPDATE) bzw. im Arbeitsspeicher genauso (feldweise in server/src/repo.ts). verweise(w) berechnet die Verweise aus dem Ergebnis. Interne Schreiber (schreibeSystemMappe, schreibeVerweisWerte, absenderBereinigen, Umbenennen von Feldern) nutzen das; schreibeSystemMappe bekommt außerdem nur die zu ändernden Felder, nie { ...mappe.werte, … } aus einem älteren Lesen.
  • Stand. mappen.geaendert ist auf die Millisekunde genau und steigt bei jedem Schreiben (greatest(clock_timestamp(), geaendert + 1 ms)), damit er als Versionszähler taugt; keine Migration nötig. Mit stand schreibt das Repo nur, wenn die Zeile noch diesen Stand hat, sonst StandVeraltet – Dienst.aendereMappe liest dann neu und versucht es bis zu fünfmal.
  • Konflikt. Dienst.aendereMappe(id, { werte, stand }): Weicht stand vom aktuellen ab, sucht feldkonflikte in der Historie die Einträge geaendert mit daten.stand > stand (jeder Eintrag merkt sich den Stand, den sein Schreiben erzeugt hat). Ein übergebenes Feld, das dort vorkommt und jetzt anders lauten soll, ergibt 409 mit Mappenkonflikt (shared/src/model.ts). Ohne stand keine Prüfung, nur feldweises Schreiben – so bleiben alte API-Aufrufe gültig.
  • Formular. MappeFormular schickt nur die Felder, deren Rohwert von basis abweicht, und stand = mappe.geaendert beim Laden von basis. Bei 409 zeigt Konfliktfrage je Feld die Wahl; „Meins“ geht mit dem neuen Stand aus der Antwort noch einmal mit, „Seins“ übernimmt den Wert und bleibt weg.

Tests: server/test/gleichzeitig.test.ts (Arbeitsspeicher und PostgreSQL), im Browser docs/handbuch/screenshots/gleichzeitig-pruefen.mjs (zwei Fenster, Workflow „Handbuch“).

Systemkonten und Kontorollen (#673)

Neben Menschen (benutzer.art = 'person') gibt es zwei Arten von Konten, die sich nie anmelden:

Art Beispiel Systemrolle Rechte an Mappen
dienst (#338) „Paket Texterkennung“, je Paket mit Code eines Paket (rolle:paket) bestätigte Rechte aus dem Manifest, eingeschränkt durch Regeln für die Rolle Paket
system (#673) „Automatisierung“ (systemkonto = 'automatisierung') Systemkonto (rolle:systemkonto) alles außer P, eingeschränkt durch Regeln für die Rolle Systemkonto
  • Anlegen: Konten.systemkonto(kennung) beim Start (index.ts), idempotent; in PostgreSQL schützt der Index benutzer_systemkonto vor einem zweiten Konto, auch bei zwei Servern (Migration 059).
  • Nie benutzbar: holeHash, listeBenutzer, anzahlBenutzer sehen nur Menschen; benutzerZu (Sitzung) und benutzerMitId (API-Schlüssel) lehnen Systemkonten ab; Benutzerverwaltung und „Als Benutzer anmelden“ kennen sie nicht (person()). personen() liefert sie nur für Namen (aktiv: false, art).
  • Rollen: Die Rollen-Mappen rolle:systemkonto und rolle:paket legt sichereSystem() an (kontorollen). listeRollen() liefert sie nur mit mitKonten, pruefeRollen() lehnt sie ab.
  • Zugriff: Dienst.zugriff(konto) setzt wer.rollen = [Kontorolle] und schneidet die Mappentypen mit kontoRegelwerk(typ, rolleId) (shared/src/rechte.ts) zu: Grundeinstellung „alles“, nur Regeln, die die Rolle nennen (nenntRolle). So sperrt ein strenger Standard für Mitglieder keine Automatisierung aus, ein Administrator kann aber gezielt einschränken. Für Pakete wird das Ergebnis mit den bestätigten Rechten geschnitten. Dienst.systemZugriff('automatisierung') liefert den Zugriff des Systemkontos (für den Motor der Automatisierungen, #674).
  • Werkbank: Systemkonten sind nie admin; welche Werkbank-Aktionen eine Karte darf, entscheidet die Karte selbst (#677).
  • Oberfläche: Werkbank → Benutzer, Abschnitt „Systemkonten“ (GET /api/benutzer/systemkonten, nur Administratoren).

Ereigniskatalog (#670)

Was in Ablago passiert, steht an einer Stelle: shared/src/ereignisse.ts (EREIGNISKATALOG), die Texte in shared/src/sprachen/de/ereignisse.json (name.<art>, text.<art> – Unterstrich als Bindestrich –, gruppe.<gruppe>). Pakete (code.ereignisse), Webhooks und Automatisierungen (#674) nehmen ihre Ereignisse aus diesem Katalog. Die Liste zum Nachschlagen steht in der Werkbank beim Paket unter Welche Ereignisse es gibt.

Regeln

  • Nur Ids, Art, Feldschlüssel und Datum in daten – nie Werte, Titel oder Namen. Wer mehr wissen will, liest mit eigenen Rechten nach.
  • Mindestens einmal, in derselben Transaktion: dienst.ereignis(art, daten, von) schreibt in den Ausgangskorb (Tabelle ereignisse, Migration 060). Läuft gerade eine Transaktion (inTransaktion), gehört das Ereignis zur Änderung – scheitert eines, wird beides nicht gespeichert. Jedes Ereignis hat eine feste Id; ein Abnehmer erkennt daran eine Wiederholung.
  • Mehrere Server: Der Ereignisverteiler (server/src/ereignisse.ts) holt offene Ereignisse mit FOR UPDATE SKIP LOCKED und einer Sperre (gesperrt_bis, 120 s). Erst wenn alle Abnehmer es angenommen haben, gilt ein Ereignis als verteilt; sonst Wiederholung nach 1, 5, 30, 120 und 360 Minuten, danach aufgegeben (Protokoll). Verteilte Ereignisse bleiben 7 Tage.
  • Bündeln: Einträge mit buendelS (heute mappe.geaendert, 5 s) fassen schnelle Folgen zur selben Mappe zusammen, solange das Ereignis noch nicht verteilt ist; die Feldschlüssel werden vereinigt (mischeFelder).
  • Frist erreicht genau einmal: frist_meldungen (Mappe, Feld, Datum, Zeitpunkt) als Primärschlüssel; Merken und Ereignis in einer Transaktion.
  • Still: dienst.ohneEreignisse(f) – Beispiele, Demo-Daten (Erzeugen und Entfernen). Eine Wiederherstellung markiert alle offenen Ereignisse als verteilt; in Wartung und während einer Wiederherstellung ruht der Verteiler.

Abnehmer

dienst.ereignisse.abonniere('mein-abnehmer', async (e: KatalogEreignis) => {
  // e.id, e.art, e.daten, e.ausloeser, e.erstellt – Fehler werfen heißt: später noch einmal
});

Der Abnehmer pakete gibt Mappen-Ereignisse und frist.erreicht an Pakete weiter, die sie abonniert haben und den Typ unter code.mappentypen nennen – mit einer aus Ereignis- und Paket-Id berechneten Id, damit eine Wiederholung beim Paket nicht doppelt ankommt.

Auslöser (e.ausloeser): { art: 'benutzer' | 'paket' | 'automatisierung' | 'system', id, konto, kette }. Den Auslöser nimmt dienst.ereignis aus dem Konto (von: Benutzer bzw. Dienstkonto eines Pakets) oder aus dienst.mitAusloeser(a, f). Eine Automatisierung (#674) führt ihre Aktionen so aus:

await dienst.mitAusloeser({ art: 'automatisierung', id: regel.id, konto: systemkonto.id, kette: e.ausloeser.kette ? e.ausloeser.kette + 1 : 1 }, () => aktion());

Damit erkennt sie ihre eigenen Änderungen und kann Ketten begrenzen. Ein Paket bekommt seine eigenen Änderungen nicht gemeldet.

Neues Ereignis: Eintrag in EREIGNISKATALOG (Gruppe, Felder, ggf. paket, mappentyp, webhook, buendelS), Texte in ereignisse.json, an der Stelle der Änderung await this.ereignis(...) – möglichst innerhalb der Transaktion. Soll es für Pakete abonnierbar sein, zusätzlich in EREIGNISSE (shared/src/pakete.ts) und in der Registry (schema, pruefen.py).

Automatisierungen (#674)

Kleine Abläufe ohne Code: Wenn · Und · Dann (Sonst kommt mit Stufe 2). Modell und Prüfungen in shared/src/automatisierung.ts, Motor und Speicher in server/src/automatisierung.ts (Tabellen automatisierungen, automatisierung_fassungen, automatisierung_laeufe, Migration 061), Texte in shared/src/sprachen/de/automatisierung.json.

Modell

  • Genau eine Wenn-Karte: ein Ereignis aus dem Katalog (alle Gruppen außer zaehler, optional nur ein Mappentyp, bei mappe.geaendert optional nur bestimmte Felder) oder datum – „Datumsfeld in N Tagen erreicht“ (negativ: vor N Tagen), täglich ab einer Uhrzeit in der Zeitzone des Haushalts. Die Wenn-Karte legt den Kontext fest: die Mappe des Ereignisses (daten.mappe, bei Benutzer-Ereignissen die Benutzer-Mappe) und ihren Mappentyp.
  • Und: Bedingungen aus dem Baukasten der Ansichten (Bedingung aus sichten.ts), dazu „Mappe ist in Ansicht …“ und „Mappe ist in den Favoriten von …“ (Person, Rolle, Feld). Jede umkehrbar (nicht), Gruppen alle/eine eine Ebene tief. Ausgewertet in der Datenbank (Dienst.trifftTeile → repo.inKlauseln), „ich“ ist das Systemkonto.
  • Dann: Karten in ihrer Reihenfolge; ausführen tun sie die Einträge in DANN_AUSFUEHRER ((karte, kontext) => Promise<string> – die Zeile fürs Protokoll; im Trockenlauf nur beschreiben). „Danach keine weiteren Automatisierungen ausführen“ (stopp) erledigt der Motor selbst.
  • Platzhalter wie beim Mappentitel ({feld}, {feld|filter}), dazu {titel}, {mappentyp}, {heute}, {automatisierung}, bei datum auch {datum} und {tage}. Werte mit den Rechten des Systemkontos; Titel verknüpfter Mappen, die es nicht lesen darf, erscheinen als „(nicht sichtbar)“; Passwörter nie.

Ablauf

  1. Der Abnehmer automatisierungen des Ereigniskatalogs sucht eingeschaltete, nicht angehaltene Automatisierungen mit passender Wenn-Karte und führt sie nacheinander nach reihenfolge (1–99, dann Name) aus. Jede liest den aktuellen Stand.
  2. Je Automatisierung und Ereignis entsteht genau ein Lauf (UNIQUE (automatisierung_id, ereignis_id)) – kommt ein Ereignis noch einmal oder prüfen zwei Server denselben Tag, läuft nichts doppelt. Beim Zeitplan ist die Ereignis-Id aus Automatisierungs-Auslöser, Mappe und Datum berechnet; letzter_tag merkt sich, bis wohin geprüft ist (verpasste Tage bis 31 zurück werden nachgeholt).
  3. Ausgeführt wird unter dem Systemkonto „Automatisierung“ (dienst.systemZugriff('automatisierung')) und mit dienst.mitAusloeser({ art: 'automatisierung', id, konto, kette }) – Historie und Ereignisse zeigen, wer gehandelt hat. Darf das Systemkonto die Mappe nicht lesen, wird der Lauf übersprungen.
  4. Jeder Schritt (Wenn, Und, jede Dann-Karte) steht mit Zustand im Lauf (schritte) – die Oberfläche zeigt den Fortschritt live. Ein Kartenfehler beendet den Lauf als fehlgeschlagen (wiederholbar), die folgenden Automatisierungen laufen trotzdem.

Dann-Karten (server/src/automatisierung-karten.ts, #676)

  • Feld ändern (feld): werte je Feldschlüssel – fest, Text mit Platzhaltern (Text, Langtext, formatierter Text), bei Datumsfeldern auch heute, heute+7, heute-3. Geschrieben wird über Dienst.aendereMappe mit dem Zugriff des Systemkontos und nur den gewählten Feldern (#609): Feldformate, Pflicht, Auswahllisten und Verweisziele werden geprüft wie im Formular, Archivmodus und Fristsperre nie umgangen. Die Historie trägt automatisierung: { id, name, eingerichtetVon }.
  • Aufgabe / Benachrichtigung (aufgabe): Empfänger Person, Rolle (alle aktiven Mitglieder) oder Feld der Mappe (Verweis auf Benutzer); als: 'aufgabe' legt jedes Mal eine Aufgabe an (Fälligkeit faellig Tage ab heute → erscheint unter „Anstehend“), als: 'benachrichtigung' bündelt je Automatisierung, Mappe und Empfänger (eine offene, der Text wird aktualisiert). Wer die Mappe nicht lesen darf, bekommt sie an seiner eigenen Benutzer-Mappe – ohne Link. Platzhalter füllt Stufe 1 mit den Rechten des Systemkontos (Empfänger-Rechte: #683).

Oberfläche (web/src/werkbank/Automatisierungen.tsx, #675): Werkbank → Ablage aufbauen → Automatisierungen, dreiteilig. Karten aus dem Vorrat kommen per useZiehen (Ablagen wenn/und/dann) oder per „+ Karte“ (Auswahlfeld, Tastatur) hinein; Dann-Karten und die Liste „nach Auslöser“ sortieren per Ziehen oder Alt+↑/↓ (Reihenfolge wird in Zehnerschritten neu vergeben). Live-Fortschritt per Abfrage: solange der Editor offen ist, holt er alle 2 s GET …/:id/laeufe – die Schritte stehen in der Datenbank, deshalb mit mehreren Servern verträglich, ohne SSE und ohne Sitzungsbindung. Die Liste fragt alle 15 s nach. Beim Start legt legeBeispiele() drei ausgeschaltete Beispiele (Herkunft dms, feste Ids aus uuidAus) an, wenn es sie noch nicht gibt.

Schutz und Grenzen (AUTOMATISIERUNG_GRENZEN)

  • Keine Selbstauslösung (eigene Änderungen lösen dieselbe Automatisierung nicht aus), Ketten höchstens 3 tief, Bremse 30 Läufe je Minute (dann angehalten), Laufzeit 60 s, 200 Automatisierungen, je Bereich 20 Karten, Verlauf 200 Läufe bzw. 30 Tage.
  • Bearbeiten geht vor Automatisierung: Fehlt etwas (Mappentyp, Feld, Ansicht), blockiert das nie das Arbeiten – die Automatisierung wird rot und angehalten (angehalten.grund = 'fehlerhaft'), spätestens beim nächsten Lauf oder Zeitplan-Durchgang. Speichern geht nur mit gültigem Stand.
  • Demo-Daten und Beispiele lösen nichts aus (ohneEreignisse), in Wartung und Wiederherstellung ruht der Verteiler. Nach einer Wiederherstellung sind alle eingeschalteten Automatisierungen angehalten, bis ein Admin sie wieder einschaltet.
  • Speichern mit version (veralteter Stand → 409); jede Fassung steht in automatisierung_fassungen und lässt sich wiederherstellen. Im Vollexport: struktur/automatisierungen.json mit Änderungsverlauf; in der Sicherung alle Tabellen.

Zähler-Endpunkt und Schlüssel-Stufen (#625)

Andere Programme (Hausautomatisierung, Wand-Tablet, Clients) wollen oft nur wissen, ob etwas Neues da ist. Dafür gibt es GET /api/v1/ich/zaehler (intern /api/ich/zaehler, Dienst.zaehlerAbruf):

curl -i -H "Authorization: Bearer dms_…" https://dms.example.org/api/v1/ich/zaehler
# HTTP/1.1 200 OK
# ETag: "z42-mJ3x0Qe7Kq9VZ1aB2cD3eF"
# {"neuePost":2,"zuErledigen":5,"anstehend":3,"ueberfaellig":1,"stand":42,"geaendert":"2026-11-03T07:15:00.000Z"}

curl -i -H "Authorization: Bearer dms_…" -H 'If-None-Match: "z42-mJ3x0Qe7Kq9VZ1aB2cD3eF"' https://dms.example.org/api/v1/ich/zaehler
# HTTP/1.1 304 Not Modified   (ohne Inhalt)
  • Nur Zahlen. Die Antwort enthält nie Titel, Namen, Absender oder Inhalte – auch künftige Felder nicht. Das OpenAPI-Schema Zaehlerstand hat additionalProperties: false, der Test server/test/zaehler-api.test.ts prüft die Schlüssel und dass keine Texte durchrutschen.
  • Woher die Zahlen kommen. zuErledigen ist eingang aus Dienst.zaehler (#512), anstehend und ueberfaellig zählen die Einträge von Dienst.anstehend mit tage = ZAEHLER_TAGE (7, #528), neuePost zählt Post im Stand „Neu“ mit „Für“ = ich oder leer über zaehleKlauseln – mit eigenem Zaehlerspeicher am Haushaltsstand. Alles in den Rechten des Benutzers (Lesefilter); nichts wird für den Endpunkt neu gerechnet, solange sich am Haushalt nichts ändert.
  • Stand in der Datenbank (Tabelle zaehlerstaende, Migration 056). Je Abruf bildet der Server einen Hash der Zahlen (fingerabdruck), vergleicht ihn mit dem gespeicherten und zählt stand nur bei einer Abweichung hoch (Repo.setzeZaehlerstand, ein INSERT … ON CONFLICT DO UPDATE … WHERE abdruck IS DISTINCT FROM …: gleichzeitige Abrufe zählen eine Änderung einmal). Unverändert heißt: nur lesen. Der Haushaltsstand aus #512 lebt nur im Arbeitsspeicher und fängt nach jedem Neustart bei 0 an – als ETag taugt er darum nicht. Das ETag ist "z<stand>-<hash>": Gleiches ETag heißt gleiche Zahlen, auch nach einem Neustart oder einer Wiederherstellung.
  • ETag und 304. passtEtag (routen.ts) versteht Listen, W/… und *. Eine Route setzt eigene Köpfe über Antwort.koepfe; Köpfe der Anfrage liest sie über Anfrage.kopf(name). Cache-Control: private, no-cache statt no-store, damit Programme nachfragen dürfen.
  • Schlüssel-Stufe „nur Zähler“ (recht = 'zaehler', NUR_ZAEHLER und ZAEHLER_PFAD in apischluessel.ts). pruefeZugang lässt sie nur an GET /api/v1/ich/zaehler durch; jede andere Route unter /api/v1 und – ausdrücklich – jede interne Route mit einem solchen Schlüssel im Kopf bekommt 403 (api.schluessel.nur-zaehler), das Laufwerk ebenfalls (webdav.ts). „Passwörter lesen“ gibt es für diese Stufe nicht. Eine neue Schnittstelle, die API-Schlüssel annimmt, muss die Stufe genauso abweisen.
  • Grenzen. Der Überlastschutz (#544) zählt auch diese Anfragen. Eine Begrenzung je Schlüssel gibt es nicht; ein Abruf aus dem Zwischenspeicher kostet wenige Millisekunden. Läuft Ablago auf mehreren Servern, hat jeder seinen eigenen Zwischenspeicher (wie bei #512, höchstens 5 bzw. 30 Minuten alt) – bis dahin können zwei Server kurz verschiedene Zahlen liefern, der Stand zählt dann öfter hoch als nötig.

Weitere Zahl aufnehmen: Feld in Zaehlerzahlen (dienst.ts) und im OpenAPI-Schema ergänzen, im Test ZAHLEN – und nur Zahlen. Ändert sich die Bedeutung einer vorhandenen Zahl, v im Hash erhöhen, damit jeder Client einmal neu liest. Der Webhook (#627) ruft zaehlerAbruf je Benutzer auf und meldet bei geändertem stand.

Webhooks (#627)

Ein Webhook meldet geänderte Zahlen eines Benutzers (dieselben wie GET /api/v1/ich/zaehler) per POST an eine Adresse. Code: server/src/webhook.ts (Dienst Webhooks, pgWebhookRepo, speicherWebhookRepo), gemeinsame Typen und Regeln in shared/src/webhook.ts, Oberfläche in web/src/profil/Webhooks.tsx und web/src/werkbank/Webhooks.tsx, Tabellen webhooks und webhook_meldungen (Migration 058).

Format

POST /dms HTTP/1.1
Content-Type: application/json
User-Agent: DMS-Webhook
X-DMS-Art: zaehler.geaendert
X-DMS-Ereignis: 7c0e6a8e-3d1f-4f0a-9c55-2b8f0d3e4a11
X-DMS-Zeit: 1762154102
X-DMS-Signatur: v1=5d2f…(64 Zeichen hex)
X-DMS-Versuch: 1
X-DMS-Webhook: 0f8a…

{"art":"zaehler.geaendert","id":"7c0e6a8e-3d1f-4f0a-9c55-2b8f0d3e4a11","zeit":"2026-11-03T07:15:02.000Z","daten":{"neuePost":2,"zuErledigen":5,"anstehend":3,"ueberfaellig":1,"stand":42,"geaendert":"2026-11-03T07:15:00.000Z"}}
  • art: zaehler.geaendert oder probe („Probe senden“, gleiche daten). Weitere Arten (post.neu …) kämen als neue Werte dazu; ein Empfänger übergeht unbekannte.
  • id: Ereignis-Id. Gleich bei einer Wiederholung derselben Daten, neu, sobald sich die Daten einer schon versuchten Meldung ändern. Der Empfänger merkt sich die letzten Ids und übergeht Doppeltes.
  • zeit: wann die Meldung entstand; daten.geaendert: wann sich die Zahlen geändert haben.
  • Der Rumpf ist kompaktes JSON (JSON.stringify), nur ASCII-Zeichen, Schlüssel in fester Reihenfolge – JSON.stringify(JSON.parse(rumpf)) === rumpf. Das erlaubt die Prüfung auch dort, wo ein Rahmenwerk nur das geparste JSON hergibt (Node-RED).
  • Nie Inhalte. Der Test server/test/webhook.test.ts prüft die Schlüssel des Rumpfs und dass keine Titel, Absender oder Namen hineinrutschen.
  • Als angekommen gilt 2xx; Weiterleitungen (3xx) folgt Ablago nicht (redirect: 'manual'), Zeitgrenze 5 s.

Signatur prüfen

X-DMS-Signatur ist v1= und HMAC-SHA256 (hex) über <X-DMS-Zeit>.<Rumpf> mit dem Geheimnis des Webhooks (whsec_…, als UTF-8-Text, nicht dekodiert). Mehrere Signaturen (später beim Wechsel des Verfahrens) stehen durch Komma getrennt; eine passende genügt. Prüfe den Rumpf als Bytes, wie er ankam, vergleiche in konstanter Zeit und verwirf Zeiten, die mehr als 5 Minuten abweichen.

JavaScript (Node.js):

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const GEHEIMNIS = process.env.DMS_WEBHOOK_GEHEIMNIS; // whsec_…
const gesehen = new Set();

function echt(kopf, rumpf) {
  const zeit = Number(kopf['x-dms-zeit']);
  if (!Number.isInteger(zeit) || Math.abs(Date.now() / 1000 - zeit) > 300) return false;
  const soll = Buffer.from('v1=' + createHmac('sha256', GEHEIMNIS).update(`${zeit}.${rumpf}`).digest('hex'));
  return String(kopf['x-dms-signatur'] ?? '').split(',').some((s) => {
    const ist = Buffer.from(s.trim());
    return ist.length === soll.length && timingSafeEqual(ist, soll);
  });
}

createServer((req, res) => {
  let rumpf = '';
  req.on('data', (c) => { rumpf += c; });
  req.on('end', () => {
    if (!echt(req.headers, rumpf)) return res.writeHead(401).end();
    res.writeHead(204).end();                      // erst bestätigen, dann arbeiten
    const m = JSON.parse(rumpf);
    if (gesehen.has(m.id)) return;                 // Wiederholung
    gesehen.add(m.id);
    console.log(m.art, m.daten.neuePost, 'neue Post');
  });
}).listen(8080);

Python:

import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

GEHEIMNIS = os.environ["DMS_WEBHOOK_GEHEIMNIS"].encode()  # whsec_…
gesehen = set()

def echt(kopf, rumpf: bytes) -> bool:
    try:
        zeit = int(kopf.get("x-dms-zeit", ""))
    except ValueError:
        return False
    if abs(time.time() - zeit) > 300:
        return False
    soll = "v1=" + hmac.new(GEHEIMNIS, f"{zeit}.".encode() + rumpf, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.strip(), soll) for s in kopf.get("x-dms-signatur", "").split(","))

class Empfang(BaseHTTPRequestHandler):
    def do_POST(self):
        rumpf = self.rfile.read(int(self.headers.get("content-length", 0)))
        if not echt(self.headers, rumpf):
            self.send_response(401); self.end_headers(); return
        self.send_response(204); self.end_headers()
        m = json.loads(rumpf)
        if m["id"] in gesehen:
            return
        gesehen.add(m["id"])
        print(m["art"], m["daten"]["neuePost"], "neue Post")

HTTPServer(("", 8080), Empfang).serve_forever()

Im Server gibt es dieselbe Prüfung als pruefeSignatur (webhook.ts) für Tests.

Ablauf und mehrere Server

  • Anstoß: Haushaltsstand.beiAenderung (zaehlerspeicher.ts) ruft nach jeder ändernden Repo-Methode Webhooks.anstossen() (über Dienst.beiAenderung, für Zahlen der Ansichten und „Anstehend“). Nach 3 s zählt pruefe() für jeden Benutzer mit eingeschaltetem Webhook einmal zaehlerAbruf (aus dem Zwischenspeicher, meist Millisekunden). Ohne Anlass zählt der Server mit der Sperre jede Minute (Mitternacht, Fristen, Änderungen auf einem anderen Server).
  • Ausgangskorb in der Datenbank: merkeStand setzt in einer Transaktion mit Zeilensperre webhooks.gemeldet_stand und legt die Meldung an – oder ersetzt die Daten der offenen (höchstens eine je Webhook, eindeutiger Teilindex). So zählt jeder Stand einmal, auch wenn zwei Server gleichzeitig prüfen. Neue Meldungen sind frühestens 30 s nach dem letzten Senden (webhooks.gesendet) fällig – das ist das Zusammenfassen.
  • Zustellen: faellige holt Meldungen mit FOR UPDATE SKIP LOCKED, zählt den Versuch hoch und schiebt faellig um 60 s (Lease). Stirbt der Server beim Senden, holt ein anderer die Meldung danach – sie kommt dann womöglich doppelt, mit derselben Ereignis-Id (mindestens einmal). Ändern sich die Daten während des Sendens, schließt zugestellt die Meldung nicht (Ereignis-Id passt nicht mehr) und sie geht nach dem Mindestabstand erneut.
  • Wiederholen und Aufgeben: Wartezeiten WEBHOOK_WARTEN_S (30 s bis 8 h, zusammen etwa 22 h, 12 Versuche); nach dem letzten Fehlschlag zustand = 'aufgegeben', Webhook aus (abgeschaltet.grund = 'fehler'), Protokoll und Mail an die Administratoren.
  • Regelmäßige Läufe (Zählen jede Minute, Zustellen alle 5 s, Aufräumen nach 7 Tagen) nur auf dem Server mit pg_try_advisory_lock(4711197); der Anstoß nach einer Änderung läuft auf dem Server, auf dem sie passiert ist. Pause im Wartungsmodus und während einer Wiederherstellung.
  • Lücke nach der Bauregel: Der Haushaltsstand und die Zwischenspeicher der Zahlen (#512) leben je Server im Arbeitsspeicher. Eine Änderung auf Server A bemerkt Server B erst, wenn sein Zwischenspeicher abläuft (höchstens 5 bzw. 30 Minuten). Für die Richtigkeit reicht das (der Stand in der Datenbank zählt), für „sofort“ nicht immer – ein gemeinsamer Ereigniskanal ist ein eigenes Thema.

Ziele und Geheimnis

  • Ziele: zielProblem (shared/src/webhook.ts) erlaubt https:// und http:// nur im Heimnetz (imHeimnetz aus heimnetz.ts), keine Anmeldedaten und kein #. Zusätzlich muss der Ursprung in der Einstellung webhook_ziele stehen (Werkbank). Ein Administrator gibt mit dem Anlegen frei; vor jedem Senden prüft Ablago erneut.
  • Geheimnis: whsec_ + 32 Zufallsbytes (base64url), verschlüsselt mit dem Schlüssel von Ablago in webhooks.geheimnis. umschluesseln und verschluesselteFundstellen (repo-pg.ts) nehmen die Spalte mit; in Werkbank → Verschlüsselung erscheint die Art „Webhooks“.
  • Neue Art aufnehmen: Wert in WEBHOOK_EREIGNISSE und, wo nötig, eigene Erkennung; der Rumpf behält art, id, zeit, daten – und nur Zahlen bzw. Ids.

Mehr Hintergrund im Administratorhandbuch: Technik und Entscheidungen.