fix(writing): decrypt drafts/versions before reading encrypted fields

Mehrere Store-Methoden lasen die verschlüsselten Felder eines frisch
aus Dexie geholten Records direkt — references/title/briefing/content
landen aber als Ciphertext-String und nicht als Array/Objekt im Speicher.

Auswirkungen die jetzt behoben sind:
- startDraftGeneration: 'refs.map is not a function' beim ersten Klick
  auf "Generieren" (draft.references war Ciphertext)
- refineSelection: Crash beim Lesen von draft.briefing.language
- applyRefinement: Slice-Konkatenation auf Ciphertext (korrumpiert die
  Version still beim ersten Selection-Refinement)
- updateBriefing: Spread-merge eines Ciphertext-Strings in den Patch
- createCheckpointVersion: kopiert die Ciphertext-Bytes als neue
  Version-Content statt des Plaintexts

Fix: decryptRecord() direkt nach jedem .get() der relevante encrypted
Felder liest. queries.ts war schon korrekt (decryptRecords im liveQuery-
Pfad), aber die Mutation-Pfade haben das übersprungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:46:56 +02:00
parent 449837354d
commit 450372e545
2 changed files with 27 additions and 7 deletions

View file

@ -12,7 +12,7 @@
* store and then call `pointToVersion` never append to an existing version.
*/
import { encryptRecord } from '$lib/data/crypto';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
@ -150,6 +150,9 @@ export const draftsStore = {
async updateBriefing(id: string, briefingPatch: Partial<DraftBriefing>) {
const existing = await draftTable.get(id);
if (!existing) return;
// briefing is in the encrypt-list — decrypt before merging or the
// ciphertext blob spreads garbage into the patch.
await decryptRecord('writingDrafts', existing);
const merged: DraftBriefing = { ...existing.briefing, ...briefingPatch };
await draftsStore.updateDraft(id, { briefing: merged });
},
@ -227,6 +230,9 @@ export const draftsStore = {
) {
const source = await draftVersionTable.get(sourceVersionId);
if (!source) throw new Error(`Version ${sourceVersionId} not found`);
// content is encrypted — decrypt so the checkpoint copies plaintext,
// not the ciphertext blob.
await decryptRecord('writingDraftVersions', source);
const existing = await draftVersionTable.where('draftId').equals(draftId).toArray();
const nextNumber = Math.max(0, ...existing.map((v) => v.versionNumber)) + 1;

View file

@ -15,7 +15,7 @@
* back into the same current version in-place.
*/
import { encryptRecord } from '$lib/data/crypto';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { generationTable, draftTable, draftVersionTable, writingStyleTable } from '../collections';
import { callWritingGeneration } from '../api';
@ -98,13 +98,21 @@ export const generationsStore = {
): Promise<string> {
const draft = await draftTable.get(draftId);
if (!draft) throw new Error(`Draft ${draftId} not found`);
// title / briefing / styleOverrides / references are all in the
// encrypt-list — without decrypting them first, references is a
// ciphertext string and refs.map() blows up.
await decryptRecord('writingDrafts', draft);
const generationId = crypto.randomUUID();
const kind: GenerationKind =
draft.currentVersionId &&
(await draftVersionTable.get(draft.currentVersionId))?.content?.trim()
? 'full-regenerate'
: 'draft-from-brief';
let priorContent = '';
if (draft.currentVersionId) {
const priorRow = await draftVersionTable.get(draft.currentVersionId);
if (priorRow) {
await decryptRecord('writingDraftVersions', priorRow);
priorContent = priorRow.content ?? '';
}
}
const kind: GenerationKind = priorContent.trim() ? 'full-regenerate' : 'draft-from-brief';
const resolved = await loadStyle(draft.styleId);
const stylePreset =
resolved?.source === 'preset'
@ -281,6 +289,9 @@ export const generationsStore = {
): Promise<{ generationId: string; refined: string }> {
const draft = await draftTable.get(draftId);
if (!draft) throw new Error(`Draft ${draftId} not found`);
// briefing.language sits behind the briefing-encryption — read after
// decrypting or the ciphertext-shaped value crashes property access.
await decryptRecord('writingDrafts', draft);
const resolved = await loadStyle(draft.styleId);
const stylePreset =
@ -413,6 +424,9 @@ export const generationsStore = {
): Promise<{ before: string; after: string }> {
const existing = await draftVersionTable.get(versionId);
if (!existing) throw new Error(`Version ${versionId} not found`);
// content is encrypted; splice in plaintext or we'd build the new
// version by concatenating ciphertext with the replacement.
await decryptRecord('writingDraftVersions', existing);
const before = existing.content;
const after = before.slice(0, selection.start) + replacement + before.slice(selection.end);