freezmo/lib/sync.ts
Martin Dvorak 5ca0de920c Chat mode, phase 2: local history, answer actions, motion polish
- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
  validated with the same schema backup/sync use, capped at 100 x 300).
  Settled turns persist (question always; answer when produced, partial
  stops included); ?c=<id> keeps reloads on the conversation via
  history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
  auto titles from the first question, inline rename, delete with an
  Undo toast, diacritic-folding search over titles and message text.
  Scope and model are remembered per conversation; history is readable
  offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
  notes, landing on the options step - deck seed gained step) and Save
  to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
  encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
  model/context/history sheets, 150ms composer height transition, all
  skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
  delete/undo, actions, export, two-device sync round-trip, offline,
  desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.

Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
2026-10-11 19:03:48 +00:00

338 lines
12 KiB
TypeScript

"use client";
/**
* Opt-in encrypted sync. The payload (decks, folders, homework — never the
* API key) is encrypted in this browser with AES-GCM using a key derived from
* the user's passphrase (PBKDF2-SHA256, 310k iterations). The server only
* ever stores the opaque envelope under a random sync ID.
*
* Conflict policy: server-stamped `updatedAt` is the clock; when both sides
* changed since the last sync the UI asks the user which one to keep.
*/
import { z } from "zod";
import { backupDataSchema } from "./backup";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { ApiError } from "./client-api";
import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings";
import { deckStore, replaceDecks } from "./storage";
import { homeworkStore } from "./homework";
import { folderStore } from "./folders";
import { clearDeviceKey, loadDeviceKey, saveDeviceKey } from "./sync-key";
import { newSyncId, setSyncRecord, syncStore, withoutDirtyTracking } from "./sync-state";
const KDF = "PBKDF2-SHA256";
const ITERATIONS = 310_000;
export type SyncEnvelope = {
v: 1;
kdf: "PBKDF2-SHA256";
iterations: number;
salt: string;
iv: string;
ciphertext: string;
};
export type SyncStatus = "pushed" | "pulled" | "up-to-date" | "conflict" | "locked" | "error" | "off";
export type SyncOutcome = { status: SyncStatus; message?: string };
const syncPayloadSchema = z.object({
app: z.literal("freezmo"),
kind: z.literal("sync"),
version: z.literal(1),
clientUpdatedAt: z.number(),
data: backupDataSchema,
});
type SyncPayload = z.infer<typeof syncPayloadSchema>;
/* ------------------------------------------------------------------ */
/* Encoding helpers (chunked: payloads can be megabytes) */
/* ------------------------------------------------------------------ */
function toBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let index = 0; index < bytes.length; index += chunk) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
}
return btoa(binary);
}
function fromBase64(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
/* ------------------------------------------------------------------ */
/* Crypto */
/* ------------------------------------------------------------------ */
export async function deriveSyncKey(passphrase: string, saltBase64: string): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: fromBase64(saltBase64) as unknown as BufferSource, iterations: ITERATIONS, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function encryptWithKey(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(cipher)) };
}
async function decryptWithKey(key: CryptoKey, ivBase64: string, ciphertextBase64: string): Promise<unknown> {
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromBase64(ivBase64) as unknown as BufferSource },
key,
fromBase64(ciphertextBase64) as unknown as BufferSource,
);
return JSON.parse(new TextDecoder().decode(plain));
}
/* ------------------------------------------------------------------ */
/* Server API */
/* ------------------------------------------------------------------ */
async function apiGet(id: string): Promise<{ updatedAt: number; envelope: SyncEnvelope } | null> {
let response: Response;
try {
response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
cache: "no-store",
headers: { ...authHeaders(), ...localeHeader() },
});
} catch {
throw new ApiError(t("settings.syncError"), "network");
}
if (response.status === 404) return null;
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
}
return (await response.json()) as { updatedAt: number; envelope: SyncEnvelope };
}
async function apiPut(id: string, envelope: SyncEnvelope): Promise<number> {
let response: Response;
try {
response = await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ id, envelope }),
});
} catch {
throw new ApiError(t("settings.syncError"), "network");
}
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
}
const body = (await response.json()) as { updatedAt: number };
return body.updatedAt;
}
async function apiDelete(id: string): Promise<void> {
const response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { ...authHeaders(), ...localeHeader() },
});
if (!response.ok) throw new ApiError(t("settings.syncError"), "sync");
}
/* ------------------------------------------------------------------ */
/* Payload */
/* ------------------------------------------------------------------ */
async function buildPayload(): Promise<SyncPayload> {
await ensureLearningHydrated();
await ensureChatsHydrated();
return {
app: "freezmo",
kind: "sync",
version: 1,
clientUpdatedAt: Date.now(),
data: {
decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
},
};
}
async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope: SyncEnvelope }): Promise<void> {
const parsed = syncPayloadSchema.parse(
await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext),
);
withoutDirtyTracking(() => {
replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework);
replaceChats(parsed.data.chats ?? []);
});
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
if (!record.enabled || !record.id) return { status: "off" };
const key = await loadDeviceKey();
if (!key) return { status: "locked" };
try {
const remote = await apiGet(record.id);
const remoteIsNewer = Boolean(remote && remote.updatedAt > (record.lastSyncedAt ?? 0));
if (strategy === "pull") {
if (!remote) return { status: "error", message: t("server.syncNotFound") };
await applyRemote(key, remote);
return { status: "pulled" };
}
if (strategy === "auto" && remoteIsNewer) {
if (record.dirtyAt) {
setSyncRecord({ conflict: true });
return { status: "conflict" };
}
await applyRemote(key, remote!);
return { status: "pulled" };
}
if (strategy === "auto" && !remoteIsNewer && !record.dirtyAt) {
return { status: "up-to-date" };
}
const { iv, ciphertext } = await encryptWithKey(key, await buildPayload());
const envelope: SyncEnvelope = {
v: 1,
kdf: KDF,
iterations: ITERATIONS,
salt: record.salt,
iv,
ciphertext,
};
const updatedAt = await apiPut(record.id, envelope);
setSyncRecord({ lastSyncedAt: updatedAt, dirtyAt: null, conflict: false });
return { status: "pushed" };
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
/** First device: brand-new id and salt, uploads straight away. */
export async function enableSync(passphrase: string): Promise<SyncOutcome> {
const id = newSyncId();
const salt = toBase64(crypto.getRandomValues(new Uint8Array(16)));
const key = await deriveSyncKey(passphrase, salt);
await saveDeviceKey(key);
setSyncRecord({ enabled: true, id, salt, lastSyncedAt: null, dirtyAt: Date.now(), conflict: false });
return syncNow("push");
}
/** Same device after the browser dropped the stored key. */
export async function unlockSync(passphrase: string): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
if (!record.enabled || !record.id) return { status: "off" };
try {
const remote = await apiGet(record.id);
const salt = remote?.envelope.salt || record.salt;
if (!salt) return { status: "error", message: t("settings.syncError") };
const key = await deriveSyncKey(passphrase, salt);
if (remote) {
try {
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
} catch {
return { status: "error", message: t("settings.syncWrongPass") };
}
}
await saveDeviceKey(key);
setSyncRecord({ salt });
return syncNow("auto");
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
/** New device: the sync ID plus the passphrase from the other device. */
export async function joinSync(id: string, passphrase: string): Promise<SyncOutcome> {
try {
const remote = await apiGet(id.trim());
if (!remote) return { status: "error", message: t("server.syncNotFound") };
const key = await deriveSyncKey(passphrase, remote.envelope.salt);
try {
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
} catch {
return { status: "error", message: t("settings.syncWrongPass") };
}
await saveDeviceKey(key);
setSyncRecord({
enabled: true,
id: id.trim(),
salt: remote.envelope.salt,
lastSyncedAt: null,
dirtyAt: null,
conflict: false,
});
await applyRemote(key, remote);
return { status: "pulled" };
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
export async function deleteSyncedData(): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
try {
if (record.id) await apiDelete(record.id);
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
await clearDeviceKey();
setSyncRecord({ enabled: false, id: "", salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false });
return { status: "up-to-date" };
}
export async function forgetDeviceKey(): Promise<void> {
await clearDeviceKey();
}
export async function hasDeviceKey(): Promise<boolean> {
return Boolean(await loadDeviceKey());
}