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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:58:49 +02:00
1 parent 1a674cf4b0
commit 46eb8c8b94
4 files changed
+51 -43

No files matched your search

@@ -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 () => {
@@ -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([
+13 -9
View File
@@ -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
? {
+14 -8
View File
@@ -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),
})),
}));