feat(frontend): gemeinsam am Server-Dokument arbeiten (?live=, Schritt 6)

Der Editor fuehrt ein Dokument des Backends: laden, nach 1,5 s Ruhe das Diff
schicken, ueber einen offenen Abruf fremde Aenderungen einspielen — ohne
Neuladen, mit mitwandernder Schreibmarke. Dazu CORS im Backend; ohne das
blockiert der Browser jeden Aufruf.

Zwei Fehler hat erst der Live-Test gegen das laufende Backend gefunden, beide
an der Naht zwischen Modul und Verdrahtung (D54-Nachtrag 3):

Der Konflikt entstand nie. Mit laufendem Feed zieht die Schattenkopie staendig
nach, die eigene Basis ist also nie veraltet — der Server haette nie 409
geantwortet, und die fremde Zeile waere stillschweigend ueberschrieben worden.
Der Client prueft die Ueberschneidung jetzt selbst gegen den ungesendeten
Text; den kennt der Server nicht.

Die Nummer begann nach jedem Neuladen wieder bei 1, waehrend die Kennung
blieb — der Server hielt die erste echte Aenderung fuer eine Wiederholung und
tat nichts. Beides liegt jetzt im sessionStorage: je Tab, ueberlebt Neuladen.
Je Tab ist zugleich die richtige Aussage, zwei Tabs sind zwei Schreiber.

Nachgemessen im Browser: fremde Aenderung erscheint ohne Neuladen, Getipptes
erreicht den Server, beide Konflikt-Knoepfe tun was sie sagen, und die
Schreibmarke steht nach zwei fremd eingefuegten Zeilen darueber unveraendert
bei Zeile+2, Spalte 8. 518 Frontend-Tests, 135 im Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:48:17 +02:00
co-authored by Claude Opus 5
parent 5881be1a2c
commit 08965157a1
12 changed files with 666 additions and 7 deletions
@@ -0,0 +1,46 @@
package de.werkbaum.api
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
/**
* Von welchen Herkünften darf der Editor die API ansprechen?
*
* Voreinstellung `*`. Das ist hier keine Nachlässigkeit, sondern das
* Zugriffsmodell (D76): Wer die unerratbare UUID hat, darf; wer sie nicht hat,
* findet sie auch über eine erlaubte Herkunft nicht. CORS schützt Cookies und
* mitgesendete Anmeldedaten — beides gibt es hier nicht (`credentials: omit`).
* Der Editor wiederum läuft je nach Installation überall: GitHub Pages, eigene
* Domain, Dev-Server, `file://`.
*
* Wer es enger will, setzt `werkbaum.cors.allowed-origins` auf die eigenen
* Adressen.
*/
@ConfigurationProperties(prefix = "werkbaum.cors")
data class CorsProperties(
val allowedOrigins: List<String> = listOf("*"),
)
@Configuration
class CorsConfiguration(private val properties: CorsProperties) {
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration().apply {
allowedOriginPatterns = properties.allowedOrigins
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
// Authorization fuer das Master-Passwort, Content-Type fuer JSON.
allowedHeaders = listOf("Authorization", "Content-Type")
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
allowCredentials = false
maxAge = 3600
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", config)
}
}
}
@@ -75,6 +75,10 @@ class SecurityConfiguration {
properties: MasterPasswordProperties,
): SecurityFilterChain =
http
// CORS-Regeln gelten auch fuer die Preflight-Anfrage - die traegt
// keine Anmeldedaten und darf deshalb nicht an der Kette haengen
// bleiben.
.cors { }
// Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz
// hängt am Passwort, nicht an einem Cookie ein CSRF-Token
// schützte hier nichts und bräche jeden Client.
@@ -37,6 +37,12 @@ werkbaum:
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
max-wait: 25s
# Von welchen Herkuenften darf der Editor die API ansprechen? "*" ist die
# Voreinstellung: Zugriff regelt die unerratbare UUID, nicht die Herkunft,
# und Cookies werden nie mitgesendet. Enger geht ueber eine Liste.
cors:
allowed-origins: "*"
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
master-password: