From 89110abeb6ac8e799cad1fe13643ddb1399d6220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:08:40 +0200 Subject: [PATCH] fix(backup): bound a Coach turn's stored tables on restore A turn never stores more than 128 KB of table JSON, but a restore accepted any size in resultsJson or resultsEncrypted and sealed it into the database. Both are now bounded: the readable form by its UTF-8 size, the sealed form at twice that, above the ciphertext's measured expansion. A file this release writes always fits; a larger value is refused like any other malformed field. --- src/lib/validations/__tests__/backup.test.ts | 52 ++++++++++++++++++++ src/lib/validations/backup.ts | 28 ++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index c200a3d37..c10d46e65 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -651,3 +651,55 @@ describe("backupPayloadSchema — unitsPerDose", () => { expect(withMedication(schedule("x")).success).toBe(false); }); }); + +describe("a Coach turn's stored tables are bounded on restore", () => { + function parseWithResults(results: Record) { + return backupPayloadSchema.safeParse({ + schemaVersion: "1", + exportedAt: "2026-09-27T07:00:00.000Z", + userId: "u1", + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [], + coachConversations: [ + { + id: "conv-1", + title: "Sleep", + documentScoped: false, + createdAt: "2026-09-27T07:00:00.000Z", + updatedAt: "2026-09-27T07:00:00.000Z", + messages: [ + { + id: "msg-1", + role: "assistant", + content: "Here is the table.", + createdAt: "2026-09-27T07:00:00.000Z", + ...results, + }, + ], + }, + ], + }); + } + + it("accepts tables the size a turn can store", () => { + const json = JSON.stringify([{ rows: "x".repeat(100 * 1024) }]); + expect(parseWithResults({ resultsJson: json }).success).toBe(true); + const sealed = "A".repeat(200 * 1024); + expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(true); + }); + + it("refuses readable tables larger than a turn can store", () => { + const json = JSON.stringify([{ rows: "x".repeat(129 * 1024) }]); + expect(parseWithResults({ resultsJson: json }).success).toBe(false); + // The cap is in bytes: multi-byte text that fits by length still counts. + const wide = JSON.stringify([{ rows: "é".repeat(70 * 1024) }]); + expect(parseWithResults({ resultsJson: wide }).success).toBe(false); + }); + + it("refuses sealed tables larger than a turn can store", () => { + const sealed = "A".repeat(300 * 1024); + expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(false); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 76bdb532a..f8a9aa1af 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -20,6 +20,7 @@ * a useful summary for them. */ import { z } from "zod/v4"; +import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/project"; import { AllergyCategory, AllergySeverity, @@ -1337,6 +1338,29 @@ const environmentTravelLocationBackupSchema = z }) .passthrough(); +/** + * A turn's stored tables, bounded at restore. The live path never stores more + * than `RESULTS_MAX_BYTES` of table JSON per turn (`fitResultsToStorage`), so + * a file this release writes always fits; a larger value is a damaged or + * crafted file, refused with the rest of it like any other malformed field + * rather than sealed into the database unbounded. The sealed form is the + * ciphertext of at most that much JSON, base64-encoded in the file: about + * 1.8 times the plaintext, so twice the plaintext bound caps it. + */ +const COACH_RESULTS_SEALED_MAX_CHARS = 2 * RESULTS_MAX_BYTES; + +const coachResultsJsonSchema = z + .string() + .refine( + (value) => new TextEncoder().encode(value).byteLength <= RESULTS_MAX_BYTES, + { message: `Coach result tables exceed ${RESULTS_MAX_BYTES} bytes` }, + ); + +const coachResultsSealedSchema = base64BytesSchema.refine( + (value) => value.length <= COACH_RESULTS_SEALED_MAX_CHARS, + { message: "Coach result tables exceed the stored size" }, +); + /** * One Coach turn. * @@ -1359,8 +1383,8 @@ const coachMessageBackupSchema = z model: z.string().nullable().optional(), // v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file, // readable JSON on a portable one, absent on every file written before. - resultsEncrypted: base64BytesSchema.nullable().optional(), - resultsJson: z.string().nullable().optional(), + resultsEncrypted: coachResultsSealedSchema.nullable().optional(), + resultsJson: coachResultsJsonSchema.nullable().optional(), createdAt: isoDateTime, }) .passthrough();