mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 10:01:08 +02:00
feat(backup): client-driven v2 snapshot export, drop server-side backup
Replaces the mana-sync event-stream export (GET /backup/export) with a
fully client-driven `.mana` v2 archive: webapp reads Dexie, decrypts
per-field, packages JSONL + manifest, optionally PBKDF2+AES-GCM seals
with a passphrase.
- New: backup/v2/{format,passphrase,export,import}.ts + format.test.ts
(10 tests: round-trip, sealed path, 3 failure modes incl. wrong-
passphrase vs. tamper distinction).
- UI: ExportImportPanel with module multi-select, optional passphrase,
progress + sealed-file detection — replaces the old backup flow in
Settings → MyData.
- Removes services/mana-sync/internal/backup/ and the corresponding
client helpers + v1 tests. No parallel paths, no legacy shim.
- Why client-driven: zero-knowledge users hold their vault key only
client-side, so a server exporter cannot produce plaintext archives;
GDPR Art. 20 portability is better served by plaintext-by-default.
- Cross-account restore works via re-encryption under the target
vault key (no MK transfer needed).
DATA_LAYER_AUDIT.md §8 rewritten to reflect the new architecture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a7bc7f1c3
commit
fd1ea47075
18 changed files with 2145 additions and 1530 deletions
309
docs/plans/data-export-v2.md
Normal file
309
docs/plans/data-export-v2.md
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
# Data Export / Import — v2
|
||||
|
||||
## Status (2026-04-22)
|
||||
|
||||
Proposed. Ersetzt den bisherigen server-cipher-Backup-Pfad (noch
|
||||
nicht GA, niemand hat Produktionsdaten davon erstellt) durch ein
|
||||
einziges client-getriebenes Export/Import-System.
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Mana-User kann seine Daten (ganz oder modulweise) als portable,
|
||||
menschenlesbare Datei exportieren und wieder importieren. Das System
|
||||
ist:
|
||||
|
||||
- **Ein Pfad**, nicht zwei. Kein „Server-Dump vs. Client-Dump" mit
|
||||
abweichenden Features.
|
||||
- **Snapshot-basiert** (pro Tabelle eine `.jsonl` mit dem aktuellen
|
||||
Row-Stand), nicht sync-event-replay. Kleiner, lesbarer, importierbar
|
||||
in beliebige andere Tools.
|
||||
- **Plaintext als Default**. GDPR-Art.-20-Datenübertragbarkeit ist ein
|
||||
Feature, kein Edge-Case. Der User muss seine eigenen Daten lesen
|
||||
können ohne Mana zu installieren.
|
||||
- **Optional passphrase-wrapped** für Transport (z. B. Cloud-Ablage).
|
||||
Nutzt Web-Crypto-Standard-Primitive — unabhängig vom Mana-Vault.
|
||||
- **Modul-selektiv**. User wählt Module-Checkboxen, Export enthält
|
||||
nur deren Tabellen.
|
||||
- **Cross-Account-migrationsfähig**. Plaintext-Datei aus Account A
|
||||
→ Import in Account B → B's Vault-Key verschlüsselt das beim
|
||||
`bulkPut` automatisch.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- **Kein Event-Replay-Format mehr**. Feld-Level-LWW-Timestamps, Actor-
|
||||
Attribution, causedBy-Chains gehen im Snapshot verloren. Für Backup-
|
||||
Zwecke egal — der User will seinen aktuellen State, nicht die
|
||||
Historie. Wenn debug-fähige Event-Dumps jemals gebraucht werden,
|
||||
kriegen die einen eigenen CLI-Pfad (nicht User-facing).
|
||||
- **Kein Foreign-Format-Native-Export** (Pocket-CSV, OPML, ICS). Die
|
||||
sind Adapter die `.mana-v2` transformieren — nicht dritte Pfade im
|
||||
Core-Code. Bauen wir nur wenn konkreter Bedarf entsteht.
|
||||
- **Kein Inkrementelles Backup**. Erster Wurf ist „voller Snapshot pro
|
||||
Export". Delta-Backups kommen falls jemand GB-Datasets fährt — heute
|
||||
irrelevant.
|
||||
- **Keine Key-Transfer-Semantik für Zero-Knowledge-Cross-Account**.
|
||||
Wenn Account A im ZK-Mode läuft und der User nach B wechselt, muss
|
||||
er während des Exports den Vault entsperren — dann klappt's wie bei
|
||||
regulären Usern.
|
||||
|
||||
## Entscheidungen vorab
|
||||
|
||||
- **Format-Versionsbruch**. Die alten `.mana-v1`-Dateien (Event-Stream
|
||||
aus mana-sync) sind nicht migrierbar nach v2 — unterschiedliche
|
||||
Semantik. Da keine Produktionsdaten existieren, löschen wir v1
|
||||
komplett (Code + Endpoint).
|
||||
- **Client-seitig vollständig**. Kein HTTP-Roundtrip fürs Export.
|
||||
Funktioniert offline, überlebt mana-sync-Ausfälle, braucht keine
|
||||
Server-Rolle.
|
||||
- **Zip-Container + jsonl-Dateien**, pro Tabelle eine Datei. Gleiche
|
||||
Technik wie v1 (`pako` für Deflate ist schon im Repo), aber mit
|
||||
neuem Inhalts-Schema.
|
||||
- **Passphrase-Crypto** ist **nicht** das Per-Feld-AES-GCM aus dem
|
||||
Vault. Stattdessen: PBKDF2-SHA-256 (600k Iterations, OWASP 2023-
|
||||
Empfehlung) für KDF + AES-GCM-256 als AEAD. Web Crypto native, kein
|
||||
argon2-Dep. Die Entscheidung gegen Argon2id: eine einzelne 32-KB-
|
||||
Entschlüsselung bei 600k PBKDF2 cost ~200 ms — ausreichend schwer
|
||||
gegen offline-Brute-force, gut handhabbar als UX. Wenn wir später
|
||||
Argon2id wollen, ist das ein additives Field-Update im Manifest.
|
||||
- **Per-Field-Decrypt nutzt den existierenden `decryptRecord()`-Pfad**
|
||||
aus `crypto/record-helpers.ts`. Keine Duplikat-Logik für Export.
|
||||
- **Per-Field-Re-Encrypt beim Import nutzt `encryptRecord()`**.
|
||||
`ENCRYPTION_REGISTRY` bestimmt was verschlüsselt wird — wenn sich
|
||||
die Allowlist später ändert, reagiert der Import mit.
|
||||
- **Schema-Version im Manifest** — sobald das Row-Shape einer Tabelle
|
||||
sich ändert (Dexie-Version-Bump mit Migration), bekommt der
|
||||
Exporter das via Manifest-Schema-Version. Import prüft und refused
|
||||
ältere Schemas mit klarer Fehlermeldung statt still zu korrumpieren.
|
||||
|
||||
## Format: `.mana` v2
|
||||
|
||||
```
|
||||
archive.mana (zip container, DEFLATE, no password protection on zip level)
|
||||
├── manifest.json
|
||||
├── data/
|
||||
│ ├── articles.jsonl Eine Zeile pro Row (JSON object)
|
||||
│ ├── articleHighlights.jsonl
|
||||
│ ├── articleTags.jsonl
|
||||
│ ├── globalTags.jsonl
|
||||
│ ├── tagGroups.jsonl
|
||||
│ ├── notes.jsonl
|
||||
│ ├── … Je nach scope + MODULE_CONFIGS
|
||||
└── README.md Menschenlesbar, erklärt Inhalt
|
||||
```
|
||||
|
||||
### `manifest.json`
|
||||
|
||||
```typescript
|
||||
interface BackupManifestV2 {
|
||||
/** Hardcoded `2`. Bump on breaking changes only. */
|
||||
formatVersion: 2;
|
||||
|
||||
/** Mana app schema version at export time — derived from Dexie version. */
|
||||
schemaVersion: number;
|
||||
|
||||
/** Who generated this. Informational, not verified. */
|
||||
producedBy: string; // z.B. "mana-web/1.2.3"
|
||||
|
||||
/** ISO timestamp of export. */
|
||||
exportedAt: string;
|
||||
|
||||
/** userId at export time. Informational; importer does NOT refuse cross-account. */
|
||||
userId: string;
|
||||
|
||||
/** Scope declaration. */
|
||||
scope:
|
||||
| { type: 'full' }
|
||||
| { type: 'filtered'; appIds: string[] };
|
||||
|
||||
/** Row-Count pro Tabelle (für UI-Progress + quick-validate). */
|
||||
rowCounts: Record<string, number>;
|
||||
|
||||
/** Plaintext der encrypted fields im JSON (true) oder re-exportiert mit */
|
||||
/** dem Mana-Vault-Key (false)? Default true. false wäre absurd — der */
|
||||
/** Export-Receiver hätte keinen Vault. Behalten als Flag damit Zukunfts-*/
|
||||
/** Clients mit z.B. Vault-Sync denselben Parser wiederverwenden können. */
|
||||
fieldsPlaintext: boolean;
|
||||
|
||||
/** Wrap-Info wenn passphrase-protected, sonst `undefined`. */
|
||||
passphrase?: {
|
||||
kdf: 'PBKDF2-SHA256';
|
||||
kdfIterations: number; // 600_000
|
||||
kdfSaltBase64: string; // 16 bytes random
|
||||
cipher: 'AES-GCM-256';
|
||||
ivBase64: string; // 12 bytes random
|
||||
/** SHA256 der plaintext `data/`-Konkatenation, hex. Post-unwrap-integritätscheck. */
|
||||
dataSha256: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Row-Schema
|
||||
|
||||
Pro Tabelle wird `LocalXxx`-TypeScript-Shape serialisiert. Beispiel `articles.jsonl`:
|
||||
|
||||
```json
|
||||
{"id":"…","originalUrl":"https://…","title":"…","content":"…","status":"unread","savedAt":"…",…}
|
||||
{"id":"…","originalUrl":"…","title":"…",…}
|
||||
```
|
||||
|
||||
Felder die im ENCRYPTION_REGISTRY stehen **und** in der Quelldatei
|
||||
verschlüsselt waren, werden beim Export entschlüsselt → plaintext in
|
||||
der jsonl.
|
||||
|
||||
## Export-Pipeline
|
||||
|
||||
```
|
||||
Client:
|
||||
1. User-Input: appIds[] + optional passphrase
|
||||
2. Sammel-Schleife:
|
||||
for appId in selected:
|
||||
for table in MODULE_CONFIGS[appId].tables:
|
||||
rows = scopedForModule(...).toArray()
|
||||
decrypted = decryptRecords(table, rows)
|
||||
jsonl += decrypted.map(toSerializable).join('\n')
|
||||
3. Manifest bauen (rowCounts, exportedAt, userId, scope, schemaVersion)
|
||||
4. Zip-Struktur schnüren (manifest.json + data/*.jsonl + README.md)
|
||||
5. Wenn passphrase:
|
||||
- data/ in-memory konkatenieren (dataBytes)
|
||||
- sha256 = hash(dataBytes)
|
||||
- kdfSalt = random(16), iv = random(12)
|
||||
- wrappedKey = PBKDF2(passphrase, salt, 600k, 32B)
|
||||
- ciphertext = AES-GCM-encrypt(wrappedKey, iv, dataBytes)
|
||||
- Manifest.passphrase = { …salt, …iv, dataSha256 }
|
||||
- Zip enthält `data.enc` statt `data/`-Ordner
|
||||
- Ciphertext-Prüfsumme (AEAD-Tag) ist implizit
|
||||
6. Return Blob → Browser-Download
|
||||
```
|
||||
|
||||
## Import-Pipeline
|
||||
|
||||
```
|
||||
Client:
|
||||
1. User-Input: File + optional passphrase-prompt
|
||||
2. parseBackupV2(file) → { manifest, data or sealedData }
|
||||
3. Manifest-Validierung:
|
||||
- formatVersion === 2
|
||||
- schemaVersion kompatibel (max 2 Versions Rückstand)
|
||||
- scope-Struktur valide
|
||||
4. Wenn passphrase:
|
||||
- User-prompt für Passphrase
|
||||
- KDF: PBKDF2(passphrase, salt, iterations, 32B)
|
||||
- Decrypt AES-GCM → dataBytes
|
||||
- sha256(dataBytes) === manifest.passphrase.dataSha256 ? sonst FAIL
|
||||
5. Pro jsonl-Datei in data/:
|
||||
- Parse Zeilen zu Row-Objekten
|
||||
- Field-by-field: wenn Feldname in ENCRYPTION_REGISTRY[table].fields
|
||||
→ encryptRecord(row) mit aktuellem Master-Key
|
||||
- bulkPut(table, rows) in Dexie
|
||||
- Dexie-Creating-Hook stempelt userId, timestamps, tracks pending_changes
|
||||
→ Sync zum Server läuft automatisch an
|
||||
6. Progress-Callback pro Tabelle
|
||||
7. Done
|
||||
```
|
||||
|
||||
## File-Struktur
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/data/backup/
|
||||
├── v2/
|
||||
│ ├── format.ts Types + Zip-Reader/Writer + sha256
|
||||
│ ├── passphrase.ts PBKDF2-KDF + AES-GCM-AEAD wrap/unwrap
|
||||
│ ├── schema.ts Pro-Tabelle-Row-Serialisation (toJson/fromJson)
|
||||
│ ├── export.ts buildClientBackup({ appIds, passphrase })
|
||||
│ ├── import.ts applyClientBackup(file, { passphrase })
|
||||
│ └── format.test.ts Round-trip-Tests (encrypted + plaintext)
|
||||
└── (v1/ wird gelöscht)
|
||||
```
|
||||
|
||||
**Kein shared-Parser mit v1**. v1 ist Event-Stream, v2 ist Row-Snapshot
|
||||
— unterschiedliche Semantik. Besser komplett separat halten.
|
||||
|
||||
## UI
|
||||
|
||||
Settings → My Data → **„Export & Import"** Panel (ersetzt bisherige
|
||||
„Backup"-Sektion):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Export & Import │
|
||||
│ │
|
||||
│ Lade deine Mana-Daten als portable .mana-Datei herunter.│
|
||||
│ │
|
||||
│ Module wählen │
|
||||
│ [✓] Alles │
|
||||
│ ─── oder einzeln ─── │
|
||||
│ [ ] Artikel [ ] Notizen [ ] Kalender … │
|
||||
│ │
|
||||
│ [○] Mit Passphrase verschlüsseln │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Passphrase │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Bestätigen │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [ Exportieren ] │
|
||||
│ │
|
||||
│ ───────────────────────── │
|
||||
│ │
|
||||
│ Import: .mana-Datei wählen [ Datei wählen ] │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Import-Ablauf:
|
||||
1. File-Picker — akzeptiert nur `*.mana`
|
||||
2. Parser liest Manifest
|
||||
3. Wenn `passphrase` gesetzt → Modal prompts user
|
||||
4. Progress-Bar mit Tabelle-für-Tabelle-Updates
|
||||
5. Success-Toast mit Summary („142 Artikel, 48 Highlights, 23 Tags
|
||||
importiert")
|
||||
|
||||
## Milestones
|
||||
|
||||
1. **M1 — Format + Crypto-Primitive**
|
||||
- `v2/format.ts`: Manifest-Types, Zip read/write (re-use v1's pako-
|
||||
basierten Zip-Code, aber eigene Manifest-Struktur), sha256-Helper
|
||||
- `v2/passphrase.ts`: PBKDF2-KDF + AES-GCM wrap/unwrap, 100% Web-
|
||||
Crypto, keine neuen Deps
|
||||
- `v2/schema.ts`: serialize/deserialize-Helpers pro bekannter Tabelle
|
||||
- Unit-Tests für Passphrase-Round-Trip + Zip-Round-Trip
|
||||
2. **M2 — Export-Builder**
|
||||
- `v2/export.ts`: `buildClientBackup({ appIds?, passphrase? }): Promise<Blob>`
|
||||
- Iteriert `MODULE_CONFIGS`, nutzt `decryptRecords()`, schreibt jsonl
|
||||
- Manifest baut `rowCounts` live
|
||||
3. **M3 — Import-Pipeline**
|
||||
- `v2/import.ts`: `applyClientBackup(file: Blob, opts): Promise<ImportResult>`
|
||||
- Re-encrypt via `encryptRecord()`, `bulkPut` in Dexie
|
||||
- Progress-Callback, strukturierte Fehler
|
||||
4. **M4 — UI**
|
||||
- `MyDataSection.svelte` — alte Backup-Buttons raus, neue Export-&-Import-Karte rein
|
||||
- Modul-Multi-Select, Passphrase-Toggle, Progress-Bar, File-Picker
|
||||
5. **M5 — Legacy-Cleanup**
|
||||
- `services/mana-sync/` — `/backup/export` Go-Handler raus
|
||||
- `apps/mana/apps/web/src/lib/api/services/backup.ts` — raus
|
||||
- `lib/data/backup/format.ts`, `import.ts`, `format.test.ts` — raus
|
||||
- Tests + Docs durchkämmen, alte Referenzen purgen
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- **Schema-Version-Kompat-Policy**: Einseitig rückwärts (neuer Import
|
||||
liest ältere Exports) ist nötig. Frage: ab wann muss der Import
|
||||
hart fehlen? Vorschlag: `schemaVersion < (currentSchema - 2)` →
|
||||
Fehler mit Upgrade-Hinweis. In zwei Versionen kann genug Migration
|
||||
nötig sein dass Auto-Migration riskant wird.
|
||||
- **Passphrase-Stärke-Indikator**: Frontend-seitig zxcvbn-ish-Hinweis
|
||||
oder minimum-length? Pragmatisch: min 12 Zeichen, keine weitere
|
||||
Validierung — User ist Erwachsen.
|
||||
- **Conflict-Handling beim Import**: Wenn ein Row mit derselben `id`
|
||||
schon existiert — überschreiben oder skip? Vorschlag: **überschreiben**
|
||||
(simpler, passt zu LWW-Semantik). UI könnte „Dry-Run mit Diff" als
|
||||
Phase-2-Feature kriegen.
|
||||
- **Binäre Daten** (uploaded files, images): Phase 1 exportiert nur
|
||||
Metadaten. Blob-Bodies leben in MinIO/Storage, nicht in Dexie. Wenn
|
||||
Binary-Export kommt, wird der Manifest-Eintrag `binaryAssets: []`
|
||||
ergänzt und die Files in `blobs/`-Unterordner gepackt.
|
||||
- **Memory**: bei sehr großen Datensätzen streamed man idealerweise.
|
||||
Erste Iteration: alles in-memory bauen. Reicht für realistische
|
||||
Haushaltsgrößen (10k Artikel + Highlights + Tags ≈ 20 MB JSON).
|
||||
Streaming kommt wenn's wirklich nötig wird.
|
||||
Loading…
Add table
Add a link
Reference in a new issue