Files
HealthLog/src/lib/labs/store.ts
T
Marc-André Bombeck 3dd5dbeb42 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.
2026-07-18 02:09:33 +02:00

52 lines
1.9 KiB
TypeScript

/**
* v1.17.1 — server-side helpers for the structured lab-result store.
*
* The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The
* free-text note is the only sensitive column on the model; it shares the
* `encrypt()` string format (`"<keyId>.<base64>"`) every other `*Encrypted`
* column uses, encoded as UTF-8 bytes.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
// Prisma `Bytes` maps to `Uint8Array<ArrayBuffer>`; allocate a fresh
// ArrayBuffer-backed view so the structural type stays stable.
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a stored `Bytes` note back to plaintext. Throws on a bad key id. */
export function decryptNoteFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
/**
* Decrypt a stored `Bytes` note, fail-soft to `null` on any error.
*
* Mirrors `decryptContextSoft` in the biomarker store: a single bad-key /
* malformed row must not fail a bulk read (list view, backup export) that
* spans many rows. The single-resource GET path uses the throwing
* `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead
* of silently masking a key-rotation gap.
*/
export function decryptNoteSoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptNoteFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`lab note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}