chore(crypto): rotate summary + fact encrypted columns

Add CoachConversation.summaryEncrypted and CoachFact.factEncrypted to
the key-rotation registry, mirroring the CoachMessage.encryptedContent
Bytes round-trip, so an operator dropping a legacy key re-encrypts the
new Coach-memory columns instead of bricking them.

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:12:59 +02:00
1 parent 01d3afcdaf
commit 54539a801e
1 file changed
+76
+76
View File
@@ -312,6 +312,82 @@ async function main() {
}
results.push(coachResult);
// ───── CoachConversation.summaryEncrypted ─────
// v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of the rolling
// conversation summary. Same Buffer round-trip as CoachMessage; nullable, so
// skip empty/absent rows.
const coachConversations = await prisma.coachConversation.findMany({
where: { summaryEncrypted: { not: null } },
select: { id: true, summaryEncrypted: true },
});
const summaryResult: RotationResult = {
table: "CoachConversation",
field: "summaryEncrypted",
scanned: coachConversations.length,
rotated: 0,
errors: 0,
};
for (const row of coachConversations) {
const buf = row.summaryEncrypted;
if (!buf || buf.byteLength === 0) continue;
const asString = Buffer.from(buf).toString("utf8");
if (!shouldRotate(asString)) continue;
try {
const rotated = encrypt(decrypt(asString));
const encoded = Buffer.from(rotated, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
await prisma.coachConversation.update({
where: { id: row.id },
data: { summaryEncrypted: next },
});
summaryResult.rotated++;
} catch (err) {
summaryResult.errors++;
console.error(
`[CoachConversation.summaryEncrypted] row ${row.id}: ${(err as Error).message}`,
);
}
}
results.push(summaryResult);
// ───── CoachFact.factEncrypted ─────
// v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of each durable
// personal fact. Same Buffer round-trip as CoachMessage.
const coachFacts = await prisma.coachFact.findMany({
select: { id: true, factEncrypted: true },
});
const factResult: RotationResult = {
table: "CoachFact",
field: "factEncrypted",
scanned: coachFacts.length,
rotated: 0,
errors: 0,
};
for (const row of coachFacts) {
const buf = row.factEncrypted;
if (!buf || buf.byteLength === 0) continue;
const asString = Buffer.from(buf).toString("utf8");
if (!shouldRotate(asString)) continue;
try {
const rotated = encrypt(decrypt(asString));
const encoded = Buffer.from(rotated, "utf8");
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
next.set(encoded);
await prisma.coachFact.update({
where: { id: row.id },
data: { factEncrypted: next },
});
factResult.rotated++;
} catch (err) {
factResult.errors++;
console.error(
`[CoachFact.factEncrypted] row ${row.id}: ${(err as Error).message}`,
);
}
}
results.push(factResult);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;