Files
HealthLog/src/lib/openapi/routes/export.ts
T
Marc-André Bombeck 0ba77778d5 feat(export): offer a passphrase-encrypted archive
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.

POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
2026-06-27 20:27:07 +02:00

52 lines
2.2 KiB
TypeScript

/**
* 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<ZodOpenApiObject["paths"]> = {
"/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:<userId>` 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,
},
},
},
};