mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user