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

31 KiB
Raw Blame History

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“.
  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.
  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:

{ "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

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.

  • 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