feat(coach): scaffold conversation-summary + facts storage

Extract the AES-256-GCM Bytes codec from persistence into a shared
bytes-codec module so the conversation summary and the new personal-fact
store reuse one ArrayBuffer-backed implementation. Add the CoachFact
table (migration 0116) for durable, user-scoped facts the Coach extracts
from conversations — encrypted fact text, plain category/confidence/
source for rank-without-decrypt, soft-delete for a reversible forget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 10:11:19 +02:00
1 parent 7c494b60cb
commit 01d3afcdaf
4 files changed
+92 -24

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
/**
* Shared AES-256-GCM ↔ `Bytes` codec for the Coach's encrypted columns.
*
* `coach_messages.encrypted_content`, `coach_conversations.summary_encrypted`,
* and `coach_facts.fact_encrypted` all store the same payload shape: the
* `encrypt()` string format from `@/lib/crypto` (`"<keyId>.<base64>"`) encoded
* as UTF-8 bytes. Centralised here so there is one ArrayBuffer-backed
* implementation every Coach persistence path shares.
*
* Prisma's `Bytes` type maps to `Uint8Array<ArrayBuffer>`, not Node's
* `Buffer<ArrayBufferLike>`, so we allocate a fresh ArrayBuffer-backed
* `Uint8Array` for writes to keep the structural type stable across Node
* versions.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
/** Encrypt a UTF-8 string into the `Bytes` payload the schema stores. */
export function encryptToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a `Bytes` payload back to its plaintext. Throws on a bad key id. */
export function decryptFromBytes(buf: Uint8Array): string {
const text = Buffer.from(buf).toString("utf8");
return decrypt(text);
}
+1 -24
View File
@@ -10,10 +10,8 @@
* provenance (window names, metric tags, sample counts) and never raw
* values, so it can be queried without decryption for analytics.
*/
import { Buffer } from "node:buffer";
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import type {
CoachConversationDTO,
@@ -49,27 +47,6 @@ export function summariseTitle(input: string): string {
return `${cut.trimEnd()}…`;
}
/**
* Encode a UTF-8 string as the AES-256-GCM payload format the schema
* stores in `coach_messages.encrypted_content`.
*
* Prisma's `Bytes` type maps to `Uint8Array<ArrayBuffer>`, not Node's
* `Buffer<ArrayBufferLike>`. We allocate a fresh ArrayBuffer-backed
* Uint8Array so the structural type matches across Node versions.
*/
function encryptToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
function decryptFromBytes(buf: Uint8Array): string {
const text = Buffer.from(buf).toString("utf8");
return decrypt(text);
}
function provenanceToJson(provenance: CoachProvenance | null): string | null {
if (!provenance) return null;
return JSON.stringify(provenance);