From 772da42348e4212a3b02b2464025ae63b3a42ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:54:42 +0200 Subject: [PATCH] feat(backup): carry emergency profile through rotation and backup Register the three encrypted emergency columns in the rotation registry and the key-rotation script so a legacy-key drop cannot strand them. Carry all six new columns through the profile backup builder and restore (the enums by value, the free text as ciphertext in a DR payload or decrypted plaintext in a portable export) and extend the wire schema. The full-backup round-trip integration test seeds the six columns and asserts each survives an export and restore. --- scripts/rotate-encryption-key.ts | 5 ++ src/lib/crypto/encrypted-columns.ts | 19 +++++ src/lib/export/profile-backup.ts | 70 +++++++++++++++++++ src/lib/validations/backup.ts | 19 +++++ .../health-profile-backup-roundtrip.test.ts | 30 +++++++- 5 files changed, 141 insertions(+), 2 deletions(-) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index acfbe3c2a..811f55c2b 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -369,12 +369,17 @@ async function main() { // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" + // "emergencyContactsEncrypted" "emergencyImplantsEncrypted" + // "emergencyNoteEncrypted" for (const field of [ "aboutMeEncrypted", "conditionsEncrypted", "allergiesEncrypted", "coachFocusEncrypted", "pendingQuestionsEncrypted", + "emergencyContactsEncrypted", + "emergencyImplantsEncrypted", + "emergencyNoteEncrypted", ]) { results.push( await rotateBytesColumn( diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 99afe45c9..9ecca647b 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -167,6 +167,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ field: "pendingQuestionsEncrypted", kind: "bytes", }, + // v1.38 — emergency ("Notfalldaten") free text: contacts, implants/devices, + // and the ICE note. Same Bytes codec as the self-context columns above; the + // three enum companions (blood type, organ-donor, advance-directive) are + // plaintext closed sets and are deliberately NOT here. + { + model: "UserHealthProfile", + field: "emergencyContactsEncrypted", + kind: "bytes", + }, + { + model: "UserHealthProfile", + field: "emergencyImplantsEncrypted", + kind: "bytes", + }, + { + model: "UserHealthProfile", + field: "emergencyNoteEncrypted", + kind: "bytes", + }, // ───── Effective-dated health profile facts (Bytes column) ───── { diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 46011f936..955b90c87 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -32,6 +32,11 @@ import { type HealthProfileAiSection, type HealthProfileFactKind, } from "@/lib/validations/health-profile-facts"; +import type { + AdvanceDirectiveStatusValue, + EmergencyBloodTypeValue, + OrganDonorStatusValue, +} from "@/lib/validations/emergency-profile"; export interface ProfileBackupOptions { purpose?: "portable-export" | "disaster-recovery"; @@ -59,6 +64,22 @@ export interface HealthProfileBackupEntry { allergiesEncrypted?: string | null; coachFocusEncrypted?: string | null; aiIncludedSections: HealthProfileAiSection[]; + /** + * Emergency ("Notfalldaten") profile. The three enums are plaintext closed + * sets, carried by value in both purposes exactly like `aiIncludedSections`. + * The three free-text columns follow the same split as the self-context above: + * a portable export decrypts them into `emergency*`, a DR payload leaves those + * null and carries the ciphertext in `emergency*Encrypted`. + */ + emergencyBloodType: EmergencyBloodTypeValue | null; + organDonorStatus: OrganDonorStatusValue | null; + advanceDirectiveStatus: AdvanceDirectiveStatusValue | null; + emergencyContacts: string | null; + emergencyImplants: string | null; + emergencyNote: string | null; + emergencyContactsEncrypted?: string | null; + emergencyImplantsEncrypted?: string | null; + emergencyNoteEncrypted?: string | null; /** * Server-derived clarifying questions awaiting an answer, encrypted JSON. * @@ -248,6 +269,19 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + emergencyBloodType: profileRow.emergencyBloodType ?? null, + organDonorStatus: profileRow.organDonorStatus ?? null, + advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, + emergencyContacts: null, + emergencyImplants: null, + emergencyNote: null, + emergencyContactsEncrypted: toBase64( + profileRow.emergencyContactsEncrypted, + ), + emergencyImplantsEncrypted: toBase64( + profileRow.emergencyImplantsEncrypted, + ), + emergencyNoteEncrypted: toBase64(profileRow.emergencyNoteEncrypted), aboutMeEncrypted: toBase64(profileRow.aboutMeEncrypted), conditionsEncrypted: toBase64(profileRow.conditionsEncrypted), allergiesEncrypted: toBase64(profileRow.allergiesEncrypted), @@ -279,6 +313,21 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + emergencyBloodType: profileRow.emergencyBloodType ?? null, + organDonorStatus: profileRow.organDonorStatus ?? null, + advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, + emergencyContacts: decryptProfileFieldSoft( + profileRow.emergencyContactsEncrypted, + "emergencyContacts", + ), + emergencyImplants: decryptProfileFieldSoft( + profileRow.emergencyImplantsEncrypted, + "emergencyImplants", + ), + emergencyNote: decryptProfileFieldSoft( + profileRow.emergencyNoteEncrypted, + "emergencyNote", + ), } : null; @@ -640,6 +689,27 @@ export async function restoreProfileData( p.coachFocus, "coachFocus", ), + // Emergency profile. The enums restore by value (null clears); + // the three free-text columns follow the same ciphertext-or-plaintext + // resolution as the self-context columns above. + emergencyBloodType: p.emergencyBloodType, + organDonorStatus: p.organDonorStatus, + advanceDirectiveStatus: p.advanceDirectiveStatus, + emergencyContactsEncrypted: resolveProfileColumn( + p.emergencyContactsEncrypted, + p.emergencyContacts, + "emergencyContacts", + ), + emergencyImplantsEncrypted: resolveProfileColumn( + p.emergencyImplantsEncrypted, + p.emergencyImplants, + "emergencyImplants", + ), + emergencyNoteEncrypted: resolveProfileColumn( + p.emergencyNoteEncrypted, + p.emergencyNote, + "emergencyNote", + ), // Portable exports do not carry the pending questions at all, so // `undefined` means "this file has nothing to say" and the account // comes back with no prompts pending — which the next profile save diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index a9fdb0522..e22f68524 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -65,6 +65,11 @@ import { healthProfileFactKindSchema, isHealthProfileFactValue, } from "@/lib/validations/health-profile-facts"; +import { + advanceDirectiveStatusSchema, + emergencyBloodTypeSchema, + organDonorStatusSchema, +} from "@/lib/validations/emergency-profile"; export const BACKUP_SCHEMA_VERSION = "2" as const; const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; @@ -529,6 +534,20 @@ const healthProfileBackupSchema = z aiIncludedSections: z .array(healthProfileAiSectionSchema) .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), + // Emergency profile: three plaintext enums (carried by value in both + // purposes), three free-text columns following the ciphertext-or-plaintext + // split of the self-context fields above. + emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null), + organDonorStatus: organDonorStatusSchema.nullable().default(null), + advanceDirectiveStatus: advanceDirectiveStatusSchema + .nullable() + .default(null), + emergencyContacts: z.string().nullable().default(null), + emergencyImplants: z.string().nullable().default(null), + emergencyNote: z.string().nullable().default(null), + emergencyContactsEncrypted: base64BytesSchema.nullable().optional(), + emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(), + emergencyNoteEncrypted: base64BytesSchema.nullable().optional(), aboutMeEncrypted: base64BytesSchema.nullable().optional(), conditionsEncrypted: base64BytesSchema.nullable().optional(), allergiesEncrypted: base64BytesSchema.nullable().optional(), diff --git a/tests/integration/health-profile-backup-roundtrip.test.ts b/tests/integration/health-profile-backup-roundtrip.test.ts index 945e2bf1f..abe77d5ae 100644 --- a/tests/integration/health-profile-backup-roundtrip.test.ts +++ b/tests/integration/health-profile-backup-roundtrip.test.ts @@ -24,6 +24,9 @@ async function seedProfile() { role: "USER", }, }); + const emergencyContactsCiphertext = encryptToBytes( + "ICE contact, reachable on the recorded number", + ); await prisma.userHealthProfile.create({ data: { id: "profile-roundtrip-row", @@ -31,6 +34,13 @@ async function seedProfile() { aboutMeEncrypted: encryptToBytes("Works rotating shifts"), conditionsEncrypted: encryptToBytes("Asthma"), aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS", "SHIFT_SCHEDULE"], + // Emergency profile: three plaintext enums + three encrypted columns. + emergencyBloodType: "O_NEG", + organDonorStatus: "YES", + advanceDirectiveStatus: "EXISTS", + emergencyContactsEncrypted: emergencyContactsCiphertext, + emergencyImplantsEncrypted: encryptToBytes("Pacemaker fitted 2021"), + emergencyNoteEncrypted: encryptToBytes("Reacts to contrast dye"), }, }); @@ -68,13 +78,14 @@ async function seedProfile() { }, }); - return { user, currentCiphertext }; + return { user, currentCiphertext, emergencyContactsCiphertext }; } describe("health profile disaster-recovery round trip", () => { it("restores deleted encrypted profile rows and effective-dated history", async () => { const prisma = getPrismaClient(); - const { user, currentCiphertext } = await seedProfile(); + const { user, currentCiphertext, emergencyContactsCiphertext } = + await seedProfile(); const built = await buildFullBackupPayload(prisma, user.id, { purpose: "disaster-recovery", }); @@ -116,6 +127,21 @@ describe("health profile disaster-recovery round trip", () => { "SHIFT_SCHEDULE", ]); + // Every emergency column has to survive the round trip. The three enums + // come back by value; the three encrypted columns come back as ciphertext, + // and the contact column decrypts to exactly what was seeded. Dropping any + // one of the six from the profile backup builder turns the matching + // assertion red naming that column. + expect(profile.emergencyBloodType).toBe("O_NEG"); + expect(profile.organDonorStatus).toBe("YES"); + expect(profile.advanceDirectiveStatus).toBe("EXISTS"); + expect(profile.emergencyContactsEncrypted).not.toBeNull(); + expect(profile.emergencyImplantsEncrypted).not.toBeNull(); + expect(profile.emergencyNoteEncrypted).not.toBeNull(); + expect(Buffer.from(profile.emergencyContactsEncrypted!)).toEqual( + Buffer.from(emergencyContactsCiphertext), + ); + const revisions = await prisma.healthProfileFactRevision.findMany({ where: { userId: user.id }, orderBy: { validFrom: "asc" },