feat(sync): backup as .mana zip with manifest + sha256 (M3)

Replace the raw JSONL response with a zip container:

  events.jsonl  — one SyncChange per line, as before
  manifest.json — formatVersion, schemaVersion, userId, eventCount,
                  eventsSha256, apps, timestamps, schemaVersionMin/Max

Single DB pass: events.jsonl is written while a sha256 hasher tees
every byte of the uncompressed JSONL. The manifest lands as a second
zip entry after the stream closes, so eventsSha256 is filled without
rescanning.

Integrity-check on the restore side becomes trivial (re-hash the
decompressed events.jsonl and compare). Signature over manifest.json
is deferred to a later phase; sha256 already catches corruption.

Client-side: default filename + UI label updated to .mana. Fetch flow
is unchanged — browser gets a zip blob and triggers a download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 15:49:47 +02:00
parent 53b3746b98
commit 1249cc49e5
2 changed files with 20 additions and 15 deletions

View file

@ -1,15 +1,20 @@
/**
* Backup / Restore API M1 thin slice.
* Backup / Restore API.
*
* Talks directly to mana-sync's /backup/export endpoint, which streams every
* sync_changes row owned by the current user as JSONL (one event per line).
* The file is immediately usable as input for a future import flow: replaying
* the events through applyServerChanges() reconstructs the user's entire
* dataset in a fresh IndexedDB.
* Talks directly to mana-sync's /backup/export endpoint, which streams a
* .mana archive (zip container) with two entries:
*
* Field-level encrypted fields stay ciphertext throughout the file is safe
* at rest for those fields. Plaintext fields (IDs, timestamps, sort keys) are
* visible as-is, which matches the GDPR data-portability expectation.
* events.jsonl every sync_changes row, one per line, chronological
* manifest.json formatVersion, schemaVersion, userId, eventCount,
* eventsSha256, app list, timestamps
*
* The file is immediately usable as input for the future import flow:
* replaying events through applyServerChanges() reconstructs the user's
* entire dataset in a fresh IndexedDB.
*
* Field-level encrypted fields stay ciphertext throughout the file is
* safe at rest for those fields. Plaintext fields (IDs, timestamps, sort
* keys) are visible as-is, matching the GDPR data-portability expectation.
*/
import { authStore } from '$lib/stores/auth.svelte';
@ -45,7 +50,7 @@ export const backupService = {
const blob = await response.blob();
const filename =
response.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] ||
`mana-backup-${new Date().toISOString().slice(0, 10)}.jsonl`;
`mana-backup-${new Date().toISOString().slice(0, 10)}.mana`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');

View file

@ -391,10 +391,10 @@
Backup & Wiederherstellung
</h3>
<p class="text-sm text-muted-foreground mb-4">
Lade eine vollstandige Kopie deiner synchronisierten Daten als JSONL-Datei herunter. Die
Datei enthalt den kompletten Sync-Event-Stream deines Accounts — geeignet fur
Account-Migration, Backups oder DSGVO-Datenportabilitat. Sensible Felder bleiben dabei
verschlusselt.
Lade eine vollstandige Kopie deiner synchronisierten Daten als <code>.mana</code>-Archiv
(ZIP) herunter. Enthalt den kompletten Sync-Event-Stream plus Manifest mit
Integritats-Hash — geeignet fur Account-Migration, Backups oder DSGVO-Datenportabilitat.
Sensible Felder bleiben dabei verschlusselt.
</p>
<div class="flex items-center gap-3">
<button
@ -403,7 +403,7 @@
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
<DownloadSimple size={16} />
<span>{backupLoading ? 'Lade Backup…' : 'Backup herunterladen (.jsonl)'}</span>
<span>{backupLoading ? 'Lade Backup…' : 'Backup herunterladen (.mana)'}</span>
</button>
<span class="text-xs text-muted-foreground">
Experimentell — Import folgt in Kurze.