freezmo/lib/sync.ts
Martin Dvorak e4a8e0ba01 Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders
Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
2026-10-10 14:15:21 +00:00

334 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 { 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();
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(),
},
};
}
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);
});
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());
}