diff --git a/docs/architecture.md b/docs/architecture.md index 608f494..21a3923 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,3 +76,7 @@ local build, with no installer or public release qualification. See ## Delivery boundary: encrypted-owners Versioned library and Collections owners capture account/epoch authority for every commit. Migration and workspace erase use the pinned engine adaptation and preserve the previous encrypted snapshot on failure. Account identity, trust, approval checkpoints and unrelated zones are retained. These domain adapters precede workspace wiring in this delivery stack. + +## Delivery boundary: journal-vault + +Journal uses a separately protected envelope and private-form recovery. Account, epoch and genuine borrowed-owner closure revoke access before any late read or crypto result can publish. This clears live references and views, without claiming allocator zeroization or remote deletion. The Journal view is wired in a later slice. diff --git a/web/src/collections/journal.ts b/web/src/collections/journal.ts new file mode 100644 index 0000000..5ce1e3c --- /dev/null +++ b/web/src/collections/journal.ts @@ -0,0 +1,185 @@ +import type { NoteDocument } from "../domain/contracts"; +import { validateDocument } from "../domain/validation"; +import type { JournalEnvelope } from "./contracts"; +import { validateJournalEnvelope } from "./validation"; + +export type JournalEntry = { id: string; date: string; document: NoteDocument; prompt: string | null; createdAt: string; updatedAt: string; revision: number }; +export type Habit = { id: string; name: string; order: number; createdAt: string; createdOn: string; archivedAt: string | null; archivedOn: string | null; revision: number }; +export type JournalData = { version: 1; entries: JournalEntry[]; habits: Habit[]; completions: { date: string; habitId: string; done: boolean; updatedAt: string }[]; preferences: { promptEnabled: boolean; lockAfterSeconds: 60 } }; +export type JournalContext = { accountId: string; epoch: string }; +export type JournalFreshness = () => void; +const MAX_BYTES = 10_000_000; +const encoder = new TextEncoder(); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function invalid(): never { throw new Error("journal_invalid: Journal data could not be read. The saved copy was kept."); } +function row(value: unknown, fields: string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) invalid(); + const result = value as Record; + if (Object.keys(result).length !== fields.length || fields.some(key => !Object.hasOwn(result, key))) invalid(); + return result; +} +function date(value: unknown): asserts value is string { + if (typeof value !== "string" || !/^[1-9]\d{3}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(`${value}T12:00:00Z`)) || new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) !== value) + throw new Error("invalid_date: Choose a valid calendar date."); +} +function timestamp(value: unknown) { + if (typeof value !== "string" || value.length !== 24 || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) invalid(); +} +function id(value: unknown): asserts value is string { if (typeof value !== "string" || !uuid.test(value)) invalid(); } +function revision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 1) invalid(); } +export function emptyJournal(): JournalData { + return { version: 1, entries: [], habits: [], completions: [], preferences: { promptEnabled: true, lockAfterSeconds: 60 } }; +} +export function validateJournalData(value: unknown): JournalData { + let serialized: string; + try { serialized = JSON.stringify(value); } catch { invalid(); } + if (!serialized! || encoder.encode(serialized!).length > MAX_BYTES) invalid(); + const data = row(value, ["version", "entries", "habits", "completions", "preferences"]); + if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 10_000 || !Array.isArray(data.habits) || data.habits.length > 100 || !Array.isArray(data.completions) || data.completions.length > 100_000) invalid(); + const prefs = row(data.preferences, ["promptEnabled", "lockAfterSeconds"]); + if (typeof prefs.promptEnabled !== "boolean" || prefs.lockAfterSeconds !== 60) invalid(); + const ids = new Set(), dates = new Set(); + for (const item of data.entries) { + const entry = row(item, ["id", "date", "document", "prompt", "createdAt", "updatedAt", "revision"]); + id(entry.id); date(entry.date); timestamp(entry.createdAt); timestamp(entry.updatedAt); revision(entry.revision); + if (ids.has(entry.id) || dates.has(entry.date) || (entry.prompt !== null && (typeof entry.prompt !== "string" || entry.prompt.length > 1000))) invalid(); + ids.add(entry.id); dates.add(entry.date); + validateDocument(entry.document); + } + const habits = new Set(); + for (const item of data.habits) { + const habit = row(item, ["id", "name", "order", "createdAt", "createdOn", "archivedAt", "archivedOn", "revision"]); + id(habit.id); timestamp(habit.createdAt); date(habit.createdOn); revision(habit.revision); + if (habit.archivedAt !== null) { timestamp(habit.archivedAt); date(habit.archivedOn); if (habit.archivedOn < habit.createdOn) invalid(); } + else if (habit.archivedOn !== null) invalid(); + if (habits.has(habit.id) || typeof habit.name !== "string" || !habit.name.trim() || habit.name.length > 200 || !Number.isInteger(habit.order) || (habit.order as number) < 0 || (habit.order as number) > 100) invalid(); + habits.add(habit.id); + } + const completions = new Set(); + for (const item of data.completions) { + const completion = row(item, ["date", "habitId", "done", "updatedAt"]); + date(completion.date); id(completion.habitId); timestamp(completion.updatedAt); + const key = `${completion.date}:${completion.habitId}`; + if (!habits.has(completion.habitId) || completions.has(key) || typeof completion.done !== "boolean") invalid(); + completions.add(key); + } + return structuredClone(value as JournalData); +} +export function writeJournalEntry(data: JournalData, day: string, document: NoteDocument, prompt: string | null, at: Date): JournalData { + date(day); + const next = structuredClone(data), existing = next.entries.find(entry => entry.date === day); + const entry: JournalEntry = { id: existing?.id ?? crypto.randomUUID(), date: day, document: validateDocument(document), prompt, createdAt: existing?.createdAt ?? at.toISOString(), updatedAt: at.toISOString(), revision: (existing?.revision ?? 0) + 1 }; + next.entries = next.entries.filter(item => item.date !== day); + next.entries.push(entry); + return validateJournalData(next); +} +export function setHabitCompletion(data: JournalData, day: string, habitId: string, done: boolean, at: Date): JournalData { + date(day); + if (!data.habits.some(habit => habit.id === habitId && habit.createdOn <= day && (!habit.archivedOn || habit.archivedOn >= day))) throw new Error("habit_missing: This habit is unavailable for this date."); + const next = structuredClone(data); + next.completions = next.completions.filter(item => item.date !== day || item.habitId !== habitId); + next.completions.push({ date: day, habitId, done, updatedAt: at.toISOString() }); + return validateJournalData(next); +} + +export function journalToday(at = new Date()): string { + if (!Number.isFinite(+at)) throw new Error("invalid_date: Choose a valid calendar date."); + return `${at.getFullYear().toString().padStart(4, "0")}-${(at.getMonth() + 1).toString().padStart(2, "0")}-${at.getDate().toString().padStart(2, "0")}`; +} +export function saveHabit(data: JournalData, name: string, at: Date, habitId?: string): JournalData { + if (!name.trim() || name.length > 200) throw new Error("invalid_habit: Enter a habit name up to 200 characters."); + const next = structuredClone(data), habit = next.habits.find(item => item.id === habitId); + if (habitId && !habit) throw new Error("habit_missing: This habit no longer exists."); + if (habit) { habit.name = name.trim(); habit.revision++; } + else { + if (next.habits.length >= 100) throw new Error("habit_limit: You can keep up to 100 habits, including archived habits."); + next.habits.push({ id: crypto.randomUUID(), name: name.trim(), order: next.habits.length, createdAt: at.toISOString(), createdOn: journalToday(at), archivedAt: null, archivedOn: null, revision: 1 }); + } + return validateJournalData(next); +} +export function archiveHabit(data: JournalData, habitId: string, at: Date): JournalData { + const next = structuredClone(data), habit = next.habits.find(item => item.id === habitId); + if (!habit) throw new Error("habit_missing: This habit no longer exists."); + if (!habit.archivedAt) { habit.archivedAt = at.toISOString(); habit.archivedOn = journalToday(at); habit.revision++; } + return validateJournalData(next); +} +export function journalMonth(data: JournalData, year: number, month: number) { + if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) throw new Error("invalid_month: Choose a valid month."); + const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const entries = new Map(data.entries.map(entry => [entry.date, entry])); + const completions = new Map>(); + for (const completion of data.completions) if (completion.done) { + const ids = completions.get(completion.date) ?? new Set(); ids.add(completion.habitId); completions.set(completion.date, ids); + } + const hasText = (nodes: NoteDocument["content"]): boolean => nodes.some(node => !!node.text?.trim() || !!node.content && hasText(node.content)); + return Array.from({ length: days }, (_, i) => { + const day = `${year}-${String(month).padStart(2, "0")}-${String(i + 1).padStart(2, "0")}`; + const entry = entries.get(day) ?? null, done = completions.get(day) ?? new Set(); + const relevant = data.habits.filter(habit => done.has(habit.id) || habit.createdOn <= day && (!habit.archivedOn || habit.archivedOn >= day)); + return { date: day, entry: entry ? structuredClone(entry) : null, written: !!entry && hasText(entry.document.content), habitsCompleted: done.size, habitTotal: relevant.length }; + }); +} + +function base64(bytes: Uint8Array): string { + // Chunked to avoid argument-list limits for real-sized journals. + let binary = ""; + for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + return btoa(binary); +} +function bytes(value: string): Uint8Array { return Uint8Array.from(atob(value), char => char.charCodeAt(0)); } +function aad(context: JournalContext): Uint8Array { + id(context.accountId); id(context.epoch); + return encoder.encode(JSON.stringify(["orbyn/journal/v1", context.accountId, context.epoch])); +} +function checkPassphrase(passphrase: string) { + if (typeof passphrase !== "string" || passphrase.length < 12 || passphrase.length > 1024) throw new Error("invalid_passphrase: Use a Journal passphrase of 12 to 1,024 characters."); +} +async function derive(passphrase: string, salt: Uint8Array, current: JournalFreshness): Promise { + checkPassphrase(passphrase); current(); + const encoded = encoder.encode(passphrase); + try { + const material = await crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"]); + current(); + const key = await crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: 600_000, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + current(); return key; + } finally { encoded.fill(0); } +} +export async function sealJournalData(key: CryptoKey, kdf: JournalEnvelope["kdf"], data: JournalData, context: JournalContext, current: JournalFreshness): Promise { + current(); + if (key.extractable || key.type !== "secret" || key.algorithm.name !== "AES-GCM" || (key.algorithm as AesKeyAlgorithm).length !== 256) throw new Error("invalid_journal_key: Unlock Journal again."); + const checked = validateJournalData(data), iv = crypto.getRandomValues(new Uint8Array(12)); + // Validate and snapshot the header before encrypt yields to a caller. The tag-sized + // placeholder is never persisted; the authenticated ciphertext replaces it below. + const header = validateJournalEnvelope({ version: 1, kdf, iv: base64(iv), ciphertext: base64(new Uint8Array(16)) })!; + const plain = encoder.encode(JSON.stringify(checked)); + try { + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad(context), tagLength: 128 }, key, plain); + current(); + return validateJournalEnvelope({ ...header, ciphertext: base64(new Uint8Array(encrypted)) })!; + } finally { plain.fill(0); } +} +export async function createJournalEnvelope(passphrase: string, data: JournalData, context: JournalContext, current: JournalFreshness): Promise<{ envelope: JournalEnvelope; key: CryptoKey }> { + current(); aad(context); + const stable = validateJournalData(data), binding = structuredClone(context); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await derive(passphrase, salt, current); + const envelope = await sealJournalData(key, { name: "PBKDF2-SHA256", iterations: 600000, salt: base64(salt) }, stable, binding, current); + current(); return { envelope, key }; +} +export async function unlockJournalEnvelope(envelope: JournalEnvelope, passphrase: string, context: JournalContext, current: JournalFreshness): Promise<{ data: JournalData; key: CryptoKey }> { + current(); + const binding = structuredClone(context); + aad(binding); + const checked = validateJournalEnvelope(envelope); + if (!checked) throw new Error("journal_missing: Set up Journal first."); + const key = await derive(passphrase, bytes(checked.kdf.salt), current); + let plain: ArrayBuffer; + try { plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes(checked.iv), additionalData: aad(binding), tagLength: 128 }, key, bytes(checked.ciphertext)); } + catch { current(); throw new Error("journal_unlock_failed: Couldn’t unlock Journal. Check your passphrase and try again."); } + try { + current(); + if (plain.byteLength > MAX_BYTES) invalid(); + const data = validateJournalData(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plain))); + current(); return { data, key }; + } finally { new Uint8Array(plain).fill(0); } +} diff --git a/web/src/collections/journalTransfer.ts b/web/src/collections/journalTransfer.ts new file mode 100644 index 0000000..30f28aa --- /dev/null +++ b/web/src/collections/journalTransfer.ts @@ -0,0 +1,108 @@ +import type { Note, NoteDocument, RichNode } from "../domain/contracts"; +import { exportMarkdown } from "../transfer"; +import { validateJournalData } from "./journal"; +import type { JournalData } from "./journal"; + +/** Complete readable backup plus Markdown views; not an automatic restore API. */ +export function exportJournalFiles( + input: JournalData, +): ReturnType { + const data = validateJournalData(input); + if (!data.entries.length && !data.habits.length && !data.completions.length) + return []; + const files = [ + { filename: "journal.json", content: `${JSON.stringify(data, null, 2)}\n` }, + ]; + let markdownWarning = ""; + try { + files.push(...exportJournalMarkdown(data)); + } catch { + // The complete structured backup remains available when added Markdown + // presentation (prompt/habit sections) exceeds an individual note limit. + markdownWarning = + "\nMarkdown views could not be generated. journal.json still contains every entry, habit definition and completion.\n"; + } + files.push({ + filename: "README.txt", + content: `Orbyn Notes Journal export\n\njournal.json is the complete structured backup: entries, prompts, habit definitions (including archived and never-completed habits), completions and Journal preferences. Dated Markdown files are readable views when available.\n\nThese files are NOT encrypted. Anyone with the files can read them. There is currently no built-in Journal import or automatic restore. Keep journal.json together with this export for manual recovery.${markdownWarning}\n`, + }); + return files; +} + +/** Call only after explicit plaintext-export confirmation on an unlocked vault. */ +export function exportJournalMarkdown( + input: JournalData, +): ReturnType { + const data = validateJournalData(input); + const dates = [ + ...new Set([ + ...data.entries.map((entry) => entry.date), + ...data.completions.map((completion) => completion.date), + ]), + ].sort(); + const entries = new Map(data.entries.map((entry) => [entry.date, entry])); + return dates.flatMap((day) => { + const entry = entries.get(day); + const content: RichNode[] = []; + if (entry?.prompt) + content.push({ + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: entry.prompt }], + }, + ], + }); + content.push(...structuredClone(entry?.document.content ?? [])); + const completions = data.completions.filter( + (completion) => completion.date === day, + ); + if (completions.length) { + content.push({ + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Habits" }], + }); + content.push({ + type: "taskList", + content: completions.map((completion) => ({ + type: "taskItem", + attrs: { checked: completion.done }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: data.habits.find( + (habit) => habit.id === completion.habitId, + )!.name, + }, + ], + }, + ], + })), + }); + } + const document: NoteDocument = { + type: "doc", + content: content.length ? content : [{ type: "paragraph", content: [] }], + }; + const fallbackTimestamp = `${day}T12:00:00.000Z`; + const note: Note = { + id: entry?.id ?? `journal-${day}`, + title: `Journal — ${day}`, + document, + tags: [], + revision: entry?.revision ?? 1, + createdAt: entry?.createdAt ?? fallbackTimestamp, + updatedAt: entry?.updatedAt ?? fallbackTimestamp, + trashedAt: null, + }; + return exportMarkdown([note]).map((file) => ({ + ...file, + filename: `${day}.md`, + })); + }); +} diff --git a/web/src/collections/journalVault.ts b/web/src/collections/journalVault.ts new file mode 100644 index 0000000..5e46047 --- /dev/null +++ b/web/src/collections/journalVault.ts @@ -0,0 +1,1092 @@ +import type { AccountIdentity, NoteDocument } from "../domain/contracts"; +import { validateDocument } from "../domain/validation"; +import { lockVersion, onLock, requireSession } from "../account/session"; +import type { + CollectionsState, + CollectionsStore, + JournalEnvelope, +} from "./contracts"; +import { + onCollectionsClose, + onCollectionsEpochChange, + replaceJournal, +} from "./store"; +import { + createJournalEnvelope, + emptyJournal, + sealJournalData, + unlockJournalEnvelope, + validateJournalData, + saveHabit, +} from "./journal"; +import type { JournalData } from "./journal"; +import { + openProtectedForms, + sealProtectedForms, + validateHabitFormDraft, + validateHabitFormInput, + validateLinkFormInput, + validateLinkFormDraft, + validateProtectedForms, +} from "./journalViewDraft"; +import type { + ProtectedFormsEnvelope, + HabitFormDraft, + HabitFormInput, + LinkFormInput, + LinkFormDraft, +} from "./journalViewDraft"; + +export type JournalVaultStatus = { + phase: + | "locked" + | "unlocking" + | "unlocked" + | "sealing" + | "lock-error" + | "revoked" + | "closed"; + hasJournal: boolean; + dirty: boolean; + habitDraftDirty: boolean; + linkDraftDirty: boolean; + saving: boolean; + pendingRecovery: boolean; + conflict: boolean; + error: string | null; +}; +export interface JournalVault { + status(): JournalVaultStatus; + read(): JournalData; + readHabitDraft(): HabitFormDraft | null; + setHabitDraft(input: HabitFormInput): void; + saveHabitDraft(): Promise; + /** Caller confirms cancellation when unsaved intent exists. */ + discardHabitDraft(): void; + readLinkDraft(): LinkFormDraft | null; + setLinkDraft(input: LinkFormInput): void; + discardLinkDraft(): void; + beginLinkApply(): string; + completeLinkDraft( + token: string, + applied: { document: NoteDocument; changed: boolean }, + ): void; + create(passphrase: string, initial?: JournalData): Promise; + unlock(passphrase: string): Promise; + update(change: (data: JournalData) => JournalData): Promise; + retry(): Promise; + lock(): Promise; + /** Must follow explicit user confirmation when a draft/recovery exists. */ + discardAndReload(): Promise; + setAway(away: boolean): void; + canClose(): boolean; + subscribe(listener: () => void): () => void; + close(): void; +} +type Recovery = { envelope: JournalEnvelope; version: number }; +type PrivateRecovery = { + data: JournalData; + key: CryptoKey; + kdf: JournalEnvelope["kdf"]; + proof: JournalEnvelope; + version: number; + dataDirty: boolean; + habit: HabitFormDraft | null; + link: LinkFormDraft | null; +}; +type FormsRecovery = { + envelope: ProtectedFormsEnvelope; + proof: JournalEnvelope; + dirty: boolean; + habitDirty: boolean; + linkDirty: boolean; +}; +type Job = { + generation: number; + kind: "read" | "create" | "unlock" | "save" | "seal"; +}; +function sameEnvelope( + a: JournalEnvelope | null, + b: JournalEnvelope | null, +): boolean { + return a === null || b === null + ? a === b + : a.version === b.version && + a.kdf.name === b.kdf.name && + a.kdf.iterations === b.kdf.iterations && + a.kdf.salt === b.kdf.salt && + a.iv === b.iv && + a.ciphertext === b.ciphertext; +} +function stale(): Error { + return new Error("journal_stale: Journal access changed. Unlock it again."); +} + +export async function openJournalVault( + input: AccountIdentity, + store: CollectionsStore, +): Promise { + const identity = structuredClone(input), + accountVersion = lockVersion(); + requireSession(identity.accountId, identity.deviceId); + let phase: JournalVaultStatus["phase"] = "locked", + generation = 0; + let baseline: CollectionsState | null = null; + let draft: JournalData | null = null, + key: CryptoKey | null = null, + kdf: JournalEnvelope["kdf"] | null = null; + let dirty = false, + conflict = false, + error: string | null = null, + draftVersion = 0; + let recovery: Recovery | null = null, + privateRecovery: PrivateRecovery | null = null, + attempt: Recovery | null = null; + let saveTask: Promise | null = null; + let habitDraft: HabitFormDraft | null = null, + formsRecovery: FormsRecovery | null = null, + habitVersion = 0; + let linkDraft: LinkFormDraft | null = null, + linkVersion = 0; + let linkApply: { + token: string; + generation: number; + epoch: string; + version: number; + draftVersion: number; + date: string; + entryId: string | null; + revision: number; + } | null = null; + const jobs = new Set(), + listeners = new Set<() => void>(); + let stopAccount = () => {}, + stopEpoch = () => {}, + stopOwner = () => {}, + stopStore = () => {}; + let timer: ReturnType | null = null, + awaySince: number | null = null, + manualAway = false; + let browserAway = + typeof document !== "undefined" && + (document.hidden || !document.hasFocus()); + const browserEvents: Array<() => void> = []; + + function status(): JournalVaultStatus { + return { + phase, + hasJournal: Boolean(baseline?.journal || recovery || privateRecovery), + dirty, + habitDraftDirty: habitDirty(), + linkDraftDirty: linkDirty(), + saving: [...jobs].some( + (job) => + job.generation === generation && + (job.kind === "save" || job.kind === "create"), + ), + pendingRecovery: Boolean( + recovery || privateRecovery || formsRecovery?.dirty, + ), + conflict, + error, + }; + } + function habitDirty() { + return Boolean( + habitDraft?.dirty || + formsRecovery?.habitDirty || + privateRecovery?.habit?.dirty, + ); + } + function linkDirty() { + return Boolean( + linkDraft?.dirty || + formsRecovery?.linkDirty || + privateRecovery?.link?.dirty, + ); + } + function notify() { + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observers never control crypto or commit outcomes. */ + } + } + } + function cancelTimer() { + if (timer !== null) clearTimeout(timer); + timer = null; + } + function detach() { + stopAccount(); + stopEpoch(); + stopOwner(); + stopStore(); + cancelTimer(); + for (const remove of browserEvents.splice(0)) remove(); + } + function revoke(next: "revoked" | "closed") { + generation++; + phase = next; + baseline = null; + draft = null; + key = null; + kdf = null; + recovery = null; + privateRecovery = null; + attempt = null; + habitDraft = null; + linkDraft = null; + linkApply = null; + linkVersion++; + formsRecovery = null; + habitVersion++; + dirty = false; + conflict = false; + saveTask = null; + error = + next === "revoked" + ? "journal_revoked: Journal access was revoked." + : null; + detach(); + notify(); + listeners.clear(); + } + function assertActive() { + if (phase === "closed" || phase === "revoked") throw stale(); + if (lockVersion() !== accountVersion) { + revoke("revoked"); + throw stale(); + } + requireSession(identity.accountId, identity.deviceId); + } + function current(token: number, epoch: string) { + assertActive(); + if (generation !== token || baseline?.epoch !== epoch) throw stale(); + if (enforceAwayDeadline()) throw stale(); + } + function job(kind: Job["kind"], token: number): () => void { + const item = { kind, generation: token }; + jobs.add(item); + notify(); + return () => { + jobs.delete(item); + notify(); + }; + } + function begin() { + assertActive(); + generation++; + linkApply = null; + saveTask = null; + return generation; + } + function markConflict() { + conflict = true; + error = + "journal_conflict: Journal changed in another window. Keep your draft or discard it explicitly before reloading."; + notify(); + } + async function latest( + token: number, + epoch: string, + ): Promise { + const state = await store.read(); + current(token, epoch); + if (state.epoch !== epoch) { + revoke("revoked"); + throw stale(); + } + return state; + } + async function refreshForSave(token: number, epoch: string) { + const state = await latest(token, epoch); + if (attempt && sameEnvelope(state.journal, attempt.envelope)) { + // A notification failure can leave the transaction committed but its result unknown. + const version = attempt.version; + baseline = state; + attempt = null; + if (draftVersion === version) { + dirty = false; + recovery = null; + } + } else if (sameEnvelope(state.journal, baseline!.journal)) baseline = state; + else { + markConflict(); + throw new Error(error!); + } + } + async function commitEnvelope( + envelope: JournalEnvelope, + version: number, + token: number, + epoch: string, + ) { + current(token, epoch); + attempt = { envelope: structuredClone(envelope), version }; + recovery = { envelope: structuredClone(envelope), version }; + const saved = await replaceJournal(store, baseline!, envelope, () => + current(token, epoch), + ); + current(token, epoch); + baseline = saved; + attempt = null; + if (draftVersion === version) { + dirty = false; + recovery = null; + } + } + function save(): Promise { + assertActive(); + if (phase !== "unlocked" || !draft || !key || !kdf) + return Promise.reject(stale()); + if (conflict) return Promise.reject(new Error(error!)); + if (saveTask) return saveTask; + if (!dirty) return Promise.resolve(); + const token = generation, + epoch = baseline!.epoch; + const done = job("save", token); + let task!: Promise; + task = (async () => { + try { + while (dirty) { + current(token, epoch); + await refreshForSave(token, epoch); + current(token, epoch); + if (!dirty) break; + const version = draftVersion, + data = validateJournalData(draft!); + const envelope = await sealJournalData( + key!, + kdf!, + data, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ); + current(token, epoch); + await latest(token, epoch); + current(token, epoch); + await commitEnvelope(envelope, version, token, epoch); + current(token, epoch); + } + error = null; + notify(); + } catch (cause) { + if (generation === token && phase === "unlocked") { + error = conflict + ? error + : "journal_save_failed: Your Journal draft is still in this window. Try again."; + notify(); + } + throw cause; + } finally { + if (saveTask === task) saveTask = null; + done(); + } + })(); + saveTask = task; + return task; + } + async function create(passphrase: string, initial = emptyJournal()) { + assertActive(); + if (phase !== "locked" || dirty || recovery || privateRecovery) + throw stale(); + const data = validateJournalData(initial), + token = begin(), + epoch = baseline!.epoch; + phase = "unlocking"; + error = null; + notify(); + const done = job("create", token); + try { + const state = await latest(token, epoch); + if (state.journal) + throw new Error("journal_exists: Unlock the existing Journal."); + baseline = state; + const created = await createJournalEnvelope( + passphrase, + data, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ); + current(token, epoch); + const latestState = await latest(token, epoch); + if (latestState.journal) { + markConflict(); + throw new Error(error!); + } + baseline = latestState; + draft = data; + key = created.key; + kdf = structuredClone(created.envelope.kdf); + draftVersion++; + dirty = true; + // Establish authenticated recovery proof before any unlocked observer runs. + attempt = { + envelope: structuredClone(created.envelope), + version: draftVersion, + }; + phase = "unlocked"; + notify(); + await commitEnvelope(created.envelope, draftVersion, token, epoch); + current(token, epoch); + error = null; + notify(); + refreshAway(); + } catch (cause) { + if (generation === token) { + if (!draft) phase = "locked"; + error = + "journal_create_failed: Journal setup did not finish. Try again."; + notify(); + } + throw cause; + } finally { + done(); + } + } + async function unlock(passphrase: string) { + assertActive(); + if (phase === "unlocked" || phase === "sealing") throw stale(); + const token = begin(), + epoch = baseline!.epoch; + const retained = privateRecovery, + sealed = recovery, + sealedForms = formsRecovery; + phase = "unlocking"; + error = null; + notify(); + const done = job("unlock", token); + try { + const state = await latest(token, epoch); + if (!retained && !sealed && !sealedForms) baseline = state; + const envelope = + retained?.proof ?? + sealed?.envelope ?? + sealedForms?.proof ?? + state.journal; + if (!envelope) throw new Error("journal_missing: Set up Journal first."); + const opened = await unlockJournalEnvelope( + envelope, + passphrase, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ); + current(token, epoch); + let restoredForms = retained + ? validateProtectedForms({ + version: 1, + habit: retained.habit, + link: retained.link, + }) + : null; + if (!restoredForms && sealedForms) { + if ( + sealedForms.proof.kdf.salt !== envelope.kdf.salt || + sealedForms.proof.kdf.iterations !== envelope.kdf.iterations || + sealedForms.proof.kdf.name !== envelope.kdf.name + ) + throw stale(); + restoredForms = await openProtectedForms( + opened.key, + sealedForms.envelope, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ); + current(token, epoch); + } + const after = await latest(token, epoch); + const ownCommitted = + sealed && sameEnvelope(after.journal, sealed.envelope); + const ownAttempt = + attempt && sameEnvelope(after.journal, attempt.envelope); + const foreign = + !sameEnvelope(after.journal, baseline!.journal) && + !ownCommitted && + !ownAttempt; + if (foreign && !retained && !sealed && !sealedForms) + throw new Error( + "journal_changed: Journal changed while unlocking. Try again.", + ); + draft = retained ? validateJournalData(retained.data) : opened.data; + key = opened.key; + kdf = structuredClone(envelope.kdf); + draftVersion = retained?.version ?? sealed?.version ?? draftVersion + 1; + dirty = (retained?.dataDirty ?? Boolean(sealed)) && !ownCommitted; + habitDraft = restoredForms?.habit ?? null; + linkDraft = restoredForms?.link ?? null; + linkApply = null; + linkVersion++; + formsRecovery = null; + habitVersion++; + privateRecovery = null; + recovery = dirty && sealed ? sealed : null; + attempt = null; + if (!foreign) baseline = after; + conflict = foreign; + phase = "unlocked"; + error = null; + if (foreign) markConflict(); + else notify(); + refreshAway(); + } catch (cause) { + if (generation === token) { + phase = privateRecovery ? "lock-error" : "locked"; + error = + "journal_unlock_failed: Couldn’t unlock Journal. Check your passphrase and try again."; + notify(); + } + throw cause; + } finally { + done(); + } + } + async function update(change: (data: JournalData) => JournalData) { + assertActive(); + if ( + phase !== "unlocked" || + !draft || + conflict || + typeof change !== "function" + ) + throw stale(); + const token = generation, + epoch = baseline!.epoch; + const next = change(structuredClone(draft)); + current(token, epoch); + if ( + next && + typeof (next as unknown as { then?: unknown }).then === "function" + ) { + void Promise.resolve(next).catch(() => {}); + throw new Error("journal_invalid: Journal edits must be synchronous."); + } + draft = validateJournalData(next); + draftVersion++; + dirty = true; + recovery = null; + error = null; + notify(); + return save(); + } + function assertUnlocked() { + assertActive(); + enforceAwayDeadline(); + if (phase !== "unlocked" || !draft || !key || !kdf) throw stale(); + } + function readHabitDraft(): HabitFormDraft | null { + assertUnlocked(); + return habitDraft ? structuredClone(habitDraft) : null; + } + function setHabitDraft(input: HabitFormInput): void { + assertUnlocked(); + if (conflict) throw stale(); + if (linkDirty()) + throw new Error( + "journal_form_dirty: Finish or cancel the current link first.", + ); + const token = generation, + epoch = baseline!.epoch; + const checked = validateHabitFormInput(input); + current(token, epoch); + const sameForm = + habitDraft && + habitDraft.mode === checked.mode && + habitDraft.id === checked.id; + if (habitDraft?.dirty && !sameForm) + throw new Error( + "journal_form_dirty: Save or discard the current habit changes first.", + ); + const existing = + checked.mode === "edit" + ? draft!.habits.find( + (habit) => habit.id === checked.id && !habit.archivedAt, + ) + : undefined; + if (checked.mode === "edit" && !existing) + throw new Error("habit_missing: This habit is unavailable."); + const originalName = sameForm + ? habitDraft!.originalName + : (existing?.name ?? ""); + const candidate = validateHabitFormDraft({ + ...checked, + version: 1, + originalName, + dirty: checked.name !== originalName, + }); + validateProtectedForms({ version: 1, habit: candidate, link: linkDraft }); + habitDraft = candidate; + habitVersion++; + notify(); + } + function discardHabitDraft(): void { + assertUnlocked(); + habitDraft = null; + formsRecovery = null; + habitVersion++; + notify(); + } + async function saveHabitDraft(): Promise { + assertUnlocked(); + if (!habitDraft || conflict) throw stale(); + const form = validateHabitFormDraft(habitDraft), + token = generation, + epoch = baseline!.epoch; + const existing = form.id + ? draft!.habits.find((habit) => habit.id === form.id) + : undefined; + if (!form.name.trim()) + throw new Error( + "invalid_habit: Enter a habit name up to 200 characters.", + ); + let id = form.id; + if (!existing || existing.name !== form.name.trim()) { + const next = saveHabit( + draft!, + form.name, + new Date(), + form.id ?? undefined, + ); + id = + form.id ?? + next.habits.find( + (habit) => !draft!.habits.some((before) => before.id === habit.id), + )!.id; + current(token, epoch); + draft = validateJournalData(next); + draftVersion++; + dirty = true; + recovery = null; + } + habitDraft = validateHabitFormDraft({ + version: 1, + mode: "edit", + id, + name: form.name, + originalName: form.name, + dirty: false, + }); + const submitted = ++habitVersion; + notify(); + await save(); + current(token, epoch); + if (habitVersion === submitted) { + habitDraft = null; + habitVersion++; + notify(); + } + } + function readLinkDraft(): LinkFormDraft | null { + assertUnlocked(); + return linkDraft ? structuredClone(linkDraft) : null; + } + function setLinkDraft(input: LinkFormInput): void { + assertUnlocked(); + if (conflict) throw stale(); + if (habitDirty()) + throw new Error( + "journal_form_dirty: Finish or cancel the current habit first.", + ); + const checked = validateLinkFormInput(input); + const same = + linkDraft && + checked.date === linkDraft.date && + checked.source === linkDraft.source && + checked.from === linkDraft.from && + checked.to === linkDraft.to && + JSON.stringify(checked.selection) === JSON.stringify(linkDraft.selection); + if (linkDraft?.dirty && !same) + throw new Error( + "journal_form_dirty: Finish or cancel the current link first.", + ); + if (same && checked.initialUrl !== linkDraft!.initialUrl) throw stale(); + const entry = draft!.entries.find((entry) => entry.date === checked.date); + const candidate = validateLinkFormDraft({ + ...checked, + version: 1, + entryId: same ? linkDraft!.entryId : (entry?.id ?? null), + entryRevision: same ? linkDraft!.entryRevision : (entry?.revision ?? 0), + dirty: checked.url !== checked.initialUrl, + }); + validateProtectedForms({ version: 1, habit: habitDraft, link: candidate }); + linkDraft = candidate; + linkVersion++; + linkApply = null; + notify(); + } + function discardLinkDraft(): void { + assertUnlocked(); + linkDraft = null; + linkApply = null; + linkVersion++; + notify(); + } + function beginLinkApply(): string { + assertUnlocked(); + if (conflict || !linkDraft) throw stale(); + const entry = draft!.entries.find( + (entry) => entry.date === linkDraft!.date, + ); + if ( + (entry?.id ?? null) !== linkDraft.entryId || + (entry?.revision ?? 0) !== linkDraft.entryRevision + ) + throw new Error( + "journal_link_stale: This text changed. Cancel this link and select the text again.", + ); + const token = crypto.randomUUID(); + linkApply = { + token, + generation, + epoch: baseline!.epoch, + version: linkVersion, + draftVersion, + date: linkDraft.date, + entryId: entry?.id ?? null, + revision: entry?.revision ?? 0, + }; + return token; + } + function completeLinkDraft( + token: string, + applied: { document: NoteDocument; changed: boolean }, + ): void { + assertUnlocked(); + const ticket = linkApply; + if ( + !ticket || + ticket.token !== token || + !linkDraft || + conflict || + ticket.version !== linkVersion || + ticket.generation !== generation || + ticket.epoch !== baseline!.epoch || + typeof applied.changed !== "boolean" + ) + throw stale(); + const entry = draft!.entries.find((entry) => entry.date === ticket.date); + if (applied.changed) { + const document = validateDocument(applied.document); + if ( + !entry || + entry.revision !== ticket.revision + 1 || + draftVersion !== ticket.draftVersion + 1 || + (ticket.entryId !== null && entry.id !== ticket.entryId) || + JSON.stringify(entry.document) !== JSON.stringify(document) + ) + throw new Error( + "journal_link_not_accepted: This link was not accepted into your Journal draft. Its URL is still here.", + ); + } else if ( + linkDraft.dirty || + linkDraft.source !== "toolbar" || + draftVersion !== ticket.draftVersion || + (entry?.revision ?? 0) !== ticket.revision + ) { + throw new Error( + "journal_link_not_accepted: This link was not applied. Its URL is still here.", + ); + } + linkDraft = null; + linkApply = null; + linkVersion++; + notify(); + } + async function lock() { + assertActive(); + const retained = privateRecovery; + const data = draft ?? retained?.data ?? null, + activeKey = key ?? retained?.key ?? null, + header = kdf ?? retained?.kdf ?? null; + const form = habitDraft ?? retained?.habit ?? null; + const link = linkDraft ?? retained?.link ?? null; + const needsForm = Boolean( + (form || link) && (form?.dirty || link?.dirty || dirty), + ); + const proof = + attempt?.envelope ?? + recovery?.envelope ?? + retained?.proof ?? + baseline?.journal ?? + null; + const token = begin(), + epoch = baseline!.epoch; + cancelTimer(); + if ((!dirty && !needsForm) || !data || !activeKey || !header) { + draft = null; + key = null; + kdf = null; + habitDraft = null; + linkDraft = null; + phase = privateRecovery ? "lock-error" : "locked"; + notify(); + return; + } + // Hide before any asynchronous encryption. No read/projection API can reach this state. + phase = "sealing"; + draft = null; + key = null; + kdf = null; + habitDraft = null; + linkDraft = null; + if (!proof) { + dirty = false; + privateRecovery = null; + recovery = null; + formsRecovery = null; + phase = "locked"; + error = "journal_create_failed: Set up Journal again."; + notify(); + return; + } + privateRecovery = { + data, + key: activeKey, + kdf: header, + proof: structuredClone(proof), + version: draftVersion, + dataDirty: dirty, + habit: needsForm ? structuredClone(form) : null, + link: needsForm ? structuredClone(link) : null, + }; + notify(); + const done = job("seal", token); + try { + const envelope = dirty + ? await sealJournalData( + activeKey, + header, + data, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ) + : null; + current(token, epoch); + const sealedForms = needsForm + ? await sealProtectedForms( + activeKey, + { version: 1, habit: form, link }, + { accountId: identity.accountId, epoch }, + () => current(token, epoch), + ) + : null; + current(token, epoch); + await latest(token, epoch); + current(token, epoch); + recovery = envelope ? { envelope, version: draftVersion } : null; + formsRecovery = sealedForms + ? { + envelope: sealedForms, + proof: structuredClone(envelope ?? proof), + dirty: Boolean(form?.dirty || link?.dirty), + habitDirty: Boolean(form?.dirty), + linkDirty: Boolean(link?.dirty), + } + : null; + privateRecovery = null; + phase = "locked"; + error = null; + notify(); + } catch (cause) { + if (generation === token) { + phase = "lock-error"; + error = + "journal_lock_failed: Journal is hidden. Unlock it to recover your draft."; + notify(); + } + throw cause; + } finally { + done(); + } + } + async function discardAndReload() { + assertActive(); + const token = begin(), + epoch = baseline!.epoch; + // Explicit destructive recovery action, called only after the UI confirms discard. + draft = null; + key = null; + kdf = null; + recovery = null; + privateRecovery = null; + attempt = null; + habitDraft = null; + linkDraft = null; + linkApply = null; + linkVersion++; + formsRecovery = null; + habitVersion++; + dirty = false; + conflict = false; + phase = "locked"; + error = null; + notify(); + const done = job("read", token); + try { + baseline = await latest(token, epoch); + current(token, epoch); + notify(); + } finally { + done(); + } + } + async function synchronize() { + const token = generation, + epoch = baseline?.epoch; + if (!epoch) return; + try { + const state = await latest(token, epoch); + if (sameEnvelope(state.journal, baseline!.journal)) { + baseline = state; + return; + } + if (attempt && sameEnvelope(state.journal, attempt.envelope)) return; + if (dirty || recovery || privateRecovery || habitDirty() || linkDirty()) { + markConflict(); + return; + } + generation++; + saveTask = null; + draft = null; + key = null; + kdf = null; + habitDraft = null; + linkDraft = null; + linkApply = null; + linkVersion++; + formsRecovery = null; + habitVersion++; + baseline = state; + phase = "locked"; + error = + "journal_changed: Journal changed in another window. Unlock it again."; + notify(); + } catch { + /* Revocation is handled by the captured account/epoch subscriptions. */ + } + } + function enforceAwayDeadline(): boolean { + if ( + awaySince !== null && + Date.now() - awaySince >= 60000 && + (phase === "unlocked" || phase === "unlocking") + ) { + // lock() hides synchronously and invalidates older crypto continuations. + // Its ordinary sealing/recovery path handles dirty drafts after hiding. + void lock().catch(() => {}); + return true; + } + return false; + } + function refreshAway() { + // Timers may be suspended by background throttling or system sleep. Enforce + // the recorded deadline BEFORE a returning-focus event clears the absence. + enforceAwayDeadline(); + const away = manualAway || browserAway; + if (!away) { + awaySince = null; + cancelTimer(); + return; + } + awaySince ??= Date.now(); + if (timer !== null || phase !== "unlocked") return; + timer = setTimeout( + () => { + timer = null; + if (phase === "unlocked") void lock().catch(() => {}); + }, + Math.max(0, 60000 - (Date.now() - awaySince)), + ); + } + function setAway(away: boolean) { + assertActive(); + if (typeof away !== "boolean") + throw new Error("journal_invalid: Invalid visibility state."); + manualAway = away; + refreshAway(); + } + function close() { + if (phase !== "closed") revoke("closed"); + } + const vault: JournalVault = { + status, + read: () => { + assertActive(); + enforceAwayDeadline(); + if (phase !== "unlocked" || !draft) + throw new Error("journal_locked: Unlock Journal to read it."); + return structuredClone(draft); + }, + create, + readHabitDraft, + setHabitDraft, + saveHabitDraft, + discardHabitDraft, + readLinkDraft, + setLinkDraft, + discardLinkDraft, + beginLinkApply, + completeLinkDraft, + unlock, + update, + retry: async () => save(), + lock, + discardAndReload, + setAway, + canClose: () => + phase === "closed" || + phase === "revoked" || + (!dirty && + !habitDirty() && + !linkDirty() && + !recovery && + !privateRecovery && + phase !== "unlocking" && + phase !== "sealing" && + ![...jobs].some((item) => item.generation === generation)), + subscribe: (listener) => { + assertActive(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + close, + }; + // Register account, owner-close and epoch revocation before the first read. + stopAccount = onLock(() => revoke("revoked")); + try { + stopOwner = onCollectionsClose(store, () => revoke("revoked")); + stopEpoch = onCollectionsEpochChange(store, () => revoke("revoked")); + stopStore = store.subscribe(() => { + void synchronize(); + }); + const token = generation, + state = await store.read(); + assertActive(); + if (generation !== token) throw stale(); + baseline = state; + if (typeof document !== "undefined" && typeof window !== "undefined") { + const visible = () => { + browserAway = document.hidden || !document.hasFocus(); + refreshAway(); + }; + const blur = () => { + browserAway = true; + refreshAway(); + }; + document.addEventListener("visibilitychange", visible); + window.addEventListener("focus", visible); + window.addEventListener("blur", blur); + browserEvents.push(() => { + document.removeEventListener("visibilitychange", visible); + window.removeEventListener("focus", visible); + window.removeEventListener("blur", blur); + }); + } + refreshAway(); + return vault; + } catch (cause) { + close(); + throw cause; + } +} diff --git a/web/src/collections/journalViewDraft.ts b/web/src/collections/journalViewDraft.ts new file mode 100644 index 0000000..37b92a8 --- /dev/null +++ b/web/src/collections/journalViewDraft.ts @@ -0,0 +1,368 @@ +import type { JournalContext, JournalFreshness } from "./journal"; + +export type HabitFormInput = { + mode: "create" | "edit"; + id: string | null; + name: string; +}; +export type HabitFormDraft = HabitFormInput & { + version: 1; + originalName: string; + dirty: boolean; +}; +export type HabitDraftEnvelope = { version: 1; iv: string; ciphertext: string }; +export type LinkSelectionJSON = + | { type: "all" } + | { type: "node"; anchor: number } + | { type: "text"; anchor: number; head: number }; +export type LinkFormInput = { + date: string; + source: "toolbar" | "slash"; + from: number; + to: number; + selection: LinkSelectionJSON; + initialUrl: string; + url: string; +}; +export type LinkFormDraft = LinkFormInput & { + version: 1; + entryId: string | null; + entryRevision: number; + dirty: boolean; +}; +export type ProtectedFormDrafts = { + version: 1; + habit: HabitFormDraft | null; + link: LinkFormDraft | null; +}; +export type ProtectedFormsEnvelope = HabitDraftEnvelope; +const MAX_FORMS_BYTES = 4 * 1024 * 1024; +const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function invalid(): never { + throw new Error( + "journal_form_invalid: The protected form draft could not be read.", + ); +} +function object(value: unknown, fields: string[]): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + invalid(); + const row = value as Record; + if ( + Object.keys(row).length !== fields.length || + fields.some((field) => !Object.hasOwn(row, field)) + ) + invalid(); + return row; +} +export function validateHabitFormInput(value: unknown): HabitFormInput { + const row = object(value, ["mode", "id", "name"]); + if (row.mode !== "create" && row.mode !== "edit") invalid(); + if ( + row.mode === "create" + ? row.id !== null + : typeof row.id !== "string" || !uuid.test(row.id) + ) + invalid(); + if (typeof row.name !== "string" || row.name.length > 200) invalid(); + return structuredClone(value as HabitFormInput); +} +export function validateHabitFormDraft(value: unknown): HabitFormDraft { + const row = object(value, [ + "version", + "mode", + "id", + "name", + "originalName", + "dirty", + ]); + validateHabitFormInput({ mode: row.mode, id: row.id, name: row.name }); + if ( + row.version !== 1 || + typeof row.originalName !== "string" || + row.originalName.length > 200 || + typeof row.dirty !== "boolean" || + row.dirty !== (row.name !== row.originalName) || + (row.mode === "create" && row.originalName !== "") + ) + invalid(); + return structuredClone(value as HabitFormDraft); +} +function bytes( + value: unknown, + min: number, + max: number, +): Uint8Array { + if ( + typeof value !== "string" || + value.length > Math.ceil(max / 3) * 4 || + value.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/.test(value) + ) + invalid(); + const decoded = atob(value); + if (decoded.length < min || decoded.length > max || btoa(decoded) !== value) + invalid(); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); +} +function base64(value: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < value.length; offset += 8192) + binary += String.fromCharCode(...value.subarray(offset, offset + 8192)); + return btoa(binary); +} +function aad( + context: JournalContext, + purpose = "orbyn/journal/habit-draft/v1", +): Uint8Array { + if (!uuid.test(context.accountId) || !uuid.test(context.epoch)) invalid(); + return new TextEncoder().encode( + JSON.stringify([purpose, context.accountId, context.epoch]), + ); +} +function position(value: unknown): asserts value is number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) > 4_000_000 + ) + invalid(); +} +export function validateLinkFormInput(value: unknown): LinkFormInput { + const item = object(value, [ + "date", + "source", + "from", + "to", + "selection", + "initialUrl", + "url", + ]); + if ( + typeof item.date !== "string" || + !/^[1-9]\d{3}-\d{2}-\d{2}$/.test(item.date) || + !Number.isFinite(Date.parse(`${item.date}T12:00:00Z`)) || + new Date(`${item.date}T12:00:00Z`).toISOString().slice(0, 10) !== item.date + ) + invalid(); + if (item.source !== "toolbar" && item.source !== "slash") invalid(); + position(item.from); + position(item.to); + if (item.to < item.from) invalid(); + const selection = item.selection as Record | null; + if (!selection) invalid(); + if (selection.type === "all") object(selection, ["type"]); + else if (selection.type === "node") { + object(selection, ["type", "anchor"]); + position(selection.anchor); + } else if (selection.type === "text") { + object(selection, ["type", "anchor", "head"]); + position(selection.anchor); + position(selection.head); + } else invalid(); + if ( + typeof item.initialUrl !== "string" || + typeof item.url !== "string" || + item.initialUrl.length > 2_000_000 || + item.url.length > 2_000_000 + ) + invalid(); + // Invalid/unfinished URLs are recoverable input, not committed link attributes. + if (new TextEncoder().encode(JSON.stringify(value)).length > MAX_FORMS_BYTES) + invalid(); + return structuredClone(value as LinkFormInput); +} +export function validateLinkFormDraft(value: unknown): LinkFormDraft { + const item = object(value, [ + "date", + "source", + "from", + "to", + "selection", + "initialUrl", + "url", + "version", + "entryId", + "entryRevision", + "dirty", + ]); + validateLinkFormInput({ + date: item.date, + source: item.source, + from: item.from, + to: item.to, + selection: item.selection, + initialUrl: item.initialUrl, + url: item.url, + }); + if ( + item.version !== 1 || + (item.entryId !== null && + (typeof item.entryId !== "string" || !uuid.test(item.entryId))) || + !Number.isSafeInteger(item.entryRevision) || + (item.entryRevision as number) < 0 || + (item.entryId === null && item.entryRevision !== 0) || + (item.entryId !== null && item.entryRevision === 0) || + typeof item.dirty !== "boolean" || + item.dirty !== (item.url !== item.initialUrl) + ) + invalid(); + return structuredClone(value as LinkFormDraft); +} +export function validateProtectedForms(value: unknown): ProtectedFormDrafts { + const item = object(value, ["version", "habit", "link"]); + if (item.version !== 1) invalid(); + if (item.habit !== null) validateHabitFormDraft(item.habit); + if (item.link !== null) validateLinkFormDraft(item.link); + if (new TextEncoder().encode(JSON.stringify(value)).length > MAX_FORMS_BYTES) + invalid(); + return structuredClone(value as ProtectedFormDrafts); +} + +export async function sealProtectedForms( + key: CryptoKey, + forms: ProtectedFormDrafts, + context: JournalContext, + current: JournalFreshness, +): Promise { + current(); + checkKey(key); + const data = validateProtectedForms(forms), + binding = aad(context, "orbyn/journal/protected-forms/v1"); + const plain = new TextEncoder().encode(JSON.stringify(data)), + iv = crypto.getRandomValues(new Uint8Array(12)); + try { + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: binding, tagLength: 128 }, + key, + plain, + ); + current(); + return { + version: 1, + iv: base64(iv), + ciphertext: base64(new Uint8Array(encrypted)), + }; + } finally { + plain.fill(0); + } +} +export async function openProtectedForms( + key: CryptoKey, + envelope: ProtectedFormsEnvelope, + context: JournalContext, + current: JournalFreshness, +): Promise { + current(); + checkKey(key); + const item = object(envelope, ["version", "iv", "ciphertext"]); + if (item.version !== 1) invalid(); + const iv = bytes(item.iv, 12, 12), + ciphertext = bytes(item.ciphertext, 16, MAX_FORMS_BYTES + 16), + binding = aad(context, "orbyn/journal/protected-forms/v1"); + let plain: ArrayBuffer; + try { + plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData: binding, tagLength: 128 }, + key, + ciphertext, + ); + } catch { + current(); + throw new Error( + "journal_form_unlock_failed: The protected form drafts could not be unlocked.", + ); + } + try { + current(); + if (plain.byteLength > MAX_FORMS_BYTES) invalid(); + const result = validateProtectedForms( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plain)), + ); + current(); + return result; + } finally { + new Uint8Array(plain).fill(0); + } +} +function checkKey(key: CryptoKey) { + if ( + key.extractable || + key.type !== "secret" || + key.algorithm.name !== "AES-GCM" || + (key.algorithm as AesKeyAlgorithm).length !== 256 + ) + invalid(); +} +export async function sealHabitDraft( + key: CryptoKey, + draft: HabitFormDraft, + context: JournalContext, + current: JournalFreshness, +): Promise { + current(); + checkKey(key); + const data = validateHabitFormDraft(draft), + binding = aad(context); + const plain = new TextEncoder().encode(JSON.stringify(data)); + if (plain.byteLength > 8192) invalid(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + try { + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: binding, tagLength: 128 }, + key, + plain, + ); + current(); + return { + version: 1, + iv: base64(iv), + ciphertext: base64(new Uint8Array(encrypted)), + }; + } finally { + plain.fill(0); + } +} +export async function openHabitDraft( + key: CryptoKey, + envelope: HabitDraftEnvelope, + context: JournalContext, + current: JournalFreshness, +): Promise { + current(); + checkKey(key); + const row = object(envelope, ["version", "iv", "ciphertext"]); + if (row.version !== 1) invalid(); + const iv = bytes(row.iv, 12, 12), + ciphertext = bytes(row.ciphertext, 16, 16384), + binding = aad(context); + let plain: ArrayBuffer; + try { + plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData: binding, tagLength: 128 }, + key, + ciphertext, + ); + } catch { + current(); + throw new Error( + "journal_form_unlock_failed: The protected habit draft could not be unlocked.", + ); + } + try { + current(); + if (plain.byteLength > 8192) invalid(); + const data = validateHabitFormDraft( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plain)), + ); + current(); + return data; + } finally { + new Uint8Array(plain).fill(0); + } +} diff --git a/web/tests/data/journal-vault.test.ts b/web/tests/data/journal-vault.test.ts new file mode 100644 index 0000000..1554341 --- /dev/null +++ b/web/tests/data/journal-vault.test.ts @@ -0,0 +1,1057 @@ +// @vitest-environment node +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { initSync, DeviceKeys } from "../../src/generated/wasm/orbyn_wasm.js"; +import { openCollectionsStore } from "../../src/collections/store"; +import { openJournalVault } from "../../src/collections/journalVault"; +import { writeJournalEntry } from "../../src/collections/journal"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { establish, lock as lockAccount } from "../../src/account/session"; +import { openNotesStore } from "../../src/data"; +import { unlockJournalEnvelope } from "../../src/collections/journal"; +import { saveHabit } from "../../src/collections/journal"; +import type { + HabitFormDraft, + HabitFormInput, +} from "../../src/collections/journalViewDraft"; + +type HabitVault = Awaited> & { + readHabitDraft(): HabitFormDraft | null; + setHabitDraft(input: HabitFormInput): void; + saveHabitDraft(): Promise; + discardHabitDraft(): void; +}; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "vault@example.test", + deviceState: "trusted" as const, +}; +const passphrase = "Synthetic vault passphrase only"; +const handles = new Set<{ close(): void }>(); +const at = new Date("2026-11-11T12:00:00.000Z"); +const document = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Private recoverable synthetic draft" }], + }, + ], +}; +async function open() { + const store = await openCollectionsStore(identity); + handles.add(store); + const vault = await openJournalVault(identity, store); + handles.add(vault); + return { store, vault }; +} +beforeAll(async () => { + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }); +}); +beforeEach(async () => { + lockAccount(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + })); + } finally { + secret.fill(0); + keys.free(); + } + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + lockAccount(); + vi.unstubAllGlobals(); +}); + +describe("protected unfinished Journal links", () => { + const input = { + date: "2026-11-11", + source: "toolbar" as const, + from: 1, + to: 1, + selection: { type: "text" as const, anchor: 1, head: 1 }, + initialUrl: "", + url: "https://example.test/private-unsubmitted", + }; + it("keeps unfinished URLs memory-only, sealed through lock, and guarded until explicit cancel", async () => { + const { store, vault } = await open(); + await vault.create(passphrase); + const before = await store.read(); + vault.setLinkDraft(input); + expect((await store.read()).journal).toEqual(before.journal); + expect(vault.read().entries).toHaveLength(0); + expect(vault.canClose()).toBe(false); + const locking = vault.lock(); + expect(() => vault.readLinkDraft()).toThrow(); + await locking; + expect(vault.status().linkDraftDirty).toBe(true); + expect(JSON.stringify(vault.status())).not.toContain(input.url); + await expect(vault.unlock("Wrong synthetic passphrase")).rejects.toThrow(); + expect(() => vault.readLinkDraft()).toThrow(); + await vault.unlock(passphrase); + expect(vault.readLinkDraft()).toMatchObject({ + ...input, + entryId: null, + entryRevision: 0, + dirty: true, + }); + vault.discardLinkDraft(); + expect(vault.canClose()).toBe(true); + }); + it("requires this exact synchronous accepted document change before clearing intent", async () => { + const { vault } = await open(); + await vault.create(passphrase); + vault.setLinkDraft(input); + const applied = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Actual link", + marks: [{ type: "link", attrs: { href: input.url } }], + }, + ], + }, + ], + }; + const ticket = vault.beginLinkApply(); + expect(() => + vault.completeLinkDraft(ticket, { document: applied, changed: true }), + ).toThrow("not accepted"); + expect(() => + vault.completeLinkDraft(ticket, { document: applied, changed: false }), + ).toThrow("not applied"); + const fresh = vault.beginLinkApply(); + const saving = vault.update((data) => + writeJournalEntry(data, input.date, applied, null, at), + ); + vault.completeLinkDraft(fresh, { document: applied, changed: true }); + await saving; + expect(vault.readLinkDraft()).toBeNull(); + expect(vault.canClose()).toBe(true); + expect(vault.read().entries[0]!.document).toEqual(applied); + }); + it("does not apply old positions to an entry changed after the form opened", async () => { + const { vault } = await open(); + await vault.create(passphrase); + vault.setLinkDraft(input); + await vault.update((data) => + writeJournalEntry(data, input.date, document, null, at), + ); + expect(() => vault.beginLinkApply()).toThrow("text changed"); + expect(vault.readLinkDraft()!.url).toBe(input.url); + await vault.lock(); + await vault.unlock(passphrase); + expect(() => vault.beginLinkApply()).toThrow("text changed"); + expect(vault.read().entries[0]!.document).toEqual(document); + }); + it("retains an applied URL in the body draft if encrypted persistence fails", async () => { + const { vault } = await open(); + await vault.create(passphrase); + vault.setLinkDraft(input); + const applied = { + type: "doc" as const, + content: [ + { type: "paragraph", content: [{ type: "text", text: input.url }] }, + ], + }; + const ticket = vault.beginLinkApply(); + vi.spyOn(IDBObjectStore.prototype, "put").mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + const saving = vault.update((data) => + writeJournalEntry(data, input.date, applied, null, at), + ); + vault.completeLinkDraft(ticket, { document: applied, changed: true }); + await expect(saving).rejects.toThrow(); + expect(vault.status().dirty).toBe(true); + expect(vault.canClose()).toBe(false); + await vault.lock(); + await vault.unlock(passphrase); + expect(vault.read().entries[0]!.document).toEqual(applied); + await vault.retry(); + expect(vault.canClose()).toBe(true); + }); + it("revokes pending URL recovery on account lock", async () => { + const { vault } = await open(); + await vault.create(passphrase); + vault.setLinkDraft(input); + await vault.lock(); + lockAccount(); + expect(vault.status().phase).toBe("revoked"); + expect(vault.status().linkDraftDirty).toBe(false); + await expect(vault.unlock(passphrase)).rejects.toThrow(); + expect(() => vault.readLinkDraft()).toThrow(); + }); +}); + +describe("Journal vault lease and hidden recovery", () => { + it.each(["create", "edit"] as const)( + "recovers an unsubmitted %s habit only after passphrase proof", + async (mode) => { + const opened = await open(); + const vault = opened.vault as HabitVault; + await vault.create(passphrase); + if (mode === "edit") + await vault.update((data) => + saveHabit(data, "Original synthetic habit", at), + ); + const before = await opened.store.read(), + original = vault.read(); + const id = mode === "edit" ? original.habits[0]!.id : null; + vault.setHabitDraft({ mode, id, name: "Typed but not submitted" }); + expect(vault.canClose()).toBe(false); + expect(vault.read().habits).toEqual(original.habits); + const locking = vault.lock(); + expect(() => vault.readHabitDraft()).toThrow(); + await locking; + expect(vault.status().pendingRecovery).toBe(true); + expect(vault.canClose()).toBe(false); + expect((await opened.store.read()).journal).toEqual(before.journal); + await expect( + vault.unlock("Wrong synthetic passphrase"), + ).rejects.toThrow(); + expect(() => vault.readHabitDraft()).toThrow(); + await vault.unlock(passphrase); + expect(vault.readHabitDraft()).toMatchObject({ + mode, + id, + name: "Typed but not submitted", + dirty: true, + }); + expect(vault.read().habits).toEqual(original.habits); + await vault.saveHabitDraft(); + expect(vault.read().habits).toHaveLength(1); + expect(vault.read().habits[0]!.name).toBe("Typed but not submitted"); + expect(vault.readHabitDraft()).toBeNull(); + expect(vault.canClose()).toBe(true); + }, + ); + it.each(["create", "edit"] as const)( + "recovers unsaved %s habit intent after automatic away lock", + async (mode) => { + const opened = await open(); + const vault = opened.vault as HabitVault; + await vault.create(passphrase); + if (mode === "edit") + await vault.update((data) => saveHabit(data, "Original habit", at)); + const id = mode === "edit" ? vault.read().habits[0]!.id : null; + vault.setHabitDraft({ mode, id, name: "Private away-lock input" }); + const stored = (await opened.store.read()).journal; + const locked = new Promise((resolve) => { + const stop = vault.subscribe(() => { + if (vault.status().phase === "locked") { + stop(); + resolve(); + } + }); + }); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vault.setAway(true); + await vi.advanceTimersByTimeAsync(60000); + expect(() => vault.readHabitDraft()).toThrow(); + await locked; + expect(vault.canClose()).toBe(false); + vault.setAway(false); + vi.useRealTimers(); + await vault.unlock(passphrase); + expect(vault.readHabitDraft()).toMatchObject({ + mode, + id, + name: "Private away-lock input", + dirty: true, + }); + expect((await opened.store.read()).journal).toEqual(stored); + }, + ); + it("derives dirty intent, isolates caller objects, and cancels only the form explicitly", async () => { + const { vault: original, store } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + const before = await store.read(); + vault.setHabitDraft({ mode: "create", id: null, name: "" }); + expect(vault.canClose()).toBe(true); + const input: HabitFormInput = { + mode: "create", + id: null, + name: "Private intent", + }; + vault.setHabitDraft(input); + input.name = "Outside mutation"; + const returned = vault.readHabitDraft()!; + returned.name = "Changed projection"; + expect(vault.readHabitDraft()!.name).toBe("Private intent"); + expect(() => + vault.setHabitDraft({ + mode: "create", + id: null, + name: "secret", + dirty: false, + } as unknown as HabitFormInput), + ).toThrow(); + expect(() => + vault.setHabitDraft({ + mode: "edit", + id: crypto.randomUUID(), + name: "Different form", + }), + ).toThrow(); + vault.discardHabitDraft(); + expect(vault.readHabitDraft()).toBeNull(); + expect(vault.canClose()).toBe(true); + expect(await store.read()).toEqual(before); + }); + it("retries failed new-habit Save with its stable ID and does not create duplicates", async () => { + const { vault: original } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + vault.setHabitDraft({ mode: "create", id: null, name: "Save once" }); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect(vault.saveHabitDraft()).rejects.toThrow(); + put.mockRestore(); + const id = vault.readHabitDraft()!.id; + expect(id).not.toBeNull(); + expect(vault.read().habits).toHaveLength(1); + await vault.lock(); + await vault.unlock(passphrase); + expect(vault.readHabitDraft()).toMatchObject({ + mode: "edit", + id, + name: "Save once", + }); + await vault.saveHabitDraft(); + expect(vault.read().habits).toHaveLength(1); + expect(vault.read().habits[0]!.id).toBe(id); + expect(vault.readHabitDraft()).toBeNull(); + expect(vault.canClose()).toBe(true); + }); + it("keeps newer typed input when an older habit Save commits", async () => { + const { vault: original } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + vault.setHabitDraft({ mode: "create", id: null, name: "First name" }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.encrypt.bind(crypto.subtle); + const encrypt = vi + .spyOn(crypto.subtle, "encrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + const saving = vault.saveHabitDraft(); + const id = vault.readHabitDraft()!.id; + await vi.waitFor(() => expect(encrypt).toHaveBeenCalled()); + vault.setHabitDraft({ mode: "edit", id, name: "Newer name" }); + release(); + await saving; + expect(vault.readHabitDraft()).toMatchObject({ + id, + name: "Newer name", + dirty: true, + }); + expect(vault.read().habits[0]!.name).toBe("First name"); + expect(vault.canClose()).toBe(false); + await vault.saveHabitDraft(); + expect(vault.read().habits).toHaveLength(1); + expect(vault.read().habits[0]!.name).toBe("Newer name"); + }); + it.each([1, 2])( + "keeps both private recovery pieces when seal %i fails", + async (failingSeal) => { + const { vault: original } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ), + ).rejects.toThrow(); + put.mockRestore(); + vault.setHabitDraft({ + mode: "create", + id: null, + name: "Unsubmitted paired recovery", + }); + const real = crypto.subtle.encrypt.bind(crypto.subtle); + let seals = 0; + const encrypt = vi + .spyOn(crypto.subtle, "encrypt") + .mockImplementation(async (algorithm, key, data) => { + if (++seals === failingSeal) + throw new Error("Synthetic pair seal failure"); + return real(algorithm, key, data); + }); + const locking = vault.lock(); + expect(() => vault.read()).toThrow(); + expect(() => vault.readHabitDraft()).toThrow(); + await expect(locking).rejects.toThrow("pair seal"); + encrypt.mockRestore(); + expect(vault.status().phase).toBe("lock-error"); + expect(vault.canClose()).toBe(false); + await expect( + vault.unlock("Wrong synthetic passphrase"), + ).rejects.toThrow(); + await vault.unlock(passphrase); + expect(vault.read().entries[0]!.document).toEqual(document); + expect(vault.readHabitDraft()!.name).toBe("Unsubmitted paired recovery"); + vault.discardHabitDraft(); + expect(vault.canClose()).toBe(false); + await vault.retry(); + expect(vault.canClose()).toBe(true); + }, + ); + it.each(["account", "erase"] as const)( + "revokes a pending habit-draft seal on %s invalidation", + async (reason) => { + const { vault: original, store } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + const notes = await openNotesStore(identity); + handles.add(notes); + vault.setHabitDraft({ + mode: "create", + id: null, + name: "Never republish revoked input", + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.encrypt.bind(crypto.subtle); + const encrypt = vi + .spyOn(crypto.subtle, "encrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + const locking = vault.lock(); + await vi.waitFor(() => expect(encrypt).toHaveBeenCalled()); + if (reason === "erase") { + await notes.erase(); + await store.read(); + } else establish(identity); + release(); + await expect(locking).rejects.toThrow("journal_stale"); + expect(vault.status()).toMatchObject({ + phase: "revoked", + habitDraftDirty: false, + pendingRecovery: false, + }); + expect(() => vault.readHabitDraft()).toThrow(); + }, + ); + it("does not publish either piece if account lock arrives while form decryption is pending", async () => { + const { vault: original } = await open(); + const vault = original as HabitVault; + await vault.create(passphrase); + vault.setHabitDraft({ + mode: "create", + id: null, + name: "No late form exposure", + }); + await vault.lock(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.decrypt.bind(crypto.subtle); + let calls = 0, + blocked = false; + vi.spyOn(crypto.subtle, "decrypt").mockImplementation( + async (algorithm, key, data) => { + if (++calls === 2) { + blocked = true; + await gate; + } + return real(algorithm, key, data); + }, + ); + let exposed = false; + vault.subscribe(() => { + if (vault.status().phase === "unlocked") exposed = true; + }); + const unlocking = vault.unlock(passphrase); + await vi.waitFor(() => expect(blocked).toBe(true)); + establish(identity); + release(); + await expect(unlocking).rejects.toThrow("journal_stale"); + expect(exposed).toBe(false); + expect(() => vault.readHabitDraft()).toThrow(); + }); + it("creates a separate encrypted vault and publishes data only after passphrase unlock", async () => { + const { store, vault } = await open(); + expect(() => vault.read()).toThrow(); + await vault.create(passphrase); + await vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ); + expect(vault.read().entries[0]?.document).toEqual(document); + const locking = vault.lock(); + expect(() => vault.read()).toThrow(); + await locking; + expect(vault.status().phase).toBe("locked"); + await expect(vault.unlock("Wrong synthetic passphrase")).rejects.toThrow(); + expect(() => vault.read()).toThrow(); + await vault.unlock(passphrase); + expect(vault.read().entries[0]?.document).toEqual(document); + const persisted = (await readRecord( + "devices", + identity.email, + ))!; + expect(new TextDecoder().decode(persisted.snapshot)).not.toContain( + "Private recoverable", + ); + expect(JSON.stringify((await store.read()).journal)).not.toContain( + "Private recoverable", + ); + }); + it("seals a failed-save draft behind lock and restores it only after correct unlock", async () => { + const { vault } = await open(); + await vault.create(passphrase); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ), + ).rejects.toThrow(); + put.mockRestore(); + expect(vault.status().dirty).toBe(true); + expect(vault.canClose()).toBe(false); + const locking = vault.lock(); + expect(() => vault.read()).toThrow(); + await locking; + expect(vault.status().pendingRecovery).toBe(true); + expect(vault.canClose()).toBe(false); + await expect(vault.unlock("Wrong synthetic passphrase")).rejects.toThrow(); + await vault.unlock(passphrase); + expect(vault.read().entries[0]?.document).toEqual(document); + await vault.retry(); + expect(vault.status().dirty).toBe(false); + expect(vault.canClose()).toBe(true); + }); + it("hides a draft synchronously even if sealing fails and recovers only after explicit passphrase unlock", async () => { + const { vault } = await open(); + await vault.create(passphrase); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ), + ).rejects.toThrow(); + put.mockRestore(); + const encrypt = vi + .spyOn(crypto.subtle, "encrypt") + .mockRejectedValueOnce(new Error("Synthetic crypto failure")); + const locking = vault.lock(); + expect(() => vault.read()).toThrow(); + await expect(locking).rejects.toThrow("Synthetic crypto failure"); + encrypt.mockRestore(); + expect(vault.status().phase).toBe("lock-error"); + expect(vault.canClose()).toBe(false); + expect(JSON.stringify(vault.status())).not.toContain("Private recoverable"); + await expect(vault.unlock("Wrong synthetic passphrase")).rejects.toThrow(); + expect(() => vault.read()).toThrow(); + await vault.unlock(passphrase); + expect(vault.read().entries[0]?.document).toEqual(document); + await vault.retry(); + expect(vault.status().dirty).toBe(false); + }); + it("fails enrollment closed when encryption fails before there is an authenticated recovery proof", async () => { + const { vault, store } = await open(); + vi.spyOn(crypto.subtle, "encrypt").mockRejectedValueOnce( + new Error("Synthetic enrollment crypto failure"), + ); + await expect(vault.create(passphrase)).rejects.toThrow( + "Synthetic enrollment", + ); + expect(vault.status()).toMatchObject({ + phase: "locked", + dirty: false, + pendingRecovery: false, + hasJournal: false, + }); + expect(() => vault.read()).toThrow(); + expect((await store.read()).journal).toBeNull(); + expect(vault.canClose()).toBe(true); + }); + it("rejects enrollment that completes after erase", async () => { + const { vault, store } = await open(); + const notes = await openNotesStore(identity); + handles.add(notes); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.deriveKey.bind(crypto.subtle); + const derive = vi + .spyOn(crypto.subtle, "deriveKey") + .mockImplementationOnce( + async (algorithm, baseKey, derivedKeyType, extractable, usages) => { + await gate; + return real(algorithm, baseKey, derivedKeyType, extractable, usages); + }, + ); + const enrolling = vault.create(passphrase); + await vi.waitFor(() => expect(derive).toHaveBeenCalled()); + expect(vault.canClose()).toBe(false); + await notes.erase(); + await store.read(); + release(); + await expect(enrolling).rejects.toThrow("journal_stale"); + expect(vault.status().phase).toBe("revoked"); + expect(() => vault.read()).toThrow(); + expect((await store.read()).journal).toBeNull(); + }); + it("rejects a decrypt result after same-account reauthentication", async () => { + const { vault } = await open(); + await vault.create(passphrase); + await vault.lock(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.decrypt.bind(crypto.subtle); + const decrypt = vi + .spyOn(crypto.subtle, "decrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + const opening = vault.unlock(passphrase); + await vi.waitFor(() => expect(decrypt).toHaveBeenCalled()); + establish(identity); + release(); + await expect(opening).rejects.toThrow("journal_stale"); + expect(vault.status().phase).toBe("revoked"); + expect(() => vault.read()).toThrow(); + }); + it("registers epoch revocation before the initial asynchronous read", async () => { + const store = await openCollectionsStore(identity); + handles.add(store); + const notes = await openNotesStore(identity); + handles.add(notes); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const realRead = store.read.bind(store); + const reading = vi.spyOn(store, "read").mockImplementationOnce(async () => { + const result = await realRead(); + await gate; + return result; + }); + const opening = openJournalVault(identity, store); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + await notes.erase(); + await realRead(); + release(); + await expect(opening).rejects.toThrow("journal_stale"); + }); + it("rejects a save whose generation changes while the store is awaiting wrapping crypto", async () => { + const { vault, store } = await open(); + await vault.create(passphrase); + const before = (await store.read()).journal; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let sealed = false, + afterSealSigns = 0, + blocked = false; + const realEncrypt = crypto.subtle.encrypt.bind(crypto.subtle), + realSign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "encrypt").mockImplementation( + async (algorithm, key, data) => { + const result = await realEncrypt(algorithm, key, data); + sealed = true; + return result; + }, + ); + vi.spyOn(crypto.subtle, "sign").mockImplementation( + async (algorithm, key, data) => { + // After sealing: explicit freshness read, saving-state subscriber read, + // then the actual replacement transaction's wrapping preparation. + if (sealed && ++afterSealSigns === 3) { + blocked = true; + await gate; + } + return realSign(algorithm, key, data); + }, + ); + const saving = vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ); + await vi.waitFor(() => expect(blocked).toBe(true)); + await vault.lock(); + release(); + await expect(saving).rejects.toThrow("journal_stale"); + expect((await store.read()).journal).toEqual(before); + expect(vault.status()).toMatchObject({ + phase: "locked", + pendingRecovery: true, + dirty: true, + }); + await vault.unlock(passphrase); + expect(vault.read().entries[0]?.document).toEqual(document); + await vault.retry(); + }); + it("retains and commits the newest draft while a previous save is pending", async () => { + const { vault, store } = await open(); + await vault.create(passphrase); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.encrypt.bind(crypto.subtle); + const encrypt = vi + .spyOn(crypto.subtle, "encrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + const first = vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ); + await vi.waitFor(() => expect(encrypt).toHaveBeenCalled()); + const latestDocument = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Newest synthetic draft" }], + }, + ], + }; + const second = vault.update((data) => + writeJournalEntry(data, "2026-11-11", latestDocument, null, at), + ); + release(); + await Promise.all([first, second]); + expect(vault.read().entries[0]?.document).toEqual(latestDocument); + const persisted = await store.read(); + const opened = await unlockJournalEnvelope( + persisted.journal!, + passphrase, + { accountId: identity.accountId, epoch: persisted.epoch }, + () => {}, + ); + expect(opened.data.entries[0]?.document).toEqual(latestDocument); + expect(vault.status().dirty).toBe(false); + }); + it("does not overwrite another vault envelope and requires explicit conflict discard", async () => { + const first = await open(); + await first.vault.create(passphrase); + const second = await open(); + await second.vault.unlock(passphrase); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + second.vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ), + ).rejects.toThrow(); + put.mockRestore(); + const other = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Another window won" }], + }, + ], + }; + await first.vault.update((data) => + writeJournalEntry(data, "2026-11-11", other, null, at), + ); + await expect(second.vault.retry()).rejects.toThrow(); + expect(second.vault.read().entries[0]?.document).toEqual(document); + const saved = (await first.store.read()).journal; + await second.vault.lock(); + await second.vault.unlock(passphrase); + expect(second.vault.status().conflict).toBe(true); + expect(second.vault.read().entries[0]?.document).toEqual(document); + await second.vault.discardAndReload(); + expect(() => second.vault.read()).toThrow(); + expect(second.vault.status()).toMatchObject({ + phase: "locked", + dirty: false, + pendingRecovery: false, + }); + await second.vault.unlock(passphrase); + expect(second.vault.read().entries[0]?.document).toEqual(other); + expect((await first.store.read()).journal).toEqual(saved); + }); + it("recognizes an unknown-result commit and refreshes Study-only revisions safely", async () => { + const { vault, store } = await open(); + await vault.create(passphrase); + const post = vi + .spyOn(BroadcastChannel.prototype, "postMessage") + .mockImplementationOnce(() => { + throw new Error("Synthetic notification failure"); + }); + await expect( + vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ), + ).rejects.toThrow(); + post.mockRestore(); + const committed = await store.read(); + await vault.retry(); + expect((await store.read()).revision).toBe(committed.revision); + expect(vault.status().dirty).toBe(false); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic quota", "QuotaExceededError"); + }); + await expect( + vault.update((data) => ({ + ...data, + preferences: { ...data.preferences, promptEnabled: false }, + })), + ).rejects.toThrow(); + put.mockRestore(); + await store.update(await store.read(), (state) => ({ + ...state, + study: { + ...state.study, + preferences: { ...state.study.preferences, showStreak: false }, + }, + })); + await vault.retry(); + expect((await store.read()).study.preferences.showStreak).toBe(false); + expect(vault.read().preferences.promptEnabled).toBe(false); + }); + it("locks only after one minute continuously away and releases timers and browser listeners", async () => { + const browserDocument = Object.assign(new EventTarget(), { + hidden: false, + hasFocus: () => true, + }); + const browserWindow = new EventTarget(); + vi.stubGlobal("document", browserDocument); + vi.stubGlobal("window", browserWindow); + const removeDocument = vi.spyOn(browserDocument, "removeEventListener"), + removeWindow = vi.spyOn(browserWindow, "removeEventListener"); + const { vault } = await open(); + await vault.create(passphrase); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vault.setAway(true); + await vi.advanceTimersByTimeAsync(59999); + expect(vault.status().phase).toBe("unlocked"); + vault.setAway(false); + await vi.advanceTimersByTimeAsync(1000); + expect(vault.status().phase).toBe("unlocked"); + vault.setAway(true); + await vi.advanceTimersByTimeAsync(60000); + expect(vault.status().phase).toBe("locked"); + vault.close(); + expect(vi.getTimerCount()).toBe(0); + expect(removeDocument).toHaveBeenCalled(); + expect(removeWindow).toHaveBeenCalledTimes(2); + expect(() => vault.read()).toThrow(); + }); + it("locks synchronously on return when an overdue away timer never ran", async () => { + const { vault } = await open(); + await vault.create(passphrase); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vault.setAway(true); + vi.setSystemTime(Date.now() + 60001); // Sleep advances the clock without delivering callbacks. + vault.setAway(false); + expect(() => vault.read()).toThrow(); + expect(vault.status().phase).toBe("locked"); + }); + it("locks synchronously when focus returns before an overdue timer callback", async () => { + const browserDocument = Object.assign(new EventTarget(), { + hidden: false, + hasFocus: () => true, + }); + const browserWindow = new EventTarget(); + vi.stubGlobal("document", browserDocument); + vi.stubGlobal("window", browserWindow); + const { vault } = await open(); + await vault.create(passphrase); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + browserWindow.dispatchEvent(new Event("blur")); + vi.setSystemTime(Date.now() + 60001); + browserWindow.dispatchEvent(new Event("focus")); + expect(() => vault.read()).toThrow(); + expect(vault.status().phase).toBe("locked"); + }); + it.each([false, true])( + "never publishes a pending unlock after overdue absence (return=%s)", + async (returned) => { + const { vault } = await open(); + await vault.create(passphrase); + await vault.lock(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.decrypt.bind(crypto.subtle); + const decrypt = vi + .spyOn(crypto.subtle, "decrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + let publishedUnlocked = false; + vault.subscribe(() => { + if (vault.status().phase === "unlocked") publishedUnlocked = true; + }); + const opening = vault.unlock(passphrase); + await vi.waitFor(() => expect(decrypt).toHaveBeenCalled()); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vault.setAway(true); + vi.setSystemTime(Date.now() + 60001); + if (returned) vault.setAway(false); + release(); + await expect(opening).rejects.toThrow("journal_stale"); + expect(publishedUnlocked).toBe(false); + expect(() => vault.read()).toThrow(); + expect(vault.status().phase).toBe("locked"); + }, + ); +}); + +it("JV-CLOSE-1 revokes unlocked plaintext and private forms before borrowed owner close returns", async () => { + const { store, vault } = await open(); + await vault.create(passphrase); + await vault.update((data) => + writeJournalEntry(data, "2026-11-11", document, null, at), + ); + vault.setHabitDraft({ + mode: "create", + id: null, + name: "Private synthetic habit draft", + }); + expect(vault.read().entries).toHaveLength(1); + let revokedDuringClose = false; + vault.subscribe(() => { + if (vault.status().phase === "revoked") { + expect(() => vault.read()).toThrow("journal_stale"); + expect(() => vault.readHabitDraft()).toThrow("journal_stale"); + revokedDuringClose = true; + } + }); + store.close(); + expect(revokedDuringClose).toBe(true); + expect(vault.status()).toMatchObject({ + phase: "revoked", + dirty: false, + habitDraftDirty: false, + pendingRecovery: false, + }); + expect(() => vault.read()).toThrow("journal_stale"); + expect(() => vault.readHabitDraft()).toThrow("journal_stale"); + expect(vault.canClose()).toBe(true); +}); +it("JV-CLOSE-2 never publishes a decrypt result after genuine owner closure", async () => { + const { store, vault } = await open(); + await vault.create(passphrase); + await vault.lock(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = crypto.subtle.decrypt.bind(crypto.subtle); + const decrypt = vi + .spyOn(crypto.subtle, "decrypt") + .mockImplementationOnce(async (algorithm, key, data) => { + await gate; + return real(algorithm, key, data); + }); + let exposed = false; + vault.subscribe(() => { + if (vault.status().phase === "unlocked") exposed = true; + }); + const opening = vault.unlock(passphrase); + await vi.waitFor(() => expect(decrypt).toHaveBeenCalled()); + store.close(); + const phaseAtClose = vault.status().phase; + release(); + await expect(opening).rejects.toThrow("journal_stale"); + expect(phaseAtClose).toBe("revoked"); + expect(exposed).toBe(false); + expect(() => vault.read()).toThrow("journal_stale"); +}); +it("JV-CLOSE-3 registers owner-close revocation before initial async read admission", async () => { + const store = await openCollectionsStore(identity); + handles.add(store); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const real = store.read.bind(store); + let reached = false; + vi.spyOn(store, "read").mockImplementationOnce(async () => { + const state = await real(); + reached = true; + await gate; + return state; + }); + const opening = openJournalVault(identity, store); + await vi.waitFor(() => expect(reached).toBe(true)); + store.close(); + release(); + await expect(opening).rejects.toThrow("journal_stale"); +}); diff --git a/web/tests/data/journal-view-draft.test.ts b/web/tests/data/journal-view-draft.test.ts new file mode 100644 index 0000000..3ae41f6 --- /dev/null +++ b/web/tests/data/journal-view-draft.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createJournalEnvelope, + emptyJournal, + unlockJournalEnvelope, +} from "../../src/collections/journal"; +import { + openHabitDraft, + sealHabitDraft, + validateHabitFormDraft, + sealProtectedForms, + openProtectedForms, + validateProtectedForms, +} from "../../src/collections/journalViewDraft"; +import type { HabitFormDraft } from "../../src/collections/journalViewDraft"; +import type { ProtectedFormDrafts } from "../../src/collections/journalViewDraft"; +const context = { + accountId: "11111111-1111-4111-8111-111111111111", + epoch: "22222222-2222-4222-8222-222222222222", +}; +const passphrase = "Synthetic recovery proof passphrase"; +const draft: HabitFormDraft = { + version: 1, + mode: "create", + id: null, + name: "Unsubmitted private habit", + originalName: "", + dirty: true, +}; +afterEach(() => vi.restoreAllMocks()); +it("authenticates the combined form purpose and snapshots unfinished URL input before awaiting", async () => { + const proof = await createJournalEnvelope( + passphrase, + emptyJournal(), + context, + () => {}, + ); + const forms: ProtectedFormDrafts = { + version: 1, + habit: draft, + link: { + version: 1, + date: "2026-11-11", + source: "toolbar", + from: 1, + to: 1, + selection: { type: "all" }, + initialUrl: "", + url: "private unfinished URL", + entryId: null, + entryRevision: 0, + dirty: true, + }, + }; + const pending = sealProtectedForms(proof.key, forms, context, () => {}); + forms.link!.url = "Changed after crypto started"; + const sealed = await pending; + expect(JSON.stringify(sealed)).not.toContain("private unfinished URL"); + expect( + (await openProtectedForms(proof.key, sealed, context, () => {})).link!.url, + ).toBe("private unfinished URL"); + const second = await sealProtectedForms(proof.key, forms, context, () => {}); + expect(second.iv).not.toBe(sealed.iv); + await expect( + openProtectedForms( + proof.key, + sealed, + { ...context, epoch: crypto.randomUUID() }, + () => {}, + ), + ).rejects.toThrow(); + await expect( + openHabitDraft(proof.key, sealed, context, () => {}), + ).rejects.toThrow(); + const legacy = await sealHabitDraft(proof.key, draft, context, () => {}); + await expect( + openProtectedForms(proof.key, legacy, context, () => {}), + ).rejects.toThrow(); + expect(() => + validateProtectedForms({ ...forms, link: { ...forms.link, dirty: false } }), + ).toThrow(); +}); +describe("memory-only habit draft crypto", () => { + it("uses fresh IVs and a distinct authenticated purpose/account/epoch", async () => { + const proof = await createJournalEnvelope( + passphrase, + emptyJournal(), + context, + () => {}, + ); + const first = await sealHabitDraft(proof.key, draft, context, () => {}), + second = await sealHabitDraft(proof.key, draft, context, () => {}); + expect(first.iv).not.toBe(second.iv); + expect(first.iv).not.toBe(proof.envelope.iv); + expect(JSON.stringify(first)).not.toContain(draft.name); + expect(await openHabitDraft(proof.key, first, context, () => {})).toEqual( + draft, + ); + await expect( + openHabitDraft( + proof.key, + first, + { ...context, epoch: crypto.randomUUID() }, + () => {}, + ), + ).rejects.toThrow(); + await expect( + openHabitDraft( + proof.key, + first, + { ...context, accountId: crypto.randomUUID() }, + () => {}, + ), + ).rejects.toThrow(); + await expect( + openHabitDraft( + proof.key, + { + version: 1, + iv: proof.envelope.iv, + ciphertext: proof.envelope.ciphertext, + }, + context, + () => {}, + ), + ).rejects.toThrow(); + await expect( + unlockJournalEnvelope( + { ...proof.envelope, iv: first.iv, ciphertext: first.ciphertext }, + passphrase, + context, + () => {}, + ), + ).rejects.toThrow(); + }); + it("snapshots input before awaits and rejects stale encryption/decryption results", async () => { + const proof = await createJournalEnvelope( + passphrase, + emptyJournal(), + context, + () => {}, + ); + const source = structuredClone(draft), + original = crypto.subtle.encrypt.bind(crypto.subtle); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(crypto.subtle, "encrypt").mockImplementationOnce( + async (algorithm, key, data) => { + await gate; + return original(algorithm, key, data); + }, + ); + const pending = sealHabitDraft(proof.key, source, context, () => {}); + source.name = "Changed outside owner"; + release(); + const envelope = await pending; + expect( + (await openHabitDraft(proof.key, envelope, context, () => {})).name, + ).toBe(draft.name); + let fresh = true; + const current = () => { + if (!fresh) throw new Error("revoked"); + }; + const encrypting = sealHabitDraft(proof.key, draft, context, current); + fresh = false; + await expect(encrypting).rejects.toThrow("revoked"); + fresh = true; + const decrypting = openHabitDraft(proof.key, envelope, context, current); + fresh = false; + await expect(decrypting).rejects.toThrow("revoked"); + }); + it("rejects malformed, oversized and falsely-clean drafts before crypto", async () => { + const proof = await createJournalEnvelope( + passphrase, + emptyJournal(), + context, + () => {}, + ); + for (const malformed of [ + { ...draft, dirty: false }, + { ...draft, name: "x".repeat(201) }, + { ...draft, extra: "not allowed" }, + { ...draft, mode: "edit", id: null }, + ]) + expect(() => validateHabitFormDraft(malformed)).toThrow(); + const decrypt = vi.spyOn(crypto.subtle, "decrypt"); + await expect( + openHabitDraft( + proof.key, + { + version: 1, + iv: btoa("i".repeat(12)), + ciphertext: btoa("x".repeat(16385)), + }, + context, + () => {}, + ), + ).rejects.toThrow(); + expect(decrypt).not.toHaveBeenCalled(); + const weak = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 128 }, + false, + ["encrypt", "decrypt"], + ); + await expect( + sealHabitDraft(weak, draft, context, () => {}), + ).rejects.toThrow(); + }); +}); diff --git a/web/tests/data/journal.test.ts b/web/tests/data/journal.test.ts new file mode 100644 index 0000000..c3d5707 --- /dev/null +++ b/web/tests/data/journal.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { createJournalEnvelope, unlockJournalEnvelope, sealJournalData, emptyJournal, writeJournalEntry, setHabitCompletion, validateJournalData, saveHabit, archiveHabit, journalMonth } from "../../src/collections/journal"; +const context = { accountId: "11111111-1111-4111-8111-111111111111", epoch: "22222222-2222-4222-8222-222222222222" }; +const passphrase = "synthetic Journal phrase only"; +const current = () => {}; +describe("separate Journal encryption boundary", () => { + it("counts real writing and historical habits without treating prompts or visits as written days", () => { + const createdAt = new Date(2026, 1, 10, 12); + let data = saveHabit(emptyJournal(), "Read", createdAt); + const habit = data.habits[0]!; + data = setHabitCompletion(data, "2026-02-10", habit.id, true, createdAt); + data = writeJournalEntry(data, "2026-02-10", { type: "doc", content: [{ type: "paragraph", content: [] }] }, "A prompt alone", createdAt); + data = archiveHabit(data, habit.id, new Date(2026, 1, 12, 12)); + expect(setHabitCompletion(data, "2026-02-10", habit.id, false, createdAt).completions[0]!.done).toBe(false); + expect(() => setHabitCompletion(data, "2026-02-13", habit.id, true, createdAt)).toThrow("unavailable"); + data = saveHabit(data, "Read a book", createdAt, habit.id); + const month = journalMonth(data, 2026, 2); + expect(month).toHaveLength(28); + expect(month[8]).toMatchObject({ habitTotal: 0, written: false }); + expect(month[9]).toMatchObject({ habitTotal: 1, habitsCompleted: 1, written: false }); + expect(month[12]).toMatchObject({ habitTotal: 0 }); + expect(data.completions[0]!.habitId).toBe(habit.id); + expect(data.entries).toHaveLength(1); + expect(journalMonth(emptyJournal(), 2028, 2)).toHaveLength(29); + }); + it("snapshots KDF metadata before encryption yields and rejects weaker AES keys", async () => { + const created = await createJournalEnvelope(passphrase, emptyJournal(), context, current); + const kdf = structuredClone(created.envelope.kdf); + const originalSalt = kdf.salt; + const realEncrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const spy = vi.spyOn(crypto.subtle, "encrypt").mockImplementation(async (algorithm, key, data) => { await gate; return realEncrypt(algorithm, key, data); }); + try { + const pending = sealJournalData(created.key, kdf, emptyJournal(), context, current); + kdf.salt = btoa("0123456789abcdef"); + release(); + const result = await pending; + expect(result.kdf.salt).toBe(originalSalt); + expect((await unlockJournalEnvelope(result, passphrase, context, current)).data).toEqual(emptyJournal()); + } finally { release(); spy.mockRestore(); } + const weak = await crypto.subtle.generateKey({ name: "AES-GCM", length: 128 }, false, ["encrypt", "decrypt"]); + await expect(sealJournalData(weak, created.envelope.kdf, emptyJournal(), context, current)).rejects.toThrow("invalid_journal_key"); + }); + it("requires its passphrase, authenticates account/epoch, and uses fresh IVs", async () => { + const data = writeJournalEntry(emptyJournal(), "2026-11-11", { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Private synthetic Journal" }] }] }, "What felt important today?", new Date("2026-11-11T12:00:00Z")); + const created = await createJournalEnvelope(passphrase, data, context, current); + expect(created.key.extractable).toBe(false); + expect(JSON.stringify(created.envelope)).not.toContain("Private synthetic"); + const opened = await unlockJournalEnvelope(created.envelope, passphrase, context, current); + expect(opened.data).toEqual(data); + const second = await sealJournalData(opened.key, created.envelope.kdf, data, context, current); + expect(second.iv).not.toBe(created.envelope.iv); + expect(second.ciphertext).not.toBe(created.envelope.ciphertext); + await expect(unlockJournalEnvelope(created.envelope, "wrong synthetic phrase", context, current)).rejects.toThrow("unlock"); + await expect(unlockJournalEnvelope(created.envelope, passphrase, { ...context, epoch: crypto.randomUUID() }, current)).rejects.toThrow("unlock"); + await expect(unlockJournalEnvelope(created.envelope, passphrase, { ...context, accountId: crypto.randomUUID() }, current)).rejects.toThrow("unlock"); + }); + it("rejects revoked async completions and malformed KDF parameters", async () => { + let valid = true; + const guard = () => { if (!valid) throw new Error("locked"); }; + const pending = createJournalEnvelope(passphrase, emptyJournal(), context, guard); + valid = false; + await expect(pending).rejects.toThrow("locked"); + const created = await createJournalEnvelope(passphrase, emptyJournal(), context, current); + const malformed = structuredClone(created.envelope); + (malformed.kdf as { iterations: number }).iterations = 1; + await expect(unlockJournalEnvelope(malformed, passphrase, context, current)).rejects.toThrow("collections_invalid"); + await expect(createJournalEnvelope("short", emptyJournal(), context, current)).rejects.toThrow("passphrase"); + }); + it("uses dated entries and stable habit identity without creating entries on visits", () => { + const data = emptyJournal(); + const habitId = crypto.randomUUID(); + data.habits.push({ id: habitId, name: "Read", order: 0, createdAt: "2026-11-10T00:00:00.000Z", createdOn: "2026-11-10", archivedAt: null, archivedOn: null, revision: 1 }); + const next = setHabitCompletion(data, "2026-11-10", habitId, true, new Date("2026-11-11T12:00:00Z")); + expect(next.entries).toHaveLength(0); + expect(next.completions).toHaveLength(1); + expect(setHabitCompletion(next, "2026-11-10", habitId, false, new Date()).completions[0]!.done).toBe(false); + expect(() => setHabitCompletion(data, "2026-02-30", habitId, true, new Date())).toThrow("date"); + expect(() => validateJournalData({ ...next, completions: [...next.completions, ...next.completions] })).toThrow("journal_invalid"); + }); +}); diff --git a/web/tests/data/search-source.test.ts b/web/tests/data/search-source.test.ts new file mode 100644 index 0000000..22f21f2 --- /dev/null +++ b/web/tests/data/search-source.test.ts @@ -0,0 +1,899 @@ +import { + notesSearchData, + collectionsSearchData, +} from "../../src/search/source"; +import { initialSchedule } from "../../src/study/scheduler"; +import { + acquireSearchSource, + assertSearchSourceFresh, + searchSourceReader, + onSearchSourceInvalidated, + releaseSearchSource, +} from "../../src/search/source"; +import type { SearchLease } from "../../src/search/contracts"; +import { SearchSourceError } from "../../src/search/contracts"; +import { noteLinkOwner, studySourceOwner } from "../../src/library/store"; +import { createDeck, saveEntry } from "../../src/study/model"; +import { replaceJournal } from "../../src/collections/store"; +import { + emptyJournal, + createJournalEnvelope, +} from "../../src/collections/journal"; +import { protectOperations } from "../../src/desktop/closeGuard"; +// @vitest-environment node +import { beforeAll, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { + initSync, + DeviceKeys, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { lock, establish } from "../../src/account/session"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { openNotesStore } from "../../src/data"; +import { openCollectionsStore } from "../../src/collections/store"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "search-source@example.test", + deviceState: "trusted" as const, +}; +const leased = new Set(); +const handles = new Set<{ close(): void }>(); +beforeAll(async () => + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }), +); +afterEach(() => { + for (const lease of leased) { + try { + releaseSearchSource(lease); + } catch { + /* Already revoked. */ + } + } + leased.clear(); + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + lock(); +}); +function sameBytes(a: Uint8Array, b: Uint8Array) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} +it.each([ + { name: "small", count: 3, chars: 256 }, + { name: "medium", count: 100, chars: 8192 }, +])( + "measures genuine owner baseline and sealed-proof read work for $name", + async (profile) => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(), + engine = SyncEngine.create(keys, identity.accountId, identity.deviceId), + at = new Date().toISOString(); + try { + engine.createZoneKey("notes-local"); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify({ + notes: { + stamp: JSON.parse(engine.tick(Date.now())), + value: Array.from({ length: profile.count }, (_, i) => ({ + id: "synthetic-note-" + i, + title: "Synthetic " + i, + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "x".repeat(profile.chars) }, + ], + }, + ], + }, + tags: [], + createdAt: at, + updatedAt: at, + trashedAt: null, + revision: 1, + })), + }, + }), + ); + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + snapshot: engine.snapshot(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } + establish(identity); + let started = performance.now(); + const notes = await openNotesStore(identity); + handles.add(notes); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const openMs = performance.now() - started, + restore = vi.spyOn(SyncEngine, "restore"); + started = performance.now(); + for (let i = 0; i < 3; i++) { + await notes.list(); + await collections.read(); + } + const ownerReadsMs = performance.now() - started; + expect(restore).toHaveBeenCalledTimes(6); + const original = (await readRecord( + "devices", + identity.email, + ))!; + started = performance.now(); + for (let i = 0; i < 3; i++) { + const current = (await readRecord( + "devices", + identity.email, + ))!; + const left = await wrappingSecret(original.wrapKey), + right = await wrappingSecret(current.wrapKey); + try { + expect(sameBytes(left, right)).toBe(true); + expect(sameBytes(original.sealedKeys, current.sealedKeys)).toBe(true); + expect(sameBytes(original.snapshot!, current.snapshot!)).toBe(true); + } finally { + left.fill(0); + right.fill(0); + } + } + const proofReadsMs = performance.now() - started; + expect(restore).toHaveBeenCalledTimes(6); + process.stdout.write( + "search-source-baseline " + + JSON.stringify({ + profile: profile.name, + notes: profile.count, + snapshotBytes: original.snapshot!.length, + ownerOpenMs: Number(openMs.toFixed(2)), + threeOwnerPairsMs: Number(ownerReadsMs.toFixed(2)), + ownerPairRestoreCount: restore.mock.calls.length, + threeRecordAndProofChecksMs: Number(proofReadsMs.toFixed(2)), + proofExtraRestores: 0, + }) + + "\n", + ); + }, +); + +async function fixture(journal = false) { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); + const notes = await openNotesStore(identity), + collections = await openCollectionsStore(identity); + handles.add(notes); + handles.add(collections); + const note = await notes.create({ + title: "ACTIVE", + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "ORIGINAL BODY" }], + }, + ], + }, + tags: ["real-tag"], + }); + const trash = await notes.create({ + title: "TRASH HIDDEN", + document: { type: "doc", content: [] }, + tags: [], + }); + await notes.trash(trash.id, 1); + const before = await collections.read(), + at = new Date(); + let callbackInput: any, callbackOutput: any; + await collections.update(before, (current) => { + callbackInput = current; + let study = createDeck(current.study, "Real deck", at); + const deckId = study.decks[0]!.id; + for (const type of ["basic", "reversed", "cloze"] as const) { + const base = { + id: crypto.randomUUID(), + deckId, + revision: 1, + tags: ["entry-tag"], + source: { + noteId: note.id, + noteRevision: note.revision, + quote: "HIDDEN-SOURCE-QUOTE", + }, + }; + study = saveEntry( + study, + type === "cloze" + ? { + ...base, + type, + text: "Fill {{one}} and {{two}}", + blanks: [ + { id: "one", answer: "FIRST" }, + { id: "two", answer: "SECOND" }, + ], + } + : { ...base, type, front: "FRONT-" + type, back: "BACK-" + type }, + at, + ); + } + callbackOutput = { + ...current, + study, + clips: { + ...current.clips, + items: [ + { + id: crypto.randomUUID(), + revision: 1, + title: "Saved archive", + originalUrl: "https://example.test/saved", + canonicalUrl: "https://example.test/saved", + text: "SAVED TEXT", + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + readAt: null, + readLater: false, + archivedAt: at.toISOString(), + }, + ], + }, + }; + return callbackOutput; + }); + if (journal) { + const current = await collections.read(), + data = emptyJournal(); + data.habits.push({ + id: crypto.randomUUID(), + name: "HIDDEN-JOURNAL-TEXT", + order: 0, + createdAt: at.toISOString(), + createdOn: at.toISOString().slice(0, 10), + archivedAt: null, + archivedOn: null, + revision: 1, + }); + const sealed = await createJournalEnvelope( + "synthetic private passphrase", + data, + { accountId: identity.accountId, epoch: current.epoch }, + () => {}, + ); + await replaceJournal(collections, current, sealed.envelope); + } + // Synchronize genuine private owner captures to the same current encrypted image. + await notes.list(); + await collections.read(); + return { notes, collections, note, callbackInput, callbackOutput }; +} +async function leaseOf(f: Awaited>) { + const lease = await acquireSearchSource( + protectOperations(f.notes, ["create", "update", "erase"]), + f.collections, + ); + leased.add(lease); + return lease; +} +function body(reader: ReturnType) { + const root = reader.note(0).root; + return reader.node(reader.child(reader.child(root, 0), 0)).text; +} +it("genuine immutable lease exposes only whitelisted actual entities and never opens Journal", async () => { + const f = await fixture(true), + restore = vi.spyOn(SyncEngine, "restore"), + decrypt = vi.spyOn(crypto.subtle, "decrypt"), + lease = await leaseOf(f), + reader = searchSourceReader(lease); + expect(reader.counts).toEqual({ + notes: 1, + clips: 1, + decks: 1, + entries: 3, + cards: 5, + }); + expect(reader.versions.library.epoch).not.toBe( + reader.versions.collections.epoch, + ); + expect(reader.clip(0).archivedAt).not.toBeNull(); + expect(body(reader)).toBe("ORIGINAL BODY"); + const cloze = reader.entry(2); + if (cloze.type !== "cloze") throw new Error("fixture"); + expect(reader.blank(cloze.cursor, 0)).toEqual({ id: "one", answer: "FIRST" }); + expect(reader.card(4).key).toBe("cloze:two"); + const exposed = JSON.stringify([ + reader.counts, + reader.note(0), + reader.clip(0), + reader.deck(0), + reader.entry(0), + reader.entry(1), + cloze, + reader.card(0), + ]); + expect(exposed).not.toMatch( + /HIDDEN-JOURNAL|HIDDEN-SOURCE|schedule|events|ciphertext/, + ); + expect(decrypt).not.toHaveBeenCalled(); + expect(restore).not.toHaveBeenCalled(); + expect(Object.isFrozen(lease)).toBe(true); + expect(Object.isFrozen(reader.note(0).tags)).toBe(true); + expect(() => { + (reader.note(0).tags as string[]).push("forged"); + }).toThrow(); + expect(() => { + (reader.node(reader.note(0).root) as any).text = "forged"; + }).toThrow(); + await expect( + assertSearchSourceFresh({} as SearchLease), + ).rejects.toMatchObject({ code: "search_source_invalid" }); + await expect( + acquireSearchSource({ library: {} } as any, f.collections), + ).rejects.toThrow(); +}); +it("escaped owner DTOs and retained mutator inputs/outputs cannot mutate private source backing", async () => { + const f = await fixture(), + lease = await leaseOf(f), + reader = searchSourceReader(lease), + invalidated = vi.fn(); + onSearchSourceInvalidated(lease, invalidated); + const listed = await f.notes.list(), + got = await f.notes.get(f.note.id), + linked = await noteLinkOwner(f.notes).read(), + sourced = await studySourceOwner(f.notes).read(), + col = await f.collections.read(); + listed[0]!.document.content[0]!.content![0]!.text = "LIST ALIAS"; + got!.tags.push("GET ALIAS"); + linked.notes[0]!.title = "LINK ALIAS"; + sourced.notes[0]!.document.content = []; + sourced.collections.study.entries[0]!.tags.push("SOURCE ALIAS"); + col.study.entries[2]!.type = "basic" as any; + f.callbackInput.study.preferences.timezone = "bad"; + f.callbackOutput.study.entries[0].tags.push("CALLBACK ALIAS"); + f.callbackOutput.study.entries[2].blanks[0].answer = "CALLBACK ANSWER"; + const restore = vi.spyOn(SyncEngine, "restore"); + await assertSearchSourceFresh(lease); + expect(restore).not.toHaveBeenCalled(); + expect(invalidated).not.toHaveBeenCalled(); + expect(reader.note(0).title).toBe("ACTIVE"); + expect(reader.note(0).tags).toEqual(["real-tag"]); + expect(body(reader)).toBe("ORIGINAL BODY"); + expect(reader.entry(0).tags).toEqual(["entry-tag"]); + const entry = reader.entry(2); + if (entry.type !== "cloze") throw new Error("fixture"); + expect(reader.blank(entry.cursor, 0).answer).toBe("FIRST"); +}); +it("repeated lease admission and freshness checks reuse genuine loaded projections with zero restores", async () => { + const f = await fixture(), + restore = vi.spyOn(SyncEngine, "restore"); + for (let i = 0; i < 5; i++) { + const lease = await leaseOf(f); + await assertSearchSourceFresh(lease); + expect(searchSourceReader(lease).counts.cards).toBe(5); + releaseSearchSource(lease); + } + expect(restore).not.toHaveBeenCalled(); +}); +it("local committed changes invalidate before ordinary observers while same-proof refresh preserves leases", async () => { + const f = await fixture(), + lease = await leaseOf(f), + order: string[] = []; + onSearchSourceInvalidated(lease, (reason) => order.push(reason)); + const stop = f.notes.subscribe(() => { + if (f.notes.persistence().status === "ready") order.push("ordinary"); + }); + await f.notes.update(f.note.id, { title: "Changed" }, 1); + stop(); + expect(order).toEqual(["changed", "ordinary"]); + expect(() => searchSourceReader(lease)).toThrow( + expect.objectContaining({ code: "search_source_changed" }), + ); +}); +it("quota rollback cannot publish attempted cache data or invalidate a genuine unchanged lease", async () => { + const f = await fixture(), + lease = await leaseOf(f), + reader = searchSourceReader(lease), + invalidated = vi.fn(); + onSearchSourceInvalidated(lease, invalidated); + vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(() => { + throw new DOMException("full", "QuotaExceededError"); + }); + await expect( + f.notes.update(f.note.id, { title: "NOT COMMITTED" }, 1), + ).rejects.toThrow(); + vi.restoreAllMocks(); + await assertSearchSourceFresh(lease); + expect(reader.note(0).title).toBe("ACTIVE"); + expect(invalidated).not.toHaveBeenCalled(); +}); + +async function externalImageChange(effect: (engine: SyncEngine) => void) { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret), + engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + effect(engine); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot: engine.snapshot(secret), + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +it.each(["notes", "collections"])( + "same-generation %s tamper revokes without restore and explicit concurrent admission loads each owner once", + async (kind) => { + const f = await fixture(), + lease = await leaseOf(f), + reader = searchSourceReader(lease), + notice = vi.fn(); + onSearchSourceInvalidated(lease, notice); + const token = reader.versions; + await externalImageChange((engine) => { + if (kind === "notes") { + const raw = JSON.parse(engine.record("notes-local", "library")!); + raw.fields.notes.value.find( + (note: any) => note.id === f.note.id, + ).title = "EXTERNAL"; + raw.fields.notes.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-local", + "notes_library", + "library", + JSON.stringify(raw.fields), + ); + } else { + const raw = JSON.parse( + engine.record("notes-collections-v1", "workspace")!, + ); + raw.fields.collections.value.clips.items[0].title = "EXTERNAL"; + raw.fields.collections.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-collections-v1", + "notes_collections", + "workspace", + JSON.stringify(raw.fields), + ); + } + }); + const restore = vi.spyOn(SyncEngine, "restore"); + await expect(assertSearchSourceFresh(lease)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + expect(restore).not.toHaveBeenCalled(); + expect(notice).toHaveBeenCalledWith("unverified"); + expect(() => reader.note(0)).toThrow(); + const newer = await Promise.all([leaseOf(f), leaseOf(f), leaseOf(f)]); + expect(restore).toHaveBeenCalledTimes(2); + for (const next of newer) { + const value = searchSourceReader(next); + expect(value.versions).toEqual(token); + expect(kind === "notes" ? value.note(0).title : value.clip(0).title).toBe( + "EXTERNAL", + ); + } + await expect(assertSearchSourceFresh(lease)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + }, +); +it("accepts cloned key wrappers but rejects different wrapping material without an engine read", async () => { + const f = await fixture(), + lease = await leaseOf(f), + first = (await readRecord("devices", identity.email))!, + second = (await readRecord("devices", identity.email))!; + expect(first.wrapKey).not.toBe(second.wrapKey); + await assertSearchSourceFresh(lease); + const key = await newWrappingKey(); + await mutateRecord("devices", identity.email, (record) => ({ + ...record!, + wrapKey: key, + })); + const restore = vi.spyOn(SyncEngine, "restore"), + notice = vi.fn(); + onSearchSourceInvalidated(lease, notice); + await expect(assertSearchSourceFresh(lease)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + expect(notice).toHaveBeenCalledWith("unverified"); + await expect(leaseOf(f)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + expect(restore).not.toHaveBeenCalled(); +}); +it("zeroes both existing wrappingSecret outputs after every proof verification", async () => { + const f = await fixture(), + lease = await leaseOf(f), + buffers: ArrayBuffer[] = []; + const real = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementation(async (...args) => { + const result = await real(...args); + buffers.push(result); + return result; + }); + await assertSearchSourceFresh(lease); + expect(buffers).toHaveLength(4); + expect( + buffers.every((buffer) => + new Uint8Array(buffer).every((byte) => byte === 0), + ), + ).toBe(true); +}); +it("account lock, same-ID reauth, owner close and release revoke retained cursors", async () => { + for (const action of ["release", "close", "reauth", "lock"]) { + const f = await fixture(), + lease = await leaseOf(f), + reader = searchSourceReader(lease), + node = reader.note(0).root, + entry = reader.entry(2), + notice = vi.fn(); + onSearchSourceInvalidated(lease, notice); + if (action === "release") releaseSearchSource(lease); + else if (action === "close") f.collections.close(); + else if (action === "reauth") establish(identity); + else lock(); + expect(notice).toHaveBeenCalled(); + expect(() => reader.node(node)).toThrow(); + if (entry.type === "cloze") + expect(() => reader.blank(entry.cursor, 0)).toThrow(); + } +}); +it("own erase reports epoch revocation before ordinary observers and preserves independent fresh epochs", async () => { + const f = await fixture(), + lease = await leaseOf(f), + old = searchSourceReader(lease).versions, + order: string[] = []; + onSearchSourceInvalidated(lease, (reason) => order.push(reason)); + const stop = f.notes.subscribe(() => { + if (f.notes.persistence().status === "ready") order.push("ordinary"); + }); + await f.notes.erase(); + stop(); + expect(order[0]).toBe("epoch"); + expect(order[1]).toBe("ordinary"); + await expect(assertSearchSourceFresh(lease)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + const next = await leaseOf(f), + reader = searchSourceReader(next); + expect(reader.versions.library.epoch).not.toBe(old.library.epoch); + expect(reader.versions.collections.epoch).not.toBe(old.collections.epoch); + expect(reader.counts).toEqual({ + notes: 0, + clips: 0, + decks: 0, + entries: 0, + cards: 0, + }); +}); +it("aborted proof work cannot grant or publish a late lease", async () => { + const f = await fixture(), + abort = new AbortController(); + let entered!: () => void, release!: () => void; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)), + real = crypto.subtle.sign.bind(crypto.subtle); + let first = true; + vi.spyOn(crypto.subtle, "sign").mockImplementation(async (...args) => { + if (first) { + first = false; + entered(); + await barrier; + } + return real(...args); + }); + const pending = acquireSearchSource(f.notes, f.collections, abort.signal); + await started; + abort.abort(); + release(); + await expect(pending).rejects.toMatchObject({ + code: "search_source_revoked", + }); +}); + +it("corrupt changed images are never zero categories and do not repeat admission restores", async () => { + const f = await fixture(), + lease = await leaseOf(f); + await externalImageChange((engine) => { + const raw = JSON.parse(engine.record("notes-collections-v1", "workspace")!); + raw.fields.collections.value.study.cards[0].entryId = crypto.randomUUID(); + raw.fields.collections.stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + "notes-collections-v1", + "notes_collections", + "workspace", + JSON.stringify(raw.fields), + ); + }); + const restore = vi.spyOn(SyncEngine, "restore"); + await expect(assertSearchSourceFresh(lease)).rejects.toThrow(); + expect(restore).not.toHaveBeenCalled(); + await expect(leaseOf(f)).rejects.toThrow(); + const first = restore.mock.calls.length; + expect(first).toBe(2); + await expect(leaseOf(f)).rejects.toThrow(); + expect(restore).toHaveBeenCalledTimes(first); +}); +it("different sealed device userdata revokes instead of silently reusing admission", async () => { + const f = await fixture(), + lease = await leaseOf(f), + record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + different = DeviceKeys.create(); + try { + const sealed = different.export(secret); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + sealedKeys: sealed, + })); + } finally { + different.free(); + secret.fill(0); + } + const restore = vi.spyOn(SyncEngine, "restore"); + await expect(assertSearchSourceFresh(lease)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + await expect(leaseOf(f)).rejects.toMatchObject({ + code: "search_source_revoked", + }); + expect(restore).not.toHaveBeenCalled(); +}); +it.each([1, 2, 3, 4, 5, 6, 7, 8])( + "a source-pair mutation at proof sign %i cannot expose stale data", + async (pauseAt) => { + const f = await fixture(); + const held = await leaseOf(f), + oldReader = searchSourceReader(held), + oldEpochs = oldReader.versions, + oldNode = oldReader.note(0).root; + let entered!: () => void, release!: () => void; + const started = new Promise((resolve) => (entered = resolve)), + barrier = new Promise((resolve) => (release = resolve)), + real = crypto.subtle.sign.bind(crypto.subtle); + let calls = 0; + vi.spyOn(crypto.subtle, "sign").mockImplementation(async (...args) => { + if (++calls === pauseAt) { + entered(); + await barrier; + } + return real(...args); + }); + // Both captures and the final pair freshness check verify two wrapping keys. + // Pause each of those eight signs without depending on which owner runs first. + const pending = acquireSearchSource(f.notes, f.collections).then( + (lease) => ({ status: "fulfilled" as const, lease }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + await started; + try { + await f.notes.erase(); + } finally { + release(); + } + expect(() => oldReader.node(oldNode)).toThrow(SearchSourceError); + const result = await pending; + if (result.status === "rejected") { + expect(result.error).toBeInstanceOf(SearchSourceError); + expect([ + "search_source_changed", + "search_source_revoked", + "search_source_unavailable", + ]).toContain((result.error as SearchSourceError).code); + } else { + // Capture may re-admit the committed new image before it creates a Part. + // Success is safe only if the genuine lease has no pre-erase content. + leased.add(result.lease); + await assertSearchSourceFresh(result.lease); + const reader = searchSourceReader(result.lease); + expect(reader.versions.library.epoch).not.toBe(oldEpochs.library.epoch); + expect(reader.versions.collections.epoch).not.toBe( + oldEpochs.collections.epoch, + ); + expect(reader.counts).toEqual({ + notes: 0, + clips: 0, + decks: 0, + entries: 0, + cards: 0, + }); + expect(() => reader.node(oldNode)).toThrow(SearchSourceError); + } + }, +); +it("measures scalar whitelist capture on the real maximum entity-count producer profile", async () => { + const f = await fixture(), + current = await f.collections.read(), + at = new Date(), + deckId = current.study.decks[0]!.id; + // Build the valid entry/card relationship together in one real owner commit. + const base = await f.collections.read(); + await f.collections.update(base, (state) => { + const entries = Array.from({ length: 10000 }, (_, i) => ({ + id: crypto.randomUUID(), + deckId, + revision: 1, + tags: [], + source: null, + type: "reversed" as const, + front: "Question " + i, + back: "Answer " + i, + })); + return { + ...state, + study: { + ...state.study, + entries, + cards: entries.flatMap((entry) => + ["reversed:forward", "reversed:reverse"].map((key) => ({ + id: crypto.randomUUID(), + entryId: entry.id, + key: key as "reversed:forward" | "reversed:reverse", + revision: 1, + schedule: initialSchedule(at), + })), + ), + }, + clips: { + items: Array.from({ length: 5000 }, (_, i) => ({ + id: crypto.randomUUID(), + revision: 1, + title: "Clip " + i, + originalUrl: "https://example.test/" + i, + canonicalUrl: "https://example.test/" + i, + text: null, + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + readAt: null, + readLater: false, + archivedAt: null, + })), + receipts: [], + }, + }; + }); + const notes = await f.notes.list(), + col = await f.collections.read(), + library = await f.notes.library.read(); + if (library.mode !== "ready") throw new Error("fixture"); + let started = performance.now(); + const left = notesSearchData(notes, library.snapshot.token), + right = collectionsSearchData(col), + projectionMs = performance.now() - started; + expect(left.notes).toHaveLength(1); + expect(right.entries).toHaveLength(10000); + expect(right.cards).toHaveLength(20000); + expect(right.clips).toHaveLength(5000); + expect(Object.hasOwn(right, "journal")).toBe(false); + expect(Object.hasOwn(right.entries[0]!, "source")).toBe(false); + expect(Object.hasOwn(right.cards[0]!, "schedule")).toBe(false); + const restore = vi.spyOn(SyncEngine, "restore"); + started = performance.now(); + const lease = await leaseOf(f); + await assertSearchSourceFresh(lease); + const leaseMs = performance.now() - started; + expect(restore).not.toHaveBeenCalled(); + expect(searchSourceReader(lease).counts.cards).toBe(20000); + process.stdout.write( + "search-source-structural " + + JSON.stringify({ + entries: 10000, + cards: 20000, + clips: 5000, + scalarProjectionMs: Number(projectionMs.toFixed(2)), + leaseAndFreshnessMs: Number(leaseMs.toFixed(2)), + extraRestores: 0, + snapshotBytes: (await readRecord( + "devices", + identity.email, + ))!.snapshot!.length, + }) + + "\n", + ); +}, 15000); + +it("unknown cross-owner notifications revoke before ordinary Notes observers", async () => { + const original = globalThis.BroadcastChannel; + const channels: EventTarget[] = []; + class Channel extends EventTarget { + constructor(_name: string) { + super(); + channels.push(this); + } + close() {} + postMessage(_value: unknown) {} + } + vi.stubGlobal("BroadcastChannel", Channel); + try { + const f = await fixture(), + lease = await leaseOf(f), + order: string[] = []; + onSearchSourceInvalidated(lease, (reason) => order.push(reason)); + const stop = f.notes.subscribe(() => order.push("observer")); + const restore = vi.spyOn(SyncEngine, "restore"); + channels[0]!.dispatchEvent(new Event("message")); + expect(order).toEqual(["unverified", "observer"]); + expect(() => searchSourceReader(lease)).toThrow(); + expect(restore).not.toHaveBeenCalled(); + stop(); + } finally { + vi.stubGlobal("BroadcastChannel", original); + } +}); + +it("a failed optional post-commit key proof revokes before committed-write observers", async () => { + const f = await fixture(), + lease = await leaseOf(f), + order: string[] = []; + onSearchSourceInvalidated(lease, (reason) => order.push(reason)); + const stop = f.notes.subscribe(() => { + if (f.notes.persistence().status === "ready") order.push("observer"); + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign") + .mockImplementationOnce((...args) => sign(...args)) + .mockRejectedValueOnce(new Error("synthetic proof failure")); + const updated = await f.notes.update( + f.note.id, + { + title: "Committed despite optional cache failure", + document: f.note.document, + tags: f.note.tags, + }, + f.note.revision, + ); + expect(updated.title).toBe("Committed despite optional cache failure"); + expect(order).toEqual(["unverified", "observer"]); + expect(() => searchSourceReader(lease)).toThrow(); + stop(); +}); diff --git a/web/tests/data/study-preferences.test.ts b/web/tests/data/study-preferences.test.ts new file mode 100644 index 0000000..4c8475d --- /dev/null +++ b/web/tests/data/study-preferences.test.ts @@ -0,0 +1,592 @@ +// @vitest-environment node +import { beforeAll, beforeEach, afterEach, it, expect, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { IDBFactory, IDBObjectStore } from "fake-indexeddb"; +import { initSync, DeviceKeys } from "../../src/generated/wasm/orbyn_wasm.js"; +import { + openCollectionsStore, + replaceJournal, +} from "../../src/collections/store"; +import { + CollectionsRevisionConflictError, + type CollectionsState, + type CollectionsStore, + type StudyPreferences, +} from "../../src/collections/contracts"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, + type DeviceRecord, +} from "../../src/data/database"; +import { establish, lock } from "../../src/account/session"; +import { + createDeck, + saveEntry, + gradeCard, + reviewQueue, + progress, +} from "../../src/study/model"; +import { studyDay } from "../../src/study/scheduler"; +import { + createJournalEnvelope, + emptyJournal, +} from "../../src/collections/journal"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "study-preferences@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set(), + createdAt = new Date("2026-11-09T00:00:00Z"); +beforeAll(async () => + initSync({ + module: await readFile( + new URL("../../src/generated/wasm/orbyn_wasm_bg.wasm", import.meta.url), + ), + }), +); +beforeEach(async () => { + lock(); + vi.stubGlobal("indexedDB", new IDBFactory()); + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + verifiedTrust: true, + approvalCheckpointId: "33333333-3333-4333-8333-333333333333", + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); +}); +afterEach(() => { + for (const h of handles) h.close(); + handles.clear(); + vi.restoreAllMocks(); + lock(); +}); +async function open() { + const store = await openCollectionsStore(identity); + handles.add(store); + return store; +} +async function device() { + return (await readRecord("devices", identity.email))!; +} +async function fixture(journal = false) { + const store = await open(), + current = await store.read(); + let state = await store.update(current, (original) => { + let study = createDeck(original.study, "Settings verification", createdAt); + for (let i = 0; i < 4; i++) + study = saveEntry( + study, + { + id: crypto.randomUUID(), + deckId: study.decks[0]!.id, + type: "basic", + front: "PRIVATE-PREFERENCE-QUESTION-" + i, + back: "Saved answer " + i, + source: null, + revision: 1, + tags: ["settings"], + }, + createdAt, + ); + return { + ...original, + study: { + ...study, + preferences: { ...study.preferences, timezone: "UTC" }, + }, + clips: { + ...original.clips, + items: [ + { + id: crypto.randomUUID(), + revision: 1, + title: "Preserve real Clip", + originalUrl: "https://example.test/settings", + canonicalUrl: "https://example.test/settings", + text: "PRIVATE-PREFERENCE-CLIP-TEXT", + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + readLater: false, + readAt: null, + archivedAt: createdAt.toISOString(), + }, + ], + }, + }; + }); + if (journal) { + const envelope = await createJournalEnvelope( + "synthetic preferences test passphrase", + emptyJournal(), + { accountId: identity.accountId, epoch: state.epoch }, + () => {}, + ); + state = await replaceJournal(store, state, envelope.envelope); + } + return { store, state }; +} +function unrelated(state: CollectionsState) { + const { preferences, ...study } = state.study; + void preferences; + return { ...state, revision: 0, study }; +} +it("commits all five real preferences, reopens them, and preserves schedules/events/Clips/Journal/identity", async () => { + const f = await fixture(true), + first = f.state.study.cards[0]!, + sessionId = crypto.randomUUID(), + operationId = crypto.randomUUID(); + const before = await f.store.update(f.state, (current) => ({ + ...current, + study: gradeCard( + current.study, + first.id, + "good", + sessionId, + operationId, + new Date("2026-11-10T03:30:00Z"), + ), + })), + recordBefore = await device(), + saved = await f.store.update(before, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + newPerDay: 7, + reviewsPerDay: 73, + dayStartHour: 2, + showNextReview: false, + showStreak: false, + }, + }, + })); + expect(saved.study.preferences).toEqual({ + ...before.study.preferences, + newPerDay: 7, + reviewsPerDay: 73, + dayStartHour: 2, + showNextReview: false, + showStreak: false, + }); + expect(unrelated(saved)).toEqual(unrelated(before)); + expect(saved.revision).toBe(before.revision + 1); + expect(saved.journal).not.toBeNull(); + f.store.close(); + const reopened = await open(); + expect(await reopened.read()).toEqual(saved); + const recordAfter = await device(); + expect(recordAfter.accountId).toBe(recordBefore.accountId); + expect(recordAfter.deviceId).toBe(recordBefore.deviceId); + expect(recordAfter.sealedKeys).toEqual(recordBefore.sealedKeys); + expect(recordBefore.approvalCheckpointId).toBe( + "33333333-3333-4333-8333-333333333333", + ); + expect(recordAfter.approvalCheckpointId).toBe( + recordBefore.approvalCheckpointId, + ); + expect(recordAfter.verifiedTrust).toBe(recordBefore.verifiedTrust); + expect(recordAfter.generation).toBe(recordBefore.generation + 1); + const bytes = new TextDecoder().decode(recordAfter.snapshot); + expect(bytes).not.toContain("PRIVATE-PREFERENCE-QUESTION"); + expect(bytes).not.toContain("PRIVATE-PREFERENCE-CLIP-TEXT"); +}); +it.each([ + { newPerDay: 0, reviewsPerDay: 0, dayStartHour: 0 }, + { newPerDay: 10000, reviewsPerDay: 20000, dayStartHour: 23 }, +])( + "admits exact existing numeric preference boundaries $newPerDay/$reviewsPerDay/$dayStartHour", + async (patch) => { + const f = await fixture(), + saved = await f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + ...patch, + showNextReview: false, + showStreak: false, + }, + }, + })); + expect(saved.study.preferences).toMatchObject(patch); + expect(saved.study.preferences.timezone).toBe("UTC"); + expect(saved.study.preferences.algorithm).toBe("orbyn-interval-v1"); + expect(unrelated(saved)).toEqual(unrelated(f.state)); + expect((await (await open()).read()).study.preferences).toEqual( + saved.study.preferences, + ); + }, +); +it.each<[keyof StudyPreferences, unknown]>([ + ["newPerDay", -1], + ["newPerDay", 10001], + ["newPerDay", 0.5], + ["reviewsPerDay", -1], + ["reviewsPerDay", 20001], + ["reviewsPerDay", 1.5], + ["dayStartHour", -1], + ["dayStartHour", 24], + ["dayStartHour", 4.5], + ["showNextReview", "false"], + ["showStreak", 0], +])( + "rejects invalid runtime %s=%s without changing durable state", + async (key, value) => { + const f = await fixture(), + before = await device(); + await expect( + f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, [key]: value }, + }, + })), + ).rejects.toThrow("collections_invalid"); + const after = await device(); + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + expect(await f.store.read()).toEqual(f.state); + }, +); +it("rejects stale settings CAS and explicit dirty-subset retry preserves a winning grade and unrelated preference/Clip", async () => { + const f = await fixture(true), + other = await open(), + operationId = crypto.randomUUID(), + sessionId = crypto.randomUUID(), + at = new Date("2026-11-11T04:58:00Z"), + card = f.state.study.cards[0]!; + const winner = await other.update(f.state, (current) => ({ + ...current, + study: { + ...gradeCard(current.study, card.id, "again", sessionId, operationId, at), + preferences: { ...current.study.preferences, showStreak: false }, + }, + clips: { + ...current.clips, + items: current.clips.items.map((clip) => ({ + ...clip, + readLater: true, + revision: clip.revision + 1, + })), + }, + })); + await expect( + f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, newPerDay: 2 }, + }, + })), + ).rejects.toBeInstanceOf(CollectionsRevisionConflictError); + const latest = await f.store.read(), + saved = await f.store.update(latest, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, newPerDay: 2 }, + }, + })); + expect(saved.study.preferences.showStreak).toBe(false); + expect(saved.study.preferences.newPerDay).toBe(2); + expect(saved.study.cards).toEqual(winner.study.cards); + expect(saved.study.events).toEqual(winner.study.events); + expect( + saved.study.events.filter((event) => event.id === operationId), + ).toHaveLength(1); + expect(saved.clips).toEqual(winner.clips); + expect(saved.journal).toEqual(winner.journal); +}); +it("a winning settings commit does not create a grade and stale grade retries keep one captured intent", async () => { + const f = await fixture(), + other = await open(), + card = f.state.study.cards[0]!, + operationId = crypto.randomUUID(), + sessionId = crypto.randomUUID(), + at = new Date("2026-11-11T03:30:00Z"); + const settings = await f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + dayStartHour: 0, + newPerDay: 0, + }, + }, + })); + expect(settings.study.events).toEqual([]); + expect(settings.study.cards).toEqual(f.state.study.cards); + await expect( + other.update(f.state, (current) => ({ + ...current, + study: gradeCard( + current.study, + card.id, + "again", + sessionId, + operationId, + at, + ), + })), + ).rejects.toBeInstanceOf(CollectionsRevisionConflictError); + const latest = await other.read(), + graded = await other.update(latest, (current) => ({ + ...current, + study: gradeCard( + current.study, + card.id, + "again", + sessionId, + operationId, + at, + ), + })); + expect(graded.study.events).toHaveLength(1); + expect(graded.study.events[0]).toMatchObject({ + id: operationId, + at: at.toISOString(), + sessionId, + grade: "again", + }); + expect(graded.study.preferences).toEqual(settings.study.preferences); + const replay = await other.update(graded, (current) => ({ + ...current, + study: gradeCard( + current.study, + card.id, + "again", + sessionId, + operationId, + at, + ), + })); + expect(replay.study.events).toEqual(graded.study.events); + expect(replay.study.cards).toEqual(graded.study.cards); +}); +it("failed local transaction keeps physical sealed bytes and all five committed values unchanged", async () => { + const f = await fixture(true), + before = await device(), + put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException( + "Synthetic preferences quota", + "QuotaExceededError", + ); + }); + await expect( + f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + newPerDay: 1, + reviewsPerDay: 2, + dayStartHour: 23, + showNextReview: false, + showStreak: false, + }, + }, + })), + ).rejects.toThrow("Synthetic preferences quota"); + put.mockRestore(); + const after = await device(); + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + expect(after.sealedKeys).toEqual(before.sealedKeys); + expect(await f.store.read()).toEqual(f.state); + expect(await (await open()).read()).toEqual(f.state); +}); +it("next normal queue uses new caps without rescheduling and due admitted learning repeats remain available at zero", async () => { + const f = await fixture(), + now = new Date("2026-11-11T05:00:00Z"), + initial = reviewQueue(f.state.study, now); + expect(initial).toHaveLength(4); + const one = await f.store.update(f.state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + newPerDay: 1, + reviewsPerDay: 0, + }, + }, + })); + expect(reviewQueue(one.study, now)).toHaveLength(1); + expect(one.study.cards).toEqual(f.state.study.cards); + expect(one.study.events).toEqual(f.state.study.events); + const card = one.study.cards[0]!, + graded = await f.store.update(one, (current) => ({ + ...current, + study: gradeCard( + current.study, + card.id, + "again", + crypto.randomUUID(), + crypto.randomUUID(), + new Date("2026-11-11T04:58:00Z"), + ), + })); + const zero = await f.store.update(graded, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { + ...current.study.preferences, + newPerDay: 0, + reviewsPerDay: 0, + }, + }, + })); + expect(reviewQueue(zero.study, now).map((card) => card.id)).toEqual([ + card.id, + ]); + expect(zero.study.cards).toEqual(graded.study.cards); + expect(zero.study.events).toEqual(graded.study.events); +}); +it("day-start regroups derived streak from immutable review timestamps without changing actual schedule/event history", async () => { + const f = await fixture(), + at = new Date("2026-11-10T03:30:00Z"), + now = new Date("2026-11-11T05:00:00Z"), + card = f.state.study.cards[0]!, + before = await f.store.update(f.state, (current) => ({ + ...current, + study: gradeCard( + current.study, + card.id, + "good", + crypto.randomUUID(), + crypto.randomUUID(), + at, + ), + })); + expect(studyDay(at, "UTC", 4)).toBe("2026-11-09"); + expect(progress(before.study, now).streak).toBe(0); + const after = await f.store.update(before, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, dayStartHour: 0 }, + }, + })); + expect( + studyDay( + new Date(after.study.events[0]!.at), + after.study.preferences.timezone, + after.study.preferences.dayStartHour, + ), + ).toBe("2026-11-10"); + expect(progress(after.study, now).streak).toBe(1); + expect(after.study.cards).toEqual(before.study.cards); + expect(after.study.events).toEqual(before.study.events); + expect(after.study.preferences.timezone).toBe( + before.study.preferences.timezone, + ); +}); +it("day-start changes next-queue admission grouping but never rewrites an already reviewed card", async () => { + const f = await fixture(), + at = new Date("2026-11-11T03:30:00Z"), + now = new Date("2026-11-11T05:00:00Z"), + card = f.state.study.cards[0]!, + before = await f.store.update(f.state, (current) => ({ + ...current, + study: { + ...gradeCard( + current.study, + card.id, + "again", + crypto.randomUUID(), + crypto.randomUUID(), + at, + ), + preferences: { + ...current.study.preferences, + newPerDay: 1, + reviewsPerDay: 0, + dayStartHour: 4, + }, + }, + })); + const old = reviewQueue(before.study, now); + expect(old).toHaveLength(1); + expect(old[0]!.id).not.toBe(card.id); + const after = await f.store.update(before, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, dayStartHour: 0 }, + }, + })); + expect(reviewQueue(after.study, now).map((item) => item.id)).toEqual([ + card.id, + ]); + expect(after.study.cards).toEqual(before.study.cards); + expect(after.study.events).toEqual(before.study.events); +}); + +it("review admission caps change the next due-review queue without touching stored schedules", async () => { + const f = await fixture(), + now = new Date("2026-11-11T05:00:00Z"), + reviewed = await f.store.update(f.state, (current) => { + let study = current.study; + const sessionId = crypto.randomUUID(); + for (const card of current.study.cards) + study = gradeCard( + study, + card.id, + "good", + sessionId, + crypto.randomUUID(), + createdAt, + ); + return { + ...current, + study: { + ...study, + preferences: { ...study.preferences, newPerDay: 0, reviewsPerDay: 2 }, + }, + }; + }); + expect(reviewQueue(reviewed.study, now)).toHaveLength(2); + const zero = await f.store.update(reviewed, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, reviewsPerDay: 0 }, + }, + })); + expect(reviewQueue(zero.study, now)).toHaveLength(0); + expect(zero.study.cards).toEqual(reviewed.study.cards); + expect(zero.study.events).toEqual(reviewed.study.events); + const four = await f.store.update(zero, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, reviewsPerDay: 4 }, + }, + })); + expect(reviewQueue(four.study, now)).toHaveLength(4); + expect(four.study.cards).toEqual(reviewed.study.cards); + expect(four.study.events).toEqual(reviewed.study.events); +}); diff --git a/web/tests/transfer/journal-export.test.ts b/web/tests/transfer/journal-export.test.ts new file mode 100644 index 0000000..d3c8fdf --- /dev/null +++ b/web/tests/transfer/journal-export.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from "vitest"; +import { + archiveHabit, + emptyJournal, + saveHabit, + setHabitCompletion, + writeJournalEntry, +} from "../../src/collections/journal"; +import { + exportJournalFiles, + exportJournalMarkdown, +} from "../../src/collections/journalTransfer"; +import { parseMarkdown } from "../../src/transfer"; + +it("includes a complete readable backup when habits have never been checked", () => { + const at = new Date(2026, 10, 11, 12); + let data = saveHabit(emptyJournal(), "Never checked", at); + data = saveHabit(data, "Archived without completion", at); + data = archiveHabit(data, data.habits[1]!.id, at); + const files = exportJournalFiles(data); + const backup = files.find((file) => file.filename === "journal.json"); + expect(backup).toBeDefined(); + expect(JSON.parse(backup!.content)).toEqual(data); + expect( + files.find((file) => file.filename === "README.txt")!.content, + ).toContain("no built-in Journal import"); +}); +it("does not present an empty Journal as having exported content", () => { + expect(exportJournalFiles(emptyJournal())).toEqual([]); +}); + +it("deliberately exports Journal writing and habit-only days without leaking key metadata", () => { + const at = new Date(2026, 10, 11, 12); + let data = saveHabit(emptyJournal(), "A synthetic habit", at); + data = setHabitCompletion(data, "2026-11-11", data.habits[0]!.id, true, at); + data = writeJournalEntry( + data, + "2026-11-10", + { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "My synthetic private entry" }], + }, + ], + }, + null, + at, + ); + const files = exportJournalMarkdown(data); + expect(files.map((file) => file.filename)).toEqual([ + "2026-11-10.md", + "2026-11-11.md", + ]); + expect(files[0]!.content).toContain("My synthetic private entry"); + expect(files[1]!.content).toContain("[x] A synthetic habit"); + expect(files[0]!.content).not.toContain("PBKDF2"); + expect( + parseMarkdown( + files.map((file) => ({ name: file.filename, content: file.content })), + ), + ).toHaveLength(2); + expect(data.entries).toHaveLength(1); +});