From 7b1a05d0d6428563682aa2bbd1b91e4484c32e50 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Mon, 28 Sep 2026 19:48:49 +0200 Subject: [PATCH] Add concept for sharing and collaborative editing Co-Authored-By: Claude Opus 5.5 --- docs/konzept-teilen-und-zusammenarbeit.md | 713 ++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 docs/konzept-teilen-und-zusammenarbeit.md diff --git a/docs/konzept-teilen-und-zusammenarbeit.md b/docs/konzept-teilen-und-zusammenarbeit.md new file mode 100644 index 0000000..52af6ad --- /dev/null +++ b/docs/konzept-teilen-und-zusammenarbeit.md @@ -0,0 +1,713 @@ +# numolus: Teilen & gemeinsames Arbeiten – Konzept + +Stand: 2026-09-28 · Autor: Wild Karl-Heinz + +numolus-Dokumente liegen künftig auf einem eigenen Server, werden live gemeinsam +bearbeitet und brauchen **kein Konto**. Ein Konto entsteht nur, wenn jemand es +will oder ein Dokument an bestimmte Personen teilen möchte. Angemeldet wird +ausschließlich per Magic Link (E-Mail). + +--- + +## 1. Ziel & Grundsätze + +- **Anonym zuerst.** App öffnen, Dokument anlegen, arbeiten, ohne Registrierung. + Der Browser bekommt eine Gast-Identität per Cookie. Ein Sheet wird ohne anderslautende + Angaben im Browser gespeichert. +- **Konto nur bei Bedarf.** Es gibt zwei Anlässe: + 1. Man will seine Dokumente auf mehreren Geräten haben bzw. nicht verlieren. + 2. Man will an bestimmte Personen teilen oder deren Rechte verwalten. +- **Login nur per Magic Link.** E-Mail eingeben, Link anklicken, fertig. Keine + Passwörter. +- **Nichts geht verloren.** Beim Anlegen oder Anmelden eines Kontos werden alle + Dokumente des bisherigen Gasts übernommen. +- **Links sind Zugänge.** Ein Bearbeiten- oder Lesen-Link funktioniert auch ohne + Konto und lässt sich jederzeit widerrufen. +- **Live-Zusammenarbeit per CRDT (Yjs).** Gleichzeitige Änderungen werden ohne + Sperren zusammengeführt; jede Person hat ihr eigenes Undo. +- **Lokale Formate bleiben.** `.num`, CSV und XLSX bleiben als Import/Export. + +**Nicht-Ziele (vorerst)** + +- Ende-zu-Ende-Verschlüsselung (siehe Kapitel 12) +- Kommentare und Versionsverlauf in der Oberfläche (das Datenmodell lässt + beides später zu) + +--- + +## 2. Nutzerflüsse + +Sechs Abläufe decken alles ab; nur C und D führen zum Magic Link. + +### A – Anonym anlegen und arbeiten + +1. Person öffnet numolus. Kein Cookie vorhanden → Server legt einen Gast an + und setzt ein langlebiges Cookie. +2. „Neues Dokument“ → Server erzeugt ein Dokument mit UUID, Besitzer = Gast. + Die URL wird `/d/`. +3. Alle Dokumente bleiben generell lokal im Browser. Doch sollte ein User + authentifiziert sein, dann wird jede Änderung geht sofort an den Server. + Kein Speichern-Knopf. +4. Dezenter Hinweis: „Dieses Dokument ist nur in diesem Browser in deiner Liste. + Mit E-Mail sichern, um es überall zu haben.“ + +### B – Wiederfinden (ohne Konto) + +1. Startseite zeigt „Meine Dokumente“: alles, was dem Gast gehört oder was er + über einen Link geöffnet hat. +2. Die Liste liegt serverseitig am Gast, hängt aber am Cookie. Anderer Browser + oder gelöschte Cookies = leere Liste. +3. Ohne Konto kein Restore wenn ein anderer Browser verwendet wird. + +### C – Konto anlegen, weil man es will + +1. „Anmelden“ → E-Mail eingeben → Mail mit Magic Link. +2. Klick auf den Link (im selben oder einem anderen Browser) → angemeldet. +3. War im Browser ein Gast aktiv, werden dessen Dokumente, Mitgliedschaften und + Link-Zugänge ins Konto übernommen (Kapitel 4.4). +4. Weiteres Gerät: E-Mail eingeben, Link klicken → dieselbe Liste. + +Es gibt keine getrennte „Registrierung“: Die erste Anmeldung mit einer neuen +E-Mail legt das Konto an. + +### D – An Personen teilen (erfordert Konto) + +1. „Teilen“ → Dialog. Ohne Konto: „Um an Personen zu teilen, bestätige deine + E-Mail“ → Ablauf C, danach geht der Dialog an derselben Stelle weiter. + Mache darauf aufmerksam, dass fuer das Share die Daten auf dem Server gepeichert + werden müssen. +2. E-Mail der Empfängerin + Rolle (Lesen/Bearbeiten) eingeben → Einladung per + Mail. +3. Die Empfängerin klickt den Link in der Einladung. Die Einladung ist + gleichzeitig ihr Magic Link: Sie ist damit angemeldet (Konto wird bei Bedarf + angelegt), das Dokument öffnet sich und steht in ihrer Liste unter + „Mit mir geteilt“. + +### E – Per Link teilen (kein Konto nötig) + +1. „Teilen“ → „Link kopieren“ mit Auswahl Lesen/Bearbeiten. +2. Wer den Link öffnet, bekommt bei Bedarf eine Gast-Identität. Das Dokument + erscheint danach in dessen Liste. +3. Der Besitzer kann den Link jederzeit neu erzeugen. Der alte Link und alle + darüber entstandenen Zugänge sind dann ungültig. + +Offene Entscheidung: Soll auch Ablauf E ein Konto verlangen? Siehe Kapitel 12. + +### F – Gemeinsam bearbeiten + +1. Alle Beteiligten sehen Änderungen der anderen innerhalb von + Sekundenbruchteilen. +2. Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen + (Konto: Anzeigename; Gast: zufälliger Name wie „Anonymer Fuchs“). +3. Oben rechts: Avatare der gerade Anwesenden. +4. Undo macht nur die eigenen Änderungen rückgängig. +5. Wer nur Leserechte hat, sieht alles live, kann aber nichts ändern + (Eingabe gesperrt, Toolbar reduziert). + +--- + +## 3. Architektur-Überblick + +Ein einziger Rails-8-Server übernimmt Login, Dokumentverwaltung und Live-Sync. +Der Browser hält den Dokumentinhalt als Yjs-CRDT und rechnet Formeln weiterhin +selbst. + +``` +┌─ Browser (je Person) ─────┐ ┌─ Rails 8 (numolus-Server) ──┐ +│ │ HTTPS │ │ ┌────────────┐ +│ Vue-App │◄────────►│ REST-API & Login │─────►│ │ +│ Oberfläche, Liste │ │ Magic Link, Liste, Teilen │ │ PostgreSQL │ +│ │ │ │ │ │ alle Daten │ +│ Store auf Yjs │ WebSocket│ DocumentChannel │─────►│ │ +│ Inhalt als CRDT, │◄────────►│ ActionCable + y-rb │ └────────────┘ +│ Formeln rechnen lokal │ │ │ ┌────────────┐ +│ │ │ │ Jobs & Mailer │─────►│ SMTP │ +│ IndexedDB (Offline) │ │ Kompaktieren, Aufräumen │ └────────────┘ +└───────────────────────────┘ └─────────────────────────────┘ +``` + +| Baustein | Wahl | Begründung | +| --- | --- | --- | +| Backend | Rails 8 | vorhandene Ruby/Rails-Kompetenz und Server | +| Live-Sync Server | ActionCable + `y-rb` (+ `yrb-actioncable`) | Yjs-Protokoll in Ruby, serverseitiges Zusammenfassen möglich | +| Live-Sync Client | `yjs`, `@y-rb/actioncable`, `y-indexeddb` | Standard-CRDT, offline-fähig | +| Datenbank | PostgreSQL | Binärdaten (`bytea`), später Volltextsuche | +| Jobs | Solid Queue (Rails-8-Standard) | kein Redis nötig | +| ActionCable-Adapter | Solid Cable (Rails-8-Standard) | kein Redis nötig; bei vielen Verbindungen später Redis oder AnyCable | +| Mail | Action Mailer über SMTP | Magic Links, Einladungen | +| Hosting | App und API unter einer Domain, z. B. `numolus.wicom.li` | First-Party-Cookies, kein CORS | + +**Auslieferung der App:** Der Vite-Build (`dist/`) wird von Rails bzw. dem +vorgeschalteten Webserver unter derselben Domain ausgeliefert. Alle Pfade +außer `/api/*` und `/cable` liefern `index.html` (Client-Routing für `/d/`). + +**Warum Yjs und nicht selbst gebaut:** Gleichzeitige Änderungen korrekt +zusammenzuführen (inkl. Offline-Phasen) ist der schwierigste Teil. Yjs ist +dafür etabliert; der Server muss das Zusammenführen nicht selbst verstehen, +kann es mit `y-rb` aber (Kompaktierung, Suche, Export). + +--- + +## 4. Identität & Login + +### 4.1 Gast-Identität + +- Keine Anlage eines Benutzers auf dem Server. +- Cookie: `numolus_session`, signiert, `HttpOnly`, `Secure`, `SameSite=Lax`, + Laufzeit 1 Jahr, bei jeder Nutzung verlängert (gleitend). +- Anzeigename für Cursor: zufällig („Anonymer Fuchs“), im Browser änderbar, + am Gast gespeichert. + +### 4.2 Magic Link + +1. `POST /api/auth/magic_link` mit E-Mail (+ optional `return_to`). +2. Server erzeugt ein zufälliges Token (32 Byte), speichert **nur dessen + SHA-256-Hash**, Gültigkeit 15 Minuten, einmal verwendbar. +3. Mail mit Link `https://numolus.wicom.li/auth/verify?token=…`. + Zusätzlich ein 6-stelliger Code in der Mail, damit man sich auch auf einem + Gerät anmelden kann, auf dem die Mail nicht gelesen wird (z. B. Link am + Handy, App am Desktop). +4. Aufruf des Links zeigt eine Bestätigungsseite mit Knopf „Anmelden“ + (`POST`). Grund: Mail-Scanner (Outlook Safe Links u. ä.) rufen Links + automatisch per `GET` auf und würden das Token sonst verbrauchen. +5. Nach dem `POST`: Token als verbraucht markieren, Konto anlegen falls neu, + Gast übernehmen (4.4), neue Session, Weiterleitung zu `return_to`. + +Antwort auf Schritt 1 ist immer gleich („Falls die Adresse gültig ist, ist eine +Mail unterwegs“), damit nicht abgefragt werden kann, welche Adressen ein Konto +haben. + +### 4.3 Sessions + +- Eine Session pro Browser/Gerät, in der Tabelle `sessions` (Rails-8-Generator + `bin/rails generate authentication` als Basis, Passwort-Teil entfernt). +- „Abmelden“ löscht die Session. „Überall abmelden“ löscht alle Sessions des + Kontos. +- Nach dem Abmelden ist der Browser wieder anonym, bekommt aber **nicht** den + alten Gast zurück (der ist ins Konto übergegangen). + +### 4.4 Übernahme Gast → Konto + +Beim erfolgreichen Magic-Link-Login in einem Browser mit Gast-Session: + +1. Alle Dokumente mit `owner = Gast` → `owner = Konto`. +2. Alle `document_memberships` des Gasts → auf das Konto umhängen; bei + Konflikt (Konto hat schon eine Mitgliedschaft) gilt die höhere Rolle. +3. Gast-Datensatz löschen, Gast-Session durch Konto-Session ersetzen. +4. Alles in einer Transaktion. + +Existiert das Konto bereits (Anmeldung auf zweitem Gerät), passiert dasselbe: +Die Gast-Dokumente des zweiten Geräts wandern ebenfalls ins Konto. + +--- + +## 5. Datenmodell + +``` +users + id bigint PK + kind enum: guest | account + email citext, unique, null bei Gästen + display_name string + color string -- Cursorfarbe + last_seen_at datetime + created_at, updated_at + +sessions + id bigint PK + user_id → users + token_digest string, unique -- Cookie enthält nur das Token, DB den Hash + user_agent, ip_address + last_active_at datetime + created_at + +magic_links + id bigint PK + email citext + token_digest string, unique + code_digest string -- 6-stelliger Code + purpose enum: login | invitation + document_id → documents, null -- bei Einladungen + role enum: viewer | editor -- bei Einladungen + return_to string + expires_at datetime + used_at datetime + created_at + +documents + id uuid PK -- steht in der URL + owner_id → users + title string + state bytea -- kompaktierter Yjs-Stand + state_vector bytea -- für effizientes Nachladen + schema_version integer -- Version des Yjs-Datenlayouts (Kap. 7.1) + size_bytes integer + last_opened_at datetime + deleted_at datetime -- Papierkorb + created_at, updated_at + +document_updates -- noch nicht kompaktierte Änderungen + id bigint PK + document_id → documents + user_id → users + data bytea -- ein Yjs-Update + created_at + +document_memberships + id bigint PK + document_id → documents + user_id → users + role enum: viewer | editor + source enum: invite | link + share_link_id → share_links, null -- für Widerruf per Link + last_opened_at datetime + unique (document_id, user_id) + +share_links + id bigint PK + document_id → documents + role enum: viewer | editor + token_digest string, unique + created_by_id → users + revoked_at datetime + expires_at datetime, null + created_at +``` + +**Hinweise** + +- Der Besitzer steht in `documents.owner_id`, nicht in `document_memberships`. + Genau ein Besitzer pro Dokument; Übertragen ist eine spätere Funktion. +- Pro Dokument höchstens ein aktiver Link je Rolle (einfachere Oberfläche). +- Tokens (Session, Magic Link, Share Link) werden nur als Hash gespeichert. + Ein Datenbank-Leak gibt damit keine gültigen Zugänge preis. +- Der Titel liegt als Spalte vor (für die Liste) und wird aus dem Yjs-Dokument + synchron gehalten. + +--- + +## 6. Zugriff & Rechte + +### 6.1 Rollen + +| Aktion | Besitzer | Editor | Viewer | +| --- | --- | --- | --- | +| Lesen, Cursor der anderen sehen | ja | ja | ja | +| Bearbeiten | ja | ja | nein | +| Exportieren (`.num`, CSV, XLSX) | ja | ja | ja | +| Per Link teilen / Link widerrufen | ja | nein | nein | +| An Personen einladen / entfernen | ja (mit Konto) | nein | nein | +| Umbenennen | ja | ja | nein | +| Löschen (Papierkorb) | ja | nein | nein | +| Aus eigener Liste entfernen | – | ja | ja | + +Später denkbar: Editoren dürfen ebenfalls teilen (Schalter pro Dokument). + +### 6.2 Wie Zugang entsteht + +1. **Besitz**: Wer anlegt, ist Besitzer. +2. **Einladung**: Magic Link mit `purpose = invitation` → beim Einlösen + entsteht eine Mitgliedschaft mit `source = invite`. +3. **Link**: Aufruf von `/d/#k=` → Client schickt Token an + `POST /api/documents/:id/join` → Mitgliedschaft mit `source = link` und + Verweis auf den Link. + +Der Link-Token steht im **Fragment** (`#`). Browser senden Fragmente nicht an +den Server; das Token landet also nicht in Access-Logs oder Referrer-Headern. +Nach dem Einlösen entfernt die App das Fragment aus der Adresszeile. + +### 6.3 Prüfung + +Eine zentrale Methode `Document#role_for(user)` → `:owner | :editor | :viewer | nil`. + +- **REST**: jeder Controller über `before_action`; kein Zugang → `404` + (nicht `403`, damit die Existenz einer UUID nicht bestätigt wird). +- **WebSocket**: `ApplicationCable::Connection` identifiziert den User über + das Session-Cookie. `DocumentChannel#subscribed` prüft die Rolle und lehnt + ab (`reject`). Viewer-Nachrichten, die Änderungen enthalten, werden + verworfen und protokolliert. +- **Rechte ändern sich live**: Wird eine Mitgliedschaft entfernt oder ein Link + widerrufen, schickt der Server den betroffenen Verbindungen ein + `access_revoked` und schließt sie. Rollenwechsel Editor → Viewer ebenso + (Client lädt im Lesemodus neu). + +### 6.4 Widerruf + +- **Link neu erzeugen**: alter Link `revoked_at` gesetzt, alle Mitgliedschaften + mit `share_link_id` = alter Link werden gelöscht. Wer weiter Zugang haben + soll, muss den neuen Link bekommen oder eingeladen werden. +- **Person entfernen**: Mitgliedschaft löschen. +- Eingeladene Personen (`source = invite`) sind vom Link-Widerruf nicht + betroffen. + +--- + +## 7. Echtzeit-Sync + +### 7.1 Yjs-Datenlayout + +Das heutige `DocumentState` (`src/store/types.ts`) wird auf Yjs-Typen +abgebildet. Wichtig: Alles, was mehrere Personen gleichzeitig ändern können, +muss eine eigene Yjs-Struktur sein, damit Änderungen feingranular +zusammengeführt werden. + +``` +Y.Doc +├─ meta: Y.Map { title, locale, csvDelimiter, schemaVersion } +├─ sheetOrder: Y.Array +├─ sheets: Y.Map +│ └─ { name, objectOrder: Y.Array, objects: Y.Map } +│ └─ Tabelle: { +│ kind, name, x, y, view…, +│ rowOrder: Y.Array, ← stabile IDs statt Indizes +│ colOrder: Y.Array, +│ rowHeights: Y.Map, +│ colWidths: Y.Map, +│ cells: Y.Map<"colId:rowId", string>, +│ formats: Y.Map<"colId:rowId", Y.Map> +│ } +├─ namedRanges: Y.Map +├─ customFunctions: Y.Map +└─ macros: Y.Map +``` + +**Nicht im Yjs-Dokument**: `selection`, `activeSheetId`, `activeObjectId`. +Das ist Zustand pro Person (Kapitel 7.4 und 8.3). + +**Zelltext**: Eine Zelle ist ein einfacher String (letzte Änderung gewinnt). +Zwei Personen, die gleichzeitig **dieselbe** Zelle ändern, erzeugen keinen +Mischtext; eine Fassung gewinnt. Das entspricht Google Sheets und ist für +Tabellen richtig. + +### 7.2 Protokoll über ActionCable + +Standard-Yjs-Sync (wie `y-websocket`), transportiert über `DocumentChannel`, +Binärdaten Base64-kodiert: + +1. **Beitritt**: Client abonniert `DocumentChannel` mit `document_id`. + Server prüft die Rolle, schickt `sync_step1` (Server-State-Vector). +2. Client antwortet mit den Änderungen, die dem Server fehlen (Offline-Arbeit), + und seinem State-Vector; Server schickt, was dem Client fehlt. +3. **Laufend**: Jede lokale Änderung → `update` an den Server → Server + speichert in `document_updates` und broadcastet an alle anderen + Abonnenten dieses Dokuments. +4. **Awareness**: Cursor/Auswahl/Name → `awareness` an den Server → nur + broadcasten, nie speichern. + +`yrb-actioncable` liefert Schritte 1–4 fertig; vor der Umsetzung ist zu +prüfen, ob das Gem aktiv gepflegt ist (Kapitel 12). Fallback: dieselbe +Logik direkt mit `y-rb` im eigenen Channel (überschaubar, ca. 150 Zeilen). + +### 7.3 Speicherung & Kompaktierung + +- Jedes Update wird sofort als Zeile in `document_updates` angehängt + (schnell, kein Lesen nötig). +- **Kompaktierung** (Solid-Queue-Job): wenn ein Dokument > 200 offene Updates + hat oder 30 s nach der letzten Änderung. Der Job lädt `documents.state` + + alle offenen Updates in `y-rb`, fasst sie zu einem neuen `state` + zusammen, schreibt `state`, `state_vector`, `size_bytes`, `title` und + löscht die verarbeiteten Updates, alles in einer Transaktion. +- Beim Beitritt lädt der Server `state` + offene Updates. +- **Grenze**: Dokumente über 20 MB Yjs-Stand werden abgelehnt (anpassbar). + Anlass: Missbrauchsschutz und Speicherbedarf im Browser. + +### 7.4 Awareness (Anwesenheit) + +Pro Verbindung ein kleines Objekt, nur live verteilt: + +```json +{ "user": { "name": "Anonymer Fuchs", "color": "#e67e22" }, + "sheetId": "…", "objectId": "…", + "selection": { "anchor": "colId:rowId", "focus": "colId:rowId" }, + "editing": true } +``` + +- Rahmen der anderen nur zeichnen, wenn sie auf demselben Sheet sind. +- Avatarleiste oben rechts; Klick auf einen Avatar springt zu dessen Auswahl. + +--- + +## 8. Frontend-Umbau + +### 8.1 Store auf Yjs + +Heute ist `doc` ein reaktives Vue-Objekt, das die Store-Funktionen in +`src/store/document.ts` direkt verändern, und das als Ganzes in +`localStorage` gespeichert wird. + +Neu: + +- **Quelle der Wahrheit** ist das `Y.Doc`. +- **Schreiben**: Jede Store-Funktion (`setCell`, `insertRows`, `fillSelection`, + …) wird zu einer Transaktion auf dem `Y.Doc` + (`ydoc.transact(() => …, origin)`). +- **Lesen**: Eine Vue-Spiegelung des `Y.Doc` (per `observeDeep`), damit alle + Komponenten und das Formel-Engine unverändert auf reaktive Daten zugreifen. + Die Spiegelung wird nur an den geänderten Stellen aktualisiert. +- Vorgehen schrittweise: erst eine Adapter-Schicht, die das heutige + `DocumentState`-Format aus dem `Y.Doc` liefert; dann Funktion für Funktion + die Schreibpfade umstellen. Tests in `src/**/__tests__` sichern jeden + Schritt ab. +- `localStorage`-Speicherung entfällt; an ihre Stelle tritt `y-indexeddb` + (Offline-Kopie pro Dokument). + +### 8.2 Undo pro Person + +Heute: Snapshots des ganzen Dokuments (`snapshot()`, `undo()`), die bei +gemeinsamer Arbeit fremde Änderungen zurückdrehen würden. + +Neu: `Y.UndoManager` über die geteilten Strukturen, beschränkt auf lokale +Transaktionen (`trackedOrigins`). Das heutige Zusammenfassen schneller +Änderungen (`HISTORY_COALESCE_MS`) entspricht `captureTimeout`. Die Auswahl +wird als Metadaten pro Undo-Schritt gespeichert, damit Undo auch die Auswahl +zurücksetzt. + +### 8.3 Auswahl aus dem Dokument lösen + +`selection`, `activeSheetId`, `activeObjectId` wandern aus `DocumentState` in +einen lokalen View-Store. Sie werden zusätzlich in die Awareness gespiegelt +(als `colId:rowId`, damit sie bei Einfügungen anderer nicht verrutschen). + +### 8.4 Stabile Zeilen- und Spalten-IDs + +Der schwierigste Teil. Heute sind Zellen über Positionen adressiert +(`cells["c,r"]`), und Einfügen/Löschen verschiebt Schlüssel und +Formelbezüge. Bei gleichzeitiger Arbeit führt das zu falsch platzierten Werten. + +Neu: + +- Jede Zeile und Spalte hat eine stabile ID; `rowOrder`/`colOrder` legen die + Reihenfolge fest. Einfügen = ID in die Liste einfügen; kein Verschieben von + Zellen. +- Zellen sind über `colId:rowId` adressiert. +- **Formeln** bleiben im Text als `A1`-Bezüge gespeichert (so zeigt sie die + Formelleiste). Beim Einfügen/Löschen passt heute die Store-Logik die + Bezüge an; das bleibt so, läuft aber in derselben Transaktion wie die + Strukturänderung. Rest-Risiko: Person A fügt eine Zeile ein, während + Person B gleichzeitig eine Formel tippt, die sich auf spätere Zeilen bezieht. + B's Formel wird dann nicht angepasst. Das ist bei Google Sheets ebenso und + wird akzeptiert. Langfristige Alternative: Bezüge intern als IDs speichern + und nur zur Anzeige in `A1` übersetzen. +- Das Formel-Engine rechnet weiter mit Positionen; die Spiegelung (8.1) + liefert ihm das heutige Positionsformat. + +### 8.5 Makros + +Makros laufen nur bei der Person, die sie startet. Alle Änderungen eines +Makrolaufs sind **eine** Yjs-Transaktion und damit ein Undo-Schritt, der sich +wie jede andere Änderung verteilt. Viewer können keine Makros starten. + +### 8.6 Offline + +- `y-indexeddb` hält eine Kopie jedes geöffneten Dokuments. +- Ohne Verbindung kann weitergearbeitet werden; die Statusleiste zeigt + „Offline – Änderungen werden später übertragen“. +- Beim Wiederverbinden gleicht das Sync-Protokoll (7.2) automatisch ab. + +### 8.7 Neue Oberflächenteile + +| Teil | Inhalt | +| --- | --- | +| Startseite / Dokumentliste | „Meine Dokumente“, „Mit mir geteilt“, Suche nach Titel, Neu, Import | +| Teilen-Dialog | Link (Lesen/Bearbeiten) kopieren und neu erzeugen; Personen einladen, Rolle ändern, entfernen | +| Anmelden-Dialog | E-Mail, Hinweis „Mail ist unterwegs“, Code-Eingabe | +| Konto-Menü | Anzeigename, Abmelden, Überall abmelden, Konto löschen | +| Anwesenheit | Avatare, farbige Auswahlrahmen, Namensschild am Cursor | +| Status | Verbunden / Offline / Synchronisiert | +| Lesemodus | Eingabe gesperrt, Hinweis „Nur Lesen“ | + +--- + +## 9. API + +### 9.1 REST (`/api`, JSON, Session-Cookie, CSRF-Token für schreibende Aufrufe) + +| Methode | Pfad | Zweck | +| --- | --- | --- | +| `GET` | `/api/me` | aktueller User (Gast oder Konto), legt keinen Gast an | +| `PATCH` | `/api/me` | Anzeigename, Farbe | +| `POST` | `/api/auth/magic_link` | Magic Link anfordern `{email, return_to}` | +| `POST` | `/api/auth/verify` | Token oder Code einlösen `{token}` / `{email, code}` | +| `DELETE` | `/api/auth/session` | Abmelden | +| `DELETE` | `/api/auth/sessions` | Überall abmelden | +| `GET` | `/api/documents` | Liste (eigene + geteilte), Metadaten, Rolle | +| `POST` | `/api/documents` | neues Dokument, optional mit Inhalt (Import) | +| `GET` | `/api/documents/:id` | Metadaten + eigene Rolle | +| `PATCH` | `/api/documents/:id` | Titel | +| `DELETE` | `/api/documents/:id` | in den Papierkorb (Besitzer) bzw. aus Liste entfernen | +| `POST` | `/api/documents/:id/restore` | aus dem Papierkorb | +| `POST` | `/api/documents/:id/join` | Link-Token einlösen `{token}` | +| `GET` | `/api/documents/:id/export?format=num` | Export serverseitig (optional) | +| `GET` | `/api/documents/:id/share` | Links und Mitglieder | +| `POST` | `/api/documents/:id/share_links` | Link (neu) erzeugen `{role}` → Token einmalig zurück | +| `DELETE` | `/api/documents/:id/share_links/:link_id` | Link widerrufen | +| `POST` | `/api/documents/:id/invitations` | Person einladen `{email, role}` | +| `PATCH` | `/api/documents/:id/members/:user_id` | Rolle ändern | +| `DELETE` | `/api/documents/:id/members/:user_id` | Person entfernen | + +### 9.2 WebSocket (`/cable`, `DocumentChannel`) + +| Richtung | Nachricht | Inhalt | +| --- | --- | --- | +| Client → Server | subscribe | `{document_id}` | +| beide | `sync_step1` | State-Vector (Base64) | +| beide | `sync_step2` | fehlende Updates (Base64) | +| beide | `update` | ein Yjs-Update (Base64) | +| beide | `awareness` | Awareness-Update (Base64) | +| Server → Client | `access_revoked` | `{reason}` → Client schließt Dokument | +| Server → Client | `role_changed` | `{role}` → Client lädt neu | +| Server → Client | `error` | `{code}` z. B. `too_large`, `read_only` | + +--- + +## 10. Sicherheit, Datenschutz & Betrieb + +### 10.1 Sicherheit + +- **Tokens**: 32 Byte aus `SecureRandom`, in der DB nur SHA-256-Hash; + Vergleich zeitkonstant. +- **Rate Limits** (Rails 8 `rate_limit`): + - Magic Link: 5 pro E-Mail und 20 pro IP je Stunde + - Code-Eingabe: 5 Fehlversuche, dann Code ungültig + - Dokument anlegen: 60 pro User/IP je Stunde + - Link einlösen: 30 pro IP je Minute + - WebSocket-Updates: Größe pro Nachricht max. 1 MB, Frequenz begrenzen +- **Cookies**: `HttpOnly`, `Secure`, `SameSite=Lax`; CSRF-Schutz für REST. +- **ActionCable**: `allowed_request_origins` auf die eigene Domain. +- **Enumeration**: UUIDs v4, `404` statt `403`, gleiche Antwort beim + Magic-Link-Anfordern. +- **Makros/Custom Functions** laufen im Browser. Da jetzt fremde Personen + Makros in ein gemeinsames Dokument schreiben können, muss geprüft werden, + wie Makros heute ausgeführt werden und ob sie auf Browser-APIs zugreifen + können. Falls ja: Ausführung isolieren oder Makros aus fremder Hand erst + nach Bestätigung ausführen (offene Frage, Kapitel 12). +- **Inhalte**: Zelltexte werden weiterhin nie als HTML gerendert. + +### 10.2 Datenschutz (DSGVO) + +- Gespeicherte Personendaten: E-Mail, Anzeigename, IP und User-Agent je + Session, Zeitstempel. Keine Tracker. +- Datenschutzerklärung und Impressum auf der Startseite verlinkt. +- Gast-Cookie ist technisch notwendig (Zugang zu eigenen Dokumenten) → + kein Einwilligungsbanner nötig; in der Datenschutzerklärung erklären. +- **Konto löschen**: löscht eigene Dokumente (nach Warnung), Mitgliedschaften, + Sessions. Dokumente, die mit anderen geteilt sind: Option „Besitz an + Editor übertragen“ oder löschen. +- **Export aller Daten**: ZIP mit allen eigenen Dokumenten als `.num`. +- IP-Adressen in Sessions nach 30 Tagen kürzen/entfernen. +- Mailversand über einen Anbieter in der EU/Schweiz. + +### 10.3 Aufräumjobs (Solid Queue, täglich) + +| Job | Regel | +| --- | --- | +| Gäste | Gast ohne Dokumente und ohne Aktivität seit 30 Tagen → löschen | +| Gast-Dokumente | Besitzer ist Gast, nicht geöffnet seit 12 Monaten → löschen (Mitglieder vorher nicht warnbar) | +| Papierkorb | `deleted_at` älter als 30 Tage → endgültig löschen | +| Magic Links | abgelaufen oder verbraucht, älter als 7 Tage → löschen | +| Sessions | inaktiv seit 1 Jahr → löschen | +| Kompaktierung | Dokumente mit offenen Updates, die der Live-Job verpasst hat | + +### 10.4 Betrieb + +- **Backups**: tägliches `pg_dump`, 30 Tage aufbewahrt, Wiederherstellung + einmal pro Quartal testen. +- **Monitoring**: Anzahl WebSocket-Verbindungen, offene Updates je Dokument, + Job-Warteschlange, Mail-Zustellfehler, Fehler-Tracking (z. B. Sentry o. ä.). +- **Deployment**: Kamal (Rails-8-Standard) oder bestehender Server; der + heutige `npm run sync`-Schritt wird durch das Ausliefern des Builds über + die Rails-App ersetzt. +- **Skalierung**: Ein Prozess reicht für den Anfang. Bei vielen gleichzeitigen + Verbindungen: AnyCable (WebSocket-Server in Go, Rails-Logik bleibt). + +--- + +## 11. Umsetzung in Phasen + +Jede Phase ist für sich nutzbar und wird einzeln ausgeliefert. + +### Phase 1 – Server-Dokumente, anonym (ohne Live-Sync) + +- Rails-App: `users` (nur Gäste), `sessions`, `documents` mit Inhalt als JSON + (heutiges `.num`-Format, `src/io/json.ts`), REST für Liste/Anlegen/Laden/ + Speichern/Löschen. +- Frontend: Dokumentliste, URL `/d/`, automatisches Speichern an den + Server statt `localStorage`. Bestehende `localStorage`-Dokumente werden beim + ersten Start einmalig hochgeladen. +- Konflikte: Versionsnummer am Dokument; veraltete Speicherung wird + abgelehnt und der Client lädt neu (es arbeitet ohnehin nur eine Person). + +**Fertig, wenn**: Dokument anonym anlegen, Browser schließen, wieder öffnen → +Dokument in der Liste; `.num`-Import/Export funktioniert weiter. + +### Phase 2 – Konto & Magic Link + +- `magic_links`, Login-Ablauf (4.2), Übernahme Gast → Konto (4.4), + Mailer, Konto-Menü, Abmelden. + +**Fertig, wenn**: Gast legt Dokument an, meldet sich an, öffnet auf zweitem +Gerät per Magic Link → Dokument ist da. Mail-Scanner verbrauchen den Link +nicht (Bestätigungsseite). + +### Phase 3 – Teilen (noch ohne Live-Sync) + +- `share_links`, `document_memberships`, Einladungen, Teilen-Dialog, + Rollenprüfung, Lesemodus. +- Gleichzeitiges Bearbeiten: einfache Sperre („Anna bearbeitet gerade – nur + Lesen“), Freigabe nach 2 Minuten Inaktivität. + +**Fertig, wenn**: Link teilen, zweite Person öffnet ohne Konto; Einladung +per Mail kommt an und öffnet das Dokument; Link widerrufen sperrt sofort. + +### Phase 4 – Live-Zusammenarbeit + +- Store auf Yjs (8.1), stabile Zeilen-/Spalten-IDs (8.4), Undo pro Person + (8.2), Auswahl lösen (8.3), `DocumentChannel`, Kompaktierung, Awareness, + Offline. Einmalige Umwandlung aller JSON-Dokumente in Yjs-Stände. +- Sperre aus Phase 3 entfällt. + +**Fertig, wenn**: Zwei Browser bearbeiten gleichzeitig verschiedene und +dieselben Zellen, fügen gleichzeitig Zeilen ein → beide sehen dasselbe +Ergebnis, kein Wert landet in einer falschen Zeile; Undo betrifft nur eigene +Änderungen; 10 Minuten offline arbeiten und wieder verbinden → alles +übernommen. + +### Phase 5 – Feinschliff + +- Papierkorb-Oberfläche, Suche, Konto löschen / Datenexport, Anzeige + „zuletzt geändert von“, Monitoring, Aufräumjobs scharf schalten. + +**Aufwand grob**: Phasen 1–3 sind überwiegend Standard-Rails-Arbeit plus +überschaubare Frontend-Ergänzungen. Phase 4 ist der größte Brocken, weil +fast jede Funktion in `src/store/document.ts` betroffen ist; sie sollte in +kleinen, jeweils getesteten Schritten erfolgen. + +--- + +## 12. Offene Fragen & Risiken + +**Entscheidungen** + +1. **Link-Teilen ohne Konto?** Konzept: Ja (Ablauf E). Alternative: Auch + Link-Teilen erfordert ein Konto, damit jedes geteilte Dokument einen + erreichbaren Besitzer hat. Vorteil: klare Verantwortung, bessere + Missbrauchskontrolle. Nachteil: eine Hürde mehr. +2. **Aufbewahrung von Gast-Dokumenten**: 12 Monate ohne Öffnen, ok? +3. **Server**: bestehender Rails-Server (`rails.wicom.li`) oder eigene + Instanz für numolus? +4. **Mailanbieter**: welcher SMTP-Dienst? +5. **Editoren dürfen teilen?** Konzept: Nein, nur Besitzer. +6. **Ende-zu-Ende-Verschlüsselung** als spätere Option? Würde serverseitige + Kompaktierung, Suche und Export ausschließen. + +**Risiken** + +| Risiko | Auswirkung | Gegenmaßnahme | +| --- | --- | --- | +| `y-rb` / `yrb-actioncable` wenig gepflegt | Sync-Server ohne Updates | vor Phase 4 prüfen; Fallback: eigener Channel mit `y-rb`, oder Node-Sidecar mit Hocuspocus nur für `/cable` | +| Store-Umbau größer als erwartet | Phase 4 verzögert sich | Adapter-Schicht, schrittweise Umstellung, Phasen 1–3 bringen vorher schon Nutzen | +| Formelbezüge bei gleichzeitigem Einfügen | vereinzelt falsche Bezüge | akzeptiert (wie Google Sheets); langfristig ID-basierte Bezüge | +| Makros aus fremder Hand | Code einer anderen Person läuft im eigenen Browser | Ausführungsmodell prüfen, ggf. isolieren oder Bestätigung verlangen | +| Mail landet im Spam | Login unmöglich | SPF/DKIM/DMARC, seriöser Anbieter, Code als Alternative | +| Gast verliert Cookie | Liste leer | deutlicher Hinweis auf Konto; URL bleibt gültig | +| Große Dokumente | langsamer Sync | Größenlimit, Kompaktierung, Messung in Phase 4 |