Merge the server concept into the README; add CLAUDE.md
The README gets a chapter on storage, accounts and live collaboration that describes what is built (local vs server documents, flows, roles, architecture, Yjs layer, channel protocol, data model, API, .env configuration, security, open points). The planning document with its outdated parts (guest accounts, y-rb, edit lock, phases) is removed. CLAUDE.md collects commands, layout and the rules that are easy to break. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
numolus: spreadsheet à la Apple Numbers (free canvas with table/chart/text/button
|
||||||
|
objects) with a hand-written formula engine. Branch `with-server` = Rails 8 app
|
||||||
|
with the Vue 3 frontend in `app/frontend/`; accounts, sharing, live collaboration.
|
||||||
|
Details for users and architecture: README.md, chapter "Speichern, Konten &
|
||||||
|
Zusammenarbeit".
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
- `main` (tag `client-only`): browser-only app, documents in IndexedDB. Keep it
|
||||||
|
free of server code; fixes that apply to both go in their own commit so they
|
||||||
|
can be cherry-picked.
|
||||||
|
- `with-server`: everything below.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/setup # gems, npm packages, database
|
||||||
|
bin/dev # Rails :3000 + Vite dev server :3036 (open localhost:3000)
|
||||||
|
npm test # Vitest (engine, store, io, collab)
|
||||||
|
bin/rails test # Minitest (models, API, auth, sharing, DocumentChannel)
|
||||||
|
npx vue-tsc --noEmit -p . # typecheck
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run build # typecheck + production build into public/vite
|
||||||
|
bin/brakeman -q --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all of them before committing. Mails in development: localhost:3000/letter_opener.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `app/frontend/engine/` formula engine (framework-agnostic, own README)
|
||||||
|
- `app/frontend/store/document.ts` the document store (~3300 lines, one open document)
|
||||||
|
- `app/frontend/store/library.ts` which document is open, local (IndexedDB) vs server, URLs
|
||||||
|
- `app/frontend/store/session.ts` signed-in user; `io/api.ts` REST client; `io/library.ts` IndexedDB
|
||||||
|
- `app/frontend/collab/` live collaboration: `ydoc.ts` (store ↔ Yjs), `session.ts` (ActionCable, undo, awareness)
|
||||||
|
- `app/channels/document_channel.rb` stores/relays Yjs updates, checks access
|
||||||
|
- `app/models/document.rb` `role_for(user:, token:)` is the one access check
|
||||||
|
|
||||||
|
## Rules that are easy to break
|
||||||
|
|
||||||
|
- **Cell addressing stays positional**: `TableObject.cells["col,row"]` in store,
|
||||||
|
engine and file format (`.num`/`.json`). Stable row/column ids exist only in
|
||||||
|
the Yjs layer (`collab/ydoc.ts`); don't change the file format for them.
|
||||||
|
- **Every content-changing store function calls `markDirty()`** (selection-only
|
||||||
|
changes call `scheduleSave()`), and must be listed in `READ_ONLY_GUARDED` in
|
||||||
|
`document.ts`, else viewers of a shared document can change it locally.
|
||||||
|
- **Mid-table row/column insert/delete must call `collabHooks?.onStructure(...)`**
|
||||||
|
(as `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` do). Edge growth/shrink
|
||||||
|
needs nothing. Without it, concurrent edits land in the wrong row.
|
||||||
|
- View state is per person: `selection`, `activeSheetId`, `activeObjectId`, sheet
|
||||||
|
`zoom` are not synced. `applyExternalState` keeps them when others' changes arrive.
|
||||||
|
- Remote changes: flush pending local changes into Yjs first, then apply, then
|
||||||
|
rebuild the store (see `CollabSession.process`). Keep that order.
|
||||||
|
- Local documents persist via `setPersistHandler` (IndexedDB); server documents
|
||||||
|
only via the collab session. Undo: snapshots locally, `Y.UndoManager` for server docs.
|
||||||
|
- Solid Queue/Cache/Cable live in the same PostgreSQL database as the app data
|
||||||
|
(plain migrations in `db/migrate`), no separate databases (user decision).
|
||||||
|
- Settings and secrets come from `.env` (gem `dotenv`, template `.env.example`),
|
||||||
|
never committed.
|
||||||
|
- Share link tokens are stored in plain text on purpose (owner can copy the link
|
||||||
|
again); magic link tokens/codes only as SHA-256.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- UI text and user-facing messages in German; code, comments, commit messages in
|
||||||
|
English. Comments explain *why*, in the surrounding (fairly thorough) style.
|
||||||
|
- Commit messages: short imperative subject, body with the reasons.
|
||||||
|
- The user sometimes commits on their own: check `git status`/`git log` fresh
|
||||||
|
before committing; don't push unless asked.
|
||||||
|
|
||||||
|
## Environment gotchas
|
||||||
|
|
||||||
|
- `vite-plugin-ruby` makes `app/frontend` the Vite root: Vitest `include`
|
||||||
|
patterns in `vite.config.ts` are relative to it (`**/*.spec.ts`).
|
||||||
|
- Vite is pinned to v5 (Vitest 2 and `@vitejs/plugin-vue` 5 don't support newer);
|
||||||
|
`vite install`/`vite upgrade` may bump it again.
|
||||||
|
- Gems install into `vendor/gems` (global `BUNDLE_PATH`), ignored by git.
|
||||||
|
- Node comes from mise; outside the project directory scripts may need
|
||||||
|
`MISE_NODE_VERSION=22.21.1`.
|
||||||
|
- Browser checks: there is no Playwright in the repo; drive Chrome
|
||||||
|
(`/Applications/Google Chrome.app`) with `playwright-core` from a scratch
|
||||||
|
directory against `bin/dev`.
|
||||||
@@ -26,14 +26,12 @@ Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Alle
|
|||||||
Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen
|
Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen
|
||||||
kommen über ActionCable sofort bei den anderen an (Yjs, siehe
|
kommen über ActionCable sofort bei den anderen an (Yjs, siehe
|
||||||
[`app/frontend/collab/`](app/frontend/collab/)), jede Person hat ihr eigenes
|
[`app/frontend/collab/`](app/frontend/collab/)), jede Person hat ihr eigenes
|
||||||
Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md).
|
Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details:
|
||||||
|
[Speichern, Konten & Zusammenarbeit](#speichern-konten--zusammenarbeit).
|
||||||
|
|
||||||
**Konfiguration:** Datenbank-Zugang, Server-Name, SMTP und `SECRET_KEY_BASE`
|
**Konfiguration:** Datenbank-Zugang, Server-Name, SMTP und `SECRET_KEY_BASE`
|
||||||
stehen in einer `.env` im Projektverzeichnis – Vorlage kopieren und ausfüllen:
|
stehen in einer `.env` im Projektverzeichnis: `cp .env.example .env` und
|
||||||
`cp .env.example .env`. Die Datei wird nie committet; echte
|
ausfüllen (siehe [Konfiguration & Betrieb](#konfiguration--betrieb)).
|
||||||
Umgebungsvariablen haben Vorrang. Solid Queue, Cache und Cable liegen in
|
|
||||||
derselben Datenbank wie die Daten. Mails landen in der Entwicklung nicht im
|
|
||||||
Postfach, sondern unter <http://localhost:3000/letter_opener>.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,7 +177,7 @@ Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
|
|||||||
Undo dieses Feldes benutzen soll.
|
Undo dieses Feldes benutzen soll.
|
||||||
|
|
||||||
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
|
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
|
||||||
`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ...,
|
Speicherung werden **nicht** über einen `watch(doc, ...,
|
||||||
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
|
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
|
||||||
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
|
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
|
||||||
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
|
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
|
||||||
@@ -700,8 +698,8 @@ Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise
|
|||||||
gerastert.
|
gerastert.
|
||||||
|
|
||||||
Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` /
|
Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` /
|
||||||
`Sheet`) und werden damit in **localStorage gespeichert und in `.json`
|
`Sheet`) und werden damit **mit dem Dokument gespeichert und in `.json`
|
||||||
exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import
|
exportiert** (der Zoom bleibt bei geteilten Dokumenten pro Person) – Round-Trip verlustfrei, ungültige Werte werden beim Import
|
||||||
verworfen bzw. geklemmt.
|
verworfen bzw. geklemmt.
|
||||||
|
|
||||||
### Zahlenformat (Ländereinstellung)
|
### Zahlenformat (Ländereinstellung)
|
||||||
@@ -1112,7 +1110,8 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
|
|||||||
### Import
|
### Import
|
||||||
`<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`.
|
`<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`.
|
||||||
|
|
||||||
- **`.num`** / **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
|
- **`.num`** / **`.json`** → *als neues Dokument* öffnen (Standard), das offene
|
||||||
|
Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
|
||||||
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
|
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
|
||||||
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
|
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
|
||||||
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
|
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
|
||||||
@@ -1123,14 +1122,263 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
|
|||||||
- Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh.
|
- Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh.
|
||||||
Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**.
|
Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**.
|
||||||
|
|
||||||
## Persistenz
|
## Speichern, Konten & Zusammenarbeit
|
||||||
|
|
||||||
Das gesamte Dokument wird (debounced) nach `localStorage` gespiegelt, robust
|
Ohne Konto bleibt alles **im Browser**. Ein Konto braucht es nur, wenn man
|
||||||
gegen leeren/kaputten State — Fallback ist ein **leeres Dokument** (ein Blatt
|
seine Dokumente auf mehreren Geräten haben oder sie mit anderen teilen will.
|
||||||
„Blatt 1" mit einer leeren „Tabelle 1"). Ein v1-State im Storage wird beim
|
Mit Konto liegen Dokumente auf dem Server und werden **live gemeinsam**
|
||||||
Laden migriert. **Datei › Neu** legt ein neues leeres Dokument an, **Datei ›
|
bearbeitet.
|
||||||
Muster · Umsatzanalyse** lädt das vorbefüllte Musterdokument (beides nach
|
|
||||||
Rückfrage, die bisherigen Änderungen gehen verloren).
|
### Grundsätze
|
||||||
|
|
||||||
|
- **Anonym zuerst.** App öffnen, arbeiten, ohne Registrierung. Kein
|
||||||
|
Gast-Konto auf dem Server.
|
||||||
|
- **Konto nur bei Bedarf**, Anmeldung nur per **Magic Link** (E-Mail, dazu ein
|
||||||
|
6-stelliger Code). Keine Passwörter. Die erste Anmeldung legt das Konto an.
|
||||||
|
- **Nichts geht verloren.** Nach der ersten Anmeldung fragt die App, welche
|
||||||
|
lokalen Dokumente ins Konto hochgeladen werden sollen.
|
||||||
|
- **Links sind Zugänge.** Ein Lesen- oder Bearbeiten-Link funktioniert auch
|
||||||
|
ohne Konto und lässt sich jederzeit widerrufen.
|
||||||
|
- **Gleichzeitig statt nacheinander.** Änderungen werden per CRDT (Yjs)
|
||||||
|
zusammengeführt, ohne Sperren; jede Person hat ihr eigenes Undo.
|
||||||
|
- **Dateien bleiben.** `.num`, `.json`, CSV und XLSX bleiben als
|
||||||
|
Import/Export.
|
||||||
|
|
||||||
|
### Wo Dokumente liegen
|
||||||
|
|
||||||
|
| | Lokal | Server |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wer | alle, auch ohne Konto | mit Konto, oder per Link |
|
||||||
|
| Wo | IndexedDB dieses Browsers | PostgreSQL |
|
||||||
|
| Adresse | `/?doc=<id>` | `/d/<uuid>` (Link: `/d/<uuid>#k=<token>`) |
|
||||||
|
| Speichern | automatisch, 250 ms nach der Änderung | live, jede Änderung sofort an alle |
|
||||||
|
| Undo | Snapshots des Dokuments | pro Person (`Y.UndoManager`) |
|
||||||
|
| Teilen | – (erst hochladen) | per Link oder Einladung |
|
||||||
|
|
||||||
|
**Datei › Dokumente …** listet alles in bis zu drei Gruppen: *Mein Konto*,
|
||||||
|
*Mit mir geteilt* (Einladungen und Links) und *In diesem Browser*. Dort lässt
|
||||||
|
sich öffnen, umbenennen, duplizieren, löschen bzw. aus der Liste entfernen und
|
||||||
|
ein lokales Dokument ins Konto hochladen. Der Dokumentname steht in der
|
||||||
|
Toolbar und ist direkt umbenennbar; er ist auch der Dateiname beim Export.
|
||||||
|
**Neues Dokument** und **Muster** legen je ein weiteres Dokument an: mit Konto
|
||||||
|
auf dem Server, sonst lokal. Ein alter Einzel-Speicherstand aus
|
||||||
|
`localStorage` wird beim ersten Start als „Mein Dokument“ übernommen.
|
||||||
|
|
||||||
|
**Mehrere Tabs:** Jeder Tab kann ein anderes Dokument offen haben. Dieselbe
|
||||||
|
lokale Datei in zwei Tabs folgt den Speicherungen des anderen Tabs
|
||||||
|
(`BroadcastChannel`); tippen beide gleichzeitig, gewinnt die letzte
|
||||||
|
Speicherung, darauf weist ein Hinweis hin. Server-Dokumente führen
|
||||||
|
gleichzeitige Änderungen immer zusammen.
|
||||||
|
|
||||||
|
### Abläufe
|
||||||
|
|
||||||
|
1. **Anonym arbeiten** – Dokumente liegen in IndexedDB. Ein anderer Browser
|
||||||
|
oder gelöschte Browserdaten bedeuten: diese Dokumente sind dort nicht da.
|
||||||
|
2. **Anmelden** – E-Mail eingeben, Mail mit Link und Code. Der Link öffnet eine
|
||||||
|
Bestätigungsseite; angemeldet wird erst per Klick (`POST`), damit
|
||||||
|
Mail-Scanner, die Links vorab aufrufen, ihn nicht verbrauchen. Alternativ
|
||||||
|
den Code in der App eingeben (Link am Handy, App am Desktop). Danach: Rückfrage
|
||||||
|
zum Hochladen lokaler Dokumente; hochgeladene werden lokal entfernt.
|
||||||
|
3. **Teilen per Link** – *Teilen* → Link „kann ansehen“ bzw. „kann bearbeiten“
|
||||||
|
erstellen und kopieren. Funktioniert ohne Konto; der Token wird im Browser
|
||||||
|
gemerkt (`localStorage`) und nach einer späteren Anmeldung dem Konto
|
||||||
|
zugeordnet. *Neu* erzeugt einen neuen Link; der alte und alle darüber
|
||||||
|
entstandenen Zugänge sind dann ungültig.
|
||||||
|
4. **Einladen** – E-Mail + Rolle. Die Einladung (7 Tage gültig) meldet beim
|
||||||
|
Öffnen gleichzeitig an und legt bei Bedarf das Konto an.
|
||||||
|
5. **Gemeinsam bearbeiten** – Änderungen kommen in ~150 ms bei den anderen an.
|
||||||
|
Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen, oben
|
||||||
|
rechts die Avatare. Anonyme Personen heißen zwei zufällige Begriffe
|
||||||
|
(„Quelle Mond“). Undo nimmt nur eigene Änderungen zurück. Wer nur lesen
|
||||||
|
darf, sieht alles live, kann aber nichts ändern („Nur Lesen“).
|
||||||
|
|
||||||
|
Ein lokales Dokument teilen: *Teilen* → ohne Konto erst anmelden, dann
|
||||||
|
*Hochladen und teilen*.
|
||||||
|
|
||||||
|
### Rollen
|
||||||
|
|
||||||
|
| Aktion | Besitzer | Bearbeiten | Ansehen |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Lesen, Anwesenheit der anderen sehen | ja | ja | ja |
|
||||||
|
| Bearbeiten, Umbenennen | ja | ja | nein |
|
||||||
|
| Exportieren, Duplizieren (ins eigene Konto) | ja | ja | ja |
|
||||||
|
| Links und Personen verwalten | ja | nein | nein |
|
||||||
|
| Löschen | ja | – | – |
|
||||||
|
| Aus der eigenen Liste entfernen | – | ja | ja |
|
||||||
|
|
||||||
|
Zugang entsteht durch **Besitz** (wer anlegt), **Einladung** (Mitgliedschaft
|
||||||
|
`invite`, bleibt beim Widerruf eines Links bestehen) oder **Link**
|
||||||
|
(angemeldet: Mitgliedschaft `link`; anonym: nur der Token im Browser). Ein
|
||||||
|
Link schwächt nie eine bestehende Rolle ab. Der Token steht im URL-Fragment
|
||||||
|
(`#k=…`), das Browser nicht an Server schicken; die App entfernt es nach dem
|
||||||
|
Einlösen aus der Adresszeile.
|
||||||
|
|
||||||
|
Geprüft wird zentral über `Document#role_for(user:, token:)`: im REST-API per
|
||||||
|
Controller (kein Zugang → `404`, nicht `403`), im WebSocket beim Verbinden.
|
||||||
|
Ändern sich Links oder Mitgliedschaften, prüft jede offene Verbindung ihre
|
||||||
|
Rolle sofort neu: Wer den Zugang verliert, wird getrennt; wer herabgestuft
|
||||||
|
wird, ist sofort im Nur-Lesen-Modus.
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Browser (je Person) ─────────┐ ┌─ Rails 8 ──────────────────────┐
|
||||||
|
│ Vue-App (app/frontend) │ HTTPS │ REST-API /api, Login /auth │ ┌────────────┐
|
||||||
|
│ Store (DocumentState, │◄────────►│ Magic Link, Liste, Teilen │────►│ PostgreSQL │
|
||||||
|
│ positionsbasiert "c,r") │ │ │ │ Daten + │
|
||||||
|
│ ▲ ▼ collab/ydoc.ts │ WebSocket│ DocumentChannel (ActionCable) │────►│ Solid Queue│
|
||||||
|
│ Y.Doc (feste Zeilen-/ │◄────────►│ speichert + verteilt Updates │ │ Cache,Cable│
|
||||||
|
│ Spalten-IDs), Awareness │ /cable │ │ └────────────┘
|
||||||
|
│ IndexedDB (lokale Dokumente) │ │ Mailer (Solid Queue) ──► SMTP │
|
||||||
|
└───────────────────────────────┘ └────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Rails 8** mit dem Vue-Frontend über `vite_rails`; `SpaController` liefert
|
||||||
|
die App für `/` und `/d/:id` und bettet den angemeldeten User ein
|
||||||
|
(`#numolus-boot`).
|
||||||
|
- **Solid Queue, Solid Cache, Solid Cable** liegen als normale Migrationen in
|
||||||
|
**derselben** PostgreSQL-Datenbank wie die Daten; kein Redis.
|
||||||
|
- Der Server versteht den Inhalt der Yjs-Updates nicht und muss es nicht: er
|
||||||
|
speichert und verteilt sie nur. Formeln rechnet weiterhin jeder Browser selbst.
|
||||||
|
|
||||||
|
### Live-Bearbeitung (Yjs)
|
||||||
|
|
||||||
|
Der **Store bleibt unverändert positionsbasiert** (`cells["col,row"]`), Engine
|
||||||
|
und Dateiformat ebenso. Die Schicht [`app/frontend/collab/`](app/frontend/collab/)
|
||||||
|
übersetzt:
|
||||||
|
|
||||||
|
- [`ydoc.ts`](app/frontend/collab/ydoc.ts) – Yjs-Form des Dokuments und die
|
||||||
|
Übersetzung in beide Richtungen. Jede Tabelle hat `rowOrder`/`colOrder` mit
|
||||||
|
**festen IDs**; Zellen, Formate, Breiten und Höhen hängen an diesen IDs
|
||||||
|
(`"colId|rowId"`). `Binding.sync()` schreibt nur, was sich geändert hat
|
||||||
|
(eine Zelle, eine Eigenschaft), damit Änderungen verschiedener Personen
|
||||||
|
zusammengeführt statt überschrieben werden. Zeile/Spalte einfügen oder
|
||||||
|
löschen melden die vier Store-Funktionen `insertRow`/`deleteRow`/
|
||||||
|
`insertColumn`/`deleteColumn` ausdrücklich (`onStructure`), denn aus
|
||||||
|
verschobenen Zellen allein ist das nicht erkennbar. So landet eine Eingabe
|
||||||
|
in Zeile 7 in der richtigen Zeile, auch wenn gleichzeitig jemand darüber
|
||||||
|
eine Zeile einfügt.
|
||||||
|
- [`session.ts`](app/frontend/collab/session.ts) – eine Sitzung je offenem
|
||||||
|
Server-Dokument: ActionCable-Abo, `Y.UndoManager` (nur eigene Änderungen),
|
||||||
|
Awareness (Name, Farbe, Auswahl als Zeilen-/Spalten-IDs). Reihenfolge ist
|
||||||
|
wichtig: Vor jeder fremden Änderung gehen eigene, noch nicht übertragene
|
||||||
|
Änderungen zuerst nach Yjs; danach wird der Store mit
|
||||||
|
`applyExternalState` neu aufgebaut, wobei Auswahl, aktives Blatt und Zoom
|
||||||
|
der Person erhalten bleiben (die eigene Auswahl folgt eingefügten Zeilen).
|
||||||
|
- **Erste Umwandlung** eines JSON-Dokuments in Yjs geschieht im Browser,
|
||||||
|
deterministisch (feste Client-ID, IDs `r0…`/`c0…`): Öffnen zwei Personen es
|
||||||
|
gleichzeitig, entstehen identische Updates, die Yjs zu einem verschmilzt.
|
||||||
|
- **Zusammenfassen:** Ab 200 gespeicherten Updates schickt ein Editor den
|
||||||
|
Gesamtstand; der Server ersetzt genau die aufgelisteten Updates durch ihn.
|
||||||
|
Editoren halten außerdem `documents.content` als JSON-Snapshot aktuell
|
||||||
|
(alle 30 s bei Änderung, beim Verlassen, beim Zusammenfassen) – für
|
||||||
|
Duplizieren, Export und die Liste.
|
||||||
|
- **Wiederverbinden:** ActionCable verbindet neu, der Server schickt alle
|
||||||
|
Updates, der Browser schickt, was dem Server fehlt. Anzeige „Offline“ in der
|
||||||
|
Toolbar, solange die Verbindung weg ist.
|
||||||
|
- **Zoom** ist pro Person und wird nicht geteilt; Auswahl, aktives Blatt und
|
||||||
|
aktives Objekt ebenso nicht. Die Z-Reihenfolge (Klick holt ein Objekt nach
|
||||||
|
vorn) ist Dokumentinhalt; im Nur-Lesen-Modus ändert ein Klick sie nicht.
|
||||||
|
|
||||||
|
**Nur-Lesen im Store:** Ist `store.ui.readOnly` gesetzt, sind alle
|
||||||
|
inhaltsändernden Store-Funktionen wirkungslos (Liste `READ_ONLY_GUARDED` in
|
||||||
|
[`document.ts`](app/frontend/store/document.ts)); ein Versuch zeigt den Grund als Hinweis.
|
||||||
|
|
||||||
|
**DocumentChannel** ([`document_channel.rb`](app/channels/document_channel.rb)),
|
||||||
|
Binärdaten Base64:
|
||||||
|
|
||||||
|
| Richtung | Nachricht | Inhalt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Browser → Server | `update` | ein Yjs-Update (nur Editoren) |
|
||||||
|
| Browser → Server | `awareness` | Auswahl/Name, wird nicht gespeichert |
|
||||||
|
| Browser → Server | `compact` | Gesamtstand + Liste der ersetzten Update-IDs + JSON-Snapshot |
|
||||||
|
| Browser → Server | `snapshot` | JSON-Snapshot |
|
||||||
|
| Server → Browser | `init` | Rolle, Titel, alle Updates; solange es keine gibt: das JSON zum Umwandeln |
|
||||||
|
| Server → Browser | `update`, `awareness`, `compacted` | weitergereicht bzw. bestätigt |
|
||||||
|
| Server → Browser | `meta`, `role`, `revoked` | Titel geändert, Rolle geändert, Zugang weg |
|
||||||
|
|
||||||
|
### Datenmodell
|
||||||
|
|
||||||
|
| Tabelle | Wichtige Spalten |
|
||||||
|
| --- | --- |
|
||||||
|
| `users` | `email` (citext, eindeutig), `display_name`, `color`, `last_seen_at` |
|
||||||
|
| `sessions` | `user_id`, `user_agent`, `ip_address`, `last_active_at` – signierte Session-ID im Cookie |
|
||||||
|
| `magic_links` | `email`, `token_digest`, `code_digest` (nur SHA-256), `purpose` (login/invitation), `document_id`, `role`, `invited_by_id`, `return_to`, `failed_attempts`, `expires_at`, `used_at` |
|
||||||
|
| `documents` | `id` (UUID, in der URL), `owner_id`, `title`, `content` (JSON-Snapshot), `updated_by_id`, `content_updated_at` |
|
||||||
|
| `document_updates` | `document_id`, `user_id`, `data` (ein Yjs-Update oder ein zusammengefasster Stand) |
|
||||||
|
| `document_memberships` | `document_id`, `user_id` (eindeutig zusammen), `role` (viewer/editor), `source` (invite/link), `share_link_id` |
|
||||||
|
| `share_links` | `document_id`, `role`, `token` (Klartext, damit der Besitzer den Link erneut kopieren kann), `created_by_id`, `revoked_at` |
|
||||||
|
|
||||||
|
Der Besitzer steht nur in `documents.owner_id`. Pro Dokument gibt es höchstens
|
||||||
|
einen aktiven Link je Rolle.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
REST unter `/api`, JSON, Session-Cookie, CSRF-Token aus dem Meta-Tag;
|
||||||
|
Link-Zugang über den Header `X-Share-Token`.
|
||||||
|
|
||||||
|
| Methode & Pfad | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/auth/magic_link` | Link + Code anfordern `{email, return_to}` – Antwort immer gleich |
|
||||||
|
| `POST /api/auth/code` | mit Code anmelden `{email, code}` |
|
||||||
|
| `DELETE /api/auth/session` | abmelden |
|
||||||
|
| `GET/PATCH /api/me` | angemeldeter User / Anzeigename |
|
||||||
|
| `GET/POST /api/documents` | eigene + geteilte Dokumente / anlegen oder hochladen `{title, content}` |
|
||||||
|
| `GET /api/documents/:id` | Metadaten, Rolle, JSON-Snapshot (`?content=0` ohne) |
|
||||||
|
| `PATCH /api/documents/:id` | umbenennen `{title}` |
|
||||||
|
| `DELETE /api/documents/:id` | Besitzer: löschen; sonst: aus der Liste entfernen |
|
||||||
|
| `POST /api/documents/:id/join` | Link-Token einlösen `{token}` |
|
||||||
|
| `GET /api/documents/:id/share` | Links, Mitglieder, offene Einladungen (Besitzer) |
|
||||||
|
| `POST/DELETE /api/documents/:id/share/links[/:id]` | Link je Rolle neu erzeugen / widerrufen |
|
||||||
|
| `POST /api/documents/:id/share/invitations` | einladen `{email, role}` |
|
||||||
|
| `PATCH/DELETE /api/documents/:id/share/members/:user_id` | Rolle ändern / entfernen |
|
||||||
|
| `GET/POST /auth/verify?token=` | Bestätigungsseite / Anmeldung aus dem Mail-Link |
|
||||||
|
|
||||||
|
### Konfiguration & Betrieb
|
||||||
|
|
||||||
|
Einstellungen stehen in einer **`.env`** im Projektverzeichnis (Gem `dotenv`;
|
||||||
|
auch `.env.production` usw.). Vorlage: [`.env.example`](.env.example) –
|
||||||
|
`cp .env.example .env` und ausfüllen. Echte Umgebungsvariablen haben Vorrang;
|
||||||
|
`.env` wird nie committet.
|
||||||
|
|
||||||
|
| Variable | Wofür |
|
||||||
|
| --- | --- |
|
||||||
|
| `NUMOLUS_DATABASE_NAME`, `_HOST`, `_PORT`, `_USER`, `_PASSWORD` | PostgreSQL (Standardname `numolus`; ohne Host/User: lokaler Socket) |
|
||||||
|
| `NUMOLUS_HOST` | Host in den Links der Mails |
|
||||||
|
| `SECRET_KEY_BASE` | Produktion (`bin/rails secret`) |
|
||||||
|
| `NUMOLUS_MAIL_FROM`, `SMTP_ADDRESS`, `SMTP_PORT`, `SMTP_USER_NAME`, `SMTP_PASSWORD` | Mailversand in Produktion |
|
||||||
|
|
||||||
|
In der Entwicklung werden Mails nicht verschickt, sondern unter
|
||||||
|
<http://localhost:3000/letter_opener> angezeigt. Produktion braucht einen
|
||||||
|
Rails-Server (Puma) mit PostgreSQL; `bin/jobs` bzw. `SOLID_QUEUE_IN_PUMA=1`
|
||||||
|
für den Mailversand.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Magic-Link-Token und Code nur als SHA-256 gespeichert, 15 Minuten gültig,
|
||||||
|
einmal verwendbar; nach 5 falschen Codes ist der Link verbrannt.
|
||||||
|
- Rate Limits: 20 Link-Anforderungen pro IP und Stunde (dazu 5 pro E-Mail),
|
||||||
|
30 Code-Versuche pro IP in 15 Minuten.
|
||||||
|
- `return_to` nur als Pfad derselben Seite; `404` statt `403`; UUIDs.
|
||||||
|
- Cookies `HttpOnly`, `SameSite=Lax`, in Produktion `Secure`; CSRF-Schutz im
|
||||||
|
REST-API.
|
||||||
|
- Yjs-Updates max. 10 MB, JSON-Snapshots max. 20 MB und nur, wenn sie wie ein
|
||||||
|
numolus-Dokument aussehen.
|
||||||
|
|
||||||
|
### Offen
|
||||||
|
|
||||||
|
- **Formeln bei gleichzeitigem Einfügen:** Tippt jemand eine Formel, während
|
||||||
|
ein anderer eine Zeile einfügt, wird deren Bezug nicht angepasst (wie in
|
||||||
|
Google Sheets). Später denkbar: Bezüge intern über IDs.
|
||||||
|
- **Große Dokumente:** Jede fremde Änderung baut den ganzen Store neu auf;
|
||||||
|
später nur die betroffene Tabelle.
|
||||||
|
- **Makros aus fremder Hand** laufen im eigenen Browser, sobald man sie
|
||||||
|
startet; eine Bestätigung für fremde Makros fehlt noch.
|
||||||
|
- Papierkorb, Aufräumjobs (abgelaufene Magic Links, alte Sessions),
|
||||||
|
„Überall abmelden“, Konto löschen / Datenexport, Offline-Kopie von
|
||||||
|
Server-Dokumenten (`y-indexeddb`), Backups und Monitoring.
|
||||||
|
- Datenschutzerklärung (gespeichert werden E-Mail, Anzeigename, IP und
|
||||||
|
User-Agent je Session).
|
||||||
|
|
||||||
## Vorbefülltes Dokument (Datei › Muster)
|
## Vorbefülltes Dokument (Datei › Muster)
|
||||||
|
|
||||||
@@ -1148,7 +1396,8 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 570 Tests:
|
`npm run test` — Vitest (Engine, Store, IO, Yjs-Abbildung);
|
||||||
|
`bin/rails test` — Minitest (Models, REST-API, Anmeldung, Teilen, DocumentChannel):
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
@@ -1513,10 +1762,21 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
|
app/
|
||||||
|
models/ # User, Session, MagicLink, Document, DocumentUpdate, DocumentMembership, ShareLink
|
||||||
|
controllers/ # SpaController (liefert die App), AuthController (Mail-Link), api/* (REST)
|
||||||
|
channels/ # DocumentChannel (Live-Bearbeitung), ApplicationCable::Connection
|
||||||
|
mailers/, views/ # AuthMailer (Anmeldung, Einladung), Bestätigungsseite, SPA-Seite
|
||||||
|
config/, db/ # Rails-Konfiguration, Migrationen (inkl. Solid Queue/Cache/Cable)
|
||||||
|
test/ # Minitest: Models, API, Channel
|
||||||
|
.env.example # Vorlage für Datenbank, Host, SMTP
|
||||||
app/frontend/
|
app/frontend/
|
||||||
|
entrypoints/ # application.ts – Einstieg (vite_rails)
|
||||||
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
|
||||||
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
store/ # Dokument-Store (v2 Canvas-Modell), Formatierung, library.ts (lokal/Server), session.ts (Konto)
|
||||||
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
|
collab/ # Live-Bearbeitung: ydoc.ts (Yjs-Abbildung), session.ts (ActionCable, Undo, Awareness)
|
||||||
|
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy),
|
||||||
|
# library.ts (IndexedDB), api.ts (REST-Client)
|
||||||
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
|
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
|
||||||
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
|
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
|
||||||
# StatusBar, TheLogo, …
|
# StatusBar, TheLogo, …
|
||||||
|
|||||||
@@ -1,731 +0,0 @@
|
|||||||
# 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).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Umsetzungsstand (Branch `with-server`, 2026-09-29)
|
|
||||||
|
|
||||||
Umgesetzt sind Phasen 1–4 in angepasster Form, einschließlich Live-Bearbeitung mit Yjs.
|
|
||||||
|
|
||||||
| Thema | Umsetzung | Abweichung vom Konzept |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Anonym | Dokumente bleiben lokal in IndexedDB (`/?doc=<id>`) | kein Gast-Konto auf dem Server |
|
|
||||||
| Konto | Magic Link + 6-stelliger Code, Konto entsteht bei der ersten Anmeldung | – |
|
|
||||||
| Übernahme | nach der ersten Anmeldung Rückfrage, welche lokalen Dokumente hochgeladen werden; hochgeladene werden lokal entfernt | statt automatischer Übernahme |
|
|
||||||
| Server-Dokumente | Inhalt = Yjs-Updates (`document_updates`), `documents.content` als JSON-Snapshot (Export, Duplizieren, erste Umwandlung); `/d/<id>` | – |
|
|
||||||
| Gleichzeitiges Bearbeiten | live über ActionCable (`DocumentChannel`), ~150 ms; feste Zeilen-/Spalten-IDs; Undo pro Person (`Y.UndoManager`); Auswahl der anderen als farbiger Rahmen | Store bleibt positionsbasiert, eine Binding-Schicht übersetzt (`app/frontend/collab/`) |
|
|
||||||
| Erste Umwandlung | JSON → Yjs deterministisch im Browser (feste Client-ID, IDs `r0…`/`c0…`), gleichzeitige Umwandlungen verschmelzen | statt serverseitig |
|
|
||||||
| Zusammenfassen | ab 200 Updates schickt ein Editor den Gesamtstand, der Server ersetzt die gelisteten Updates | clientseitig, kein `y-rb` nötig |
|
|
||||||
| Teilen per Link | ohne Konto nutzbar; Token im Browser gespeichert (localStorage), nach Anmeldung dem Konto zugeordnet | Link-Token im Klartext gespeichert, damit der Besitzer den Link erneut kopieren kann |
|
|
||||||
| Einladungen | Mail mit Link (7 Tage gültig), meldet gleichzeitig an | – |
|
|
||||||
| Sessions | signierte Session-ID im Cookie (Rails-8-Standard) | statt Token-Digest |
|
|
||||||
| Datenbank | App-Daten, Solid Queue, Cache und Cable in **einer** PostgreSQL-Datenbank | – |
|
|
||||||
| Papierkorb, Aufräumjobs, Konto löschen | noch offen | – |
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
Reference in New Issue
Block a user