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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 05:32:59 +02:00
1 parent 9e52285acc
commit 772da42348
5 files changed
+141 -2

No files matched your search

+5
View File
@@ -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(
+19
View File
@@ -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) ─────
{
+70
View File
@@ -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
+19
View File
@@ -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(),
@@ -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" },