mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(coach): store a turn's result tables encrypted beside the message
A new nullable column, coach_messages.results_encrypted, holds the tables of values a turn read as AES-GCM ciphertext; only their metadata rides the plaintext provenance. readMessageResults serves them to the owner, each table whole or withheld with a reason. The provenance restore learns the steps, method, table metadata, chips, clarification and the forced-answer marker, entry by entry against the wire schema. The column is registered for key rotation, carried through both backup formats (ciphertext in a disaster-recovery file, readable JSON in a portable one) and goes with its row on conversation delete and wipe.
This commit is contained in:
1 parent
cde7af3cf8
commit
a8b3e5d7f4
9 files changed
+714
-1
No files matched your search
@@ -0,0 +1,339 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.39.4 — the dialog fields of the provenance survive a reload, malformed
|
||||
* entries are dropped one by one, the tables are sealed into their own
|
||||
* column, and the owner-narrowed read serves each table whole or names why
|
||||
* it does not.
|
||||
*/
|
||||
const txCreate = {
|
||||
coachConversation: { update: vi.fn() },
|
||||
coachMessage: { create: vi.fn() },
|
||||
};
|
||||
const findFirst = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise<unknown>) =>
|
||||
cb(txCreate),
|
||||
),
|
||||
coachMessage: { findFirst: (...args: unknown[]) => findFirst(...args) },
|
||||
},
|
||||
}));
|
||||
vi.mock("../bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn((text: string) =>
|
||||
new TextEncoder().encode(`enc:${text}`),
|
||||
),
|
||||
decryptFromBytes: vi.fn((bytes: Uint8Array) => {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
if (!text.startsWith("enc:")) throw new Error("bad ciphertext");
|
||||
return text.slice(4);
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
RESULTS_MAX_BYTES,
|
||||
appendMessage,
|
||||
readMessageResults,
|
||||
} from "../persistence";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
} from "../types";
|
||||
|
||||
const META: CoachResultMeta = {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_series",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.metricByPeriod",
|
||||
title: "Blood pressure by day",
|
||||
rowCount: 2,
|
||||
chartKind: "line",
|
||||
displayed: true,
|
||||
};
|
||||
|
||||
const TABLE: CoachResultTable = {
|
||||
...META,
|
||||
columns: [
|
||||
{
|
||||
key: "period",
|
||||
kind: "period",
|
||||
labelKey: "coach.result.column.day",
|
||||
label: "Day",
|
||||
},
|
||||
{
|
||||
key: "sys",
|
||||
kind: "number",
|
||||
labelKey: "coach.result.column.systolic",
|
||||
label: "Systolic",
|
||||
unit: "mmHg",
|
||||
decimals: 0,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
["2026-09-01", 128],
|
||||
["2026-09-02", null],
|
||||
],
|
||||
truncated: false,
|
||||
chart: { kind: "line", x: "period", series: ["sys"] },
|
||||
};
|
||||
|
||||
const SLEEP_META: CoachResultMeta = {
|
||||
...META,
|
||||
ref: "r2",
|
||||
source: { ...META.source, domain: "sleep", tool: "get_sleep" },
|
||||
title: "Sleep by night",
|
||||
};
|
||||
|
||||
const FULL: CoachProvenance = {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp"],
|
||||
steps: [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.read",
|
||||
label: "Blood pressure, last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: 42,
|
||||
resultRef: "r1",
|
||||
},
|
||||
],
|
||||
method: {
|
||||
entries: [
|
||||
{ domain: "bp", window: "last30days", count: 42, aggregation: "mean" },
|
||||
],
|
||||
text: "Blood pressure, last 30 days: 42 readings",
|
||||
},
|
||||
results: [META],
|
||||
followUps: [
|
||||
{
|
||||
id: "f1",
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label: "Compare with the period before",
|
||||
anchor: { ref: "r1", domain: "bp", window: "last30days" },
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
],
|
||||
clarification: {
|
||||
kind: "window",
|
||||
choices: [
|
||||
{
|
||||
id: "c1",
|
||||
labelKey: "coach.clarify.window.last30days",
|
||||
label: "Last 30 days",
|
||||
value: { window: "last30days" },
|
||||
},
|
||||
],
|
||||
freeText: true,
|
||||
},
|
||||
forcedFinal: true,
|
||||
};
|
||||
|
||||
function stubEchoCreate(overrideJson?: string): void {
|
||||
txCreate.coachConversation.update.mockResolvedValue({});
|
||||
txCreate.coachMessage.create.mockImplementation(
|
||||
async (arg: { data: { metricSourceJson: string | null } }) => ({
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
createdAt: new Date("2026-09-26T00:00:00.000Z"),
|
||||
metricSourceJson: overrideJson ?? arg.data.metricSourceJson,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("appendMessage — the dialog fields", () => {
|
||||
it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => {
|
||||
stubEchoCreate();
|
||||
const out = await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
metricSource: FULL,
|
||||
});
|
||||
expect(out.metricSource).toEqual(FULL);
|
||||
});
|
||||
|
||||
it("drops a malformed entry and keeps its well-formed siblings", async () => {
|
||||
stubEchoCreate(
|
||||
JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [FULL.steps![0], { id: "s2", status: "exploded" }],
|
||||
results: [{ ref: "r9" }, META],
|
||||
followUps: [{ ...FULL.followUps![0], kind: "free_text" }],
|
||||
method: { entries: "nope", text: "x" },
|
||||
clarification: { kind: "metric", choices: [], freeText: "yes" },
|
||||
forcedFinal: "true",
|
||||
}),
|
||||
);
|
||||
const out = await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
expect(out.metricSource).toEqual({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: FULL.steps,
|
||||
results: [META],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips a key the stored blob carries that the contract does not know", async () => {
|
||||
stubEchoCreate(
|
||||
JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
results: [{ ...META, rows: [["2026-09-01", 128]] }],
|
||||
}),
|
||||
);
|
||||
const out = await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
// Values never ride the plaintext provenance, even when a blob has them.
|
||||
expect(out.metricSource?.results?.[0]).not.toHaveProperty("rows");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendMessage — the tables", () => {
|
||||
it("seals the tables into their own column and leaves them out of the provenance", async () => {
|
||||
stubEchoCreate();
|
||||
await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
metricSource: { windows: [], metrics: [], results: [META] },
|
||||
results: [TABLE],
|
||||
});
|
||||
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
|
||||
expect(new TextDecoder().decode(data.resultsEncrypted)).toBe(
|
||||
`enc:${JSON.stringify([TABLE])}`,
|
||||
);
|
||||
expect(data.metricSourceJson).not.toContain("2026-09-01");
|
||||
});
|
||||
|
||||
it("writes NULL on a turn without tables", async () => {
|
||||
stubEchoCreate();
|
||||
await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
results: [],
|
||||
});
|
||||
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
|
||||
expect(data.resultsEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("drops whole tables from the end until the message fits the ceiling", async () => {
|
||||
stubEchoCreate();
|
||||
const big: CoachResultTable = {
|
||||
...TABLE,
|
||||
ref: "r2",
|
||||
rows: Array.from({ length: 400 }, () => ["x".repeat(400), 1]),
|
||||
};
|
||||
await appendMessage({
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
content: "reply",
|
||||
results: [TABLE, big],
|
||||
});
|
||||
const data = txCreate.coachMessage.create.mock.calls[0][0].data;
|
||||
const json = new TextDecoder().decode(data.resultsEncrypted).slice(4);
|
||||
expect(Buffer.byteLength(json)).toBeLessThanOrEqual(RESULTS_MAX_BYTES);
|
||||
expect(JSON.parse(json)).toEqual([TABLE]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMessageResults", () => {
|
||||
function row(results: CoachResultMeta[], bytes: Uint8Array | null) {
|
||||
return {
|
||||
metricSourceJson: JSON.stringify({ windows: [], metrics: [], results }),
|
||||
resultsEncrypted: bytes,
|
||||
};
|
||||
}
|
||||
const sealed = (tables: unknown) =>
|
||||
new TextEncoder().encode(`enc:${JSON.stringify(tables)}`);
|
||||
|
||||
it("narrows to the owner through the conversation", async () => {
|
||||
findFirst.mockResolvedValue(null);
|
||||
await expect(readMessageResults("u1", "c1", "m1")).resolves.toBeNull();
|
||||
expect(findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: "m1",
|
||||
conversationId: "c1",
|
||||
conversation: { userId: "u1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("serves every table the provenance lists, in its order", async () => {
|
||||
findFirst.mockResolvedValue(row([META], sealed([TABLE])));
|
||||
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
|
||||
TABLE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("withholds a table whose domain is switched off, without decrypting it", async () => {
|
||||
findFirst.mockResolvedValue(
|
||||
row([META, SLEEP_META], sealed([TABLE, { ...TABLE, ...SLEEP_META }])),
|
||||
);
|
||||
const out = await readMessageResults(
|
||||
"u1",
|
||||
"c1",
|
||||
"m1",
|
||||
(domain) => domain === "sleep",
|
||||
);
|
||||
expect(out).toEqual([TABLE, { ref: "r2", withheld: "module_disabled" }]);
|
||||
|
||||
findFirst.mockResolvedValue(row([SLEEP_META], sealed([TABLE])));
|
||||
const { decryptFromBytes } = await import("../bytes-codec");
|
||||
vi.mocked(decryptFromBytes).mockClear();
|
||||
await readMessageResults("u1", "c1", "m1", () => true);
|
||||
expect(decryptFromBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a table it cannot read rather than serving nothing", async () => {
|
||||
findFirst.mockResolvedValue(
|
||||
row([META], new TextEncoder().encode("garbage")),
|
||||
);
|
||||
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
|
||||
{ ref: "r1", withheld: "unavailable" },
|
||||
]);
|
||||
|
||||
// A ref the ciphertext does not carry (dropped at the size ceiling).
|
||||
findFirst.mockResolvedValue(row([META, SLEEP_META], sealed([TABLE])));
|
||||
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
|
||||
TABLE,
|
||||
{ ref: "r2", withheld: "unavailable" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an empty list for a message without tables", async () => {
|
||||
findFirst.mockResolvedValue({
|
||||
metricSourceJson: null,
|
||||
resultsEncrypted: null,
|
||||
});
|
||||
await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,18 @@
|
||||
* provenance (window names, metric tags, sample counts) and never raw
|
||||
* values, so it can be queried without decryption for analytics.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
|
||||
import {
|
||||
coachClarificationSchema,
|
||||
coachFollowUpSchema,
|
||||
coachMethodSchema,
|
||||
coachResultMetaSchema,
|
||||
coachResultTableSchema,
|
||||
coachStepSchema,
|
||||
} from "./stream-events";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
|
||||
import {
|
||||
isCheckupIntervalId,
|
||||
@@ -20,12 +30,20 @@ import {
|
||||
} from "./suggest-action";
|
||||
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachConversationAttachmentDTO,
|
||||
CoachConversationDTO,
|
||||
CoachConversationDetailDTO,
|
||||
CoachFollowUp,
|
||||
CoachMessageDTO,
|
||||
CoachMessageRole,
|
||||
CoachMethod,
|
||||
CoachProvenance,
|
||||
CoachResultEntry,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
CoachSuggestion,
|
||||
} from "./types";
|
||||
|
||||
@@ -56,6 +74,9 @@ export function summariseTitle(input: string): string {
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */
|
||||
const MAX_RESULTS_PER_MESSAGE = 6;
|
||||
|
||||
function provenanceToJson(provenance: CoachProvenance | null): string | null {
|
||||
if (!provenance) return null;
|
||||
return JSON.stringify(provenance);
|
||||
@@ -162,6 +183,53 @@ function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] {
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.4 — restore an array field of the provenance entry by entry against
|
||||
* the wire schema. A malformed entry is dropped, not the whole field; an
|
||||
* empty or absent array restores to undefined. The schemas strip keys they do
|
||||
* not know, so a stored blob can never widen what a reader sees.
|
||||
*/
|
||||
function restoreEach<T>(
|
||||
raw: unknown,
|
||||
schema: z.ZodType<T>,
|
||||
cap: number,
|
||||
): T[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const cleaned: T[] = [];
|
||||
for (const item of raw.slice(0, cap)) {
|
||||
const parsed = schema.safeParse(item);
|
||||
if (parsed.success) cleaned.push(parsed.data);
|
||||
}
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/** The live steps a turn persisted (at most 12 per turn). */
|
||||
function restoreSteps(raw: unknown): CoachStep[] | undefined {
|
||||
return restoreEach(raw, coachStepSchema, 12);
|
||||
}
|
||||
|
||||
/** The method line; dropped whole when it does not parse. */
|
||||
function restoreMethod(raw: unknown): CoachMethod | undefined {
|
||||
const parsed = coachMethodSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/** The tables' metadata (at most 6 per message). No values live here. */
|
||||
function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined {
|
||||
return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE);
|
||||
}
|
||||
|
||||
/** The chips offered under the reply (at most 3). */
|
||||
function restoreFollowUps(raw: unknown): CoachFollowUp[] | undefined {
|
||||
return restoreEach(raw, coachFollowUpSchema, 3);
|
||||
}
|
||||
|
||||
/** The clarification choices; dropped whole when they do not parse. */
|
||||
function restoreClarification(raw: unknown): CoachClarification | undefined {
|
||||
const parsed = coachClarificationSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
@@ -249,6 +317,14 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
const suggestion = restoreSuggestion(parsed.suggestion);
|
||||
const suggestedAction = restoreSuggestedAction(parsed.suggestedAction);
|
||||
const toolCalls = restoreToolCalls(parsed.toolCalls);
|
||||
// v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
|
||||
// clarification choices, and the forced-answer marker.
|
||||
const steps = restoreSteps(parsed.steps);
|
||||
const method = restoreMethod(parsed.method);
|
||||
const results = restoreResultMetas(parsed.results);
|
||||
const followUps = restoreFollowUps(parsed.followUps);
|
||||
const clarification = restoreClarification(parsed.clarification);
|
||||
const forcedFinal = parsed.forcedFinal === true;
|
||||
return {
|
||||
windows,
|
||||
metrics,
|
||||
@@ -259,6 +335,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
...(suggestedAction ? { suggestedAction } : {}),
|
||||
...(toolCalls ? { toolCalls } : {}),
|
||||
...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}),
|
||||
...(steps ? { steps } : {}),
|
||||
...(method ? { method } : {}),
|
||||
...(results ? { results } : {}),
|
||||
...(followUps ? { followUps } : {}),
|
||||
...(clarification ? { clarification } : {}),
|
||||
...(forcedFinal ? { forcedFinal: true as const } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -295,6 +377,39 @@ export interface AppendMessageParams {
|
||||
*/
|
||||
tokensUsed?: number | null;
|
||||
model?: string | null;
|
||||
/**
|
||||
* v1.39.4 — the tables of values this turn read. Encrypted into
|
||||
* `resultsEncrypted`; their metadata rides `metricSource.results`. Omitted
|
||||
* or empty on every turn without a table.
|
||||
*/
|
||||
results?: CoachResultTable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.4 — the at-rest ceiling for one message's tables, as JSON before
|
||||
* encryption. Tables arrive trimmed to 400 rows; a message whose tables still
|
||||
* exceed this keeps the leading tables that fit, and a reader is told the
|
||||
* rest are unavailable rather than handed a partial table.
|
||||
*/
|
||||
export const RESULTS_MAX_BYTES = 128 * 1024;
|
||||
|
||||
/**
|
||||
* Serialise a turn's tables for the ciphertext column, or null when there are
|
||||
* none. Drops whole tables from the end until the JSON fits the ceiling.
|
||||
*/
|
||||
function resultsToBytes(
|
||||
results: CoachResultTable[] | undefined,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (!results || results.length === 0) return null;
|
||||
const kept = results.slice(0, MAX_RESULTS_PER_MESSAGE);
|
||||
while (kept.length > 0) {
|
||||
const json = JSON.stringify(kept);
|
||||
if (Buffer.byteLength(json, "utf8") <= RESULTS_MAX_BYTES) {
|
||||
return encryptToBytes(json);
|
||||
}
|
||||
kept.pop();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,6 +532,7 @@ export async function appendMessage(
|
||||
promptVersion: params.promptVersion ?? null,
|
||||
tokensUsed: params.tokensUsed ?? null,
|
||||
model: params.model ?? null,
|
||||
resultsEncrypted: resultsToBytes(params.results),
|
||||
},
|
||||
});
|
||||
await tx.coachConversation.update({
|
||||
@@ -547,6 +663,9 @@ export async function fetchConversationWithMessages(
|
||||
// changing the oldest->newest contract the client renders.
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: CONVERSATION_MESSAGE_DETAIL_CAP,
|
||||
// v1.39.4 — the tables are read lazily, one message at a time, through
|
||||
// `readMessageResults`; the detail read never loads their ciphertext.
|
||||
omit: { resultsEncrypted: true },
|
||||
},
|
||||
// v1.29.x (S7) — the LIVE attachment set (join → document label columns
|
||||
// only; the encrypted body is untouched), ordered by attach time. Always
|
||||
@@ -601,6 +720,72 @@ export async function fetchConversationWithMessages(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.4 — the stored tables of one assistant message, for its owner.
|
||||
*
|
||||
* Narrowed like the conversation read: the message must sit in a
|
||||
* conversation `userId` owns, and a foreign or missing id is null (the route
|
||||
* answers 404 for both, so existence never leaks across accounts).
|
||||
*
|
||||
* One entry per table the message's provenance lists, in that order. A
|
||||
* table is served whole or withheld with a reason, never partially:
|
||||
* - `module_disabled` — `withhold(domain)` said the table's domain is
|
||||
* switched off for the record now. Such a table is not even decrypted.
|
||||
* - `unavailable` — the ciphertext did not decrypt, did not parse, or did
|
||||
* not carry that ref (a table dropped at the size ceiling). Fail closed
|
||||
* and say so, rather than serve nothing silently.
|
||||
*/
|
||||
export async function readMessageResults(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
withhold: (domain: CoachStepDomain) => boolean = () => false,
|
||||
): Promise<CoachResultEntry[] | null> {
|
||||
const row = await prisma.coachMessage.findFirst({
|
||||
where: {
|
||||
id: messageId,
|
||||
conversationId,
|
||||
conversation: { userId },
|
||||
},
|
||||
select: { metricSourceJson: true, resultsEncrypted: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
|
||||
const metas = provenanceFromJson(row.metricSourceJson)?.results ?? [];
|
||||
if (metas.length === 0) return [];
|
||||
|
||||
const served = metas.filter((meta) => !withhold(meta.source.domain));
|
||||
const tables =
|
||||
served.length > 0 ? decryptResultTables(row.resultsEncrypted) : null;
|
||||
|
||||
return metas.map((meta): CoachResultEntry => {
|
||||
if (withhold(meta.source.domain)) {
|
||||
return { ref: meta.ref, withheld: "module_disabled" };
|
||||
}
|
||||
const table = tables?.get(meta.ref);
|
||||
return table ?? { ref: meta.ref, withheld: "unavailable" };
|
||||
});
|
||||
}
|
||||
|
||||
/** Decrypt and parse a message's tables by ref; null when unreadable. */
|
||||
function decryptResultTables(
|
||||
bytes: Uint8Array | null,
|
||||
): Map<string, CoachResultTable> | null {
|
||||
if (!bytes || bytes.byteLength === 0) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(decryptFromBytes(bytes));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const tables = restoreEach(
|
||||
parsed,
|
||||
coachResultTableSchema,
|
||||
MAX_RESULTS_PER_MESSAGE,
|
||||
);
|
||||
return new Map((tables ?? []).map((table) => [table.ref, table]));
|
||||
}
|
||||
|
||||
export interface ListConversationsParams {
|
||||
userId: string;
|
||||
cursor?: string | null;
|
||||
|
||||
@@ -215,6 +215,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
|
||||
// v1.39.4 — the tables of values a turn read, JSON under the same codec.
|
||||
{ model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" },
|
||||
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
|
||||
// v1.39.3 — the title: the opening words of the first message, or a rename.
|
||||
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
|
||||
|
||||
@@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry {
|
||||
promptVersion: string | null;
|
||||
tokensUsed: number | null;
|
||||
model: string | null;
|
||||
/**
|
||||
* v1.39.4 — the tables of values the turn read, as ciphertext in base64.
|
||||
* Present on a disaster-recovery payload only; null on a turn without one.
|
||||
*/
|
||||
resultsEncrypted?: string | null;
|
||||
/**
|
||||
* v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]`
|
||||
* array), on a portable payload only; null on a turn without one. The
|
||||
* unreadable-row marker stands in for tables that did not decrypt.
|
||||
*/
|
||||
resultsJson?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
|
||||
promptVersion: true,
|
||||
tokensUsed: true,
|
||||
model: true,
|
||||
resultsEncrypted: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
@@ -248,6 +260,14 @@ export async function buildCoachBackupSection(
|
||||
promptVersion: message.promptVersion,
|
||||
tokensUsed: message.tokensUsed,
|
||||
model: message.model,
|
||||
// v1.39.4 — the tables follow the same contract as the prose.
|
||||
...(disasterRecovery
|
||||
? {
|
||||
resultsEncrypted: message.resultsEncrypted
|
||||
? Buffer.from(message.resultsEncrypted).toString("base64")
|
||||
: null,
|
||||
}
|
||||
: { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
})),
|
||||
attachments: row.attachments.map((attachment) => ({
|
||||
@@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick<
|
||||
| "promptVersion"
|
||||
| "tokensUsed"
|
||||
| "model"
|
||||
| "resultsEncrypted"
|
||||
| "resultsJson"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -401,6 +423,7 @@ export async function restoreCoachData(
|
||||
promptVersion: message.promptVersion ?? null,
|
||||
tokensUsed: message.tokensUsed ?? null,
|
||||
model: message.model ?? null,
|
||||
resultsEncrypted: resolveResultsBytes(message),
|
||||
createdAt: new Date(message.createdAt),
|
||||
})),
|
||||
},
|
||||
@@ -457,6 +480,23 @@ function resolveTurnBytes(
|
||||
return encryptToBytes(message.content ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.4 — a turn's stored tables, from either end of the contract. A
|
||||
* disaster-recovery file carries the ciphertext; a portable file carries the
|
||||
* JSON, sealed here under the target instance's key. Neither end writes a
|
||||
* zero-byte ciphertext for a turn without tables.
|
||||
*/
|
||||
function resolveResultsBytes(
|
||||
message: RestoredCoachMessage,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (message.resultsEncrypted !== undefined) {
|
||||
return message.resultsEncrypted === null
|
||||
? null
|
||||
: decodeBase64(message.resultsEncrypted);
|
||||
}
|
||||
return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
|
||||
}
|
||||
|
||||
function resolveTitleBytes(
|
||||
conversation: RestoredCoachConversation,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
|
||||
@@ -1354,6 +1354,10 @@ const coachMessageBackupSchema = z
|
||||
promptVersion: z.string().nullable().optional(),
|
||||
tokensUsed: z.number().int().nullable().optional(),
|
||||
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(),
|
||||
createdAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
Reference in new issue
Block a user