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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:08:40 +02:00
1 parent 4558271b3e
commit 89110abeb6
2 files changed
+78 -2

No files matched your search

@@ -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<string, unknown>) {
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);
});
});
+26 -2
View File
@@ -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();