Files
mhoennigandClaude Opus 5 5e72cd873f build(ci): Gradle-Wrapper ins Repo — ohne ihn baut kein frischer Klon das Backend
`.gitignore` schloss `backend/gradlew`, `gradlew.bat` und `gradle/wrapper/`
aus (40db415). Lokal fiel das nie auf, weil die Dateien unversioniert im
Arbeitsverzeichnis liegen — auf der Werkator-Instanz scheiterte der erste
Build sofort mit `/bin/sh: 1: ./gradlew: not found`.

Der Wrapper ist der vereinbarte Weg, eine Gradle-Version festzuschreiben
(9.1.0); die Sandbox hat kein eigenes Gradle, und `!gradle-wrapper.jar`
stand in derselben Datei bereits als Ausnahme zur `*.jar`-Regel — die drei
Ignore-Zeilen widersprachen ihr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:12:54 +02:00
..

Editor Backend Grundgerippe

CRUD mit Spring Boot 4, Kotlin und API First (OpenAPI 3, YAML) für die Ressource Document (GET, POST, PUT, DELETE), dazu Historie, Wiederherstellung und das Einreichen von Zeilen-Diffs fürs Live-Editing (PATCH …/content) bewusst noch ohne Autorisierung, aber mit vorbereiteten Erweiterungspunkten dafür und für clientseitige Verschlüsselung.

Voraussetzungen

  • JDK 21
  • Gradle 9 (einmalig gradle wrapper --gradle-version 9.1 ausführen, danach ./gradlew)

Wichtige Kommandos

Kommando Zweck
./gradlew openApiGenerate Generiert API-Interfaces + Modelle aus src/main/resources/openapi/api.yaml
./gradlew build Generierung, Kompilierung, alle Tests, Coverage-Prüfung
./gradlew test Unit- und Behavior-Tests (Cucumber)
./gradlew jacocoTestReport Coverage-Report unter build/reports/jacoco/test/html
./gradlew bootRun Startet das Backend auf Port 8080

API First Ablauf

  1. Vertrag ändern: src/main/resources/openapi/api.yaml
  2. ./gradlew openApiGenerate → erzeugt DocumentsApi (Interface) und Modelle nach build/generated/openapi (Pakete de.werkbaum.generated.*)
  3. DocumentsController implementiert das Interface mit skipDefaultInterface=true: Weicht die Implementierung vom Vertrag ab, bricht der Build Spezifikation und Code können nicht auseinanderlaufen.

Generierter Code wird nicht eingecheckt und zählt nicht zur Code Coverage.

Teststrategie

  • Behavior-Tests (Cucumber, src/test/resources/features/) testen die API von außen gegen die laufende Anwendung (RANDOM_PORT): Statuscodes, Payloads, Fehlerpfade. Die Szenarien sind auf Deutsch (# language: de) und dienen als lebende Dokumentation.
  • Unit-Tests (JUnit 5 + MockK) decken die Geschäftslogik isoliert ab: DocumentService (Versionierung, Zeitstempel per festem Clock, Meilenstein-Regeln), LiveEditingService (Rebasen, Konflikt, Idempotenz, Grenzen) und LineDiff (Anwenden, Berechnen, Überschneidung, Prüfsumme).
  • Gegenprobe statt Zählerei: Zu jeder Regel wird geprüft, dass ihre Mutation genau die nach ihr benannten Zusicherungen fallen lässt. Ein Test, von dem man das nicht geprüft hat, ist nur eine Behauptung.
  • Coverage: JaCoCo, Verifikation mit mind. 80 % Line Coverage (jacocoTestCoverageVerification, hängt an check).

Architektur

api/         DocumentsController (implementiert generiertes Interface),
             GlobalExceptionHandler (RFC 9457 ProblemDetail), Diff-Mapper
service/     DocumentService (Geschäftslogik, Versionierung),
             LiveEditingService (Diffs einreichen), PatchLog, Clock-Bean
diff/        Zeilen-Diff: anwenden, berechnen, rebasen, Prüfsumme (Spring-frei)
repository/  DocumentRepository + DocumentHistoryRepository (Interfaces)
persistence/ JPA-Entities, Spring-Data-Repositories und Adapter (H2/Liquibase)
domain/      Document, ContentPatch (interne Modelle, getrennt vom API-Modell)

Das interne Domänenmodell ist bewusst vom generierten API-Modell getrennt so können API-Vertrag und Persistenz unabhängig voneinander weiterentwickelt werden.

Historie & Wiederherstellung

  • Jede Änderung wird als Snapshot in einer vom Dokument getrennten Historie protokolliert sie überlebt ein DELETE.
  • Zwei Ebenen (D76): Meilensteine sind die nutzersichtbare Historie und bleiben; Sync-Versionen tragen die Diffs des Live-Editings, sind kurzlebig und werden verdichtet. Ohne die Trennung würde die Historie beim getakteten Schreiben zum Transaktionslog hunderte Volltext-Snapshots eines 40-kB-Dokuments je Sitzung.
    • Meilenstein wird ein Stand bei einer strukturellen Änderung (Anlegen, Löschen, Wiederherstellen, Rückfall), beim Vollersatz per PUT und nach einer Schreibpause. Letzteres ohne Zeitgeber: Die nächste Änderung stellt fest, dass eine Pause war, und befördert die Version davor nachträglich. Auf Knopfdruck setzt PATCH /content denselben Schalter (milestone: true).
    • Stellschrauben: werkbaum.live-editing.milestone-pause (30 s) und sync-retention (1 h).
  • GET /api/v1/documents/{uuid}/history liefert die Meilensteine (älteste zuerst) und immer den jüngsten Stand; Identifier ist die UUID, wie bei GET (der Titel ist nicht eindeutig).
  • POST /api/v1/documents/{uuid}/restore stellt ein gelöschtes Dokument unter derselben UUID wieder her (RESTORED, letzter Stand aus dem Tombstone). Mit optionalem Body {"version": n} wird eine bestimmte Version übernommen bei einem noch lebenden Dokument ist das ein Rückfall (ROLLED_BACK), kein Wiederherstellen: Der Client hatte nie eine Sperre. Ohne Zielversion antwortet der Server bei existierendem Dokument mit 409, eine bereits verdichtete Zielversion mit 404.

Live-Editing: Änderungen als Zeilen-Diff

PATCH /api/v1/documents/{uuid}/content nimmt eine Änderung als Diff gegen eine Basisversion entgegen — Zeilen sind opake Strings, das Backend parst die Notation nicht (D14).

{ "baseVersion": 41, "checksum": "sha256:9f2b…",
  "clientId": "c-8a41…", "seq": 17,
  "ops": [ { "op": "replace", "index": 12, "count": 1,
             "lines": ["  - [~] Backend (L) @ben"] } ] }
  • Der Server rebased selbst. Ist die Basis veraltet, überschneiden sich die Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und antwortet mit 200; die fremden Operationen stehen in opsSinceBase, damit der Client seine Schattenkopie nachzieht. Reines Ablehnen führte zu Starvation — ein Client mit hoher Latenz käme bei fleißigen Mitschreibern womöglich nie durch.
  • 409 nur bei echter Überschneidung, mit currentVersion und dem Diff von der eingereichten Basis dorthin — der Client entscheidet, ohne neu zu laden. Eine Einfügung ist dabei ein Punkt zwischen den Zeilen: an den Rändern eines fremden Blocks kein Konflikt, in seinem Inneren schon.
  • checksum ist Pflicht. Die Versionsnummer bestätigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich lesen. Passt sie nicht: 422, Client lädt einmal neu. Ebenso bei Index außerhalb, bereits verdichteter Basis und veralteter seq.
  • clientId + seq machen den Aufruf wiederholbar. Geht die Antwort unterwegs verloren, liefert eine Wiederholung das Ergebnis von damals, statt die Änderung ein zweites Mal anzuwenden.
  • 400 bei Grenzüberschreitung (max-ops, max-content-length) und bei delete/replace ohne count — als 0 gelesen täte die Operation stillschweigend nichts.
  • Ohne milestone: true entsteht eine Sync-Version (siehe Historie oben); der Knopfdruck setzt das Feld.

Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID, außerhalb der Transaktion — innen gäbe der Proxy sie vor dem Commit frei).

Live-Editing: der Änderungsfeed

GET /api/v1/documents/{uuid}/changes?since={version}&wait={sekunden} liefert alles, was seit since geschehen ist. Gibt es nichts, hält der Server die Anfrage offen und antwortet sofort, sobald eine Änderung eintrifft; sonst 204, und der Client fragt erneut. Kosten im Leerlauf: eine offene Anfrage je Beobachter, rund 2,4 Requests pro Minute.

  • Der Feed arbeitet auf der Historie, nicht am Dokument. Ein gelöschtes Dokument muss sein DELETED noch zustellen können — 404 gibt es deshalb nur bei gänzlich unbekannter UUID.
  • Ist since verdichtet (oder 0, also Erstkontakt), kommt statt ops der Volltext; fromVersion fehlt dann. Ein Roundtrip und ein Sonderzustand weniger als ein eigener Fehlerpfad — und über hunderte Versionen hinweg wäre der Cursor ohnehin nicht zu retten.
  • Ereignisse: CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK, je mit clientId und displayName des Absenders. (RENAMED kommt mit PATCH /title; solange es das nicht gibt, wäre der Typ eine Zusage ohne Deckung.)
  • Cache-Control: no-store ist Pflicht: Ein Proxy dürfte sonst eine 204 zwischenspeichern, und der Feed stünde still.
  • wait wird serverseitig geklemmt (werkbaum.live-editing.max-wait, 25 s).

Umgesetzt blockierend auf virtuellen Threads (spring.threads.virtual), nicht mit DeferredResult: So behält der Endpunkt die aus der Spezifikation generierte Signatur, und ein Wartender kostet trotzdem fast nichts. Geweckt wird nach dem Commit — davor läse ein Beobachter einen Stand, der noch nicht steht. Voraussetzung ist eine Einzelinstanz; hinter einem Load Balancer erführe ein Beobachter auf der zweiten Instanz nichts. Begründung: D76-Nachtrag 5.

Master-Passwort für die Dokumentenliste

GET /api/v1/documents listet alle Dokumente und machte damit jede UUID auffindbar — das Zugriffsmodell „unerratbarer Link" wäre hinfällig. Dieser eine Endpunkt verlangt deshalb HTTP Basic mit dem Benutzer werkbaum:

export WERKBAUM_MASTER_PASSWORD_HASH="{bcrypt}$(htpasswd -nBC 12 '' | tr -d ':\n')"

Ohne -b, also mit Eingabeaufforderung. Ein Passwort auf der Kommandozeile landet in der Shell-History — und schlimmer: Die Shell fasst es vorher an. ge$heim wird zu ge, ge heim zu geheim. Gehasht wird dann etwas anderes als das, was man später eintippt, und der Server antwortet mit 401, obwohl alles richtig aussieht.

  • Ohne gesetzten Hash ist die Liste gesperrt (401), nicht offen. Ein vergessener Konfigurationsschritt darf nichts preisgeben; beim Start warnt das Log.
  • Der Hash trägt sein Verfahren als Präfix ({bcrypt}…) — so steht in der Konfiguration, womit gehasht wurde.
  • Nach max-attempts Fehlversuchen (5) ist der Endpunkt für lockout (15 min) gesperrt: 429 mit Retry-After. Die Sperre ist global, nicht je Adresse — es gibt genau ein Passwort, und hinter einem Reverse Proxy sähe der Server ohnehin für alle dieselbe Adresse. Der Preis: Wer falsch rät, sperrt die Liste für alle; die Dokumente selbst bleiben über ihre UUID erreichbar.
  • Die API ist zustandslos: keine Sitzung, kein CSRF-Token (der schützte hier nichts und bräche jeden Client).

Vorbereitete Erweiterungen

Autorisierung

  • Geschützt ist bisher genau ein Endpunkt, siehe unten. Alles andere ist über die unerratbare UUID erreichbar — das ist das Zugriffsmodell, nicht eine Lücke.
  • bearerAuth (JWT) ist in der OpenAPI-Spec als Security Scheme definiert, aber noch auf keine Operation angewendet. Später kommt echte Authentifizierung als Schicht davor; am Protokoll ändert sich dadurch nichts.

Live-Editing (Konzept: docs/live-editing-proposal.md, Entscheidung: D76)

  • Offen: Umbenennen per PATCH /title (und damit das Ereignis RENAMED) sowie die Client-Anpassung im Frontend.
  • DocumentUpdateRequest.expectedVersion ist im Vertrag vorgesehen, wird aber noch nicht ausgewertet.

Clientseitige Verschlüsselung

  • content ist ein opaker String, den der Server nie interpretiert. Der Wechsel auf Ciphertext erfordert keine API-Änderung; ggf. kommen später Metadaten-Felder (z. B. Schlüssel-ID, Nonce) als eigene Properties hinzu.

Persistenz

  • H2 im File-Modus (${werkbaum.data-dir:./data}/editor.mv.db) läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Historie überleben einen Neustart.
  • Bewusst ohne MODE=PostgreSQL, obwohl es naheliegt: In dem Modus legt H2 unquotierte Bezeichner klein an, Liquibase sucht seine Verwaltungstabellen aber groß — findet nichts, legt sie an, und H2 antwortet „Table databasechangelog already exists". Der erste Start ging, jeder weitere stürzte ab. Gemessen und in D77 begründet.
  • Die JDBC-URL hat einen Regler: werkbaum.data-dir. Der Regressionstest überschreibt nur den, damit alles Übrige an der ausgelieferten URL unter Test steht.
  • Schema per Liquibase im formatierten SQL-Format (src/main/resources/db/changelog/db.changelog-master.sql, kein XML). Neue Änderungen werden als weitere --changeset-Blöcke angehängt; Hibernate validiert nur (ddl-auto: validate).
  • Umstieg auf echtes PostgreSQL: im Wesentlichen JDBC-URL/Credentials in der application.yaml tauschen und den Postgres-Treiber als Dependency ergänzen Schema-Migrationen und Code bleiben unverändert. (Der H2-PostgreSQL-Modus war ein Nachbau davon und ist es nicht wert, siehe oben.)
  • Tests laufen gegen H2 in-memory. Die Testkonfiguration heißt application-test.yaml und ist eine Profil-Überlagerung: Gleichnamig (application.yaml) verdeckte sie die Hauptkonfiguration vollständig, und die Tests prüften eine Konfiguration, die in Produktion nie läuft.
  • Service-Methoden sind @Transactional: Dokument-Änderung und Historieneintrag werden atomar geschrieben.

Läuft er? GET /api/v1/info

{"name":"editor-backend","version":"0.1.0-SNAPSHOT","builtAt":"2026-08-26T16:59:40.341Z"}

Offen, ohne Nebenwirkung, und sagt zugleich, welcher Stand läuft. Vorher war die Lebendprobe eine Anfrage nach einem nicht existierenden Dokument mit der Erwartung 404 — ein erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert.

Die Angaben kommen aus META-INF/build-info.properties (springBoot { buildInfo() }, Teil des Boot-Plugins). Fehlt die Datei — etwa beim Start aus der IDE —, steht dort unbekannt statt eines Fehlers.

Betrieb auf der stabilen Instanz

remote backend deploy (aus dem Repo-Wurzelordner; ohne direnv tools/remote …) baut das Fat-Jar, legt es samt systemd-User-Unit ins Home des Servers und startet den Dienst neu; remote backend install-jdk bringt einmalig ein JDK 21 dorthin. Der Dienst lauscht nur auf 127.0.0.1 — von außen kommt man über die Proxy-Regel in scripts/prod.htaccess, die der Frontend-Deploy mitspiegelt.

Dahinter stehen unverändert scripts/deploy-backend.sh und scripts/install-jdk.sh; remote --help zeigt alle Ziele und Aktionen.

  • Speicher: -Xmx192m -Xms48m plus Freiraum-Verhältnisse; gemessen rund 174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig Luft? BACKEND_XMX in .env.
  • Master-Passwort: remote backend reset-password fragt es verdeckt ab, hasht es auf dem Server und prüft selbst nach, ob Hash und Passwort zueinander passen. Gespeichert wird nur der Hash (<BACKEND_DIR>/env, Modus 600); ohne ihn bleibt GET /documents gesperrt. Abrufen lässt sich die Liste mit remote backend documents.
  • Datenbank: H2 im Dateimodus unter <BACKEND_DIR>/data/. Ein Deploy fasst das Verzeichnis nicht an (kein --delete). Umstieg auf das dort laufende PostgreSQL: JDBC-URL in der application.yaml tauschen und den Treiber ergänzen — Schema und Code bleiben.
  • Sichern: remote backend backup. H2 hält die Datei offen, solange der Dienst läuft — gesichert wird deshalb mit kurzem Anhalten, und das Archiv wird nach dem Herunterladen gelesen, bevor der Befehl es behält.
  • Nachsehen: remote backend status, remote backend log, remote backend info.

Begründungen: docs/DECISIONS.md D77 (und D76-Nachträge 13 zur Vermessung der Zielumgebung).

Hinweise

  • Versionsnummern in build.gradle.kts (Spring Boot, OpenAPI Generator, Cucumber, MockK) beim ersten Build ggf. auf den aktuellen Patch-Stand heben.