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 mitTEST_DATABASE_URLdecken PostgreSQL ab. Neue Funktionen brauchen beides, und beide müssen sich gleich verhalten. - Routen stehen gesammelt in
server/src/routen.ts, je mitzugang: 'offen' | 'angemeldet' | 'admin'. Zwei Merkmale kommen dazu:api: truemacht eine Route zum Teil der öffentlichen API (Anmeldung auch mit API-Schlüssel);konsole: trueerlaubt sie nur über den Befehldmsim 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.nulllöscht einen Eintrag. - Heimnetz erkennen (#595): Ob eine Adresse im Heimnetz liegt, entscheidet nur
shared/src/heimnetz.ts(imHeimnetz, ListeHEIMNETZ_ENDUNGENmit.local,.lan,.internal,.home.arpa). Artausnahme(streng) erlaubthttp://ohne TLS – Anmeldung über einen Anbieter, Endpunkte eigener Postfach-Anbieter; Arthinweis(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“ (nurlocalhost) 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 überbeiVerklemmungWiederholen(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
sitzungenundschema_migrationen(OHNE_INHALTinserver/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 mittextdokumentPdf()zeichnen. So laufen alle Vorgänge im Arbeiter-Pool (#542):pdf/index.tsgibt sie dorthin; ein neuer Vorgang kommt zusätzlich inarbeit/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.
hinweisebeim 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 andereawait arbeite('exportTabellen', …). Die Typen der Parameter und Ergebnisse kommen ausVORGAENGE. - 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) invorgaenge.tseintragen. EinBufferkommt im Arbeiter alsUint8Arrayan und wird vonalsBufferwieder zumBuffer– auch in Listen und einfachen Objekten.Fachfehlerkommen 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 auscpu.maxder 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 vonrichteArbeiterEin()(Tests, Befehldms) entsteht der Pool beim ersten Auftrag. - Tests:
server/test/arbeiter.test.tsprü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 mittsx(Lade-Argumente ausladeArgumente). - 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
- Passenden Bereich wählen oder einen neuen anlegen (
anmeldung,mail,werkbank…; Dateiname = Bereich, klein, ohne Punkt). Ein neuer Bereich wird inweb/src/sprache.tsbzw.server/src/sprache.tseingetragen. - Schlüssel in die JSON-Datei schreiben – flach, Teile mit Punkt, klein beginnend:
"vergessen.knopf": "Link anfordern". - 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;tiefe1–10;maxbis 5000;seitwaerts;ohneTypen;nurZugehoerigkeit),bereinigeBaumOptionen(eingabe, standard),Baum,BaumKnoten, HilfenkinderVon,haengtAuchAnderswo,wirdAnderswoGebraucht,baumZahlen. - Algorithmus:
server/src/baum.ts–sammleBaum(quelle, startId, optionen): Breitensuche, je Ebene ein Zugriff auf Verweise (Repo.kantenVon), Mappen und Lesbarkeit. Ohneseitwaertsgeht ein Knoten nur in der Richtung weiter, über die er erreicht wurde (die Start-Mappe in alle gewählten). Kreise und mehrfache Wege landen inauchUeberundkreise, jeder Knoten steht einmal da. Übermaxhinaus:abgeschnitten. MitnurZugehoerigkeitgeht es nur über Verweisfelder der Art Zugehörigkeit weiter (verweisartVon(typ, feldKey), Standard Bezug); Knoten, die nur über Bezug dranhängen, sind Blätter mitnurBezug– 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
BaumQuellebautDienst.baumQuelle(zugriff); nur lesbare Mappen werden aufgenommen und weiterverfolgt, nicht lesbare zählen nur inverborgen. Zum Aufrufen:await dienst.baum(startId, eingabe, zugriff)(Benutzer und Rollen sind ohneohneTypenausgenommen), über HTTPGET /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) undrechte(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 nurverwiesenVonmitnurZugehoerigkeit(Standardtiefe 5) und ergänztkanten(alle Verweise zwischen Knoten, nicht nur die Baumwege),geteiltundnichtLoeschbar. Kennzeichen (kennzeichen(v, k)): 🔒 =gesperrt, 📎 =nurBezug(wird nie gelöscht,loeschengilt alsentkoppeln), ⛔ =!rechte.DodernichtLoeschbar, 🔗 =geteilt– enger alshaengtAuchAnderswo(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 Idloeschen | entkoppeln | behalten(fehlende Ids bleiben). Fixpunkt: Eine Mappe bleibt, wennwirdAnderswoGebraucht(k)oder eine bleibende Mappe im Baum ohneentkoppelnauf sie verweist. Gesperrte Mappen werden nie entkoppelt. - Ausführen:
Dienst.loescheMitVerknuepftensammelt neu, plant, prüft jede Mappe wie beim einzelnen Löschen (loeschPruefungmit allen Rückverweisen), kappt die Verweise (entkoppeln, Recht U) und legt alles mitRepo.legeVorgangInPapierkorbin einer Transaktion in den Papierkorb (Verweise untereinander zuerst – so gehen auch Kreise). Spaltenpapierkorb.vorgangundpapierkorb.entkoppelt(Migration 042).stelleVorgangWiederHerbzw.Repo.stelleMehrereWiederHerlegen 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überbasis(den gelesenen Werten) ändern – unter Sperre der Zeile (SELECT … FOR UPDATE) bzw. im Arbeitsspeicher genauso (feldweiseinserver/src/repo.ts).verweise(w)berechnet die Verweise aus dem Ergebnis. Interne Schreiber (schreibeSystemMappe,schreibeVerweisWerte,absenderBereinigen, Umbenennen von Feldern) nutzen das;schreibeSystemMappebekommt außerdem nur die zu ändernden Felder, nie{ ...mappe.werte, … }aus einem älteren Lesen. - Stand.
mappen.geaendertist 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. Mitstandschreibt das Repo nur, wenn die Zeile noch diesen Stand hat, sonstStandVeraltet–Dienst.aendereMappeliest dann neu und versucht es bis zu fünfmal. - Konflikt.
Dienst.aendereMappe(id, { werte, stand }): Weichtstandvom aktuellen ab, suchtfeldkonfliktein der Historie die Einträgegeaendertmitdaten.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 mitMappenkonflikt(shared/src/model.ts). Ohnestandkeine Prüfung, nur feldweises Schreiben – so bleiben alte API-Aufrufe gültig. - Formular.
MappeFormularschickt nur die Felder, deren Rohwert vonbasisabweicht, undstand=mappe.geaendertbeim Laden vonbasis. Bei 409 zeigtKonfliktfrageje 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 Indexbenutzer_systemkontovor einem zweiten Konto, auch bei zwei Servern (Migration 059). - Nie benutzbar:
holeHash,listeBenutzer,anzahlBenutzersehen nur Menschen;benutzerZu(Sitzung) undbenutzerMitId(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:systemkontoundrolle:paketlegtsichereSystem()an (kontorollen).listeRollen()liefert sie nur mitmitKonten,pruefeRollen()lehnt sie ab. - Zugriff:
Dienst.zugriff(konto)setztwer.rollen = [Kontorolle]und schneidet die Mappentypen mitkontoRegelwerk(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 (Tabelleereignisse, 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 mitFOR UPDATE SKIP LOCKEDund 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(heutemappe.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, beimappe.geaendertoptional nur bestimmte Felder) oderdatum– „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 (
Bedingungaussichten.ts), dazu „Mappe ist in Ansicht …“ und „Mappe ist in den Favoriten von …“ (Person, Rolle, Feld). Jede umkehrbar (nicht), Gruppenalle/eineeine 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}, beidatumauch{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
- Der Abnehmer
automatisierungendes Ereigniskatalogs sucht eingeschaltete, nicht angehaltene Automatisierungen mit passender Wenn-Karte und führt sie nacheinander nachreihenfolge(1–99, dann Name) aus. Jede liest den aktuellen Stand. - 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_tagmerkt sich, bis wohin geprüft ist (verpasste Tage bis 31 zurück werden nachgeholt). - Ausgeführt wird unter dem Systemkonto „Automatisierung“ (
dienst.systemZugriff('automatisierung')) und mitdienst.mitAusloeser({ art: 'automatisierung', id, konto, kette })– Historie und Ereignisse zeigen, wer gehandelt hat. Darf das Systemkonto die Mappe nicht lesen, wird der Lauf übersprungen. - 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 alsfehlgeschlagen(wiederholbar), die folgenden Automatisierungen laufen trotzdem.
Dann-Karten (server/src/automatisierung-karten.ts, #676)
- Feld ändern (
feld):werteje Feldschlüssel – fest, Text mit Platzhaltern (Text, Langtext, formatierter Text), bei Datumsfeldern auchheute,heute+7,heute-3. Geschrieben wird überDienst.aendereMappemit 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ägtautomatisierung: { 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älligkeitfaelligTage 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 inautomatisierung_fassungenund lässt sich wiederherstellen. Im Vollexport:struktur/automatisierungen.jsonmit Ä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
ZaehlerstandhatadditionalProperties: false, der Testserver/test/zaehler-api.test.tsprüft die Schlüssel und dass keine Texte durchrutschen. - Woher die Zahlen kommen.
zuErledigenisteingangausDienst.zaehler(#512),anstehendundueberfaelligzählen die Einträge vonDienst.anstehendmittage = ZAEHLER_TAGE(7, #528),neuePostzählt Post im Stand „Neu“ mit „Für“ = ich oder leer überzaehleKlauseln– mit eigenemZaehlerspeicheram 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ähltstandnur bei einer Abweichung hoch (Repo.setzeZaehlerstand, einINSERT … 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 überAntwort.koepfe; Köpfe der Anfrage liest sie überAnfrage.kopf(name).Cache-Control: private, no-cachestattno-store, damit Programme nachfragen dürfen. - Schlüssel-Stufe „nur Zähler“ (
recht = 'zaehler',NUR_ZAEHLERundZAEHLER_PFADinapischluessel.ts).pruefeZuganglässt sie nur anGET /api/v1/ich/zaehlerdurch; jede andere Route unter/api/v1und – ausdrücklich – jede interne Route mit einem solchen Schlüssel im Kopf bekommt403(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.geaendertoderprobe(„Probe senden“, gleichedaten). 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.tsprü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-MethodeWebhooks.anstossen()(überDienst.beiAenderung, für Zahlen der Ansichten und „Anstehend“). Nach 3 s zähltpruefe()für jeden Benutzer mit eingeschaltetem Webhook einmalzaehlerAbruf(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:
merkeStandsetzt in einer Transaktion mit Zeilensperrewebhooks.gemeldet_standund 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:
faelligeholt Meldungen mitFOR UPDATE SKIP LOCKED, zählt den Versuch hoch und schiebtfaelligum 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ßtzugestelltdie 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 Fehlschlagzustand = '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) erlaubthttps://undhttp://nur im Heimnetz (imHeimnetzausheimnetz.ts), keine Anmeldedaten und kein#. Zusätzlich muss der Ursprung in der Einstellungwebhook_zielestehen (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 inwebhooks.geheimnis.umschluesselnundverschluesselteFundstellen(repo-pg.ts) nehmen die Spalte mit; in Werkbank → Verschlüsselung erscheint die Art „Webhooks“. - Neue Art aufnehmen: Wert in
WEBHOOK_EREIGNISSEund, wo nötig, eigene Erkennung; der Rumpf behältart,id,zeit,daten– und nur Zahlen bzw. Ids.
Mehr Hintergrund im Administratorhandbuch: Technik und Entscheidungen.