feat(export): make the full backup actually full

The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 02:09:33 +02:00
1 parent 837c44e4c2
commit 3dd5dbeb42
22 files changed
+1491 -68

No files matched your search

@@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
// v1.28 backup-completeness — the records section the shared payload
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
allergy: { findMany: vi.fn().mockResolvedValue([]) },
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
workout: { findMany: vi.fn().mockResolvedValue([]) },
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
@@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => {
const payload = JSON.parse(plaintext);
expect(payload).toHaveProperty("schemaVersion");
expect(payload).toHaveProperty("measurements");
// v1.28 backup-completeness — the encrypted archive carries the same
// records domains as the plaintext export.
expect(payload).toHaveProperty("labResults");
expect(payload).toHaveProperty("illnessEpisodes");
expect(payload).toHaveProperty("allergies");
expect(payload).toHaveProperty("familyHistory");
expect(payload).toHaveProperty("workouts");
expect(payload).toHaveProperty("documents");
expect(payload.manifest).toMatchObject({
documents: { included: "metadata-only" },
workouts: { included: "summary-only" },
});
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
+12 -3
View File
@@ -2,9 +2,10 @@
* POST /api/export/encrypted
*
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
* the caller supplies in the request body. The binary archive is returned as
* same payload as `GET /api/export/full-backup` (see that route's doc comment
* for which domains restore recreates vs. export-only), sealed into an
* `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the
* caller supplies in the request body. The binary archive is returned as
* `application/octet-stream`.
*
* SECURITY:
@@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_archive_bytes: archive.byteLength,
},
});