From 46eb8c8b94275dd8c86a7bfc60764126f921edcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 19:58:49 +0200 Subject: [PATCH] fix(backup): carry a not-yet-migrated title or note as stored Sealing a readable Coach title or custom-metric note while building a disaster-recovery file gave each export a fresh IV, so the streaming writer and the materialising builder no longer produced the same file. A row the free-text backfill has not reached yet now rides as stored, readable, inside the encrypted backup; the restore seals it, and a migrated row carries its ciphertext verbatim as before. --- .../__tests__/coach-backup-title.test.ts | 34 +++++++++++-------- .../__tests__/full-backup-payload.test.ts | 16 +++------ src/lib/export/coach-backup.ts | 22 +++++++----- src/lib/export/profile-backup.ts | 22 +++++++----- 4 files changed, 51 insertions(+), 43 deletions(-) diff --git a/src/lib/export/__tests__/coach-backup-title.test.ts b/src/lib/export/__tests__/coach-backup-title.test.ts index d7f809942..3913fca8e 100644 --- a/src/lib/export/__tests__/coach-backup-title.test.ts +++ b/src/lib/export/__tests__/coach-backup-title.test.ts @@ -1,11 +1,12 @@ /** * v1.39.3 — the Coach conversation title in both backup formats. * - * A disaster-recovery file carries the title as ciphertext only, including for - * a row the free-text backfill has not reached yet (it is sealed on the way - * out). A portable file carries it readable, for the person who owns it. A - * restore of either writes the ciphertext column and never the readable one, - * and a file written before this release (readable title only) still restores. + * A disaster-recovery file carries the title as stored: the ciphertext, or + * for a row the free-text backfill has not reached yet the old readable value + * (sealing it on the way out would make the export non-deterministic). A + * portable file carries it readable, for the person who owns it. A restore of + * any of these writes the ciphertext column and never the readable one, and a + * file written before this release (readable title only) still restores. */ import { describe, expect, it, vi } from "vitest"; @@ -50,19 +51,24 @@ const rows = [ ]; describe("coach backup title", () => { - it("a disaster-recovery file carries only ciphertext, sealing legacy rows on the way out", async () => { + it("a disaster-recovery file carries the title as stored", async () => { const section = await buildCoachBackupSection(prismaWith(rows), "u1", { purpose: "disaster-recovery", }); - const json = JSON.stringify(section); - expect(json).not.toContain("Sealed title"); - expect(json).not.toContain("Legacy readable title"); + const [sealed, legacy] = section.coachConversations; + expect(JSON.stringify(sealed)).not.toContain("Sealed title"); + expect(sealed).not.toHaveProperty("title"); expect( - section.coachConversations.map((c) => { - expect(c).not.toHaveProperty("title"); - return decryptFromBytes(Buffer.from(c.titleEncrypted!, "base64")); - }), - ).toEqual(["Sealed title", "Legacy readable title"]); + decryptFromBytes(Buffer.from(sealed.titleEncrypted!, "base64")), + ).toBe("Sealed title"); + expect(legacy).toMatchObject({ title: "Legacy readable title" }); + expect(legacy).not.toHaveProperty("titleEncrypted"); + // The same input exports byte-identically twice: nothing is sealed with + // a fresh IV on the way out. + const again = await buildCoachBackupSection(prismaWith(rows), "u1", { + purpose: "disaster-recovery", + }); + expect(JSON.stringify(again)).toBe(JSON.stringify(section)); }); it("a portable file carries the readable title and no ciphertext", async () => { diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 2b62e707e..649ba2230 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1286,22 +1286,14 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { value: 44.5, unit: "kg", measuredAt: "2026-07-19T06:30:00.000Z", - // v1.39.3 — a DR file carries the note sealed, never readable, - // including for a row the backfill has not reached yet. - note: null, - noteEncrypted: expect.any(String), + // v1.39.3 — the fixture row predates the encryption backfill: + // a DR file carries it as stored, and the restore seals it. + note: "felt strong", + noteEncrypted: null, }), ], }), ]); - const [sealedMetric] = payload.customMetrics as Array<{ - entries: Array<{ noteEncrypted: string }>; - }>; - expect( - decryptFromBytes( - Buffer.from(sealedMetric.entries[0].noteEncrypted, "base64"), - ), - ).toBe("felt strong"); expect(counts.customMetrics).toBe(1); expect(counts.customMetricEntries).toBe(1); expect(payload.correlationPatterns).toEqual([ diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index 55f7fe7dd..a373843e6 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -93,8 +93,9 @@ export interface CoachConversationBackupEntry { * and reminders all address a conversation by it. */ id: string; /** - * The readable title. Present on a portable payload only (and on every file - * written before v1.39.3, when the title was stored readable). + * The readable title. Present on a portable payload, on every file written + * before v1.39.3, and on a disaster-recovery payload for a row the free-text + * backfill had not reached yet. */ title?: string; /** v1.39.3 — the title's ciphertext as base64. Disaster-recovery only. */ @@ -208,15 +209,18 @@ export async function buildCoachBackupSection( return { coachConversations: rows.map((row) => ({ id: row.id, - ...(disasterRecovery + // A disaster-recovery file carries the stored bytes: the ciphertext, + // or, for a row the free-text backfill has not reached yet, the old + // readable title exactly as stored (the restore seals it). Sealing it + // here instead would make the export non-deterministic, and the + // streaming writer and this builder must produce the same file. + ...(disasterRecovery && row.titleEncrypted ? { - // A row the backfill has not reached yet is sealed here, so a DR - // file never carries a title readable. - titleEncrypted: Buffer.from( - row.titleEncrypted ?? encryptToBytes(row.title ?? ""), - ).toString("base64"), + titleEncrypted: Buffer.from(row.titleEncrypted).toString("base64"), } - : { title: readTitleSoft(row) }), + : disasterRecovery + ? { title: row.title ?? "" } + : { title: readTitleSoft(row) }), documentScoped: row.documentScoped, ...(disasterRecovery ? { diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index e41205515..614a4d4a3 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -117,7 +117,10 @@ export interface CustomMetricEntryBackupEntry { /** Unit snapshot taken from the metric at write time, not a live join. */ unit: string; measuredAt: string; - /** Portable exports carry the readable note; DR payloads leave this null. */ + /** + * The readable note: portable exports, and DR payloads for a row the + * free-text backfill had not reached yet. Null otherwise. + */ note: string | null; /** * v1.39.3 — base64 ciphertext of the note, present only in disaster-recovery @@ -480,18 +483,21 @@ export async function buildProfileBackupSection( id: entry.id, createdAt: entry.createdAt.toISOString(), deletedAt: entry.deletedAt?.toISOString() ?? null, - // v1.39.3 — the ciphertext rides verbatim. A row the backfill has - // not reached yet is sealed here, so a DR file never carries the - // note readable either way. - noteEncrypted: toBase64( - entry.noteEncrypted ?? encryptNote(entry.note), - ), + // v1.39.3 — the ciphertext rides verbatim. A row the free-text + // backfill has not reached yet carries its old readable note in + // `note` instead, as stored; the restore seals it. Sealing it + // here would make the export non-deterministic. + noteEncrypted: toBase64(entry.noteEncrypted), } : {}), value: entry.value, unit: entry.unit, measuredAt: entry.measuredAt.toISOString(), - note: disasterRecovery ? null : readEntryNoteSoft(entry, decryptFailures), + note: disasterRecovery + ? entry.noteEncrypted + ? null + : entry.note + : readEntryNoteSoft(entry, decryptFailures), })), }));