Merge pull request #1135 from MBombeck/fix/cycle-backup-and-med-put

fix(backup): carry encrypted free text readable in portable backups
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 12:23:35 +02:00
commit 32de7ecf37
31 files changed
+2336 -612

No files matched your search

+80
View File
@@ -36645,6 +36645,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:
@@ -36748,6 +36796,10 @@ components:
anyOf:
- type: string
- type: "null"
label:
anyOf:
- type: string
- type: "null"
required:
- key
- labelKey
@@ -36784,6 +36836,10 @@ components:
anyOf:
- type: string
- type: "null"
label:
anyOf:
- type: string
- type: "null"
required:
- id
- key
@@ -36841,6 +36897,10 @@ components:
anyOf:
- type: string
- type: "null"
label:
anyOf:
- type: string
- type: "null"
scaleMin:
default: 1
type: integer
@@ -38428,6 +38488,10 @@ components:
- type: string
minLength: 1
- type: "null"
note:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
minLength: 1
@@ -38495,6 +38559,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
@@ -38651,6 +38727,10 @@ components:
- type: string
minLength: 1
- type: "null"
note:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
minLength: 1
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
+5 -1
View File
@@ -7545,7 +7545,11 @@
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)"
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)",
"restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)",
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)"
},
"danger-zone": {
"title": "위험 구역",
+5 -1
View File
@@ -7566,7 +7566,11 @@
"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)",
"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",
@@ -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,
);
});
+327 -339
View File
@@ -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<void> => {
// The rollup days the tombstoned slots sat on, recomputed once the
// transaction below has committed the tombstones.
const staleRollupDays = new Set<string>();
const tombstoneOpenSlots = async (
db: Prisma.TransactionClient,
): Promise<void> => {
// 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,331 +713,337 @@ 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,
);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error:
err instanceof Error ? err.message : String(err),
},
});
}
// Deduped via the user-tz day key.
for (const row of tombstoned) {
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
}
};
// 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 },
});
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,
},
});
}
}
// 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<Record<string, unknown>> = [
{ ...baseUpdateData, ...pausedAtPatch },
];
if (hasPausedAtPatch) {
updateCandidates.push(baseUpdateData);
}
if (hasNotificationsPatch) {
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
if (hasPausedAtPatch) {
updateCandidates.push(withoutNotifications);
}
}
// 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;
let lastUpdateErr: unknown;
for (const candidate of updateCandidates) {
try {
medication = await prisma.medication.update({
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,
}),
);
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 updated = await tx.medication.update({
where: { id },
data: candidate,
data: { ...baseUpdateData, ...pausedAtPatch },
include: { schedules: true },
});
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() },
// 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() },
});
}
// 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;
});
} 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;
}
// 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 {
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),
},
});
}
}
// Apply the v1.5.5 primary-schedule grace bridge after the
@@ -1055,29 +1066,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
+12
View File
@@ -464,6 +464,18 @@ 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");
}
if (catalogue === "cycleCiphertext") {
return t("admin.section.backups.restoreSkippedCycleCiphertext");
}
if (catalogue === "medicationTarget") {
return t("admin.section.backups.restoreSkippedMedicationTarget");
}
+88 -1
View File
@@ -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<string, unknown>, 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();
});
});
+113 -22
View File
@@ -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,12 @@ import type {
PrismaClient,
SecondarySymptom,
} from "@/generated/prisma/client";
import {
openSealedForExport,
sealForRestore,
UNOPENED,
} from "@/lib/export/sealed-text";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import type { BackupPayload } from "@/lib/validations/backup";
import {
recordUnknownKeys,
@@ -91,8 +112,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 +172,40 @@ 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;
}
/** 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 = openSealedForExport(sealed, "cycle 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 +311,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
? openSealedForExport(d.notesEncrypted, "cycle day-log note")
: null,
}),
source: d.source,
externalId: d.externalId,
tz: d.tz,
@@ -277,7 +338,13 @@ export async function buildCycleBackupSection(
icon: sym.icon,
sortOrder: sym.sortOrder,
isActive: sym.isActive,
labelEncrypted: sym.labelEncrypted,
...(disasterRecovery
? { labelEncrypted: sym.labelEncrypted }
: {
label: sym.labelEncrypted
? openSealedForExport(sym.labelEncrypted, "cycle symptom label")
: null,
}),
})),
};
}
@@ -383,8 +450,9 @@ function enumOrNull<T extends string>(
* 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 +468,9 @@ export async function restoreCycleData(
payload: BackupPayload,
skips: RestoreSkipLog,
): Promise<CycleRestoreCleared> {
// 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 +559,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 +667,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 +697,8 @@ export async function restoreCycleData(
});
}
recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened);
return {
cycles: cycles.count,
cycleDayLogs: dayLogs.count,
@@ -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");
+40 -2
View File
@@ -97,6 +97,32 @@ 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, 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
* 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<string> = new Set([
"cycleDayLogs",
"customSymptoms",
"customMoodTags",
"customMoodTagCategories",
"practitioners",
"encounters",
"vaccinations",
]);
interface KeyUse {
/** How many values need this key. */
count: number;
@@ -127,7 +153,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 +286,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 +305,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)) {
+22 -5
View File
@@ -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,
+125 -102
View File
@@ -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";
@@ -1514,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),
@@ -1523,108 +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.
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: category.labelEncrypted ?? null,
},
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: tag.labelEncrypted ?? null,
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: {},
});
}
// 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
? []
+24 -1
View File
@@ -152,6 +152,25 @@
* 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.<date>.notesEncrypted`), never the value.
*
* The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood
* tag or mood category the account created
* (`customMoodTags.<key>.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.<id>.noteEncrypted`). The row restores
* without the field.
*/
export type SkippedCatalogue =
| "cycleSymptom"
@@ -172,7 +191,11 @@ export type SkippedCatalogue =
| "scheduleRevisionLink"
| "checkupClosure"
| "accountSetting"
| "symptomEpisodeReference";
| "symptomEpisodeReference"
| "cycleCiphertext"
| "moodLabelCiphertext"
| "visitCiphertext"
| "vaccinationCiphertext";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
+134
View File
@@ -0,0 +1,134 @@
/**
* Both ends of a sealed free-text column in a backup section: the string
* codec (`<keyId>.<base64>` 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
* 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 { 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";
/** 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;
}
/**
* 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<ArrayBuffer> {
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<ArrayBuffer> | null {
if (sealed) {
const bytes = decodeBase64(sealed);
try {
decryptFromBytes(bytes);
return bytes;
} catch {
unopened.push(path);
return null;
}
}
return readable ? encryptToBytes(readable) : null;
}
+27 -21
View File
@@ -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<ArrayBuffer> {
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),
+91 -49
View File
@@ -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<ArrayBuffer> {
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));
+56 -44
View File
@@ -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<ProjectedWindow> {
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<ProjectedWindow> {
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);
}
/**
+41 -5
View File
@@ -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(),
@@ -696,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
@@ -752,7 +775,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();
@@ -1181,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({
@@ -1196,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(),
@@ -1214,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.
@@ -1255,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(),
@@ -1618,7 +1652,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();
@@ -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",
@@ -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<string, string>, 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<typeof POST>[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<string, unknown>;
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);
});
});
@@ -0,0 +1,251 @@
/**
* 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<string, string>, 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<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
}
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");
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");
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
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,
},
]);
});
});
@@ -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<string, string> = 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<string, number>();
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({
@@ -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<string, string>, 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<typeof POST>[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<string, unknown>;
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,
},
]);
});
});
@@ -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");
@@ -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.
@@ -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<typeof import("@/lib/medications/courses")>()),
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);
});
});