diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4055a7446..24aa38241 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1349,6 +1349,35 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/export/encrypted: + post: + tags: + - Export + summary: Download the full backup as a passphrase-encrypted archive + description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive + (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a + `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, + cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal + session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten + passphrase makes the archive unrecoverable. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EncryptedExportRequest" + responses: + "200": + description: Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 + params | salt | iv | tag | ciphertext). + content: + application/octet-stream: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/login: post: tags: @@ -7612,6 +7641,17 @@ paths: "429": *a4 components: schemas: + EncryptedExportRequest: + type: object + properties: + passphrase: + type: string + minLength: 12 + maxLength: 1024 + description: User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no + server-side recovery. + required: + - passphrase MfaVerifyRequest: type: object properties: diff --git a/messages/de.json b/messages/de.json index 523158b31..99c4d6007 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Voll-Backup", - "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload." + "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.", + "encryptToggle": "Mit einer Passphrase verschlüsseln", + "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", + "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", + "noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.", + "stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut." }, "cycle": { "title": "Zyklus", diff --git a/messages/en.json b/messages/en.json index 16ec52b02..c21dd63e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.", + "encryptToggle": "Encrypt with a passphrase", + "passphrasePlaceholder": "Passphrase (min. 12 characters)", + "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", + "noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.", + "stepUpNeeded": "Confirm your second factor, then try again." }, "cycle": { "title": "Cycle", diff --git a/messages/es.json b/messages/es.json index 8bea74277..bed491e0e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Copia completa", - "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador." + "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.", + "encryptToggle": "Cifrar con una contraseña", + "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", + "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", + "noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.", + "stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo." }, "cycle": { "title": "Ciclo", diff --git a/messages/fr.json b/messages/fr.json index d943773ff..e4f6a672f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Sauvegarde complète", - "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur." + "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.", + "encryptToggle": "Chiffrer avec une phrase secrète", + "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", + "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", + "noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.", + "stepUpNeeded": "Confirmez votre second facteur, puis réessayez." }, "cycle": { "title": "Cycle", diff --git a/messages/it.json b/messages/it.json index 38c204fbb..702f0f4ff 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Backup completo", - "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore." + "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.", + "encryptToggle": "Cifra con una passphrase", + "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", + "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", + "noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.", + "stepUpNeeded": "Conferma il secondo fattore e riprova." }, "cycle": { "title": "Ciclo", diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..9f5463383 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4057,7 +4057,12 @@ }, "fullBackup": { "title": "Pełna kopia zapasowa", - "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora." + "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.", + "encryptToggle": "Zaszyfruj hasłem-frazą", + "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", + "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", + "noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.", + "stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie." }, "cycle": { "title": "Cykl", diff --git a/scripts/decrypt-export.ts b/scripts/decrypt-export.ts new file mode 100644 index 000000000..3b93897bf --- /dev/null +++ b/scripts/decrypt-export.ts @@ -0,0 +1,102 @@ +/** + * scripts/decrypt-export.ts [output.json] + * + * Standalone decryptor for a passphrase-encrypted HealthLog export (`HLX1` + * wire format — see src/lib/export/passphrase-archive.ts). A user runs this + * with their passphrase to recover the plaintext JSON backup WITHOUT the app: + * + * pnpm dlx tsx scripts/decrypt-export.ts healthlog-backup-....hlx + * + * The passphrase is read interactively with echo disabled, or from the + * `EXPORT_PASSPHRASE` environment variable for non-interactive use. It is + * never written to disk or logged. There is no server-side recovery: a wrong + * or forgotten passphrase cannot be worked around — the archive is opaque. + * + * If no output path is given, the decrypted JSON is written next to the input + * with a `.json` extension; pass `-` to stream to stdout instead. + * + * This script depends ONLY on the archive codec (Argon2id + node:crypto) — no + * database, no Prisma, no app boot — so it stays runnable from a checkout with + * just `pnpm install`. + */ +import { readFile, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { decryptArchive } from "@/lib/export/passphrase-archive"; + +function usage(): never { + console.error( + "Usage: pnpm dlx tsx scripts/decrypt-export.ts [output.json|-]", + ); + process.exit(2); +} + +/** Read a line from the TTY with local echo disabled. */ +async function promptPassphrase(): Promise { + const fromEnv = process.env.EXPORT_PASSPHRASE; + if (fromEnv && fromEnv.length > 0) return fromEnv; + + if (!process.stdin.isTTY) { + console.error( + "No TTY available and EXPORT_PASSPHRASE is unset — cannot read passphrase.", + ); + process.exit(2); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + // Mute echo: intercept the output stream while the prompt is active. + const out = process.stdout; + let muted = false; + const realWrite = out.write.bind(out); + (out as unknown as { write: typeof out.write }).write = (( + chunk: string | Uint8Array, + ...rest: unknown[] + ) => { + if (muted) return true; + // @ts-expect-error variadic passthrough + return realWrite(chunk, ...rest); + }) as typeof out.write; + + process.stdout.write("Passphrase: "); + muted = true; + try { + const answer = await new Promise((resolve) => { + rl.question("", resolve); + }); + return answer; + } finally { + muted = false; + (out as unknown as { write: typeof out.write }).write = realWrite; + process.stdout.write("\n"); + rl.close(); + } +} + +async function main(): Promise { + const input = process.argv[2]; + if (!input) usage(); + const output = process.argv[3] ?? input.replace(/\.hlx$/i, "") + ".json"; + + const buf = await readFile(input); + const passphrase = await promptPassphrase(); + + let plaintext: string; + try { + plaintext = await decryptArchive(buf, passphrase); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + + if (output === "-") { + process.stdout.write(plaintext); + if (!plaintext.endsWith("\n")) process.stdout.write("\n"); + } else { + await writeFile(output, plaintext, "utf8"); + console.error(`Decrypted -> ${output}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts new file mode 100644 index 000000000..970e10616 --- /dev/null +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * v1.23 — POST /api/export/encrypted unit coverage. + * + * Mock-based. Pins the step-up gating contract (single-factor accounts export + * without step-up; MFA accounts need a fresh second factor) and that the + * passphrase produces a decryptable HLX1 archive — never a plaintext body. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + session: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(), + lookupIpAsn: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); + +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { POST } from "../route"; +import { decryptArchive } from "@/lib/export/passphrase-archive"; + +const PASSPHRASE = "a-strong-passphrase-123"; + +function mkReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/export/encrypted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + reset: Date.now() + 3600_000, + } as never); +}); + +describe("POST /api/export/encrypted", () => { + it("returns a decryptable HLX1 archive for a single-factor account", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1"); + const plaintext = await decryptArchive(buf, PASSPHRASE); + const payload = JSON.parse(plaintext); + expect(payload).toHaveProperty("schemaVersion"); + expect(payload).toHaveProperty("measurements"); + }); + + it("blocks an MFA account without a fresh second factor (401 step-up)", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() }, + session: { id: "sess-1" }, + } as never); + // No fresh mfaVerifiedAt on the session row. + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(401); + }); + + it("allows an MFA account with a fresh second factor", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(), + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion"); + }); + + it("rejects a too-short passphrase with 422", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + + const res = await POST(mkReq({ passphrase: "short" })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts new file mode 100644 index 000000000..f832f17ab --- /dev/null +++ b/src/app/api/export/encrypted/route.ts @@ -0,0 +1,127 @@ +/** + * 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 + * `application/octet-stream`. + * + * SECURITY: + * - Step-up: when the account has a second factor enrolled, this is gated by + * `requireFreshMfa` — exporting the whole record is a sensitive action. + * Single-factor accounts fall back to a normal authenticated session. + * - The passphrase NEVER hits a log or wide-event: it is read off the parsed + * body, passed straight into the KDF, and never `annotate()`d. The egress + * redaction denylist already scrubs `/passphrase/i` (key-name) and the + * `passphrase=` query-string form as defence in depth. + * - There is NO server-side recovery — the passphrase is not stored. A + * forgotten passphrase means the archive is unrecoverable; the UI says so. + * + * Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh + * second factor for MFA accounts (Bearer can never satisfy step-up). + * Rate-limit: shared `export:` bucket (10/h) — same bucket as the + * plaintext export so the encrypted variant cannot be used to bypass the cap. + * Audit: `user.export.encrypted` with the row counts (never the passphrase). + */ +import { prisma } from "@/lib/db"; +import { z } from "zod/v4"; +import { + apiHandler, + requireAuth, + requireFreshMfa, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + encryptArchive, + MIN_EXPORT_PASSPHRASE_LENGTH, +} from "@/lib/export/passphrase-archive"; +import { NextRequest, NextResponse } from "next/server"; + +const encryptedExportSchema = z + .object({ + passphrase: z + .string() + .min(MIN_EXPORT_PASSPHRASE_LENGTH) + // A generous upper bound — a passphrase, not a file. Keeps the KDF input + // bounded. + .max(1024), + }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + // Resolve the session first (cookie or Bearer), then escalate to a fresh + // second factor when the account has one enrolled. A single-factor account + // cannot produce a fresh-MFA proof, so gating it unconditionally would lock + // it out of its own encrypted export — the step-up applies to MFA users. + const auth = await requireAuth(); + if (auth.user.totpConfirmedAt) { + await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + } + const user = auth.user; + annotate({ action: { name: "user.export.encrypted" } }); + + const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 10 exports per hour", 429); + } + + // The body is a single passphrase string — small and bounded. 8 KB is far + // above any legitimate passphrase while rejecting a large body before parse. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = encryptedExportSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error); + } + const { passphrase } = parsed.data; + + const { payload, counts } = await buildFullBackupPayload(prisma, user.id); + + // Encrypt the JSON bytes. The passphrase goes no further than the KDF. + const archive = await encryptArchive(JSON.stringify(payload), passphrase); + + await auditLog("user.export.encrypted", { + userId: user.id, + ipAddress: getClientIp(request), + details: { counts, format: "HLX1" }, + }); + + annotate({ + meta: { + export_measurements_count: counts.measurements, + export_medications_count: counts.medications, + export_intake_count: counts.intakeEvents, + export_mood_count: counts.moodEntries, + export_cycle_count: counts.cycles, + export_cycle_day_log_count: counts.cycleDayLogs, + export_archive_bytes: archive.byteLength, + }, + }); + + const stamp = new Date().toISOString().slice(0, 10); + // Return the raw binary archive (NOT the apiSuccess envelope). The file is a + // self-contained `.hlx` archive openable with the user's passphrase via + // scripts/decrypt-export.ts. + return new NextResponse(new Uint8Array(archive), { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`, + "Cache-Control": "no-store", + }, + }); +}); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 16c8a85b6..aad5fd565 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -21,9 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; -import { readNote } from "@/lib/crypto/note-cipher"; -import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -35,125 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const [measurements, medications, intakeEvents, moodEntries, cycle] = - await Promise.all([ - prisma.measurement.findMany({ - // v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the - // user-initiated backup bundle so the round-trip via admin - // restore does not resurrect deleted measurements. - where: { userId: user.id, deletedAt: null }, - orderBy: { measuredAt: "desc" }, - }), - // Explicit `select` on the schedule columns — see the matching - // comment in `medications/route.ts` for the rationale (the - // integration testbed has an unmigrated `days_of_week` column on - // the schema branch we ship alongside). - prisma.medication.findMany({ - where: { userId: user.id }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, - }, - }, - }), - prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows (mirrors the measurement - // `deletedAt: null` filter above on this user-facing export). - where: { userId: user.id, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId: user.id, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - }), - // v1.15.0 — cycle tables (profile + observed spans + day-logs). The - // shared helper carries `notesEncrypted` verbatim (never decrypted). - buildCycleBackupSection(prisma, user.id), - ]); - - // Shape mirrors the pg-boss `data-backup` worker exactly so the same - // `parseBackupPayload()` validator round-trips both blobs. Keep these - // two writers in sync — `src/lib/jobs/reminder-worker.ts` is the - // canonical reference. - const payload = { - schemaVersion: BACKUP_SCHEMA_VERSION, - exportedAt: new Date().toISOString(), - userId: user.id, - measurements: measurements.map((m) => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - // v1.23 — decrypt the note for the human-readable backup; an admin - // restore re-encrypts it on re-insert. - notes: readNote(m.notesEncrypted, m.notes), - })), - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - active: m.active, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - dose: s.dose, - })), - })), - intakeEvents: intakeEvents.map((e) => ({ - medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - skipped: e.skipped, - source: e.source, - })), - moodEntries: moodEntries.map((e) => ({ - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - })), - // v1.15.0 — cycle slice (profile + observed spans + day-logs). - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - }; + // v1.23 — the payload builder is shared with the passphrase-encrypted + // export route so both emit the byte-for-byte same restore-compatible shape. + const { payload, counts } = await buildFullBackupPayload(prisma, user.id); await auditLog("user.export.full-backup", { userId: user.id, ipAddress: getClientIp(request), - details: { - counts: { - measurements: measurements.length, - medications: medications.length, - intakeEvents: intakeEvents.length, - moodEntries: moodEntries.length, - cycles: cycle.cycles.length, - cycleDayLogs: cycle.cycleDayLogs.length, - }, - }, + details: { counts }, }); annotate({ meta: { - export_measurements_count: measurements.length, - export_medications_count: medications.length, - export_intake_count: intakeEvents.length, - export_mood_count: moodEntries.length, - export_cycle_count: cycle.cycles.length, - export_cycle_day_log_count: cycle.cycleDayLogs.length, + export_measurements_count: counts.measurements, + export_medications_count: counts.medications, + export_intake_count: counts.intakeEvents, + export_mood_count: counts.moodEntries, + export_cycle_count: counts.cycles, + export_cycle_day_log_count: counts.cycleDayLogs, }, }); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 7078532a6..08582fbb0 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -38,6 +38,7 @@ import { import { Button } from "@/components/ui/button"; import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -412,20 +413,55 @@ function MoodCsvCard() { ); } +const MIN_EXPORT_PASSPHRASE_LENGTH = 12; + function FullBackupCard() { const { t } = useTranslations(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // v1.23 — opt-in passphrase encryption. When on, the download POSTs to + // /api/export/encrypted with the passphrase and saves an opaque `.hlx` + // archive instead of plaintext JSON. + const [encrypt, setEncrypt] = useState(false); + const [passphrase, setPassphrase] = useState(""); + + const passphraseTooShort = + encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH; async function handleDownload() { setBusy(true); setError(null); try { const stamp = new Date().toISOString().slice(0, 10); - await downloadFromUrl( - "/api/export/full-backup", - `healthlog-backup-${stamp}.json`, - ); + if (encrypt) { + const res = await apiFetchRaw("/api/export/encrypted", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }); + if (res.status === 401) { + setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded")); + return; + } + if (!res.ok) { + throw new Error(`Download failed (${res.status})`); + } + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = `healthlog-backup-${stamp}.hlx`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + } else { + await downloadFromUrl( + "/api/export/full-backup", + `healthlog-backup-${stamp}.json`, + ); + } } catch { setError(t("settings.sections.export.downloadFailed")); } finally { @@ -447,7 +483,7 @@ function FullBackupCard() { size="sm" className="min-h-11 sm:min-h-9" onClick={handleDownload} - disabled={busy} + disabled={busy || passphraseTooShort} > {busy ? ( @@ -458,6 +494,44 @@ function FullBackupCard() { } > + + {encrypt && ( +
+ setPassphrase(e.target.value)} + placeholder={t( + "settings.sections.export.cards.fullBackup.passphrasePlaceholder", + )} + autoComplete="new-password" + className="h-9 text-sm" + /> +

+ {t("settings.sections.export.cards.fullBackup.passphraseHelp")} +

+

+ {t("settings.sections.export.cards.fullBackup.noRecoveryWarning")} +

+
+ )} {error && (

{error} diff --git a/src/lib/export/__tests__/passphrase-archive.test.ts b/src/lib/export/__tests__/passphrase-archive.test.ts new file mode 100644 index 000000000..2cd158c97 --- /dev/null +++ b/src/lib/export/__tests__/passphrase-archive.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { Buffer } from "node:buffer"; +import { + encryptArchive, + decryptArchive, + parseArchiveHeader, + EXPORT_ARGON2_PARAMS, + MIN_EXPORT_PASSPHRASE_LENGTH, +} from "../passphrase-archive"; + +const PASSPHRASE = "correct horse battery staple"; +const PAYLOAD = JSON.stringify({ + schemaVersion: 1, + measurements: [{ type: "WEIGHT", value: 81.2, unit: "kg" }], + note: "Zugangsdaten 🔐 € ñ", +}); + +describe("passphrase-encrypted export archive (HLX1)", () => { + it("round-trips encrypt -> decrypt with the right passphrase", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const out = await decryptArchive(archive, PASSPHRASE); + expect(out).toBe(PAYLOAD); + }); + + it("emits the HLX1 magic + version header", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + expect(archive.subarray(0, 4).toString("ascii")).toBe("HLX1"); + expect(archive.readUInt8(4)).toBe(0x01); // version + expect(archive.readUInt8(5)).toBe(0x01); // KDF = Argon2id + }); + + it("carries the Argon2id KDF params + salt in the header", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const { header, bodyOffset } = parseArchiveHeader(archive); + expect(header.memoryCost).toBe(EXPORT_ARGON2_PARAMS.memoryCost); + expect(header.timeCost).toBe(EXPORT_ARGON2_PARAMS.timeCost); + expect(header.parallelism).toBe(EXPORT_ARGON2_PARAMS.parallelism); + expect(header.salt.length).toBe(16); + expect(bodyOffset).toBe(16 + 16 + 12 + 16); // header + salt + iv + tag + }); + + it("uses a random salt + iv so two archives of the same input differ", async () => { + const a = await encryptArchive(PAYLOAD, PASSPHRASE); + const b = await encryptArchive(PAYLOAD, PASSPHRASE); + expect(Buffer.compare(a, b)).not.toBe(0); + // ...but both decrypt back to the same plaintext. + expect(await decryptArchive(a, PASSPHRASE)).toBe(PAYLOAD); + expect(await decryptArchive(b, PASSPHRASE)).toBe(PAYLOAD); + }); + + it("fails cleanly with a wrong passphrase (no plaintext leak)", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + await expect( + decryptArchive(archive, "totally wrong passphrase"), + ).rejects.toThrow(/wrong passphrase or corrupt archive/i); + }); + + it("fails when the ciphertext is tampered", async () => { + const archive = await encryptArchive(PAYLOAD, PASSPHRASE); + const tampered = Buffer.from(archive); + tampered[tampered.length - 1] ^= 0xff; // flip a ciphertext bit + await expect(decryptArchive(tampered, PASSPHRASE)).rejects.toThrow(); + }); + + it("rejects a passphrase shorter than the minimum on encrypt", async () => { + await expect(encryptArchive(PAYLOAD, "short")).rejects.toThrow( + new RegExp(`at least ${MIN_EXPORT_PASSPHRASE_LENGTH}`), + ); + }); + + it("rejects a non-HLX1 buffer", () => { + expect(() => parseArchiveHeader(Buffer.from("not an archive"))).toThrow(); + }); + + it("accepts a Buffer payload, not only a string", async () => { + const buf = Buffer.from(PAYLOAD, "utf8"); + const archive = await encryptArchive(buf, PASSPHRASE); + expect(await decryptArchive(archive, PASSPHRASE)).toBe(PAYLOAD); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts new file mode 100644 index 000000000..27357f1e6 --- /dev/null +++ b/src/lib/export/full-backup-payload.ts @@ -0,0 +1,128 @@ +/** + * v1.23 — shared builder for the user-scoped full-backup payload. + * + * Extracted so both the plaintext `GET /api/export/full-backup` route and the + * passphrase-encrypted `POST /api/export/encrypted` route emit the byte-for-byte + * same shape — the one that the pg-boss `data-backup` worker writes and that + * `parseBackupPayload()` round-trips on admin restore. Keep this writer in sync + * with the `data-backup` worker (`src/lib/jobs/reminder-worker.ts`). + * + * The decrypted notes are surfaced here (the backup is the human-readable + * artefact); an admin restore re-encrypts them on re-insert. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { readNote } from "@/lib/crypto/note-cipher"; +import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; +import { buildCycleBackupSection } from "@/lib/cycle/backup"; + +export interface FullBackupCounts { + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; + cycles: number; + cycleDayLogs: number; +} + +export interface FullBackupResult { + payload: Record; + counts: FullBackupCounts; +} + +/** + * Build the canonical full-backup payload for `userId`. Soft-deleted rows are + * excluded so a round-trip via admin restore never resurrects deleted records. + */ +export async function buildFullBackupPayload( + prisma: PrismaClient, + userId: string, +): Promise { + const [measurements, medications, intakeEvents, moodEntries, cycle] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + }), + prisma.medication.findMany({ + where: { userId }, + select: { + name: true, + dose: true, + active: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + label: true, + dose: true, + }, + }, + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + }), + buildCycleBackupSection(prisma, userId), + ]); + + const payload = { + schemaVersion: BACKUP_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + userId, + measurements: measurements.map((m) => ({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: readNote(m.notesEncrypted, m.notes), + })), + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + active: m.active, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + dose: s.dose, + })), + })), + intakeEvents: intakeEvents.map((e) => ({ + medication: e.medication.name, + scheduledFor: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + skipped: e.skipped, + source: e.source, + })), + moodEntries: moodEntries.map((e) => ({ + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags, + source: e.source, + loggedAt: e.moodLoggedAt.toISOString(), + })), + cycleProfile: cycle.cycleProfile, + cycles: cycle.cycles, + cycleDayLogs: cycle.cycleDayLogs, + }; + + return { + payload, + counts: { + measurements: measurements.length, + medications: medications.length, + intakeEvents: intakeEvents.length, + moodEntries: moodEntries.length, + cycles: cycle.cycles.length, + cycleDayLogs: cycle.cycleDayLogs.length, + }, + }; +} diff --git a/src/lib/export/passphrase-archive.ts b/src/lib/export/passphrase-archive.ts new file mode 100644 index 000000000..e30ee171b --- /dev/null +++ b/src/lib/export/passphrase-archive.ts @@ -0,0 +1,227 @@ +/** + * v1.23 — passphrase-encrypted export archive (`HLX1` wire format). + * + * A user can choose to download their export as an encrypted archive instead + * of plaintext JSON. The passphrase the user supplies is run through Argon2id + * to derive a 256-bit key, and the export bytes are sealed with AES-256-GCM. + * + * THREAT MODEL. The data is already plaintext-at-rest on the server (the + * server computes insights / rollups / FHIR over it). What this closes is the + * "plaintext health file lands in Downloads / iCloud / Google-Drive sync" + * path: the file that leaves the server is opaque without the passphrase. + * There is NO server-side recovery — the passphrase is never stored and never + * logged, so a forgotten passphrase means the archive is unrecoverable. The + * UI states this loudly. + * + * Wire format (binary), mirroring the off-host backup `HLBK` envelope so there + * is one decryption mental model in the codebase + docs: + * + * magic(4) = "HLX1" + * version(1) = 0x01 + * kdf(1) = 0x01 (Argon2id) + * memoryCost(4) = uint32 BE (KiB; Argon2 `m`) + * timeCost(4) = uint32 BE (Argon2 `t`) + * parallelism(1)= uint8 (Argon2 `p`) + * saltLen(1) = uint8 (= 16) + * salt(saltLen) + * iv(12) (AES-GCM nonce) + * tag(16) (AES-GCM auth tag) + * ciphertext(...) + * + * The Argon2 params travel in the header (self-describing) so a future cost + * bump still decrypts every previously-written archive — the decryptor reads + * `m/t/p` + `salt` from the file, never from a constant. The standalone + * `scripts/decrypt-export.ts` decryptor depends ONLY on this module, so a user + * can open the archive with `pnpm dlx tsx` and their passphrase, app or not. + * + * KDF defaults are the 2025 OWASP minimum for Argon2id (RFC 9106): + * 19 MiB / t=2 / p=1, 32-byte output. Kept independent from the password + * hashing params so tuning one never silently changes the other. + */ +import { hashRaw, type Algorithm } from "@node-rs/argon2"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + timingSafeEqual, +} from "node:crypto"; + +const MAGIC = "HLX1"; +const VERSION = 0x01; +const KDF_ARGON2ID = 0x01; +// `Algorithm.Argon2id` is a const enum (= 2); reference the value directly so +// the module compiles under `isolatedModules` without importing the enum. +const ARGON2ID: Algorithm = 2 as Algorithm; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; +const SALT_LENGTH = 16; +const KEY_LENGTH = 32; + +/** + * Argon2id KDF parameters for newly-written archives. OWASP-minimum for + * 2025 (RFC 9106): 19 MiB memory, 2 passes, single lane, 32-byte key. + * These are written into each archive header, so changing them never + * orphans archives written under the old cost. + */ +export const EXPORT_ARGON2_PARAMS = { + /** Argon2 `m` — memory cost in KiB (19 MiB). */ + memoryCost: 19456, + /** Argon2 `t` — iterations. */ + timeCost: 2, + /** Argon2 `p` — lanes. */ + parallelism: 1, +} as const; + +/** + * Minimum passphrase length the encrypt path enforces. A short passphrase + * defeats the KDF; 12 mirrors the account password floor. + */ +export const MIN_EXPORT_PASSPHRASE_LENGTH = 12; + +export interface ArchiveHeader { + version: number; + kdf: number; + memoryCost: number; + timeCost: number; + parallelism: number; + salt: Buffer; +} + +/** Derive the 256-bit AES key from a passphrase + the archive's KDF params. */ +async function deriveKey( + passphrase: string, + params: { memoryCost: number; timeCost: number; parallelism: number }, + salt: Buffer, +): Promise { + return hashRaw(passphrase, { + algorithm: ARGON2ID, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + outputLen: KEY_LENGTH, + salt, + }); +} + +/** + * Encrypt `plaintext` into an `HLX1` archive sealed with `passphrase`. + * Throws if the passphrase is shorter than `MIN_EXPORT_PASSPHRASE_LENGTH`. + */ +export async function encryptArchive( + plaintext: string | Buffer, + passphrase: string, +): Promise { + if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) { + throw new Error( + `Passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters`, + ); + } + + const salt = randomBytes(SALT_LENGTH); + const key = await deriveKey(passphrase, EXPORT_ARGON2_PARAMS, salt); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const body = + typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : plaintext; + const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]); + const tag = cipher.getAuthTag(); + + const header = Buffer.alloc(4 + 1 + 1 + 4 + 4 + 1 + 1); + header.write(MAGIC, 0, "ascii"); + header.writeUInt8(VERSION, 4); + header.writeUInt8(KDF_ARGON2ID, 5); + header.writeUInt32BE(EXPORT_ARGON2_PARAMS.memoryCost, 6); + header.writeUInt32BE(EXPORT_ARGON2_PARAMS.timeCost, 10); + header.writeUInt8(EXPORT_ARGON2_PARAMS.parallelism, 14); + header.writeUInt8(SALT_LENGTH, 15); + + return Buffer.concat([header, salt, iv, tag, ciphertext]); +} + +/** Parse + validate the fixed `HLX1` header. Throws on any structural fault. */ +export function parseArchiveHeader(buf: Buffer): { + header: ArchiveHeader; + bodyOffset: number; +} { + if (buf.length < 16) { + throw new Error("Not an HLX1 archive (too short)"); + } + const magic = buf.subarray(0, 4).toString("ascii"); + if (!timingSafeEqual(Buffer.from(magic), Buffer.from(MAGIC))) { + throw new Error("Not an HLX1 archive (bad magic)"); + } + const version = buf.readUInt8(4); + if (version !== VERSION) { + throw new Error(`Unsupported HLX1 version ${version}`); + } + const kdf = buf.readUInt8(5); + if (kdf !== KDF_ARGON2ID) { + throw new Error(`Unsupported KDF id ${kdf}`); + } + const memoryCost = buf.readUInt32BE(6); + const timeCost = buf.readUInt32BE(10); + const parallelism = buf.readUInt8(14); + const saltLen = buf.readUInt8(15); + const saltStart = 16; + const ivStart = saltStart + saltLen; + const tagStart = ivStart + IV_LENGTH; + const bodyOffset = tagStart + TAG_LENGTH; + if (buf.length < bodyOffset) { + throw new Error("Corrupt HLX1 archive (truncated header)"); + } + if (memoryCost < 1 || timeCost < 1 || parallelism < 1 || saltLen < 8) { + throw new Error("Corrupt HLX1 archive (implausible KDF params)"); + } + return { + header: { + version, + kdf, + memoryCost, + timeCost, + parallelism, + salt: buf.subarray(saltStart, ivStart), + }, + bodyOffset, + }; +} + +/** + * Decrypt an `HLX1` archive with `passphrase`. A wrong passphrase fails the + * GCM authentication tag and throws a clean, generic error (no oracle about + * which part was wrong). The returned value is the original plaintext (UTF-8). + */ +export async function decryptArchive( + buf: Buffer, + passphrase: string, +): Promise { + const { header, bodyOffset } = parseArchiveHeader(buf); + const saltLen = header.salt.length; + const ivStart = 16 + saltLen; + const tagStart = ivStart + IV_LENGTH; + const iv = buf.subarray(ivStart, tagStart); + const tag = buf.subarray(tagStart, bodyOffset); + const ciphertext = buf.subarray(bodyOffset); + + const key = await deriveKey( + passphrase, + { + memoryCost: header.memoryCost, + timeCost: header.timeCost, + parallelism: header.parallelism, + }, + Buffer.from(header.salt), + ); + + try { + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + const out = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return out.toString("utf8"); + } catch { + // GCM tag mismatch — wrong passphrase or a tampered/corrupt archive. + // Deliberately opaque: never reveal which. + throw new Error("Decryption failed — wrong passphrase or corrupt archive"); + } +} diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts new file mode 100644 index 000000000..ecee44185 --- /dev/null +++ b/src/lib/openapi/routes/export.ts @@ -0,0 +1,51 @@ +/** + * OpenAPI route table — generic data export (passphrase-encrypted variant). + * + * The plaintext `GET /api/export/full-backup` + per-type CSV routes predate the + * OpenAPI registry and are not iOS-contract surfaces; this module documents the + * v1.23 passphrase-encrypted variant because it adds a request flag + a binary + * response shape the iOS client may consume. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { stdResponses } from "./shared"; + +const encryptedExportRequest = z + .object({ + passphrase: z.string().min(12).max(1024).meta({ + description: + "User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no server-side recovery.", + }), + }) + .meta({ id: "EncryptedExportRequest" }); + +export const exportPaths: NonNullable = { + "/api/export/encrypted": { + post: { + tags: ["Export"], + summary: "Download the full backup as a passphrase-encrypted archive", + description: + "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", + requestBody: { + required: true, + content: { + "application/json": { schema: encryptedExportRequest }, + }, + }, + responses: { + "200": { + description: + "Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 params | salt | iv | tag | ciphertext).", + content: { + "application/octet-stream": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index b4b5a9c91..a57314ece 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -34,6 +34,7 @@ import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; +import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; @@ -55,6 +56,7 @@ export const openApiPaths: NonNullable = { ...cyclePaths, ...metaPaths, ...healthRecordPaths, + ...exportPaths, ...authPaths, ...syncPaths, ...measurementPaths,