Files
numolus/docs/konzept-teilen-und-zusammenarbeit.md
2026-09-28 19:49:56 +02:00

713 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<uuid>`.
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/<uuid>`).
**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. Dokumente werden nach Rückfrage auf dem Server gespeichert.
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
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/<uuid>#k=<token>` → 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<sheetId>
├─ sheets: Y.Map<sheetId, Y.Map>
│ └─ { name, objectOrder: Y.Array<objectId>, objects: Y.Map<objectId, Y.Map> }
│ └─ Tabelle: {
│ kind, name, x, y, view…,
│ rowOrder: Y.Array<rowId>, ← stabile IDs statt Indizes
│ colOrder: Y.Array<colId>,
│ rowHeights: Y.Map<rowId, number>,
│ colWidths: Y.Map<colId, number>,
│ cells: Y.Map<"colId:rowId", string>,
│ formats: Y.Map<"colId:rowId", Y.Map>
│ }
├─ namedRanges: Y.Map<id, Y.Map>
├─ customFunctions: Y.Map<id, Y.Map>
└─ macros: Y.Map<id, 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/<uuid>`, 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 |