From 8121935642591936d6c0aea22ad91898a43e0344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 08:17:34 +0200 Subject: [PATCH 1/7] Carry cycle free text readable in a portable backup A portable file carried the day-log note, the sensitive-category envelope and custom symptom labels as the stored ciphertext, so a restore onto a host with another key either refused the file or wrote values nobody could open. The portable file now carries them readable and the restore seals them under the receiving host's key; a disaster-recovery file keeps the ciphertext. A file written before this still restores: each ciphertext value is opened with this host's keys first, and one that does not open is kept out of the row and reported in the skip summary as cycleCiphertext. The key preflight leaves the cycle sections to that per-value check. --- docs/api/openapi.yaml | 52 +++ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- src/components/admin/backups-section.tsx | 3 + src/lib/cycle/__tests__/backup.test.ts | 89 ++++- src/lib/cycle/backup.ts | 179 ++++++++-- .../export/__tests__/backup-key-ids.test.ts | 24 ++ src/lib/export/backup-key-ids.ts | 35 +- src/lib/export/restore-skips.ts | 11 +- src/lib/validations/backup.ts | 24 ++ .../admin-backups-canonical-roundtrip.test.ts | 6 +- .../backup-cycle-cross-key-restore.test.ts | 313 ++++++++++++++++++ tests/integration/cycle-import-export.test.ts | 17 +- 18 files changed, 731 insertions(+), 43 deletions(-) create mode 100644 tests/integration/backup-cycle-cross-key-restore.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 25431a0bd..ffdc1ead6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36605,6 +36605,54 @@ components: anyOf: - type: string - type: "null" + note: + anyOf: + - type: string + - type: "null" + sensitive: + anyOf: + - anyOf: + - type: object + properties: + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + additionalProperties: false + - type: string + - type: "null" source: type: string enum: @@ -36708,6 +36756,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - key - labelKey diff --git a/messages/de.json b/messages/de.json index 010e91b92..5390c5fb4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", - "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)" + "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", + "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index b65774c65..dd65185b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", - "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)" + "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", + "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 1f9b678f2..8ec71bc7a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", - "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)" + "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", + "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 1c76f7992..e9d1163a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", - "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)" + "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", + "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 61397873a..d1538b30b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", - "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)" + "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", + "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index cdabb7b22..a61e278d9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7533,7 +7533,8 @@ "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", - "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)" + "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", + "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 1f931f743..6a51e0eeb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7554,7 +7554,8 @@ "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", - "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)" + "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", + "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 3a0550cc5..9a295f8a3 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,9 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "cycleCiphertext") { + return t("admin.section.backups.restoreSkippedCycleCiphertext"); + } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 2bcc923ec..cd7318a86 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { buildCycleBackupSection } from "../backup"; @@ -236,3 +239,87 @@ describe("a symptom the account created survives the round trip", () => { expect(section.customSymptoms).toEqual([]); }); }); + +describe("a portable file carries the cycle free text readable", () => { + // Ciphertext in a portable file is noise on any host with another key, so + // the note, the sensitive envelope and a custom label travel opened, and + // the restore seals them under the receiving host's key. + const savedKey = process.env.ENCRYPTION_KEY; + beforeEach(() => { + process.env.ENCRYPTION_KEY = "ab".repeat(32); + _resetCryptoCacheForTests(); + }); + afterEach(() => { + process.env.ENCRYPTION_KEY = savedKey; + _resetCryptoCacheForTests(); + }); + + function client(dayLog: Record, label: string | null) { + return { + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { + findMany: vi + .fn() + .mockResolvedValue([ + { date: "2026-07-02", symptomLinks: [], ...dayLog }, + ]), + }, + cycleSymptom: { + findMany: vi.fn().mockResolvedValue([ + { + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: label, + }, + ]), + }, + } as never; + } + + it("opens each sealed value and carries no ciphertext", async () => { + const section = await buildCycleBackupSection( + client( + { + notesEncrypted: encrypt("Cramps at four"), + sensitiveEncrypted: encrypt( + JSON.stringify({ sexualActivity: true, contraceptive: "ORAL" }), + ), + }, + encrypt("Back ache"), + ), + "user-1", + {}, + ); + const day = section.cycleDayLogs[0]; + expect(day.note).toBe("Cramps at four"); + expect(day.sensitive).toEqual({ + sexualActivity: true, + contraceptive: "ORAL", + }); + expect(day).not.toHaveProperty("notesEncrypted"); + expect(day).not.toHaveProperty("sensitiveEncrypted"); + expect(section.customSymptoms[0].label).toBe("Back ache"); + expect(section.customSymptoms[0]).not.toHaveProperty("labelEncrypted"); + }); + + it("writes the unreadable marker where this host cannot open a value", async () => { + const section = await buildCycleBackupSection( + client( + { + notesEncrypted: "v9.bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWF0LWFsbC1yZWFsbHk=", + }, + null, + ), + "user-1", + {}, + ); + expect(section.cycleDayLogs[0].note).toBe(UNREADABLE_EXPORT_MARKER); + expect(section.cycleDayLogs[0].sensitive).toBeNull(); + expect(section.customSymptoms[0].label).toBeNull(); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 1a216b812..4862a41a3 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -5,10 +5,25 @@ * never drift. Mirrors the `backupPayloadSchema` cycle shapes. * * Reads are scoped to `userId` + `deletedAt: null` and exclude predicted - * (forecast) cycle rows — only observed history round-trips. `notesEncrypted` - * is carried as the ciphertext envelope VERBATIM: the backup never decrypts - * the day-log note, so a wrong-surface plaintext leak is impossible and the - * owner's note round-trips encrypted at rest. + * (forecast) cycle rows — only observed history round-trips. + * + * ## Free text follows the note contract + * + * Three columns are sealed: the day-log note, the sensitive-category envelope + * and a custom symptom's label. A disaster-recovery file carries them as the + * stored ciphertext, which the same instance's key reads back. A portable file + * carries them readable (`note`, `sensitive`, `label`), and the restore seals + * them under the receiving host's key, because a portable file exists to be + * moved, and ciphertext from one host is noise on another. + * + * Until v1.40 the portable file carried the ciphertext too, so a note restored + * onto a host with a different key was unreadable for good. Such a file is + * still accepted: every ciphertext value in it is opened with this host's keys + * before it is written, and one that does not open is kept out of the row and + * named in the skip report (`cycleCiphertext`), never written back as + * ciphertext nobody can read. The same check covers a disaster-recovery file, + * which is why `RESTORE_SELF_VERIFIED_SECTIONS` lets these sections past the + * restore's key preflight. */ import type { CervicalMucus, @@ -25,6 +40,9 @@ import type { PrismaClient, SecondarySymptom, } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { getEvent } from "@/lib/logging/context"; import type { BackupPayload } from "@/lib/validations/backup"; import { recordUnknownKeys, @@ -91,8 +109,17 @@ export interface CycleBackupSection { pregnancyTest: string | null; progesteroneTest: string | null; contraceptive: string | null; - sensitiveEncrypted: string | null; - notesEncrypted: string | null; + /** Ciphertext; present on a disaster-recovery payload. */ + sensitiveEncrypted?: string | null; + /** Ciphertext; present on a disaster-recovery payload. */ + notesEncrypted?: string | null; + /** Plaintext; present on a portable payload. */ + note?: string | null; + /** + * The sensitive-category envelope opened; present on a portable payload. + * The unreadable marker when this instance could not open it. + */ + sensitive?: CycleSensitiveFields | string | null; source: string; externalId: string | null; tz: string | null; @@ -142,18 +169,54 @@ export interface CycleBackupSection { sortOrder: number; isActive: boolean; /** - * The user's own words for the symptom, carried as ciphertext verbatim. + * The user's own words for the symptom: ciphertext on a disaster-recovery + * payload, plaintext (`label`) on a portable one. * - * The builder has always emitted it and this interface did not declare it, - * which held while the payload was assembled key by key and stopped holding - * the moment the section is spread onto the wire: an undeclared field is a - * field the wire type says is not there. Declared, so the type describes - * the file. + * Declared rather than left to the spread, because an undeclared field is + * a field the wire type says is not there. */ - labelEncrypted: string | null; + labelEncrypted?: string | null; + label?: string | null; }>; } +/** The five intent fields the sensitive-category envelope holds. */ +export interface CycleSensitiveFields { + sexualActivity?: boolean; + protectedSex?: boolean | null; + pregnancyTest?: string | null; + progesteroneTest?: string | null; + contraceptive?: string | null; +} + +/** Open a sealed value for a portable file, or the unreadable marker. */ +function openForExport(sealed: string, field: string): string { + try { + return decrypt(sealed); + } catch (err) { + getEvent()?.addWarning( + `cycle ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** The sensitive envelope opened for a portable file, or the marker. */ +function openSensitiveForExport( + sealed: string | null, +): CycleSensitiveFields | string | null { + if (!sealed) return null; + const opened = openForExport(sealed, "sensitive envelope"); + if (opened === UNREADABLE_EXPORT_MARKER) return opened; + try { + return JSON.parse(opened) as CycleSensitiveFields; + } catch { + return UNREADABLE_EXPORT_MARKER; + } +} + /** * Build the cycle section of a user's full backup. Accepts any client with * the cycle delegates (the route's global client OR the worker's local @@ -259,8 +322,17 @@ export async function buildCycleBackupSection( pregnancyTest: d.pregnancyTest, progesteroneTest: d.progesteroneTest, contraceptive: d.contraceptive, - sensitiveEncrypted: d.sensitiveEncrypted, - notesEncrypted: d.notesEncrypted, + ...(disasterRecovery + ? { + sensitiveEncrypted: d.sensitiveEncrypted, + notesEncrypted: d.notesEncrypted, + } + : { + sensitive: openSensitiveForExport(d.sensitiveEncrypted), + note: d.notesEncrypted + ? openForExport(d.notesEncrypted, "day-log note") + : null, + }), source: d.source, externalId: d.externalId, tz: d.tz, @@ -277,7 +349,13 @@ export async function buildCycleBackupSection( icon: sym.icon, sortOrder: sym.sortOrder, isActive: sym.isActive, - labelEncrypted: sym.labelEncrypted, + ...(disasterRecovery + ? { labelEncrypted: sym.labelEncrypted } + : { + label: sym.labelEncrypted + ? openForExport(sym.labelEncrypted, "symptom label") + : null, + }), })), }; } @@ -369,6 +447,39 @@ function severityOrNull(value: number | null | undefined): number | null { : null; } +/** A readable value the file could not supply: the writer could not open it. */ +const UNOPENED = Symbol("unopened"); + +/** + * The sealed column value for one free-text field from the file. + * + * Ciphertext is written back only when this host opens it — a value sealed + * under a key this host does not hold (or the same key id over other key + * material) would be a row nobody can read. A readable value is sealed under + * this host's active key. Anything kept out is recorded under `path`. + */ +function sealForRestore( + sealed: string | null | undefined, + readable: string | null | undefined | typeof UNOPENED, + path: string, + unopened: string[], +): string | null { + if (sealed) { + try { + decrypt(sealed); + return sealed; + } catch { + unopened.push(path); + return null; + } + } + if (readable === UNOPENED) { + unopened.push(path); + return null; + } + return readable ? encrypt(readable) : null; +} + function enumOrNull( value: string | null | undefined, allow: ReadonlySet, @@ -383,8 +494,9 @@ function enumOrNull( * inside an existing transaction. Delete-then-recreate, matching the * measurement/mood restore contract. Symptom links are re-resolved against * the seeded catalogue by key, and each one gets back the 1-4 intensity the - * file recorded for it, or none if the file recorded none. `notesEncrypted` is - * written back as the ciphertext envelope verbatim — never re-encrypted. + * file recorded for it, or none if the file recorded none. Sealed free text + * comes back as the file-header comment describes: readable values sealed + * under this host's key, ciphertext written back only when this host opens it. * * A key that resolves against nothing is dropped from the day's links and * recorded on `skips`. It is a required argument rather than an optional one: @@ -400,6 +512,9 @@ export async function restoreCycleData( payload: BackupPayload, skips: RestoreSkipLog, ): Promise { + // Sealed values from the file this host's keys do not open, by file path. + const unopened: string[] = []; + // Wipe (child links cascade off the day-log delete). const dayLogs = await tx.cycleDayLog.deleteMany({ where: { userId: ownerId }, @@ -488,7 +603,12 @@ export async function restoreCycleData( icon: sym.icon ?? null, sortOrder: sym.sortOrder, isActive: sym.isActive, - labelEncrypted: sym.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + sym.labelEncrypted, + sym.label, + `customSymptoms.${sym.key}.labelEncrypted`, + unopened, + ), }, // `key` is globally unique, so a seeded key would collide. Leave the // catalogue row alone: the account's links resolve against it either way. @@ -591,9 +711,22 @@ export async function restoreCycleData( pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS), progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS), contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES), - // Both ciphertext envelopes written back verbatim — never re-encrypted. - sensitiveEncrypted: d.sensitiveEncrypted ?? null, - notesEncrypted: d.notesEncrypted ?? null, + sensitiveEncrypted: sealForRestore( + d.sensitiveEncrypted, + d.sensitive === undefined || d.sensitive === null + ? d.sensitive + : typeof d.sensitive === "string" + ? UNOPENED + : JSON.stringify(d.sensitive), + `cycleDayLogs.${d.date}.sensitiveEncrypted`, + unopened, + ), + notesEncrypted: sealForRestore( + d.notesEncrypted, + d.note, + `cycleDayLogs.${d.date}.notesEncrypted`, + unopened, + ), source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, @@ -608,6 +741,8 @@ export async function restoreCycleData( }); } + recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened); + return { cycles: cycles.count, cycleDayLogs: dayLogs.count, diff --git a/src/lib/export/__tests__/backup-key-ids.test.ts b/src/lib/export/__tests__/backup-key-ids.test.ts index 03377c809..386f12878 100644 --- a/src/lib/export/__tests__/backup-key-ids.test.ts +++ b/src/lib/export/__tests__/backup-key-ids.test.ts @@ -138,6 +138,30 @@ describe("assessBackupKeys", () => { ).toEqual([]); }); + it("leaves the cycle sections to the restore, which opens each value itself", () => { + // A same-id impostor in a cycle note must neither refuse the file nor be + // picked as the sample that proves the key for the sections that need it. + const fine = encrypt("a measurement note under the real key"); + useKeys({ old: K3, cur: K2 }, "old"); + const impostor = encrypt("x"); + useKeys({ old: K1, cur: K2 }, "cur"); + const file = { + measurements: [{ notesEncrypted: fine }], + cycleDayLogs: [ + { notesEncrypted: impostor }, + { notesEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` }, + ], + customSymptoms: [ + { labelEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` }, + ], + }; + const collector = new BackupKeyIdCollector(); + collector.visit(file); + const verdict = assessBackupKeys(collector); + expect(verdict.missing).toEqual([]); + expect(verdict.unreadable).toEqual([]); + }); + it("catches an id that is configured with different key material", () => { const file = fileWrittenUnderOld(); useKeys({ old: K3, cur: K2 }, "cur"); diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index acbe75e07..aead1cb94 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -97,6 +97,25 @@ export function isEncryptedMember(name: string): boolean { return name.endsWith("Encrypted") && name.length > "Encrypted".length; } +/** + * Sections whose restore opens every inner value with this host's keys itself, + * and keeps out (and names in the skip report) any value it cannot open. + * + * The cycle day-logs and custom cycle symptoms are here because a portable + * file written before v1.40 carried their free text as the stored ciphertext, + * not as readable text. Such a file is still restorable onto another host: + * what that host can open comes back, what it cannot stays out of the row and + * is reported. Refusing the whole file over a note would cost every other + * part of the account. The trade, stated: a disaster-recovery copy whose ONLY + * missing key sits in these sections restores with those values reported as + * skipped instead of being refused; any other section needing the key still + * refuses it, which in practice is every real copy. + */ +export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ + "cycleDayLogs", + "customSymptoms", +]); + interface KeyUse { /** How many values need this key. */ count: number; @@ -127,7 +146,10 @@ export class BackupKeyIdCollector { } use.count += 1; use.sections.add(section); + // A sample from a self-verified section proves nothing the restore needs: + // a value there that does not open is skipped, not fatal. if ( + !RESTORE_SELF_VERIFIED_SECTIONS.has(section) && value.length <= MAX_SAMPLE_CHARS && (!use.sample || value.length < use.sample.value.length) ) { @@ -257,7 +279,9 @@ function openSample(sample: { value: string; form: InnerCiphertextForm }) { * key to prove the key under that id is the right one. * * `ignoreSections` leaves out sections the caller will not write: the - * instance settings, when the operator has not asked for them back. + * instance settings, when the operator has not asked for them back. The + * sections in {@link RESTORE_SELF_VERIFIED_SECTIONS} are always left out, + * because their restore checks each value itself. */ export function assessBackupKeys( collector: BackupKeyIdCollector, @@ -274,7 +298,14 @@ export function assessBackupKeys( for (const [keyId, use] of [...collector.entries()].sort(([a], [b]) => a.localeCompare(b), )) { - if ([...use.sections].every((section) => ignore.has(section))) continue; + if ( + [...use.sections].every( + (section) => + ignore.has(section) || RESTORE_SELF_VERIFIED_SECTIONS.has(section), + ) + ) { + continue; + } keyIds.push(keyId); configured ??= new Set(getConfiguredKeyIds()); if (!configured.has(keyId)) { diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index d79d09bbd..2b2ca30fc 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -152,6 +152,14 @@ * real foreign key, so the POINTER is dropped and the occurrence restores * unlinked. A portable export omits soft-deleted episodes, which is the * ordinary way an occurrence ends up naming one the file does not carry. + * + * The sixteenth, `cycleCiphertext`, is not a reference at all. It names a + * sealed cycle value (a day-log note, the sensitive-category envelope, a + * custom symptom's label) the file carried as ciphertext this host's keys do + * not open — a portable file written before v1.40 on a host with another key. + * Writing it back would store a value no reader can open, so the FIELD is kept + * out and the day or the symptom restores without it. Its key is the path in + * the file (`cycleDayLogs..notesEncrypted`), never the value. */ export type SkippedCatalogue = | "cycleSymptom" @@ -172,7 +180,8 @@ export type SkippedCatalogue = | "scheduleRevisionLink" | "checkupClosure" | "accountSetting" - | "symptomEpisodeReference"; + | "symptomEpisodeReference" + | "cycleCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8a9af3f62..ad0334bcc 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -625,6 +625,15 @@ const cycleSymptomSeveritySchema = z }) .passthrough(); +/** The sensitive-category envelope's five fields, as a portable file carries them. */ +const cycleSensitiveFieldsSchema = z.object({ + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: z.enum(HomeTestResult).nullable().optional(), + progesteroneTest: z.enum(HomeTestResult).nullable().optional(), + contraceptive: z.enum(ContraceptiveKind).nullable().optional(), +}); + const cycleDayLogSchema = z .object({ id: z.string().min(1).optional(), @@ -644,8 +653,20 @@ const cycleDayLogSchema = z pregnancyTest: z.enum(HomeTestResult).nullable().optional(), progesteroneTest: z.enum(HomeTestResult).nullable().optional(), contraceptive: z.enum(ContraceptiveKind).nullable().optional(), + // Free text travels like every other note: a disaster-recovery file + // carries the ciphertext (`*Encrypted`), a portable file the readable + // value (`note`, `sensitive`), which the restore seals under the + // receiving host's key. A portable file written before v1.40 carries the + // ciphertext too; the restore keeps only what this host can open. sensitiveEncrypted: z.string().nullable().optional(), notesEncrypted: z.string().nullable().optional(), + note: z.string().nullable().optional(), + // The five intent fields the sensitive-category envelope holds, or the + // unreadable marker when the writing host could not open the envelope. + sensitive: z + .union([cycleSensitiveFieldsSchema, z.string()]) + .nullable() + .optional(), source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), @@ -752,7 +773,10 @@ const customCycleSymptomSchema = z sortOrder: z.number().int().default(0), isActive: z.boolean().default(true), // Same reasoning as the mood tag's: the user's own words live here. + // Ciphertext on a disaster-recovery file, readable `label` on a portable + // one (the day-log note's split). labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), }) .passthrough(); diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index ee3015901..bb20699a6 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -391,8 +391,10 @@ describe("canonical disaster-recovery backup round-trip", () => { cervixFirmness: "FIRM", cervixOpening: "CLOSED", sexualActivity: false, - sensitiveEncrypted: "sensitive-cycle-dr", - notesEncrypted: "notes-cycle-dr", + // Real ciphertext: the restore writes a sealed value back only when + // this host opens it. + sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })), + notesEncrypted: encrypt("notes-cycle-dr"), source: "APPLE_HEALTH", externalId: "cycle-day-external-dr", tz: "Europe/London", diff --git a/tests/integration/backup-cycle-cross-key-restore.test.ts b/tests/integration/backup-cycle-cross-key-restore.test.ts new file mode 100644 index 000000000..4d3f50aee --- /dev/null +++ b/tests/integration/backup-cycle-cross-key-restore.test.ts @@ -0,0 +1,313 @@ +/** + * A portable file's cycle free text survives a move to a host with other keys. + * + * The day-log note, the sensitive-category envelope and a custom symptom's + * label are sealed under the writing host's key. A portable file used to carry + * those three as the stored ciphertext, so a restore onto a host with a + * different key either refused the whole file at the key check or, with a + * matching key id over different key material, wrote rows nobody could open. + * + * Both ends and the pipe: the file is built by the real `buildFullBackupPayload` + * under key A, stored and restored through the real route under key B, and + * the restored rows are opened under key B. + * + * The second case is the file written before the fix, which still carries + * ciphertext. A value this host's keys open comes back; a value they cannot + * open is kept out of the row and named in the skip report, rather than + * written back as ciphertext no reader can open. + * + * Mutation: restore the verbatim copy of `notesEncrypted` in + * `restoreCycleData` and the legacy case writes the foreign ciphertext back. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const NOTE = "Cramps woke me at four"; +const SENSITIVE = { + sexualActivity: true, + protectedSex: false, + pregnancyTest: "NEGATIVE", + progesteroneTest: null, + contraceptive: "ORAL", +}; +const LABEL = "Lower back ache"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "cycle-cross-key-owner", + email: "cycle-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "CYCLE_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("cycle free text in a portable file restored under another key", () => { + it("comes back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + /* ── host A writes the account and exports it ─────────────────────── */ + useKeys(HOST_A, "a"); + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: "custom:cross-key-ache", + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt(LABEL), + }, + }); + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + date: "2026-06-02", + flow: "MEDIUM", + notesEncrypted: encrypt(NOTE), + sensitiveEncrypted: encrypt(JSON.stringify(SENSITIVE)), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + /* ── host B, which has never held key A, restores it ──────────────── */ + useKeys(HOST_B, "b"); + // Host B is another database: the account's own symptom row from host A + // is not there (the restore upserts by key and keeps an existing row). + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } }); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const day = await prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(day.flow).toBe("MEDIUM"); + expect(day.notesEncrypted).not.toBeNull(); + expect(decrypt(day.notesEncrypted!)).toBe(NOTE); + expect(day.sensitiveEncrypted).not.toBeNull(); + expect(JSON.parse(decrypt(day.sensitiveEncrypted!))).toEqual(SENSITIVE); + + const symptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: "custom:cross-key-ache" }, + }); + expect(decrypt(symptom.labelEncrypted!)).toBe(LABEL); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + // Written under host B's own key: opens here. + useKeys(HOST_B, "b"); + const ownNote = encrypt(NOTE); + // Written elsewhere: an id host B does not hold, and host B's id over + // different key material. + useKeys(HOST_A, "a"); + const foreignNote = encrypt("written under key a"); + const foreignLabel = encrypt("label under key a"); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorSensitive = encrypt(JSON.stringify(SENSITIVE)); + + // The shape a portable file had before the fix: every free-text field as + // the stored ciphertext, no plaintext beside it. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + file.customSymptoms = [ + { + key: "custom:legacy-foreign", + labelKey: "cycle.symptom.custom", + categoryId: category.id, + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: foreignLabel, + }, + ]; + const day = ( + date: string, + notes: string | null, + sensitive: string | null, + ) => ({ + date, + flow: "LIGHT", + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: sensitive, + notesEncrypted: notes, + source: "MANUAL", + externalId: null, + tz: null, + symptomKeys: [], + symptomSeverities: [], + }); + file.cycleDayLogs = [ + day("2026-06-01", ownNote, null), + day("2026-06-02", foreignNote, impostorSensitive), + ]; + // Still a file this release accepts. + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const days = await prisma.cycleDayLog.findMany({ + where: { userId: owner.id }, + orderBy: { date: "asc" }, + }); + expect(days.map((d) => d.date)).toEqual(["2026-06-01", "2026-06-02"]); + expect(decrypt(days[0].notesEncrypted!)).toBe(NOTE); + // The day itself comes back; only what cannot be opened stays out. + expect(days[1].flow).toBe("LIGHT"); + expect(days[1].notesEncrypted).toBeNull(); + expect(days[1].sensitiveEncrypted).toBeNull(); + + const symptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: "custom:legacy-foreign" }, + }); + expect(symptom.labelEncrypted).toBeNull(); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "cycleCiphertext", + key: "customSymptoms.custom:legacy-foreign.labelEncrypted", + links: 1, + }, + { + catalogue: "cycleCiphertext", + key: "cycleDayLogs.2026-06-02.notesEncrypted", + links: 1, + }, + { + catalogue: "cycleCiphertext", + key: "cycleDayLogs.2026-06-02.sensitiveEncrypted", + links: 1, + }, + ]); + expect(body.data.skipped.links).toBe(3); + }); +}); diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts index 73eed32e2..a6f3014b3 100644 --- a/tests/integration/cycle-import-export.test.ts +++ b/tests/integration/cycle-import-export.test.ts @@ -6,8 +6,8 @@ * into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled, * merging same-day samples into one row with first-write-wins re-import * - the full-backup cycle section round-trips through - * `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted` - * preserved verbatim and symptom links re-resolved by key + * `buildCycleBackupSection` → `restoreCycleData` with the note carried + * readable, re-sealed on restore, and symptom links re-resolved by key */ import { writeFileSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup"; +import { decrypt, encrypt } from "@/lib/crypto"; import type { RestoreSkipLog } from "@/lib/export/restore-skips"; import { parseBackupPayload } from "@/lib/validations/backup"; @@ -163,7 +164,7 @@ describe("cycle import — reproductive HK samples route to CycleDayLog", () => }); describe("cycle backup — round-trips through build + restore", () => { - it("preserves notesEncrypted ciphertext and symptom links by key", async () => { + it("carries the note readable and re-seals it, with symptom links by key", async () => { const prisma = getPrismaClient(); const cycle = await prisma.menstrualCycle.create({ @@ -184,7 +185,7 @@ describe("cycle backup — round-trips through build + restore", () => { cervixPosition: "HIGH", cervixFirmness: "SOFT", cervixOpening: "OPEN", - notesEncrypted: "v1:abc123:cipher-envelope", + notesEncrypted: encrypt("Cramps in the evening"), source: "MANUAL", }, }); @@ -207,9 +208,9 @@ describe("cycle backup — round-trips through build + restore", () => { const section = await buildCycleBackupSection(prisma, USER_ID); expect(section.cycles).toHaveLength(1); expect(section.cycleDayLogs).toHaveLength(1); - expect(section.cycleDayLogs[0].notesEncrypted).toBe( - "v1:abc123:cipher-envelope", - ); + // A portable section carries the note readable, never the ciphertext. + expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening"); + expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted"); expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]); const payload = parseBackupPayload({ @@ -234,7 +235,7 @@ describe("cycle backup — round-trips through build + restore", () => { include: { symptomLinks: { include: { symptom: true } } }, }); expect(restored).toHaveLength(1); - expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope"); + expect(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening"); expect(restored[0].cervixPosition).toBe("HIGH"); expect(restored[0].cervixFirmness).toBe("SOFT"); expect(restored[0].cervixOpening).toBe("OPEN"); From 6bc764fa289d49a39f8c1602193f3a9af2f0c8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 08:17:34 +0200 Subject: [PATCH 2/7] Write a medication PUT and its course in one transaction The row update (name, schedule, oneShot) committed before the course write. When a concurrent course made the course write refuse, the client got the refusal while the rest was already written. The schedule replace, the row update, the pause era and the course write now commit together, so a refusal leaves the medication unchanged. The rollup recompute runs after the commit. --- .../medications/[id]/__tests__/route.test.ts | 12 +- src/app/api/medications/[id]/route.ts | 691 +++++++++--------- src/lib/medications/courses.ts | 100 +-- .../medication-put-course-race.test.ts | 152 ++++ 4 files changed, 572 insertions(+), 383 deletions(-) create mode 100644 tests/integration/medication-put-course-race.test.ts diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 5545404eb..67d9fe8f5 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The PUT writes inside one interactive transaction; run its callback + // against the same mocked client. + vi.mocked(prisma.$transaction).mockImplementation((async ( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); // v1.40 — the category and course helpers' defaults, which // `resetAllMocks` clears. vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); @@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { vi.mocked(auditLog).mockResolvedValue(undefined); const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX); expect(res.status).toBe(200); - // v1.40 (#1024) — the window goes through the course writer, which - // writes the row and the latest course in one transaction; the plain - // row update no longer carries it. + // v1.40 (#1024) — the window goes through the course writer, inside the + // same transaction as the row update; the plain row update no longer + // carries it. const call = lastUpdateCall(); expect(call.data).not.toHaveProperty("endsOn"); expect(setCurrentWindow).toHaveBeenCalledWith( @@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { medicationId: "m1", endsOn: new Date("2026-12-31"), }), + prisma, ); }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c30e57891..f7e62e198 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -667,7 +667,12 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - const tombstoneOpenSlots = async (): Promise => { + // The rollup days the tombstoned slots sat on, recomputed once the + // transaction below has committed the tombstones. + const staleRollupDays = new Set(); + const tombstoneOpenSlots = async ( + db: Prisma.TransactionClient, + ): Promise => { // A schedule replace invalidates the open slot anchors the projector / // reminder worker minted for the OLD times: a pending 08:00 row for a // medication that now doses at 20:00 would linger as a phantom slot, @@ -689,12 +694,12 @@ export const PUT = apiHandler( scheduledFor: { gte: todayStart }, } as const; // Capture the affected slots BEFORE the tombstone so the compliance - // rollups for those days can be recomputed immediately below — + // rollups for those days can be recomputed after the commit — // `updateMany` does not return the touched rows. Without the // recompute a tombstoned pending kept counting as `scheduled` in // the rollup row until the next event-driven write for that day, // depressing the rate the cards read in the meantime. - const tombstoned = await prisma.medicationIntakeEvent.findMany({ + const tombstoned = await db.medicationIntakeEvent.findMany({ where: tombstoneWhere, select: { scheduledFor: true }, }); @@ -708,332 +713,369 @@ export const PUT = apiHandler( tombstonedTo = row.scheduledFor; } } - await prisma.medicationIntakeEvent.updateMany({ + await db.medicationIntakeEvent.updateMany({ where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // Recompute each affected rollup day (deduped via the user-tz day - // key). Best-effort like every other rollup write-hook: a failure - // is annotated, never blocks the schedule replace. - const staleDayKeys = new Set( - tombstoned.map((row) => - dayKeyForScheduledFor(row.scheduledFor, userTz), - ), - ); - for (const dayKey of staleDayKeys) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - userTz, + // Deduped via the user-tz day key. + for (const row of tombstoned) { + staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); + } + }; + + // v1.40 — everything the PUT writes to the medication, its schedule and + // its course commits as one transaction. The course write can still be + // refused after the check above (another request wrote a course in + // between); with the row update committed first, the client got the + // refusal while the name, the schedule and `oneShot` were already + // changed. Now a refusal rolls all of it back. The rollup recompute and + // the grace bridge stay outside: both read or write what this commit + // settles, and neither may undo it. + let medication; + try { + medication = await prisma.$transaction(async (tx) => { + // If schedules provided, replace all + if (schedules && normalisedSchedules) { + // v1.16.3 — effective dating. Before the wholesale replace below wipes + // the old rows, archive them as ONE revision covering + // `[previous validUntil | medication.createdAt, now)` — but only when a + // cadence-relevant field actually changes. A no-op edit (the Zeitplan + // tab echoing the same rows back) must not mint a phantom era. + const previousRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + const previousEntries = previousRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), + replacedScheduleCount = previousRows.length; + replacedCadence = + previousRows.length > 0 + ? previousRows + .map((row) => + [ + row.timesOfDay, + row.rrule ?? "", + row.rollingIntervalDays !== null + ? `every ${row.rollingIntervalDays}d` + : "", + row.daysOfWeek ?? "", + ] + .filter((part) => part !== "") + .join(" "), + ) + .join(" | ") + : null; + + // v1.39.1 (#1033) — a stretch with intake tracking off expected + // nothing, so it archives with an empty payload whatever the rows + // said; and a tracking transition is itself an era boundary even when + // the rows stay the same. + const archivedEntries = wasTracked ? previousEntries : []; + if ( + previousRows.length > 0 && + (trackingChanged || + !wasTracked || + schedulesMateriallyDiffer( + previousEntries, + normalisedSchedules.map((n) => n.snapshot), + )) + ) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: archivedEntries.length }, + }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + asNeeded === false && + normalisedSchedules.length > 0 + ) { + // v1.16.11 — flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: [] as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: 0 }, + }); + } + await tombstoneOpenSlots(tx); + await tx.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); + } else if (trackingChanged) { + // v1.39.1 (#1033) — intake tracking switched without a schedule + // replace. Close the current era at this instant: switching OFF + // archives the schedule that was in force (the tracked stretch keeps + // its expected doses); switching back ON archives the untracked + // stretch with an empty payload, so compliance never counts it as + // missed and the live schedule, the projector and the reminder worker + // all resume from now. + const liveRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + if (liveRows.length > 0) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + const archivedEntries = wasTracked + ? liveRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), + ) + : []; + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { + schedule_revision_rows: archivedEntries.length, + track_intake: willTrack, + }, + }); + } + } + + // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: + // the open placeholders of today and later go the way a schedule + // replace sends them (a replace in the same request already did it). + if (trackingChanged && !willTrack && !schedules) { + await tombstoneOpenSlots(tx); + } + + const baseUpdateData = { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(treatmentClass !== undefined && { treatmentClass }), + ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), + // v1.17.0 — reorder lead override. Field-by-field; `null` is a + // valid explicit value (clears the override → user-level default). + ...(reorderLeadDays !== undefined && { reorderLeadDays }), + ...(deliveryForm !== undefined && { deliveryForm }), + // v1.8.5 — injection-site tracking opt-in + per-medication allowed + // sites. Field-by-field; `false` / `[]` are valid explicit values + // (deactivate tracking / clear the per-med restriction). + ...(trackInjectionSites !== undefined && { trackInjectionSites }), + ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), + ...(active !== undefined && { active }), + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + // v1.7.0 — iOS reminder flags, field-by-field. + ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), + ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), + // v1.5 scheduling primitives — pass-through when supplied. + // `startsOn` / `endsOn` are `Date | null | undefined` (the + // schema lets the user clear them explicitly with null). + // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the + // course writer below writes them together with the course they + // project, in one transaction, so the row and its courses cannot + // disagree if one of the two writes fails. + ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field (the invariants above + // already guaranteed the medication ends schedule-consistent). + ...(asNeeded !== undefined && { asNeeded }), + // v1.39.1 (#1033) — intake tracking, field-by-field. + ...(trackIntake !== undefined && { trackIntake }), + ...(normalisedSchedules && { + schedules: { + create: normalisedSchedules.map((n) => n.createData), + }, + }), + }; + + const withoutNotifications = { ...baseUpdateData } as Record< + string, + unknown + >; + delete withoutNotifications.notificationsEnabled; + const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; + const hasNotificationsPatch = notificationsEnabled !== undefined; + + const updateCandidates: Array> = [ + { ...baseUpdateData, ...pausedAtPatch }, + ]; + if (hasPausedAtPatch) { + updateCandidates.push(baseUpdateData); + } + if (hasNotificationsPatch) { + updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); + if (hasPausedAtPatch) { + updateCandidates.push(withoutNotifications); + } + } + + let updated; + let lastUpdateErr: unknown; + for (const candidate of updateCandidates) { + try { + updated = await tx.medication.update({ + where: { id }, + data: candidate, + include: { schedules: true }, + }); + break; + } catch (updateErr) { + lastUpdateErr = updateErr; + } + } + if (!updated) throw lastUpdateErr; + + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await tx.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, }, }); + } else if (active === true && !existing.active) { + await tx.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); } - } - }; - // If schedules provided, replace all - if (schedules && normalisedSchedules) { - // v1.16.3 — effective dating. Before the wholesale replace below wipes - // the old rows, archive them as ONE revision covering - // `[previous validUntil | medication.createdAt, now)` — but only when a - // cadence-relevant field actually changes. A no-op edit (the Zeitplan - // tab echoing the same rows back) must not mint a phantom era. - const previousRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, + // v1.40 (#1024) — write the window onto the latest course and + // project it back, then serve the projected row. + if (touchesWindow) { + const projected = await setCurrentWindow( + { + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }, + tx, + ); + return { ...updated, ...projected }; + } + return updated; }); - const previousEntries = previousRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ); - replacedScheduleCount = previousRows.length; - replacedCadence = - previousRows.length > 0 - ? previousRows - .map((row) => - [ - row.timesOfDay, - row.rrule ?? "", - row.rollingIntervalDays !== null - ? `every ${row.rollingIntervalDays}d` - : "", - row.daysOfWeek ?? "", - ] - .filter((part) => part !== "") - .join(" "), - ) - .join(" | ") - : null; - - // v1.39.1 (#1033) — a stretch with intake tracking off expected - // nothing, so it archives with an empty payload whatever the rows - // said; and a tracking transition is itself an era boundary even when - // the rows stay the same. - const archivedEntries = wasTracked ? previousEntries : []; - if ( - previousRows.length > 0 && - (trackingChanged || - !wasTracked || - schedulesMateriallyDiffer( - previousEntries, - normalisedSchedules.map((n) => n.snapshot), - )) - ) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - // Chain from the ACTIVE boundary: a superseded row is an - // audit record whose `validUntil` may sit past its correction. - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: archivedEntries.length }, - }); - } else if ( - previousRows.length === 0 && - existing.asNeeded && - asNeeded === false && - normalisedSchedules.length > 0 - ) { - // v1.16.11 — flipping as-needed OFF. The medication carried ZERO - // schedule rows while as-needed, so the wholesale-replace archive - // above never fires — and without a revision the live era would - // start at the PREVIOUS revision's `validUntil` (or `createdAt`), - // retro-painting the schedule-less as-needed stretch with the NEW - // schedule's expected slots: every PRN day would read as missed. - // Archive an EMPTY era covering the as-needed stretch instead; an - // empty payload expands to zero schedules, so era-aware compliance - // expects nothing there — exactly the as-needed contract. - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: [] as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: 0 }, - }); - } - await tombstoneOpenSlots(); - await prisma.medicationSchedule.deleteMany({ - where: { medicationId: id }, - }); - } else if (trackingChanged) { - // v1.39.1 (#1033) — intake tracking switched without a schedule - // replace. Close the current era at this instant: switching OFF - // archives the schedule that was in force (the tracked stretch keeps - // its expected doses); switching back ON archives the untracked - // stretch with an empty payload, so compliance never counts it as - // missed and the live schedule, the projector and the reminder worker - // all resume from now. - const liveRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - if (liveRows.length > 0) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - const archivedEntries = wasTracked - ? liveRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ) - : []; - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { - schedule_revision_rows: archivedEntries.length, - track_intake: willTrack, - }, - }); + } catch (err) { + // A concurrent course write landed between the check above and the + // course write. The transaction rolled back: nothing was written. + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); } + throw err; } - // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: - // the open placeholders of today and later go the way a schedule - // replace sends them (a replace in the same request already did it). - if (trackingChanged && !willTrack && !schedules) { - await tombstoneOpenSlots(); - } - - const baseUpdateData = { - ...(name !== undefined && { name }), - ...(dose !== undefined && { dose }), - ...(treatmentClass !== undefined && { treatmentClass }), - ...(dosesPerUnit !== undefined && { dosesPerUnit }), - // v1.16.10 — units consumed per dose. Field-by-field; the stamp on - // already-taken intake events freezes their recorded consumption. - ...(unitsPerDose !== undefined && { unitsPerDose }), - // v1.17.0 — reorder lead override. Field-by-field; `null` is a - // valid explicit value (clears the override → user-level default). - ...(reorderLeadDays !== undefined && { reorderLeadDays }), - ...(deliveryForm !== undefined && { deliveryForm }), - // v1.8.5 — injection-site tracking opt-in + per-medication allowed - // sites. Field-by-field; `false` / `[]` are valid explicit values - // (deactivate tracking / clear the per-med restriction). - ...(trackInjectionSites !== undefined && { trackInjectionSites }), - ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), - ...(active !== undefined && { active }), - ...(notificationsEnabled !== undefined && { notificationsEnabled }), - // v1.7.0 — iOS reminder flags, field-by-field. - ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), - ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), - // v1.9.0 — optional drug-classification codes (ATC / RxNorm). - // Field-by-field; `null` is a valid explicit value (clears the code). - ...(atcCode !== undefined && { atcCode }), - ...(rxNormCode !== undefined && { rxNormCode }), - // v1.5 scheduling primitives — pass-through when supplied. - // `startsOn` / `endsOn` are `Date | null | undefined` (the - // schema lets the user clear them explicitly with null). - // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the - // course writer below writes them together with the course they - // project, in one transaction, so the row and its courses cannot - // disagree if one of the two writes fails. - ...(oneShot !== undefined && { oneShot }), - // v1.16.11 — as-needed flag, field-by-field (the invariants above - // already guaranteed the medication ends schedule-consistent). - ...(asNeeded !== undefined && { asNeeded }), - // v1.39.1 (#1033) — intake tracking, field-by-field. - ...(trackIntake !== undefined && { trackIntake }), - ...(normalisedSchedules && { - schedules: { - create: normalisedSchedules.map((n) => n.createData), - }, - }), - }; - - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); - } - } - - let medication; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { + // Recompute each rollup day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every other rollup + // write-hook: a failure is annotated, never fails the edit. + for (const dayKey of staleRollupDays) { try { - medication = await prisma.medication.update({ - where: { id }, - data: candidate, - include: { schedules: true }, + await recomputeMedicationComplianceForDay( + user.id, + id, + dayKey, + user.timezone || DEFAULT_TIMEZONE, + ); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_failed: true, + medication_compliance_rollup_error: + err instanceof Error ? err.message : String(err), + }, }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; } } - if (!medication) throw lastUpdateErr; - - // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a - // single live marker that resume clears, so the paused window is - // irrecoverable once resumed and every paused day's slot collapses to - // "missed" in the compliance denominator. Mirror the live transition - // onto an additive era record the rate paths read: a pause opens an era - // (resumedAt null), a resume closes the latest open era. `userId` comes - // from the authenticated session; the data object is built field-by-field. - if (active === false && existing.active) { - await prisma.medicationPauseEra.create({ - data: { - medicationId: id, - userId: user.id, - pausedAt: new Date(), - resumedAt: null, - }, - }); - } else if (active === true && !existing.active) { - await prisma.medicationPauseEra.updateMany({ - where: { medicationId: id, userId: user.id, resumedAt: null }, - data: { resumedAt: new Date() }, - }); - } // Apply the v1.5.5 primary-schedule grace bridge after the // medication update so a failure here doesn't roll the medication @@ -1055,29 +1097,6 @@ export const PUT = apiHandler( } } - // v1.40 (#1024) — write the window onto the latest course and project it - // back, then serve the projected row. - if (touchesWindow) { - let projected; - try { - projected = await setCurrentWindow({ - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - }); - } catch (err) { - // A concurrent course write landed between the check above and this - // transaction. Nothing of the window was written. - if (err instanceof CourseWriteError) { - const r = courseRefusalResponse(err.refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - throw err; - } - medication = { ...medication, ...projected }; - } - // v1.40 (#1041) — an iPhone app build that predates custom categories // decodes an unknown category as OTHER and sends it back on every edit, // which would overwrite the person's own category with OTHER. A native diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 760c5baee..4abd7b7d0 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -323,57 +323,69 @@ function windowAsCourse( * Clearing both on a medication with one course deletes it (chronic again, * the pre-course meaning of no dates); on one with several it is refused, * because it would silently drop the history. + * + * `tx` runs the write inside the caller's transaction, so the PUT's row + * update and this course write commit or roll back together: a refusal here + * then leaves nothing of the PUT behind. */ export async function setCurrentWindow( args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, + tx?: Tx, +): Promise { + return tx + ? writeCurrentWindow(tx, args) + : prisma.$transaction((own) => writeCurrentWindow(own, args)); +} + +async function writeCurrentWindow( + tx: Tx, + args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, ): Promise { const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); - return prisma.$transaction(async (tx) => { - await lockMedication(tx, args.medicationId); - const { medication, courses } = await loadOwned( - tx, - args.userId, - args.medicationId, - ); - const latest = sortCourses(courses).at(-1); + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const latest = sortCourses(courses).at(-1); - if (args.startsOn === null && args.endsOn === null) { - if (!latest) return project(tx, args.medicationId); - if (courses.length > 1) throw new CourseWriteError("windowRequired"); - await tx.medicationCourse.delete({ where: { id: latest.id } }); - return project(tx, args.medicationId); - } - - const next = windowAsCourse( - args.startsOn, - args.endsOn, - latest, - medication.createdAt, - args.timeZone, - ); - - if (latest) { - refuseIfInvalid( - courses.map((c) => (c.id === latest.id ? next : c)), - todayKey, - medication.oneShot, - ); - await tx.medicationCourse.update({ - where: { id: latest.id }, - data: next, - }); - } else { - refuseIfInvalid([next], todayKey, medication.oneShot); - await tx.medicationCourse.create({ - data: { - medicationId: args.medicationId, - userId: args.userId, - ...next, - }, - }); - } + if (args.startsOn === null && args.endsOn === null) { + if (!latest) return project(tx, args.medicationId); + if (courses.length > 1) throw new CourseWriteError("windowRequired"); + await tx.medicationCourse.delete({ where: { id: latest.id } }); return project(tx, args.medicationId); - }); + } + + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + medication.createdAt, + args.timeZone, + ); + + if (latest) { + refuseIfInvalid( + courses.map((c) => (c.id === latest.id ? next : c)), + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.update({ + where: { id: latest.id }, + data: next, + }); + } else { + refuseIfInvalid([next], todayKey, medication.oneShot); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...next, + }, + }); + } + return project(tx, args.medicationId); } /** diff --git a/tests/integration/medication-put-course-race.test.ts b/tests/integration/medication-put-course-race.test.ts new file mode 100644 index 000000000..5b6410394 --- /dev/null +++ b/tests/integration/medication-put-course-race.test.ts @@ -0,0 +1,152 @@ +/** + * A medication PUT the course writer refuses changes nothing. + * + * The PUT checks the window against the other courses first, then writes the + * row, then writes the course. A course written by another request between + * the check and the course write makes the course write refuse. If the row + * write had already committed, the client got an error while the name, the + * schedule and `oneShot` were changed anyway: a one-time medication with a + * window that spans more than its one day. + * + * The race is reproduced by letting the up-front check pass (it is the check + * the other request outran), so the real course writer meets the conflicting + * courses and refuses, through the shipped route and a real Postgres. + * + * Mutation: move the row update back out of the transaction the course write + * runs in, and the name and `oneShot` assertions turn red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-put-race"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The check the concurrent request outran: it saw no conflict. +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + checkCurrentWindow: vi.fn(async () => null), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER, + username: OWNER, + email: `${OWNER}@example.test`, + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +describe("a medication PUT refused by the course writer", () => { + it("leaves the medication row, its schedule and its courses as they were", async () => { + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/medications/route"); + const created = await POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + startsOn: "2026-03-01", + endsOn: "2026-03-07", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(created.status).toBe(201); + const { id } = (await created.json()).data as { id: string }; + + // The course the other request wrote. + const { POST: postCourse } = + await import("@/app/api/medications/[id]/courses/route"); + const course = await postCourse( + req("POST", `/api/medications/${id}/courses`, { + startsOn: "2026-06-10", + endsOn: "2026-06-16", + }), + { params: Promise.resolve({ id }) }, + ); + expect(course.status).toBe(201); + + const before = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + const revisionsBefore = await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }); + + // A one-time medication cannot carry two courses: the writer refuses. + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${id}`, { + name: "Oseltamivir", + oneShot: true, + startsOn: "2026-03-05", + schedules: [{ windowStart: "20:00", windowEnd: "21:00" }], + }), + { params: Promise.resolve({ id }) }, + ); + expect(res.status).toBe(422); + expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./); + + const after = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + expect(after.name).toBe("Tamiflu"); + expect(after.oneShot).toBe(false); + expect(after.startsOn).toEqual(before.startsOn); + expect(after.endsOn).toEqual(before.endsOn); + expect(after.updatedAt).toEqual(before.updatedAt); + expect(after.schedules.map((s) => s.id)).toEqual( + before.schedules.map((s) => s.id), + ); + expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]); + expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual( + before.courses.map((c) => [c.startsOn, c.endsOn]), + ); + expect( + await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }), + ).toBe(revisionsBefore); + }); +}); From 43a53255c94f727625ec7958667f9534e61ac6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 08:31:11 +0200 Subject: [PATCH 3/7] Carry custom mood labels readable in a portable backup The labels of the mood tags and mood categories an account created travelled as the stored ciphertext in a portable file, so a restore onto a host with another key refused the file or wrote labels nobody could open. They now follow the cycle note: readable in a portable file, sealed under the receiving host's key on restore, ciphertext kept only where this host opens it and otherwise reported as moodLabelCiphertext. The sealing helpers move to one module both sections share. --- docs/api/openapi.yaml | 8 + messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- src/components/admin/backups-section.tsx | 3 + src/lib/cycle/backup.ts | 60 +--- src/lib/export/backup-key-ids.ts | 7 +- src/lib/export/full-backup-payload.ts | 27 +- src/lib/export/restore-backup.ts | 23 +- src/lib/export/restore-skips.ts | 8 +- src/lib/export/sealed-text.ts | 70 +++++ src/lib/validations/backup.ts | 10 +- ...ackup-mood-label-cross-key-restore.test.ts | 266 ++++++++++++++++++ 17 files changed, 431 insertions(+), 72 deletions(-) create mode 100644 src/lib/export/sealed-text.ts create mode 100644 tests/integration/backup-mood-label-cross-key-restore.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ffdc1ead6..9f672d83b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36796,6 +36796,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - id - key @@ -36853,6 +36857,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" scaleMin: default: 1 type: integer diff --git a/messages/de.json b/messages/de.json index 5390c5fb4..d50600800 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", - "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)" + "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", + "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index dd65185b3..b2a3d92c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", - "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)" + "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", + "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 8ec71bc7a..daf60d074 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", - "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)" + "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", + "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index e9d1163a5..29378a5a1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", - "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)" + "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", + "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index d1538b30b..00a8957cf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", - "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)" + "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", + "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index a61e278d9..7a1991f25 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7534,7 +7534,8 @@ "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", - "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)" + "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 6a51e0eeb..d8cbfe8b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7555,7 +7555,8 @@ "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", - "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)" + "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", + "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 9a295f8a3..96f34bdd9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,9 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "moodLabelCiphertext") { + return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); + } if (catalogue === "cycleCiphertext") { return t("admin.section.backups.restoreSkippedCycleCiphertext"); } diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 4862a41a3..c04859d87 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -40,9 +40,12 @@ import type { PrismaClient, SecondarySymptom, } from "@/generated/prisma/client"; -import { decrypt, encrypt } from "@/lib/crypto"; +import { + openSealedForExport, + sealForRestore, + UNOPENED, +} from "@/lib/export/sealed-text"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; -import { getEvent } from "@/lib/logging/context"; import type { BackupPayload } from "@/lib/validations/backup"; import { recordUnknownKeys, @@ -189,26 +192,12 @@ export interface CycleSensitiveFields { contraceptive?: string | null; } -/** Open a sealed value for a portable file, or the unreadable marker. */ -function openForExport(sealed: string, field: string): string { - try { - return decrypt(sealed); - } catch (err) { - getEvent()?.addWarning( - `cycle ${field} decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return UNREADABLE_EXPORT_MARKER; - } -} - /** The sensitive envelope opened for a portable file, or the marker. */ function openSensitiveForExport( sealed: string | null, ): CycleSensitiveFields | string | null { if (!sealed) return null; - const opened = openForExport(sealed, "sensitive envelope"); + const opened = openSealedForExport(sealed, "cycle sensitive envelope"); if (opened === UNREADABLE_EXPORT_MARKER) return opened; try { return JSON.parse(opened) as CycleSensitiveFields; @@ -330,7 +319,7 @@ export async function buildCycleBackupSection( : { sensitive: openSensitiveForExport(d.sensitiveEncrypted), note: d.notesEncrypted - ? openForExport(d.notesEncrypted, "day-log note") + ? openSealedForExport(d.notesEncrypted, "cycle day-log note") : null, }), source: d.source, @@ -353,7 +342,7 @@ export async function buildCycleBackupSection( ? { labelEncrypted: sym.labelEncrypted } : { label: sym.labelEncrypted - ? openForExport(sym.labelEncrypted, "symptom label") + ? openSealedForExport(sym.labelEncrypted, "cycle symptom label") : null, }), })), @@ -447,39 +436,6 @@ function severityOrNull(value: number | null | undefined): number | null { : null; } -/** A readable value the file could not supply: the writer could not open it. */ -const UNOPENED = Symbol("unopened"); - -/** - * The sealed column value for one free-text field from the file. - * - * Ciphertext is written back only when this host opens it — a value sealed - * under a key this host does not hold (or the same key id over other key - * material) would be a row nobody can read. A readable value is sealed under - * this host's active key. Anything kept out is recorded under `path`. - */ -function sealForRestore( - sealed: string | null | undefined, - readable: string | null | undefined | typeof UNOPENED, - path: string, - unopened: string[], -): string | null { - if (sealed) { - try { - decrypt(sealed); - return sealed; - } catch { - unopened.push(path); - return null; - } - } - if (readable === UNOPENED) { - unopened.push(path); - return null; - } - return readable ? encrypt(readable) : null; -} - function enumOrNull( value: string | null | undefined, allow: ReadonlySet, diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index aead1cb94..c4113125e 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -101,8 +101,9 @@ export function isEncryptedMember(name: string): boolean { * Sections whose restore opens every inner value with this host's keys itself, * and keeps out (and names in the skip report) any value it cannot open. * - * The cycle day-logs and custom cycle symptoms are here because a portable - * file written before v1.40 carried their free text as the stored ciphertext, + * The cycle day-logs, custom cycle symptoms and custom mood tags and mood + * categories are here because a portable file written before v1.40 carried + * their free text as the stored ciphertext, * not as readable text. Such a file is still restorable onto another host: * what that host can open comes back, what it cannot stays out of the row and * is reported. Refusing the whole file over a note would cost every other @@ -114,6 +115,8 @@ export function isEncryptedMember(name: string): boolean { export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ "cycleDayLogs", "customSymptoms", + "customMoodTags", + "customMoodTagCategories", ]); interface KeyUse { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9bd1b84d7..f96f79935 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; +import { openSealedForExport } from "@/lib/export/sealed-text"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; import { @@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload( icon: category.icon, sortOrder: category.sortOrder, isActive: category.isActive, - // Ciphertext verbatim, like the tag label beside it. - labelEncrypted: category.labelEncrypted, + // The person's own words: ciphertext in a disaster-recovery payload, + // readable in a portable one (see `sealed-text.ts`). + ...(disasterRecovery + ? { labelEncrypted: category.labelEncrypted } + : { + label: category.labelEncrypted + ? openSealedForExport( + category.labelEncrypted, + "mood category label", + ) + : null, + }), })), hiddenMoodTags: hiddenMoodTags.map((hidden) => ({ key: hidden.moodTag.key, @@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload( isActive: tag.isActive, icon: tag.icon, sortOrder: tag.sortOrder, - // Ciphertext verbatim, like every other *Encrypted column in a DR - // payload: the same instance's key reads it back unchanged. - labelEncrypted: tag.labelEncrypted, + // Ciphertext in a disaster-recovery payload (the same instance's key + // reads it back), readable in a portable one (see `sealed-text.ts`). + ...(disasterRecovery + ? { labelEncrypted: tag.labelEncrypted } + : { + label: tag.labelEncrypted + ? openSealedForExport(tag.labelEncrypted, "mood tag label") + : null, + }), scaleMin: tag.scaleMin, scaleMax: tag.scaleMax, inverse: tag.inverse, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 0ac7e1a68..edf38d815 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -66,6 +66,7 @@ import { listTenantEdges, } from "@/lib/export/tenant-boundary"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { sealForRestore } from "@/lib/export/sealed-text"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptNoteToBytes } from "@/lib/labs/store"; @@ -1533,6 +1534,8 @@ export async function restoreBackup( // Upserted by key for the same reason the tags below are: `key` is // globally unique, so a seeded category that later claims the key // must be left alone rather than overwritten by a restore. + // Labels this host's keys do not open, by file path. + const unopenedMoodLabels: string[] = []; for (const category of payload.customMoodTagCategories) { await tx.moodTagCategory.upsert({ where: { key: category.key }, @@ -1544,7 +1547,12 @@ export async function restoreBackup( icon: category.icon ?? null, sortOrder: category.sortOrder ?? 0, isActive: category.isActive ?? true, - labelEncrypted: category.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + category.labelEncrypted, + category.label, + `customMoodTagCategories.${category.key}.labelEncrypted`, + unopenedMoodLabels, + ), }, update: {}, }); @@ -1567,7 +1575,12 @@ export async function restoreBackup( isActive: tag.isActive, icon: tag.icon ?? null, sortOrder: tag.sortOrder, - labelEncrypted: tag.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + tag.labelEncrypted, + tag.label, + `customMoodTags.${tag.key}.labelEncrypted`, + unopenedMoodLabels, + ), scaleMin: tag.scaleMin, scaleMax: tag.scaleMax, inverse: tag.inverse, @@ -1577,6 +1590,12 @@ export async function restoreBackup( update: {}, }); } + recordUnknownKeys( + skips, + "moodLabelCiphertext", + unopenedMoodLabels, + unopenedMoodLabels, + ); // Which tags the account hid. Delete-then-recreate like every // other section, and resolved by KEY: what people hide is almost diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 2b2ca30fc..e292b78e4 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -160,6 +160,11 @@ * Writing it back would store a value no reader can open, so the FIELD is kept * out and the day or the symptom restores without it. Its key is the path in * the file (`cycleDayLogs..notesEncrypted`), never the value. + * + * The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood + * tag or mood category the account created + * (`customMoodTags..labelEncrypted`): the tag or category restores, under + * its key, without the person's own name for it. */ export type SkippedCatalogue = | "cycleSymptom" @@ -181,7 +186,8 @@ export type SkippedCatalogue = | "checkupClosure" | "accountSetting" | "symptomEpisodeReference" - | "cycleCiphertext"; + | "cycleCiphertext" + | "moodLabelCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/sealed-text.ts b/src/lib/export/sealed-text.ts new file mode 100644 index 000000000..c1e28a3c7 --- /dev/null +++ b/src/lib/export/sealed-text.ts @@ -0,0 +1,70 @@ +/** + * Both ends of a sealed text column stored with the string codec + * (`.`), for the backup sections that carry one: the cycle + * day-log note and sensitive envelope, custom cycle symptom labels, and the + * custom mood tag and mood category labels. + * + * The split is the one every other section uses. A disaster-recovery file + * carries the stored ciphertext, which the same instance's key reads back. A + * portable file carries the readable value (or the unreadable marker when + * the writing host could not open it), and the restore seals it under the + * receiving host's active key, because ciphertext from one host is noise on + * another. + * + * Portable files written before v1.40 carried these columns as ciphertext. + * They still restore: a ciphertext value is written back only when this + * host's keys open it. One that does not open is kept out of the row and its + * file path is recorded, so the caller can name it in the skip report rather + * than store a value nobody can read. That per-value check is why the key + * preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`). + */ +import { decrypt, encrypt } from "@/lib/crypto"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { getEvent } from "@/lib/logging/context"; + +/** Open a sealed value for a portable file, or the unreadable marker. */ +export function openSealedForExport(sealed: string, field: string): string { + try { + return decrypt(sealed); + } catch (err) { + getEvent()?.addWarning( + `${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** A readable value the file could not supply: the writer could not open it. */ +export const UNOPENED: unique symbol = Symbol("unopened"); + +/** + * The sealed column value for one free-text field from the file. + * + * Ciphertext is written back only when this host opens it — a value sealed + * under a key this host does not hold (or the same key id over other key + * material) would be a row nobody can read. A readable value is sealed under + * this host's active key. Anything kept out is recorded under `path`. + */ +export function sealForRestore( + sealed: string | null | undefined, + readable: string | null | undefined | typeof UNOPENED, + path: string, + unopened: string[], +): string | null { + if (sealed) { + try { + decrypt(sealed); + return sealed; + } catch { + unopened.push(path); + return null; + } + } + if (readable === UNOPENED) { + unopened.push(path); + return null; + } + return readable ? encrypt(readable) : null; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ad0334bcc..a96fa893a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -717,10 +717,12 @@ const customMoodTagSchema = z isActive: z.boolean().default(true), icon: z.string().nullable().optional(), sortOrder: z.number().int().default(0), - // The user's own words for the tag, encrypted at rest and carried - // verbatim. Without it the tag comes back as a bare key and the person - // who named it "Migräne" gets `custom:cm3x9…` instead. + // The user's own words for the tag. Without it the tag comes back as a + // bare key and the person who named it "Migräne" gets `custom:cm3x9…` + // instead. Ciphertext on a disaster-recovery file, readable `label` on a + // portable one; the restore seals the readable value under its own key. labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), // The scale a RATED factor was recorded on. `inverse` marks a factor // where a HIGH value is bad (stress, conflict). Dropping it does not lose // a label, it silently reverses the meaning of every rating already @@ -1642,7 +1644,9 @@ const customMoodTagCategorySchema = z icon: z.string().nullable().optional(), sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), + // Same split as the custom tag's label. labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), }) .passthrough(); diff --git a/tests/integration/backup-mood-label-cross-key-restore.test.ts b/tests/integration/backup-mood-label-cross-key-restore.test.ts new file mode 100644 index 000000000..e2bb51883 --- /dev/null +++ b/tests/integration/backup-mood-label-cross-key-restore.test.ts @@ -0,0 +1,266 @@ +/** + * A portable file's custom mood labels survive a move to a host with other + * keys. + * + * The labels of the mood tags and mood tag categories a person created are + * sealed under the writing host's key. A portable file used to carry them as + * the stored ciphertext, so a restore onto a host with a different key + * refused the file at the key check, or with a matching key id over other key + * material wrote labels nobody could open. + * + * Both ends and the pipe: built by the real `buildFullBackupPayload` under key + * A, restored through the real route under key B, opened under key B. The + * second case is a file written before the fix: a label this host opens comes + * back, one it cannot open stays out and is named in the skip report. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const CATEGORY_LABEL = "Evenings"; +const TAG_LABEL = "Migraine"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "mood-cross-key-owner", + email: "mood-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "MOOD_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +/** + * One mood entry. The restore writes the account's own tags and categories + * inside its mood-entry branch, so a file without entries never reaches them. + */ +async function seedMoodEntry(userId: string) { + await getPrismaClient().moodEntry.create({ + data: { + userId, + date: "2026-07-01", + mood: "GUT", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + }, + }); +} + +describe("custom mood labels in a portable file restored under another key", () => { + it("come back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + await seedMoodEntry(owner.id); + const category = await prisma.moodTagCategory.create({ + data: { + userId: owner.id, + key: "customcat:cross-key", + labelKey: "mood.category.custom", + labelEncrypted: encrypt(CATEGORY_LABEL), + }, + }); + await prisma.moodTag.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: "custom:cross-key", + labelKey: "mood.tag.custom", + kind: "BINARY", + labelEncrypted: encrypt(TAG_LABEL), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + // Host B is another database: the rows host A wrote are not there. + useKeys(HOST_B, "b"); + await prisma.moodTag.deleteMany({ where: { userId: owner.id } }); + await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } }); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "customcat:cross-key" }, + }); + expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL); + const restoredTag = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "custom:cross-key" }, + }); + expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's label ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_B, "b"); + const ownLabel = encrypt(TAG_LABEL); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorLabel = encrypt(CATEGORY_LABEL); + + // The shape a portable file had before the fix: labels as ciphertext. + useKeys(HOST_B, "b"); + await seedMoodEntry(owner.id); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + file.customMoodTagCategories = [ + { + id: "legacy-category-id", + key: "customcat:legacy", + labelKey: "mood.category.custom", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: impostorLabel, + }, + ]; + file.customMoodTags = [ + { + key: "custom:legacy", + labelKey: "mood.tag.custom", + categoryId: "legacy-category-id", + kind: "BINARY", + isActive: true, + icon: null, + sortOrder: 0, + labelEncrypted: ownLabel, + scaleMin: 1, + scaleMax: 5, + inverse: false, + }, + ]; + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const category = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "customcat:legacy" }, + }); + expect(category.labelEncrypted).toBeNull(); + const tag = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "custom:legacy" }, + }); + expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "moodLabelCiphertext", + key: "customMoodTagCategories.customcat:legacy.labelEncrypted", + links: 1, + }, + ]); + }); +}); From c7a2eec078d48084e39b876aa37e7297299a961b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 08:31:11 +0200 Subject: [PATCH 4/7] Drop the column fallback around the medication update The update retried without pausedAt and notificationsEnabled for a database that lacked the columns. Migrations guarantee both, and inside the transaction a failed statement aborts it, so the retry could not work anyway. --- src/app/api/medications/[id]/route.ts | 41 ++++----------------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index f7e62e198..3587a4dda 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -969,42 +969,11 @@ export const PUT = apiHandler( }), }; - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); - } - } - - let updated; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { - try { - updated = await tx.medication.update({ - where: { id }, - data: candidate, - include: { schedules: true }, - }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; - } - } - if (!updated) throw lastUpdateErr; + const updated = await tx.medication.update({ + where: { id }, + data: { ...baseUpdateData, ...pausedAtPatch }, + include: { schedules: true }, + }); // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a // single live marker that resume clears, so the paused window is From 8ac3fddc3b9b6e9feafbff616b7835d87aee8824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:19:31 +0200 Subject: [PATCH 5/7] Carry visit and vaccination free text readable in a portable backup A practitioner's note, a visit's reason, outcome and body site and a vaccination's note travelled as stored ciphertext in a portable file, the same defect the cycle and mood sections had. They now go through the shared sealed-text helpers: readable in a portable file, sealed under the receiving host's key on restore, ciphertext kept only where this host opens it and otherwise reported as visitCiphertext or vaccinationCiphertext. The practitioner address and phone ciphertext of a disaster-recovery file gets the same check, since the key preflight now leaves these sections to it. --- docs/api/openapi.yaml | 20 ++ messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- src/components/admin/backups-section.tsx | 6 + src/lib/export/backup-key-ids.ts | 10 +- src/lib/export/restore-skips.ts | 10 +- src/lib/export/sealed-text.ts | 72 ++++- src/lib/export/vaccinations-backup.ts | 48 +-- src/lib/export/visits-backup.ts | 140 ++++++--- src/lib/validations/backup.ts | 12 +- .../backup-visits-cross-key-restore.test.ts | 296 ++++++++++++++++++ .../integration/encounter-procedures.test.ts | 5 +- 17 files changed, 559 insertions(+), 88 deletions(-) create mode 100644 tests/integration/backup-visits-cross-key-restore.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9f672d83b..59f2766f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38448,6 +38448,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -38515,6 +38519,18 @@ components: - type: string minLength: 1 - type: "null" + reason: + anyOf: + - type: string + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" laterality: anyOf: - type: string @@ -38671,6 +38687,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 diff --git a/messages/de.json b/messages/de.json index d50600800..11af9ce58 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", - "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)" + "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)", + "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", + "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index b2a3d92c6..e1d6f6fed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", - "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)" + "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)", + "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", + "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index daf60d074..a5b8de087 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", - "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)" + "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)", + "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", + "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 29378a5a1..7e023dc5a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", - "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)" + "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)", + "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", + "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 00a8957cf..9e4a387e9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", - "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)" + "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)", + "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", + "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 7a1991f25..9ef99ba16 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7535,7 +7535,9 @@ "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", - "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)" + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", + "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index d8cbfe8b5..d3c92bc44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7556,7 +7556,9 @@ "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", - "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)" + "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)", + "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", + "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 96f34bdd9..22b557d71 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,12 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "visitCiphertext") { + return t("admin.section.backups.restoreSkippedVisitCiphertext"); + } + if (catalogue === "vaccinationCiphertext") { + return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); + } if (catalogue === "moodLabelCiphertext") { return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); } diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index c4113125e..88bb09ad6 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -101,9 +101,10 @@ export function isEncryptedMember(name: string): boolean { * Sections whose restore opens every inner value with this host's keys itself, * and keeps out (and names in the skip report) any value it cannot open. * - * The cycle day-logs, custom cycle symptoms and custom mood tags and mood - * categories are here because a portable file written before v1.40 carried - * their free text as the stored ciphertext, + * The cycle day-logs, custom cycle symptoms, custom mood tags and mood + * categories, practitioners, visits and vaccinations are here because a + * portable file written before v1.40 carried their free text as the stored + * ciphertext, * not as readable text. Such a file is still restorable onto another host: * what that host can open comes back, what it cannot stays out of the row and * is reported. Refusing the whole file over a note would cost every other @@ -117,6 +118,9 @@ export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ "customSymptoms", "customMoodTags", "customMoodTagCategories", + "practitioners", + "encounters", + "vaccinations", ]); interface KeyUse { diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e292b78e4..c8bb1fd92 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -165,6 +165,12 @@ * tag or mood category the account created * (`customMoodTags..labelEncrypted`): the tag or category restores, under * its key, without the person's own name for it. + * + * The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`, + * are the same again for the free text of the visits record (a practitioner's + * note, address and phone; a visit's reason, outcome and body site) and of a + * vaccination's note (`vaccinations..noteEncrypted`). The row restores + * without the field. */ export type SkippedCatalogue = | "cycleSymptom" @@ -187,7 +193,9 @@ export type SkippedCatalogue = | "accountSetting" | "symptomEpisodeReference" | "cycleCiphertext" - | "moodLabelCiphertext"; + | "moodLabelCiphertext" + | "visitCiphertext" + | "vaccinationCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/sealed-text.ts b/src/lib/export/sealed-text.ts index c1e28a3c7..4773d6186 100644 --- a/src/lib/export/sealed-text.ts +++ b/src/lib/export/sealed-text.ts @@ -1,8 +1,9 @@ /** - * Both ends of a sealed text column stored with the string codec - * (`.`), for the backup sections that carry one: the cycle - * day-log note and sensitive envelope, custom cycle symptom labels, and the - * custom mood tag and mood category labels. + * Both ends of a sealed free-text column in a backup section: the string + * codec (`.` in a text column — the cycle day-log note and + * sensitive envelope, custom cycle symptom and mood labels) and the shared + * bytes codec (the practitioner note, the visit's reason, outcome and body + * site, the vaccination note). * * The split is the one every other section uses. A disaster-recovery file * carries the stored ciphertext, which the same instance's key reads back. A @@ -18,6 +19,9 @@ * than store a value nobody can read. That per-value check is why the key * preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`). */ +import { Buffer } from "node:buffer"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { decrypt, encrypt } from "@/lib/crypto"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { getEvent } from "@/lib/logging/context"; @@ -68,3 +72,63 @@ export function sealForRestore( } return readable ? encrypt(readable) : null; } + +/** + * The `Bytes` twin of {@link openSealedForExport}, for columns written with + * the shared bytes codec (`encryptToBytes`): the practitioner note, the + * visit's reason, outcome and body site, the vaccination note. + */ +export function openSealedBytesForExport( + sealed: Uint8Array | null, + field: string, +): string | null { + if (!sealed || sealed.byteLength === 0) return null; + try { + return decryptFromBytes(sealed); + } catch (err) { + getEvent()?.addWarning( + `${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */ +export function encodeSealedBytes(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +function decodeBase64(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a + * disaster-recovery (or pre-v1.40 portable) file carries; it is written back + * only when this host opens it. A readable value is sealed under this host's + * active key. + */ +export function sealBytesForRestore( + sealed: string | null | undefined, + readable: string | null | undefined, + path: string, + unopened: string[], +): Uint8Array | null { + if (sealed) { + const bytes = decodeBase64(sealed); + try { + decryptFromBytes(bytes); + return bytes; + } catch { + unopened.push(path); + return null; + } + } + return readable ? encryptToBytes(readable) : null; +} diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index f88bcc151..2e15242b0 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -35,7 +35,11 @@ * guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts` * holds. */ -import { Buffer } from "node:buffer"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { VaccinationSite } from "@/generated/prisma/client"; @@ -72,8 +76,10 @@ export interface VaccinationBackupEntry { * lacks the reminder. */ reminderId: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts { vaccinationLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model — a structural matcher binds a model * to the literal beside its delegate call, and an inline object inside a @@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection( practitionerId: row.practitionerId, encounterId: row.encounterId, reminderId: row.reminderId, - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "vaccination note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -240,16 +247,10 @@ export type RestoredVaccination = Pick< | "encounterId" | "reminderId" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's immunization log and its document links. * @@ -307,6 +308,8 @@ export async function restoreVaccinationsData( const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; const droppedReminders: string[] = []; + // Sealed notes this host's keys do not open, by file path. + const unopened: string[] = []; if (payload.vaccinations.length > 0) { await tx.vaccinationRecord.createMany({ @@ -349,10 +352,12 @@ export async function restoreVaccinationsData( practitionerId, encounterId, reminderId, - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `vaccinations.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -379,6 +384,7 @@ export async function restoreVaccinationsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened); const restoredVaccinations = new Set( payload.vaccinations.map((entry) => entry.id), diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 32ebca5f4..2957bb947 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -33,7 +33,6 @@ * result ids, so a lab link in one cannot resolve; a disaster-recovery * payload carries every id and they all do. */ -import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { EncounterKind, @@ -42,6 +41,11 @@ import type { } from "@/generated/prisma/client"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import { readPractitionerContact } from "@/lib/practitioners/dto"; import { recordUnknownKeys, @@ -71,8 +75,10 @@ export interface PractitionerBackupEntry { locationEncrypted?: string | null; /** Base64 ciphertext of the phone number, disaster-recovery payloads only. */ phoneEncrypted?: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -87,10 +93,15 @@ export interface EncounterBackupEntry { status: EncounterStatus; kind: EncounterKind; practitionerId: string | null; - reasonEncrypted: string | null; - outcomeEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + reasonEncrypted?: string | null; + outcomeEncrypted?: string | null; /** The procedure's body site, ciphertext like the two above (v1.39.1). */ - bodySiteEncrypted: string | null; + bodySiteEncrypted?: string | null; + /** The three readable; portable payloads only. */ + reason?: string | null; + outcome?: string | null; + bodySite?: string | null; laterality: Laterality | null; /** * The appointment's reminder row, which restores in the same run since @@ -124,12 +135,6 @@ export interface VisitsBackupCounts { encounterLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model. * @@ -256,10 +261,10 @@ export async function buildVisitsBackupSection( ? { location: null, phone: null, - locationEncrypted: encodeCiphertext( + locationEncrypted: encodeSealedBytes( row.locationEncrypted ?? encryptNote(row.location), ), - phoneEncrypted: encodeCiphertext( + phoneEncrypted: encodeSealedBytes( row.phoneEncrypted ?? encryptNote(row.phone), ), } @@ -275,7 +280,14 @@ export async function buildVisitsBackupSection( "phone", ), }), - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "practitioner note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -288,9 +300,26 @@ export async function buildVisitsBackupSection( status: row.status, kind: row.kind, practitionerId: row.practitionerId, - reasonEncrypted: encodeCiphertext(row.reasonEncrypted), - outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted), - bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted), + ...(disasterRecovery + ? { + reasonEncrypted: encodeSealedBytes(row.reasonEncrypted), + outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted), + bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted), + } + : { + reason: openSealedBytesForExport( + row.reasonEncrypted, + "visit reason", + ), + outcome: openSealedBytesForExport( + row.outcomeEncrypted, + "visit outcome", + ), + bodySite: openSealedBytesForExport( + row.bodySiteEncrypted, + "visit body site", + ), + }), laterality: row.laterality, reminderId: row.reminderId, createdAt: row.createdAt.toISOString(), @@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick< | "locationEncrypted" | "phoneEncrypted" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; @@ -394,18 +424,14 @@ export type RestoredEncounter = Pick< | "reasonEncrypted" | "outcomeEncrypted" | "bodySiteEncrypted" + | "reason" + | "outcome" + | "bodySite" | "laterality" | "reminderId" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's visits, address book and link rows. * @@ -440,6 +466,9 @@ export async function restoreVisitsData( where: { userId: ownerId }, }); + // Sealed values this host's keys do not open, by file path. + const unopened: string[] = []; + if (payload.practitioners.length > 0) { await tx.practitioner.createMany({ data: payload.practitioners.map((entry) => ({ @@ -451,18 +480,24 @@ export async function restoreVisitsData( // v1.39.4 — ciphertext wins when a disaster-recovery file carried it; // a portable file's (or an older file's) readable value is sealed // under this instance's key. The readable columns are never written. - locationEncrypted: - entry.locationEncrypted != null - ? decodeCiphertext(entry.locationEncrypted) - : encryptNote(entry.location), - phoneEncrypted: - entry.phoneEncrypted != null - ? decodeCiphertext(entry.phoneEncrypted) - : encryptNote(entry.phone), - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + locationEncrypted: sealBytesForRestore( + entry.locationEncrypted, + entry.location, + `practitioners.${entry.id}.locationEncrypted`, + unopened, + ), + phoneEncrypted: sealBytesForRestore( + entry.phoneEncrypted, + entry.phone, + `practitioners.${entry.id}.phoneEncrypted`, + unopened, + ), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `practitioners.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -517,20 +552,26 @@ export async function restoreVisitsData( status: entry.status, kind: entry.kind, practitionerId, - reasonEncrypted: - entry.reasonEncrypted == null - ? null - : decodeCiphertext(entry.reasonEncrypted), - outcomeEncrypted: - entry.outcomeEncrypted == null - ? null - : decodeCiphertext(entry.outcomeEncrypted), + reasonEncrypted: sealBytesForRestore( + entry.reasonEncrypted, + entry.reason, + `encounters.${entry.id}.reasonEncrypted`, + unopened, + ), + outcomeEncrypted: sealBytesForRestore( + entry.outcomeEncrypted, + entry.outcome, + `encounters.${entry.id}.outcomeEncrypted`, + unopened, + ), // Absent in a file written before v1.39.1: the visit comes back // with no site, which is what it had. - bodySiteEncrypted: - entry.bodySiteEncrypted == null - ? null - : decodeCiphertext(entry.bodySiteEncrypted), + bodySiteEncrypted: sealBytesForRestore( + entry.bodySiteEncrypted, + entry.bodySite, + `encounters.${entry.id}.bodySiteEncrypted`, + unopened, + ), laterality: entry.laterality ?? null, reminderId, createdAt: new Date(entry.createdAt), @@ -553,6 +594,7 @@ export async function restoreVisitsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "visitCiphertext", unopened, unopened); const restoredEncounters = new Set(payload.encounters.map((e) => e.id)); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index a96fa893a..f0a31c31f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1207,8 +1207,9 @@ const symptomDefinitionBackupSchema = z * * `id` is required on both records, unlike the lab result above: a link * addresses an encounter and its far side by id and nothing else on either row - * is unique enough to rebuild the reference from. The ciphertext columns ride - * verbatim as base64 — a visit note is never decrypted into the file. + * is unique enough to rebuild the reference from. Free text follows the note + * contract: ciphertext as base64 on a disaster-recovery file, the readable + * value on a portable one (sealed under the receiving host's key on restore). */ const practitionerBackupSchema = z .object({ @@ -1222,6 +1223,7 @@ const practitionerBackupSchema = z locationEncrypted: base64BytesSchema.nullable().optional(), phoneEncrypted: base64BytesSchema.nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), @@ -1240,6 +1242,10 @@ const encounterBackupSchema = z // v1.39.1 — the procedure's body site and side. Optional so a file written // before they existed still parses; the visit restores with neither. bodySiteEncrypted: base64BytesSchema.nullable().optional(), + // The readable reason, outcome and body site of a portable file. + reason: z.string().nullable().optional(), + outcome: z.string().nullable().optional(), + bodySite: z.string().nullable().optional(), laterality: z.enum(Laterality).nullable().optional(), // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. @@ -1281,7 +1287,9 @@ const vaccinationBackupSchema = z // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), + // Ciphertext on a disaster-recovery file, readable on a portable one. noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), diff --git a/tests/integration/backup-visits-cross-key-restore.test.ts b/tests/integration/backup-visits-cross-key-restore.test.ts new file mode 100644 index 000000000..234f6c136 --- /dev/null +++ b/tests/integration/backup-visits-cross-key-restore.test.ts @@ -0,0 +1,296 @@ +/** + * A portable file's visit and vaccination free text survives a move to a host + * with other keys. + * + * A practitioner's note, a visit's reason, outcome and body site and a + * vaccination's note are sealed under the writing host's key. A portable file + * used to carry them as the stored ciphertext, so a restore onto a host with + * a different key refused the file at the key check, or with a matching key id + * over other key material wrote values nobody could open. + * + * Both ends and the pipe: built by the real `buildFullBackupPayload` under key + * A, restored through the real route under key B, opened under key B. The + * second case is a file written before the fix: a value this host opens comes + * back, one it cannot open stays out and is named in the skip report. + */ +import { Buffer } from "node:buffer"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "visits-cross-key-owner", + email: "visits-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "VISITS_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("visit and vaccination free text restored under another key", () => { + it("comes back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + const practitioner = await prisma.practitioner.create({ + data: { + userId: owner.id, + name: "Practice", + noteEncrypted: encryptToBytes("ring the upper bell"), + locationEncrypted: encryptToBytes("Main street 1"), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-06-30T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + reasonEncrypted: encryptToBytes("annual check"), + outcomeEncrypted: encryptToBytes("all fine"), + bodySiteEncrypted: encryptToBytes("left knee"), + }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + vaccineName: "Tetanus", + noteEncrypted: encryptToBytes("sore arm for a day"), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + useKeys(HOST_B, "b"); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: practitioner.id }, + }); + expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell"); + expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1"); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: encounter.id }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine"); + expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee"); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: vaccination.id }, + }); + expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day"); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_B, "b"); + const ownReason = b64(encryptToBytes("annual check")); + useKeys(HOST_A, "a"); + const foreignNote = b64(encryptToBytes("written under key a")); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorOutcome = b64(encryptToBytes("same id, other key")); + const impostorVaccination = b64(encryptToBytes("same id, other key")); + + // The shape a portable file had before the fix: ciphertext, no text. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + const at = "2026-06-30T08:00:00.000Z"; + file.practitioners = [ + { + id: "legacy-practitioner", + name: "Practice", + specialty: null, + practice: null, + location: null, + phone: null, + noteEncrypted: foreignNote, + createdAt: at, + updatedAt: at, + }, + ]; + file.encounters = [ + { + id: "legacy-encounter", + occurredAt: at, + status: "DONE", + kind: "ROUTINE", + practitionerId: "legacy-practitioner", + reasonEncrypted: ownReason, + outcomeEncrypted: impostorOutcome, + bodySiteEncrypted: null, + laterality: null, + reminderId: null, + createdAt: at, + updatedAt: at, + }, + ]; + file.vaccinations = [ + { + id: "legacy-vaccination", + occurredAt: at, + vaccineName: "Tetanus", + noteEncrypted: impostorVaccination, + createdAt: at, + updatedAt: at, + }, + ]; + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: "legacy-practitioner" }, + }); + expect(p.noteEncrypted).toBeNull(); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: "legacy-encounter" }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(e.outcomeEncrypted).toBeNull(); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: "legacy-vaccination" }, + }); + expect(v.vaccineName).toBe("Tetanus"); + expect(v.noteEncrypted).toBeNull(); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "vaccinationCiphertext", + key: "vaccinations.legacy-vaccination.noteEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "encounters.legacy-encounter.outcomeEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "practitioners.legacy-practitioner.noteEncrypted", + links: 1, + }, + ]); + }); +}); diff --git a/tests/integration/encounter-procedures.test.ts b/tests/integration/encounter-procedures.test.ts index 31198c652..036aa2416 100644 --- a/tests/integration/encounter-procedures.test.ts +++ b/tests/integration/encounter-procedures.test.ts @@ -343,7 +343,10 @@ describe("the backup", () => { const entry = section.encounters.find((row) => row.id === created.id); expect(entry?.kind).toBe("PROCEDURE"); expect(entry?.laterality).toBe("BOTH"); - expect(entry?.bodySiteEncrypted).toEqual(expect.any(String)); + // A portable section carries the site readable, never the ciphertext; the + // restore seals it under this host's key. + expect(entry?.bodySite).toBe("Hüfte"); + expect(entry).not.toHaveProperty("bodySiteEncrypted"); // Through the file schema, as a restore reads it: a field the schema does // not know would be dropped here and come back as null. From b36190b2dfd9c31bf8aadc8af430c84148071615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:19:32 +0200 Subject: [PATCH 6/7] Restore custom mood tags without mood entries The account's own mood categories, tags and hidden set were written only inside the restore's mood-entry branch, so a vocabulary set up before the first entry did not come back. They are written whether or not the file carries an entry. --- src/lib/export/restore-backup.ts | 244 +++++++++--------- ...ackup-mood-label-cross-key-restore.test.ts | 23 +- 2 files changed, 128 insertions(+), 139 deletions(-) diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index edf38d815..e0bd34eef 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1515,6 +1515,130 @@ export async function restoreBackup( } reportSection("mood"); + // The account's own categories, tags and hidden set are written + // whether or not the file carries a mood entry: an account that set + // up its mood vocabulary before logging a single entry has them too, + // and they used to be written only inside the entries branch. + // The account's own CATEGORIES go in before its tags. A custom + // tag's `categoryId` is a real foreign key, so writing the tag + // first threw `mood_tags_category_id_fkey` and rolled the entire + // restore back — an account that had grouped its own mood factors + // could not be restored at all, and the failure named a constraint + // rather than the thing that was missing. + // + // Upserted by key for the same reason the tags below are: `key` is + // globally unique, so a seeded category that later claims the key + // must be left alone rather than overwritten by a restore. + // Labels this host's keys do not open, by file path. + const unopenedMoodLabels: string[] = []; + for (const category of payload.customMoodTagCategories) { + await tx.moodTagCategory.upsert({ + where: { key: category.key }, + create: { + id: category.id, + userId: ownerId, + key: category.key, + labelKey: category.labelKey, + icon: category.icon ?? null, + sortOrder: category.sortOrder ?? 0, + isActive: category.isActive ?? true, + labelEncrypted: sealForRestore( + category.labelEncrypted, + category.label, + `customMoodTagCategories.${category.key}.labelEncrypted`, + unopenedMoodLabels, + ), + }, + update: {}, + }); + } + + // Re-create the account's own tag definitions first. The lookup + // below used to ask only for the seeded catalogue (`userId: null`), + // so a single custom rated tag made the whole restore throw + // "Unknown mood factor keys" — the file was complete and unusable. + for (const tag of payload.customMoodTags) { + await tx.moodTag.upsert({ + where: { key: tag.key }, + create: { + ...(tag.id ? { id: tag.id } : {}), + userId: ownerId, + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + icon: tag.icon ?? null, + sortOrder: tag.sortOrder, + labelEncrypted: sealForRestore( + tag.labelEncrypted, + tag.label, + `customMoodTags.${tag.key}.labelEncrypted`, + unopenedMoodLabels, + ), + scaleMin: tag.scaleMin, + scaleMax: tag.scaleMax, + inverse: tag.inverse, + }, + // `key` is globally unique, so a seeded key would collide. + // Leave the catalogue row alone; the links resolve either way. + update: {}, + }); + } + recordUnknownKeys( + skips, + "moodLabelCiphertext", + unopenedMoodLabels, + unopenedMoodLabels, + ); + + // Which tags the account hid. Delete-then-recreate like every + // other section, and resolved by KEY: what people hide is almost + // always a seeded tag, whose id differs on every instance. + // + // A key this instance no longer knows costs one hidden row and is + // REPORTED. Dropping it silently would un-hide a factor the person + // had deliberately taken out of their mood editor, and they would + // have no way to know why it came back. + await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); + if (payload.hiddenMoodTags.length > 0) { + const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); + const hiddenRows = await tx.moodTag.findMany({ + where: { + key: { in: [...new Set(hiddenKeys)] }, + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const hiddenByKey = new Map( + hiddenRows.map((row) => [row.key, row.id]), + ); + const unresolvedHidden: string[] = []; + const writableHidden = payload.hiddenMoodTags.filter((entry) => { + if (hiddenByKey.has(entry.key)) return true; + unresolvedHidden.push(entry.key); + return false; + }); + if (writableHidden.length > 0) { + await tx.moodTagHidden.createMany({ + data: writableHidden.map((entry) => ({ + userId: ownerId, + moodTagId: hiddenByKey.get(entry.key)!, + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + })), + skipDuplicates: true, + }); + } + recordUnknownKeys( + skips, + "moodTag", + [...new Set(unresolvedHidden)], + unresolvedHidden, + ); + } + if (payload.moodEntries.length > 0) { const referencedFactorKeys = payload.moodEntries.flatMap((entry) => entry.factors.map((factor) => factor.key), @@ -1524,126 +1648,6 @@ export async function restoreBackup( (entry) => entry.structuredTags, ); const tagKeys = [...new Set(referencedTagKeys)]; - // The account's own CATEGORIES go in before its tags. A custom - // tag's `categoryId` is a real foreign key, so writing the tag - // first threw `mood_tags_category_id_fkey` and rolled the entire - // restore back — an account that had grouped its own mood factors - // could not be restored at all, and the failure named a constraint - // rather than the thing that was missing. - // - // Upserted by key for the same reason the tags below are: `key` is - // globally unique, so a seeded category that later claims the key - // must be left alone rather than overwritten by a restore. - // Labels this host's keys do not open, by file path. - const unopenedMoodLabels: string[] = []; - for (const category of payload.customMoodTagCategories) { - await tx.moodTagCategory.upsert({ - where: { key: category.key }, - create: { - id: category.id, - userId: ownerId, - key: category.key, - labelKey: category.labelKey, - icon: category.icon ?? null, - sortOrder: category.sortOrder ?? 0, - isActive: category.isActive ?? true, - labelEncrypted: sealForRestore( - category.labelEncrypted, - category.label, - `customMoodTagCategories.${category.key}.labelEncrypted`, - unopenedMoodLabels, - ), - }, - update: {}, - }); - } - - // Re-create the account's own tag definitions first. The lookup - // below used to ask only for the seeded catalogue (`userId: null`), - // so a single custom rated tag made the whole restore throw - // "Unknown mood factor keys" — the file was complete and unusable. - for (const tag of payload.customMoodTags) { - await tx.moodTag.upsert({ - where: { key: tag.key }, - create: { - ...(tag.id ? { id: tag.id } : {}), - userId: ownerId, - key: tag.key, - labelKey: tag.labelKey, - categoryId: tag.categoryId, - kind: tag.kind, - isActive: tag.isActive, - icon: tag.icon ?? null, - sortOrder: tag.sortOrder, - labelEncrypted: sealForRestore( - tag.labelEncrypted, - tag.label, - `customMoodTags.${tag.key}.labelEncrypted`, - unopenedMoodLabels, - ), - scaleMin: tag.scaleMin, - scaleMax: tag.scaleMax, - inverse: tag.inverse, - }, - // `key` is globally unique, so a seeded key would collide. - // Leave the catalogue row alone; the links resolve either way. - update: {}, - }); - } - recordUnknownKeys( - skips, - "moodLabelCiphertext", - unopenedMoodLabels, - unopenedMoodLabels, - ); - - // Which tags the account hid. Delete-then-recreate like every - // other section, and resolved by KEY: what people hide is almost - // always a seeded tag, whose id differs on every instance. - // - // A key this instance no longer knows costs one hidden row and is - // REPORTED. Dropping it silently would un-hide a factor the person - // had deliberately taken out of their mood editor, and they would - // have no way to know why it came back. - await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); - if (payload.hiddenMoodTags.length > 0) { - const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); - const hiddenRows = await tx.moodTag.findMany({ - where: { - key: { in: [...new Set(hiddenKeys)] }, - OR: [{ userId: null }, { userId: ownerId }], - }, - select: { id: true, key: true }, - }); - const hiddenByKey = new Map( - hiddenRows.map((row) => [row.key, row.id]), - ); - const unresolvedHidden: string[] = []; - const writableHidden = payload.hiddenMoodTags.filter((entry) => { - if (hiddenByKey.has(entry.key)) return true; - unresolvedHidden.push(entry.key); - return false; - }); - if (writableHidden.length > 0) { - await tx.moodTagHidden.createMany({ - data: writableHidden.map((entry) => ({ - userId: ownerId, - moodTagId: hiddenByKey.get(entry.key)!, - ...(entry.createdAt - ? { createdAt: new Date(entry.createdAt) } - : {}), - })), - skipDuplicates: true, - }); - } - recordUnknownKeys( - skips, - "moodTag", - [...new Set(unresolvedHidden)], - unresolvedHidden, - ); - } - const factorRows = factorKeys.length === 0 ? [] diff --git a/tests/integration/backup-mood-label-cross-key-restore.test.ts b/tests/integration/backup-mood-label-cross-key-restore.test.ts index e2bb51883..f1a0b6719 100644 --- a/tests/integration/backup-mood-label-cross-key-restore.test.ts +++ b/tests/integration/backup-mood-label-cross-key-restore.test.ts @@ -122,30 +122,16 @@ async function restore(ownerId: string, payload: unknown) { }); } -/** - * One mood entry. The restore writes the account's own tags and categories - * inside its mood-entry branch, so a file without entries never reaches them. - */ -async function seedMoodEntry(userId: string) { - await getPrismaClient().moodEntry.create({ - data: { - userId, - date: "2026-07-01", - mood: "GUT", - score: 4, - source: "MOODLOG", - moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), - }, - }); -} - describe("custom mood labels in a portable file restored under another key", () => { + // Neither case logs a mood entry: an account's own tags and categories + // travel whether or not it has entries (they used to be written only inside + // the restore's entries branch, and a vocabulary set up before the first + // entry did not come back). it("come back readable under the receiving host's key", async () => { const prisma = getPrismaClient(); const owner = await seedOwner(); useKeys(HOST_A, "a"); - await seedMoodEntry(owner.id); const category = await prisma.moodTagCategory.create({ data: { userId: owner.id, @@ -200,7 +186,6 @@ describe("custom mood labels in a portable file restored under another key", () // The shape a portable file had before the fix: labels as ciphertext. useKeys(HOST_B, "b"); - await seedMoodEntry(owner.id); const { payload: built } = await buildFullBackupPayload(prisma, owner.id); const file = JSON.parse(JSON.stringify(built)) as Record; file.customMoodTagCategories = [ From bf35779db77ae269bfd2eef6f548a06f85e65ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 10:19:32 +0200 Subject: [PATCH 7/7] Fail the round trip when a portable file carries ciphertext With every sealed column filled, the column round trip now also builds the portable file of the same account and fails on any section that carries a sealed column as stored ciphertext, unless the section is named with a reason. The disaster-recovery file of the same account is the floor, so a matcher that finds nothing cannot pass the check. --- tests/integration/backup-round-trip.test.ts | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2911cadfc..cd3018e99 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -77,6 +77,7 @@ import { } from "@/lib/labs/biomarker-store"; import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; +import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { TWO_ENDED_MODELS, USER_COLUMN_BACKUP_CLASS, @@ -3694,6 +3695,12 @@ function lostColumns( return { lost: lost.sort(), compared }; } +/** + * Sections a portable file may carry stored ciphertext in, each with the + * reason. Empty: every sealed column travels readable in a portable file. + */ +const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap = new Map(); + describe("every column of every two-ended model survives a real restore", () => { it("brings each column back with the value it left with", async () => { const prisma = getPrismaClient(); @@ -3758,6 +3765,58 @@ describe("every column of every two-ended model survives a real restore", () => }, { purpose: "disaster-recovery", exportedAt }, ); + + // ── No ciphertext in a portable file ────────────────────────────────── + // + // Every sealed column is filled at this point, so the portable file of + // this account is the one place a column still exported as stored + // ciphertext shows up. A portable file is read and restored on hosts + // with other keys; ciphertext in it is noise there, and a restore either + // refuses the file or writes values nobody can open. That class was + // fixed one section at a time (cycle, mood labels, visits, vaccinations) + // and kept coming back, because each builder decides its own encoding. + // This reads what the builders actually wrote, rather than how their + // source is spelt. Limit: it sees members named `…Encrypted` only, the + // naming the key preflight relies on too. + const sealedIn = (text: string) => { + const collector = new BackupKeyIdCollector(); + collector.visit(JSON.parse(text)); + const sections = new Map(); + for (const [, use] of collector.entries()) { + for (const section of use.sections) { + sections.set(section, (sections.get(section) ?? 0) + use.count); + } + } + return sections; + }; + // The floor: the same matcher over the disaster-recovery file of the same + // account, which carries every sealed column as ciphertext. A matcher + // that finds nothing there would make the portable check pass vacuously. + const drSealed = sealedIn(json); + expect( + [...drSealed.values()].reduce((a, b) => a + b, 0), + ).toBeGreaterThanOrEqual(40); + expect(drSealed.size).toBeGreaterThanOrEqual(15); + let portableJson = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + portableJson += chunk; + }, + { purpose: "portable-export", exportedAt }, + ); + const portableSealed = [...sealedIn(portableJson).keys()].filter( + (section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section), + ); + expect( + portableSealed, + "a portable file carries these sections' sealed columns as stored " + + "ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " + + "and seal them again on restore, or name the section in " + + "PORTABLE_CIPHERTEXT_ALLOWED with the reason", + ).toEqual([]); + await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); const backup = await prisma.dataBackup.create({