From 7b6427e5c7c465393126f5ac9ddc7d9614c3c66d Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:28:48 +1100 Subject: [PATCH 1/2] feat(clips): persist validated links and readable exports --- web/src/clips/model.ts | 111 +++++ web/src/clips/store.ts | 262 +++++++++++ web/src/clips/transfer.ts | 22 + web/tests/data/clips.test.ts | 635 +++++++++++++++++++++++++++ web/tests/data/library-erase.test.ts | 556 +++++++++++++++++++++++ 5 files changed, 1586 insertions(+) create mode 100644 web/src/clips/model.ts create mode 100644 web/src/clips/store.ts create mode 100644 web/src/clips/transfer.ts create mode 100644 web/tests/data/clips.test.ts create mode 100644 web/tests/data/library-erase.test.ts diff --git a/web/src/clips/model.ts b/web/src/clips/model.ts new file mode 100644 index 0000000..7a420eb --- /dev/null +++ b/web/src/clips/model.ts @@ -0,0 +1,111 @@ +import { ClipDuplicateError, ClipError } from "./contracts"; +import type { ClipActionInput, ClipReceipt, ClipState } from "./contracts"; +import { CLIP_LIMITS, clipUrl, validateClipState } from "./validation"; + +export function applyClipAction( + state: ClipState, + action: ClipActionInput, + operationId: string, + clipId: string, + digest: string, + preparedAt: string, +): { clips: ClipState; receipt: ClipReceipt } { + const next = structuredClone(state); + let changed = true, + resultRevision: number | null = null; + if (action.kind === "create") { + const url = clipUrl(action.url); + const duplicate = next.items.find( + (item) => item.canonicalUrl === url.canonicalUrl, + ); + if (!action.allowDuplicate && duplicate) + throw new ClipDuplicateError(duplicate); + if (next.items.length >= CLIP_LIMITS.items) + throw new ClipError( + "clip_limit", + "The local clip limit has been reached.", + ); + if (next.items.some((item) => item.id === clipId)) + throw new ClipError( + "clip_operation_reused", + "This clip identity is already in use.", + ); + // Timestamp admission follows all target and duplicate checks. + const at = new Date().toISOString(); + next.items.push({ + id: clipId, + revision: 1, + originalUrl: url.originalUrl, + canonicalUrl: url.canonicalUrl, + title: action.title, + text: action.text || null, + createdAt: preparedAt, + updatedAt: at, + readAt: null, + readLater: action.readLater, + archivedAt: null, + }); + resultRevision = 1; + return finish(at); + } + const item = next.items.find((item) => item.id === clipId); + if (!item || item.revision !== action.targetRevision) + throw new ClipError( + "clip_stale", + "This clip changed or is no longer available.", + ); + if (action.kind === "undo-create") { + const original = next.receipts.find( + (receipt) => receipt.operationId === action.receiptOperationId, + ); + const now = Date.now(); + if ( + !original || + original.kind !== "create" || + original.clipId !== clipId || + original.resultRevision !== 1 || + item.revision !== 1 || + now < Date.parse(original.at) || + now > Date.parse(original.at) + 5000 + ) + throw new ClipError( + "clip_undo_unavailable", + "Undo is no longer available for this clip.", + ); + next.items = next.items.filter((item) => item.id !== clipId); + return finish(new Date(now).toISOString()); + } + changed = + action.kind === "set-read" + ? Boolean(item.readAt) !== action.read + : action.kind === "set-read-later" + ? item.readLater !== action.readLater + : Boolean(item.archivedAt) !== action.archived; + if (changed && item.revision === Number.MAX_SAFE_INTEGER) + throw new ClipError("clip_limit", "Clip revisions are exhausted."); + const at = new Date().toISOString(); + if (changed) { + if (action.kind === "set-read") item.readAt = action.read ? at : null; + else if (action.kind === "set-read-later") + item.readLater = action.readLater; + else item.archivedAt = action.archived ? at : null; + item.updatedAt = at; + item.revision++; + } + resultRevision = item.revision; + return finish(at); + function finish(at: string) { + const receipt: ClipReceipt = { + operationId, + digest, + kind: action.kind, + clipId, + resultRevision, + at, + changed, + }; + next.receipts.push(receipt); + next.receipts = next.receipts.slice(-CLIP_LIMITS.receipts); + return { clips: validateClipState(next), receipt }; + } +} diff --git a/web/src/clips/store.ts b/web/src/clips/store.ts new file mode 100644 index 0000000..d477146 --- /dev/null +++ b/web/src/clips/store.ts @@ -0,0 +1,262 @@ +import { lockVersion, onLock } from "../account/session"; +import { onCollectionsEpochChange } from "../collections/store"; +import { CollectionsRevisionConflictError } from "../collections/contracts"; +import type { + CollectionsRevision, + CollectionsState, + CollectionsStore, +} from "../collections/contracts"; +import { ClipConflictError, ClipError } from "./contracts"; +import type { + ClipActionInput, + ClipIntent, + ClipMutationResult, + ClipReceipt, + ClipSnapshot, + ClipsStore, +} from "./contracts"; +import { applyClipAction } from "./model"; +import { id, integer, object, validateClipAction } from "./validation"; + +type Prepared = { + expected: CollectionsRevision; + action: ClipActionInput; + operationId: string; + clipId: string; + preparedAt: string; + digest: string; + generation: number; +}; +function snapshot(state: CollectionsState): ClipSnapshot { + return structuredClone({ + epoch: state.epoch, + revision: state.revision, + clips: state.clips, + }); +} +export function createClipsStore(collections: CollectionsStore): ClipsStore { + let closed = false, + generation = 0; + const openedLock = lockVersion(); + let intents = new WeakMap(); + const listeners = new Set<() => void>(), + invalidations = new Set<(reason: "epoch" | "closed") => void>(); + const stopLock = onLock(close); + let stopEpoch: () => void, stopChange: () => void; + try { + stopEpoch = onCollectionsEpochChange(collections, () => + invalidate("epoch"), + ); + stopChange = collections.subscribe(notify); + } catch (error) { + stopLock(); + throw error; + } + function notify() { + if (!closed) + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observer isolation. */ + } + } + } + function invalidate(reason: "epoch" | "closed") { + generation++; + intents = new WeakMap(); + for (const listener of invalidations) { + try { + listener(reason); + } catch { + /* Revocation always completes. */ + } + } + } + function close() { + if (closed) return; + closed = true; + invalidate("closed"); + stopLock(); + stopEpoch?.(); + stopChange?.(); + listeners.clear(); + invalidations.clear(); + } + function guard(captured = generation) { + if (closed || openedLock !== lockVersion() || captured !== generation) + throw new ClipError( + "clip_intent_revoked", + "This clip operation is no longer authorized.", + ); + } + async function read() { + guard(); + const captured = generation; + const state = await collections.read(); + guard(captured); + return snapshot(state); + } + function reconcile( + state: CollectionsState, + prepared: Prepared, + ): ClipMutationResult | null { + guard(prepared.generation); + if (state.epoch !== prepared.expected.epoch) + throw new ClipError( + "clip_intent_revoked", + "The workspace was erased. Reload Clips.", + ); + const receipt = state.clips.receipts.find( + (row) => row.operationId === prepared.operationId, + ); + if (!receipt) return null; + if (receipt.digest !== prepared.digest) + throw new ClipError( + "clip_operation_reused", + "This operation identity has different saved content.", + ); + return { + snapshot: snapshot(state), + receipt: structuredClone(receipt), + changed: receipt.changed, + replayed: true, + }; + } + return { + read, + async prepare(expectedInput, input) { + guard(); + const token = object( + { epoch: expectedInput?.epoch, revision: expectedInput?.revision }, + ["epoch", "revision"], + ); + id(token.epoch); + integer(token.revision); + const expected = { epoch: token.epoch, revision: token.revision }; + // Validation returns a fresh primitive-only action, captured before awaits. + const action = validateClipAction(input), + captured = generation; + const operationId = crypto.randomUUID(), + preparedAt = new Date().toISOString(); + const state = await collections.read(); + guard(captured); + if (state.epoch !== expected.epoch) + throw new ClipError( + "clip_intent_revoked", + "The workspace changed. Reload Clips.", + ); + const clipId = + action.kind === "create" + ? operationId + : action.kind === "undo-create" + ? state.clips.receipts.find( + (row) => + row.operationId === action.receiptOperationId && + row.kind === "create", + )?.clipId + : action.clipId; + if (!clipId) + throw new ClipError( + "clip_undo_unavailable", + "The original save receipt is no longer available.", + ); + const canonicalAction = Object.fromEntries( + Object.entries(action).sort(([a], [b]) => a.localeCompare(b)), + ); + const bytes = new TextEncoder().encode( + JSON.stringify({ + expected, + action: canonicalAction, + operationId, + clipId, + preparedAt, + }), + ); + let digest: string; + try { + digest = Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + } finally { + bytes.fill(0); + } + guard(captured); + const latest = await collections.read(); + guard(captured); + if (latest.epoch !== expected.epoch) + throw new ClipError( + "clip_intent_revoked", + "The workspace changed. Reload Clips.", + ); + const intent = Object.freeze({ operationId, clipId }) as ClipIntent; + intents.set(intent, { + expected, + action, + operationId, + clipId, + preparedAt, + digest, + generation: captured, + }); + return intent; + }, + async commit(intent) { + guard(); + const prepared = intent && intents.get(intent); + if (!prepared) + throw new ClipError( + "clip_intent_revoked", + "Prepare a new authorized clip operation.", + ); + guard(prepared.generation); + const current = await collections.read(); + const replay = reconcile(current, prepared); + if (replay) return replay; + let receipt: ClipReceipt | undefined; + try { + const saved = await collections.update(prepared.expected, (current) => { + guard(prepared.generation); + const result = applyClipAction( + current.clips, + prepared.action, + prepared.operationId, + prepared.clipId, + prepared.digest, + prepared.preparedAt, + ); + receipt = result.receipt; + return { ...current, clips: result.clips }; + }); + guard(prepared.generation); + return { + snapshot: snapshot(saved), + receipt: structuredClone(receipt!), + changed: receipt!.changed, + replayed: false, + }; + } catch (error) { + guard(prepared.generation); + if (error instanceof CollectionsRevisionConflictError) { + const replay = reconcile(error.current, prepared); + if (replay) return replay; + throw new ClipConflictError(snapshot(error.current)); + } + throw error; + } + }, + subscribe(listener) { + guard(); + listeners.add(listener); + return () => listeners.delete(listener); + }, + onInvalidate(listener) { + guard(); + invalidations.add(listener); + return () => invalidations.delete(listener); + }, + persistence: () => collections.persistence(), + close, + }; +} diff --git a/web/src/clips/transfer.ts b/web/src/clips/transfer.ts new file mode 100644 index 0000000..1006bc2 --- /dev/null +++ b/web/src/clips/transfer.ts @@ -0,0 +1,22 @@ +import type { ClipExportFile, ClipState } from "./contracts"; +import { validateClipState } from "./validation"; + +export function exportClipsFiles(input: ClipState): ClipExportFile[] { + const clips = validateClipState(input); + if (!clips.items.length) return []; + return [ + { + filename: "clips.json", + content: JSON.stringify({ version: 1, ...clips }, null, 2), + }, + { + filename: "README.txt", + content: + "Clips plaintext export\n\nThis export contains private links and pasted text. Store it somewhere safe. clips.json contains all saved clip fields and operation receipts. Each item also has a readable text file. Text is user-pasted content, not fetched from the source. Recovery is manual; this version has no automatic import or restore. No account keys, passphrases or engine snapshots are included.\n", + }, + ...clips.items.map((item) => ({ + filename: `clip-${item.id}.txt`, + content: `${item.title}\nSource: ${item.originalUrl}\nCanonical URL: ${item.canonicalUrl}\nProvenance: ${item.text === null ? "Link only" : "Pasted text"}\nSaved: ${item.createdAt}\nUpdated: ${item.updatedAt}\nRead: ${item.readAt ?? "Unread"}\nRead later: ${item.readLater ? "Yes" : "No"}\nArchived: ${item.archivedAt ?? "No"}\nID: ${item.id}\nRevision: ${item.revision}\n\n---\n${item.text ?? "Only the link is saved."}`, + })), + ]; +} diff --git a/web/tests/data/clips.test.ts b/web/tests/data/clips.test.ts new file mode 100644 index 0000000..b6d0be4 --- /dev/null +++ b/web/tests/data/clips.test.ts @@ -0,0 +1,635 @@ +// @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, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { establish, lock } from "../../src/account/session"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import { openCollectionsStore } from "../../src/collections/store"; +import type { + CollectionsStore, + LegacyCollectionsState, +} from "../../src/collections/contracts"; +import { createClipsStore } from "../../src/clips/store"; +import type { ClipsStore, ClipActionInput } from "../../src/clips/contracts"; +import { + ClipConflictError, + ClipDuplicateError, +} from "../../src/clips/contracts"; +import { + validateClipAction, + validateClipState, + CLIP_LIMITS, +} from "../../src/clips/validation"; +import { exportClipsFiles } from "../../src/clips/transfer"; +import { + emptyStudy, + validateCollections, + validateLegacyCollections, + normalizeCollections, + LEGACY_COLLECTIONS_BYTES, + EMPTY_CLIPS_OVERHEAD, + COLLECTIONS_LIMITS, +} from "../../src/collections/validation"; +import { openNotesStore } from "../../src/data"; + +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "clips@example.test", + deviceState: "trusted" as const, +}; +const handles = new Set<{ close(): void }>(); +const create: ClipActionInput = { + kind: "create", + url: "https://Example.test/path?q=1#fragment", + title: " Actual clip ", + text: " pasted \n", + readLater: false, + allowDuplicate: false, +}; +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, + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + lock(); +}); +async function open() { + const collections = await openCollectionsStore(identity); + handles.add(collections); + const clips = createClipsStore(collections); + handles.add(clips); + return { collections, clips }; +} +async function act(clips: ClipsStore, input: ClipActionInput) { + return clips.commit(await clips.prepare(await clips.read(), input)); +} +async function rawPayload() { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret); + const engine = SyncEngine.restore(keys, record.snapshot!, secret, Date.now()); + try { + return JSON.parse(engine.record("notes-collections-v1", "workspace")!) + .fields.collections.value; + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +async function seed(value: unknown, owner = "notes_collections") { + const record = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(record.wrapKey), + keys = DeviceKeys.import(record.sealedKeys, secret); + const engine = SyncEngine.create(keys, identity.accountId, identity.deviceId); + try { + engine.createZoneKey("notes-collections-v1"); + engine.stage( + "notes-collections-v1", + owner, + "workspace", + JSON.stringify({ + collections: { value, stamp: JSON.parse(engine.tick(Date.now())) }, + }), + ); + const snapshot = engine.snapshot(secret); + await mutateRecord("devices", identity.email, (current) => ({ + ...current!, + snapshot, + generation: current!.generation + 1, + })); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } +} +function legacy(): LegacyCollectionsState { + return { + version: 1, + epoch: crypto.randomUUID(), + revision: 9, + study: emptyStudy("UTC"), + journal: { + version: 1, + kdf: { + name: "PBKDF2-SHA256", + iterations: 600000, + salt: btoa("s".repeat(16)), + }, + iv: btoa("i".repeat(12)), + ciphertext: btoa("c".repeat(16)), + }, + }; +} + +it("saves real pasted text offline, reopens, mutates desired flags and exports complete actual data", async () => { + vi.stubGlobal("navigator", { onLine: false }); + const { clips } = await open(); + const result = await act(clips, create), + item = result.snapshot.clips.items[0]!; + expect(item).toMatchObject({ + originalUrl: create.url, + canonicalUrl: "https://example.test/path?q=1", + title: "Actual clip", + text: create.text, + revision: 1, + }); + const reopened = (await open()).clips; + expect((await reopened.read()).clips.items).toEqual([item]); + const read = await act(reopened, { + kind: "set-read", + clipId: item.id, + targetRevision: 1, + read: true, + }); + const later = await act(reopened, { + kind: "set-read-later", + clipId: item.id, + targetRevision: 2, + readLater: true, + }); + const archived = await act(reopened, { + kind: "set-archived", + clipId: item.id, + targetRevision: 3, + archived: true, + }); + expect(read.snapshot.clips.items[0]!.readAt).not.toBeNull(); + expect(later.snapshot.clips.items[0]!.readLater).toBe(true); + expect(archived.snapshot.clips.items[0]!.archivedAt).not.toBeNull(); + const unchanged = await act(reopened, { + kind: "set-archived", + clipId: item.id, + targetRevision: 4, + archived: true, + }); + expect(unchanged.changed).toBe(false); + expect(unchanged.receipt.changed).toBe(false); + expect(unchanged.snapshot.clips.items[0]!.revision).toBe(4); + expect(unchanged.snapshot.revision).toBe(archived.snapshot.revision + 1); + const files = exportClipsFiles(unchanged.snapshot.clips); + expect(JSON.parse(files[0]!.content)).toEqual({ + version: 1, + ...unchanged.snapshot.clips, + }); + expect( + files + .find((file) => file.filename.endsWith(item.id + ".txt"))! + .content.endsWith(create.text), + ).toBe(true); + expect( + files.find((file) => file.filename === "README.txt")!.content, + ).toContain("no automatic import"); + expect(exportClipsFiles({ items: [], receipts: [] })).toEqual([]); +}); +it("reconciles an unknown committed result and concurrent exact retries without duplicate writes", async () => { + const { clips, collections } = await open(), + intent = await clips.prepare(await clips.read(), create); + const update = collections.update.bind(collections); + vi.spyOn(collections, "update").mockImplementationOnce(async (...args) => { + await update(...args); + throw new Error("Synthetic unknown result"); + }); + await expect(clips.commit(intent)).rejects.toThrow("unknown result"); + const before = (await readRecord("devices", identity.email))!; + const [a, b] = await Promise.all([ + clips.commit(intent), + clips.commit(intent), + ]); + expect(a.replayed && b.replayed).toBe(true); + expect(a.receipt).toEqual(b.receipt); + expect((await clips.read()).clips.items).toHaveLength(1); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); +}); +it("checks canonical duplicates including archived clips, requires deliberate new intent, and rejects races", async () => { + const { clips } = await open(), + expected = await clips.read(); + const a = await clips.prepare(expected, create), + b = await clips.prepare(expected, create); + const saved = await clips.commit(a), + item = saved.snapshot.clips.items[0]!; + await expect(clips.commit(b)).rejects.toBeInstanceOf(ClipConflictError); + await act(clips, { + kind: "set-archived", + clipId: item.id, + targetRevision: 1, + archived: true, + }); + const duplicate = await clips.prepare(await clips.read(), { + ...create, + url: "https://example.test/path?q=1#different", + }); + await expect(clips.commit(duplicate)).rejects.toBeInstanceOf( + ClipDuplicateError, + ); + const again = await act(clips, { ...create, allowDuplicate: true }); + expect(again.snapshot.clips.items).toHaveLength(2); + expect(again.snapshot.clips.items[1]!.id).not.toBe(item.id); +}); +it("uses admission time for Undo after slow hashing, never resets it on replay, and refuses stale/expired undo", async () => { + const { clips } = await open(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-11T10:00:00Z")); + const intent = await clips.prepare(await clips.read(), create); + vi.setSystemTime(Date.now() + 20000); + const saved = await clips.commit(intent); + expect(saved.receipt.at).toBe(new Date().toISOString()); + expect(saved.snapshot.clips.items[0]!.createdAt).not.toBe(saved.receipt.at); + const replay = await clips.commit(intent); + expect(replay.receipt.at).toBe(saved.receipt.at); + const undo = await clips.prepare(await clips.read(), { + kind: "undo-create", + receiptOperationId: saved.receipt.operationId, + targetRevision: 1, + }); + vi.setSystemTime(Date.now() + 5001); + await expect(clips.commit(undo)).rejects.toThrow("clip_undo_unavailable"); + const fresh = await act(clips, { + ...create, + url: "https://example.test/new", + }); + const undone = await act(clips, { + kind: "undo-create", + receiptOperationId: fresh.receipt.operationId, + targetRevision: 1, + }); + expect(undone.snapshot.clips.items).toHaveLength(1); + expect(validateClipState(undone.snapshot.clips)).toEqual( + undone.snapshot.clips, + ); +}); +it("keeps edited clips when old Undo is attempted and preserves exact snapshot on quota failure", async () => { + const { clips } = await open(), + saved = await act(clips, create), + item = saved.snapshot.clips.items[0]!; + await act(clips, { + kind: "set-read", + clipId: item.id, + targetRevision: 1, + read: true, + }); + await expect( + act(clips, { + kind: "undo-create", + receiptOperationId: saved.receipt.operationId, + targetRevision: 1, + }), + ).rejects.toThrow("clip_stale"); + const intent = await clips.prepare(await clips.read(), { + ...create, + url: "https://example.test/quota", + }); + const before = (await readRecord("devices", identity.email))!; + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Synthetic clip quota", "QuotaExceededError"); + }); + await expect(clips.commit(intent)).rejects.toThrow("clip quota"); + put.mockRestore(); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + expect((await clips.commit(intent)).snapshot.clips.items).toHaveLength(2); +}); +it.each(["lock", "erase", "close"] as const)( + "revokes hash work and held intent on %s", + async (reason) => { + const { clips } = await open(), + held = await clips.prepare(await clips.read(), create); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const digest = crypto.subtle.digest.bind(crypto.subtle); + const hashing = vi + .spyOn(crypto.subtle, "digest") + .mockImplementationOnce(async (...args) => { + await gate; + return digest(...args); + }); + const pending = clips.prepare(await clips.read(), { + ...create, + url: "https://example.test/delayed", + }); + await vi.waitFor(() => expect(hashing).toHaveBeenCalled()); + const invalidation = vi.fn(); + clips.onInvalidate(invalidation); + if (reason === "lock") establish(identity); + else if (reason === "close") clips.close(); + else { + const notes = await openNotesStore(identity); + handles.add(notes); + await notes.erase(); + await expect(clips.read()).rejects.toThrow("clip_intent_revoked"); + } + release(); + await expect(pending).rejects.toThrow("clip_intent_revoked"); + await expect(clips.commit(held)).rejects.toThrow("clip_intent_revoked"); + expect(invalidation).toHaveBeenCalledWith( + reason === "erase" ? "epoch" : "closed", + ); + }, +); +it("captures input before awaits and fences commits awaiting wrapping crypto after facade close", async () => { + const { clips } = await open(), + input = { ...create }, + pending = clips.prepare(await clips.read(), input); + input.url = "javascript:changed"; + const intent = await pending; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + vi.spyOn(crypto.subtle, "sign").mockImplementationOnce(async (...args) => { + await gate; + return sign(...args); + }); + const committing = clips.commit(intent); + clips.close(); + release(); + await expect(committing).rejects.toThrow("clip_intent_revoked"); + expect((await (await open()).clips.read()).clips.items).toEqual([]); +}); +it("fences an atomic commit delayed across whole-workspace erase", async () => { + const { clips } = await open(), + intent = await clips.prepare(await clips.read(), create); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const sign = crypto.subtle.sign.bind(crypto.subtle); + // Commit's first read completes, then its writer's wrapping crypto is paused. + const signing = vi + .spyOn(crypto.subtle, "sign") + .mockImplementationOnce(sign) + .mockImplementationOnce(async (...args) => { + await gate; + return sign(...args); + }); + const committing = clips.commit(intent); + await vi.waitFor(() => expect(signing).toHaveBeenCalledTimes(2)); + const notes = await openNotesStore(identity); + handles.add(notes); + await notes.erase(); + release(); + await expect(committing).rejects.toThrow(); + expect((await (await open()).clips.read()).clips.items).toEqual([]); + await expect(clips.commit(intent)).rejects.toThrow("clip_intent_revoked"); +}); +it.each([ + "javascript:alert(1)", + "mailto:a@example.test", + "https://user:pass@example.test", + "https://@example.test", + "https:///example.test", + "https://example.test\\path", + "https://example.test/has space", + "https://example.test/\n", + "file:///tmp/a", +])("rejects unsafe URL %s", (url) => { + expect(() => validateClipAction({ ...create, url })).toThrow("clip_invalid"); +}); +it("validates exact shapes, canonical URLs, text/title bounds and null text normalization", () => { + expect( + validateClipAction({ ...create, title: " ", text: " \n" }), + ).toMatchObject({ title: "example.test", text: "" }); + for (const value of [ + { ...create, unknown: true }, + { ...create, title: "x".repeat(301) }, + { ...create, text: "x".repeat(100001) }, + { ...create, url: "https://example.test/" + "x".repeat(8192) }, + ]) + expect(() => validateClipAction(value)).toThrow(); + expect( + validateClipAction({ + ...create, + url: "https://example.test/path@name?q=@value", + }), + ).toMatchObject({ url: "https://example.test/path@name?q=@value" }); +}); +it("bounds entities, receipts and total text, rejects malformed persisted data, and retains no-op receipt semantics", async () => { + const { clips } = await open(), + saved = await act(clips, create), + item = saved.snapshot.clips.items[0]!, + receipt = saved.receipt; + for (const bad of [ + { ...saved.snapshot.clips, extra: true }, + { + items: [{ ...item, canonicalUrl: item.canonicalUrl + "#bad" }], + receipts: [], + }, + { items: [{ ...item, text: " " }], receipts: [] }, + { items: [{ ...item, revision: 0 }], receipts: [] }, + { items: [{ ...item, readLater: "yes" }], receipts: [] }, + { items: [item, item], receipts: [] }, + { items: [item], receipts: [receipt, receipt] }, + { items: [item], receipts: [{ ...receipt, digest: "wrong" }] }, + { items: [item], receipts: [{ ...receipt, kind: "undo-create" }] }, + { items: Array.from({ length: 5001 }, () => item), receipts: [] }, + { items: [], receipts: Array.from({ length: 501 }, () => receipt) }, + { + items: Array.from({ length: 126 }, () => ({ + ...item, + id: crypto.randomUUID(), + text: "x".repeat(100000), + })), + receipts: [], + }, + ]) + expect(() => validateClipState(bad)).toThrow(); +}); +it("does not overwrite after a receipt is pruned or its identity/digest was reused", async () => { + const { clips, collections } = await open(), + intent = await clips.prepare(await clips.read(), create); + const saved = await clips.commit(intent); + await collections.update(saved.snapshot, (current) => ({ + ...current, + clips: { + ...current.clips, + receipts: [{ ...current.clips.receipts[0]!, digest: "f".repeat(64) }], + }, + })); + await expect(clips.commit(intent)).rejects.toThrow("clip_operation_reused"); + await collections.update(await collections.read(), (current) => ({ + ...current, + clips: { ...current.clips, receipts: [] }, + })); + await expect(clips.commit(intent)).rejects.toBeInstanceOf(ClipConflictError); + expect((await clips.read()).clips.items).toHaveLength(1); +}); +it("isolates observer failures and closing the facade leaves the collections owner usable", async () => { + const { clips, collections } = await open(); + clips.subscribe(() => { + throw new Error("Synthetic observer"); + }); + await act(clips, create); + clips.close(); + expect((await collections.read()).clips.items).toHaveLength(1); + await collections.update(await collections.read(), (state) => state); +}); +it("normalizes fully validated v1 without rewriting and first v2 write preserves Study/Journal", async () => { + const old = legacy(); + await seed(old); + const before = (await readRecord("devices", identity.email))!; + const put = vi.spyOn(IDBObjectStore.prototype, "put"), + { clips } = await open(); + const current = await clips.read(); + expect(put).not.toHaveBeenCalled(); + put.mockRestore(); + expect(current.clips).toEqual({ items: [], receipts: [] }); + expect((await rawPayload()).version).toBe(1); + expect( + (await readRecord("devices", identity.email))!.snapshot, + ).toEqual(before.snapshot); + await act(clips, create); + const persisted = await rawPayload(); + expect(persisted.version).toBe(3); + expect(persisted.study).toEqual(old.study); + expect(persisted.journal).toEqual(old.journal); + expect(() => validateLegacyCollections(persisted)).toThrow( + "collections_invalid", + ); + expect(() => validateCollections(old)).toThrow("collections_invalid"); +}); +it.each(["corrupt", "future", "foreign"] as const)( + "rejects %s legacy payload without rewriting", + async (kind) => { + const old = legacy(); + await seed( + kind === "future" + ? { ...old, version: 4 } + : kind === "corrupt" + ? { ...old, extra: "bad" } + : old, + kind === "foreign" ? "foreign_owner" : "notes_collections", + ); + const before = (await readRecord("devices", identity.email))!; + await expect(open()).rejects.toThrow("collections_invalid"); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + }, +); + +it("reads exact-cap legacy data but atomically rejects nonempty Clips and revision digit growth beyond exact v2 overhead", async () => { + const old = legacy(), + deckId = crypto.randomUUID(), + at = new Date().toISOString(); + old.study.decks.push({ + id: deckId, + title: "Boundary fixture", + createdAt: at, + updatedAt: at, + revision: 1, + }); + for (let i = 0; i < 170; i++) { + const entryId = crypto.randomUUID(); + old.study.entries.push({ + id: entryId, + deckId, + tags: [], + source: null, + revision: 1, + type: "basic", + front: "x", + back: "x", + }); + old.study.cards.push({ + id: crypto.randomUUID(), + entryId, + key: "basic:forward", + revision: 1, + schedule: { + version: 1, + state: "new", + due: at, + intervalDays: 0, + ease: 2.5, + repetitions: 0, + lapses: 0, + lastReviewedAt: null, + }, + }); + } + let remaining = + LEGACY_COLLECTIONS_BYTES - + new TextEncoder().encode(JSON.stringify(old)).byteLength; + for (const entry of old.study.entries) { + if (entry.type !== "basic") throw new Error("fixture variant"); + for (const key of ["front", "back"] as const) { + const add = Math.min(99999, remaining); + entry[key] += "x".repeat(add); + remaining -= add; + } + } + expect(remaining).toBe(0); + expect(new TextEncoder().encode(JSON.stringify(old)).byteLength).toBe( + LEGACY_COLLECTIONS_BYTES, + ); + const normalized = normalizeCollections(old); + expect(new TextEncoder().encode(JSON.stringify(normalized)).byteLength).toBe( + LEGACY_COLLECTIONS_BYTES + EMPTY_CLIPS_OVERHEAD, + ); + expect(COLLECTIONS_LIMITS.serializedBytes).toBe( + LEGACY_COLLECTIONS_BYTES + EMPTY_CLIPS_OVERHEAD, + ); + await seed(old); + const before = (await readRecord("devices", identity.email))!; + const { clips, collections } = await open(), + current = await collections.read(); + expect(current.study.entries).toHaveLength(170); + expect(exportClipsFiles((await clips.read()).clips)).toEqual([]); + await expect(act(clips, create)).rejects.toThrow("collections_invalid"); + // A receipt-free no-op still increments the global revision from9 to10; + // that one extra decimal byte must not bypass the strict writer cap. + await expect(collections.update(current, (state) => state)).rejects.toThrow( + "collections_invalid", + ); + const after = (await readRecord("devices", identity.email))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); +}, 30000); diff --git a/web/tests/data/library-erase.test.ts b/web/tests/data/library-erase.test.ts new file mode 100644 index 0000000..aedc7ee --- /dev/null +++ b/web/tests/data/library-erase.test.ts @@ -0,0 +1,556 @@ +// @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, + SyncEngine, +} from "../../src/generated/wasm/orbyn_wasm.js"; +import { openNotesStore } from "../../src/data"; +import { establish, lock } from "../../src/account/session"; +import { createAccountService } from "../../src/account"; +import { createClipsStore } from "../../src/clips/store"; +import { + newWrappingKey, + wrappingSecret, + mutateRecord, + readRecord, +} from "../../src/data/database"; +import type { DeviceRecord, LocalAccessGrant } from "../../src/data/database"; +import { + openCollectionsStore, + replaceJournal, +} from "../../src/collections/store"; +import { + createJournalEnvelope, + emptyJournal, + unlockJournalEnvelope, + writeJournalEntry, +} from "../../src/collections/journal"; +const identity = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", + email: "erase@example.test", + deviceState: "trusted" as const, +}; +const zones = ["notes-local", "notes-library-v1", "notes-collections-v1"]; +const handles = new Set<{ close(): void }>(); +type Upload = { + changes: Array<{ + op: "save"; + record_id: string; + record_type: string; + ciphertext: string; + wrapped_key: string; + }>; +}; +type PutEnvelope = { + zone: string; + key_id: string; + envelope: string; + rotation?: unknown; +}; +function device(id: string, keys: DeviceKeys) { + return { + id, + name: "Synthetic erase proof", + platform: "web", + state: "trusted", + signing_key: keys.signingKey(), + agreement_key: keys.agreementKey(), + created_at: "2026-11-11T00:00:00Z", + approved_by: null, + approval_signature: null, + current: false, + }; +} +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: "synthetic-checkpoint", + })); + await mutateRecord("access", "active", () => ({ + email: identity.email, + accountId: identity.accountId, + deviceId: identity.deviceId, + signingKey: keys.signingKey(), + })); + } finally { + keys.free(); + secret.fill(0); + } + establish(identity); +}); +afterEach(() => { + for (const handle of handles) handle.close(); + handles.clear(); + vi.restoreAllMocks(); + lock(); +}); +describe("post-erase engine residue", () => { + it.each(["quota", "revision-exhausted", "foreign-owner"] as const)( + "preserves the entire queued snapshot when erase fails at %s", + async (failure) => { + const store = await openNotesStore(identity); + handles.add(store); + await store.create({ + title: "Keep on failed erase", + document: { type: "doc", content: [] }, + tags: [], + }); + const collections = await openCollectionsStore(identity); + handles.add(collections); + await collections.read(); + 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 { + if (failure !== "quota") { + const current = JSON.parse( + engine.record("notes-collections-v1", "workspace")!, + ).fields.collections.value; + if (failure === "revision-exhausted") + current.revision = Number.MAX_SAFE_INTEGER; + else { + engine.purgeLocalZones(JSON.stringify(["notes-collections-v1"])); + engine.createZoneKey("notes-collections-v1"); + } + engine.stage( + "notes-collections-v1", + failure === "foreign-owner" + ? "foreign_collection_owner" + : "notes_collections", + "workspace", + JSON.stringify({ + collections: { + value: current, + stamp: JSON.parse(engine.tick(Date.now())), + }, + }), + ); + } + for (const zone of zones) engine.buildUpload(zone); + const queued = engine.snapshot(secret); + await mutateRecord( + "devices", + identity.email, + (current) => ({ + ...current!, + snapshot: queued, + generation: current!.generation + 1, + }), + ); + } finally { + engine.free(); + keys.free(); + secret.fill(0); + } + const before = (await readRecord( + "devices", + identity.email, + ))!; + const injected = + failure === "quota" + ? vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException( + "Synthetic purge quota", + "QuotaExceededError", + ); + }) + : null; + try { + await expect(store.erase()).rejects.toThrow( + failure === "quota" + ? "purge quota" + : failure === "foreign-owner" + ? "collections_invalid" + : "collections_revision_exhausted", + ); + } finally { + injected?.mockRestore(); + } + const after = (await readRecord( + "devices", + identity.email, + ))!; + expect(after.snapshot).toEqual(before.snapshot); + expect(after.generation).toBe(before.generation); + expect((await store.list())[0]?.title).toBe("Keep on failed erase"); + }, + ); + + it("cannot recover removed Notes/history/catalog/Journal from retained queued batches and zone keys", async () => { + const store = await openNotesStore(identity); + handles.add(store); + const note = await store.create({ + title: "ERASE-HISTORY-MARKER", + document: { type: "doc", content: [] }, + tags: [], + }); + await store.update( + note.id, + { title: "ERASE-CURRENT-MARKER" }, + note.revision, + ); + const library = await store.library.read(); + if (library.mode !== "ready") throw new Error("fixture not ready"); + await store.library.createTag( + { name: "ERASE-CATALOG-MARKER", color: null }, + library.snapshot.token, + crypto.randomUUID(), + ); + const collections = await openCollectionsStore(identity); + handles.add(collections); + const previous = await collections.read(); + const passphrase = "Synthetic erase Journal phrase"; + const data = writeJournalEntry( + emptyJournal(), + "2026-11-11", + { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "ERASE-JOURNAL-MARKER" }], + }, + ], + }, + null, + new Date(), + ); + const created = await createJournalEnvelope( + passphrase, + data, + { accountId: identity.accountId, epoch: previous.epoch }, + () => {}, + ); + await replaceJournal(collections, previous, created.envelope); + const clips = createClipsStore(collections); + handles.add(clips); + await clips.commit( + await clips.prepare(await clips.read(), { + kind: "create", + url: "https://example.test/ERASE-CLIP-URI-MARKER", + title: "Actual synthetic clip", + text: "ERASE-CLIP-TEXT-MARKER", + readLater: true, + allowDuplicate: false, + }), + ); + const beforeCollections = await collections.read(); + const beforeLibrary = await store.library.read(); + if (beforeLibrary.mode !== "ready") throw new Error("fixture not ready"); + const before = (await readRecord("devices", identity.email))!, + secret = await wrappingSecret(before.wrapKey), + keys = DeviceKeys.import(before.sealedKeys, secret); + const peerId = "33333333-3333-4333-8333-333333333333", + peerKeys = DeviceKeys.create(); + const revokedId = "44444444-4444-4444-8444-444444444444", + revokedKeys = DeviceKeys.create(), + revokedDevice = device(revokedId, revokedKeys), + revokedPublic = { + signing: revokedKeys.signingKey(), + agreement: revokedKeys.agreementKey(), + }; + const devices = JSON.stringify({ + devices: [device(identity.deviceId, keys), device(peerId, peerKeys)], + }); + const uploads = new Map(), + oldKeyIds = new Map(), + preservedRecords = new Map(), + preservedKeyIds = new Map(); + let protection = "", + nextRemovalSequence = 0; + const engine = SyncEngine.restore( + keys, + before.snapshot!, + secret, + Date.now(), + ); + try { + engine.pinDevice(peerId, peerKeys.signingKey(), peerKeys.agreementKey()); + engine.pinDevice( + revokedId, + revokedPublic.signing, + revokedPublic.agreement, + ); + const removal = JSON.parse( + engine.signRemoval(JSON.stringify(revokedDevice)), + ); + engine.verifyDevices( + JSON.stringify({ + devices: [...JSON.parse(devices).devices, revokedDevice], + removals: [ + { + device_id: revokedId, + signing_key: revokedPublic.signing, + agreement_key: revokedPublic.agreement, + removed_by: identity.deviceId, + ...removal, + }, + ], + }), + ); + const signed = JSON.parse( + engine.signProtection("advanced", Math.floor(Date.now() / 1000)), + ); + engine.verifyProtection( + JSON.stringify({ + links: [ + { + level: signed.level, + seq: signed.seq, + prev: signed.prev, + signed_at: signed.signed_at, + signer_device_id: identity.deviceId, + signature: signed.device_signature, + }, + ], + more: false, + }), + devices, + ); + protection = engine.protectionLevel(); + nextRemovalSequence = JSON.parse( + engine.signRemoval(JSON.stringify(device(peerId, peerKeys))), + ).sequence; + for (const zone of ["notes-local-approval", "unrelated-fixture"]) { + engine.createZoneKey(zone); + const stamp = JSON.parse(engine.tick(Date.now())); + engine.stage( + zone, + zone === "notes-local-approval" ? "approval_checkpoint" : "sentinel", + zone === "notes-local-approval" ? "verified-checkpoint" : "sentinel", + JSON.stringify( + zone === "notes-local-approval" + ? { + approvalId: { value: "synthetic-checkpoint", stamp }, + code: { value: "123456", stamp }, + } + : { value: { value: "keep unrelated record", stamp } }, + ), + ); + engine.buildUpload(zone); + preservedRecords.set( + zone, + engine.record( + zone, + zone === "notes-local-approval" + ? "verified-checkpoint" + : "sentinel", + )!, + ); + preservedKeyIds.set( + zone, + ( + JSON.parse(engine.envelopesFor(zone, devices)) + .envelopes as PutEnvelope[] + ).map((x) => x.key_id), + ); + } + for (const zone of zones) { + const upload = engine.buildUpload(zone); + if (!upload) throw new Error("fixture lacks batch"); + uploads.set(zone, upload); + } + const queuedSnapshot = engine.snapshot(secret); + for (const zone of zones) + oldKeyIds.set( + zone, + ( + JSON.parse(engine.envelopesFor(zone, devices)) + .envelopes as PutEnvelope[] + ).map((envelope) => envelope.key_id), + ); + await mutateRecord( + "devices", + identity.email, + (current) => ({ + ...current!, + snapshot: queuedSnapshot, + generation: current!.generation + 1, + }), + ); + } finally { + engine.free(); + revokedKeys.free(); + } + await store.erase(); + expect(await store.list()).toEqual([]); + const currentCollections = await collections.read(); + expect(currentCollections.journal).toBeNull(); + expect(currentCollections.epoch).not.toBe(previous.epoch); + expect(currentCollections.revision).toBe(beforeCollections.revision + 1); + const currentLibrary = await store.library.read(); + if (currentLibrary.mode !== "ready") + throw new Error("erased library not ready"); + expect(currentLibrary.snapshot.token.epoch).not.toBe( + beforeLibrary.snapshot.token.epoch, + ); + expect(currentLibrary.snapshot.token.revision).toBe( + beforeLibrary.snapshot.token.revision + 1, + ); + const after = (await readRecord("devices", identity.email))!; + expect(after.sealedKeys).toEqual(before.sealedKeys); + expect(after.verifiedTrust).toBe(true); + expect(after.approvalCheckpointId).toBe(before.approvalCheckpointId); + expect(await readRecord("access", "active")).toMatchObject({ + accountId: identity.accountId, + deviceId: identity.deviceId, + signingKey: keys.signingKey(), + }); + const post = SyncEngine.restore(keys, after.snapshot!, secret, Date.now()), + peer = SyncEngine.create(peerKeys, identity.accountId, peerId); + let retainedBatches = 0, + retainedKeyIds = 0, + recoverableMarkers = 0; + try { + expect(JSON.parse(post.verifyDevices(devices))).toEqual([ + identity.deviceId, + peerId, + ]); + expect(post.protectionLevel()).toBe(protection); + expect( + JSON.parse(post.signRemoval(JSON.stringify(device(peerId, peerKeys)))) + .sequence, + ).toBe(nextRemovalSequence); + expect(() => + post.pinDevice( + revokedId, + revokedPublic.signing, + revokedPublic.agreement, + ), + ).toThrow(); + for (const zone of preservedRecords.keys()) { + expect( + post.record( + zone, + zone === "notes-local-approval" + ? "verified-checkpoint" + : "sentinel", + ) === preservedRecords.get(zone), + ).toBe(true); + const ids = ( + JSON.parse(post.envelopesFor(zone, devices)) + .envelopes as PutEnvelope[] + ).map((x) => x.key_id); + expect( + JSON.stringify(ids) === JSON.stringify(preservedKeyIds.get(zone)), + ).toBe(true); + } + peer.pinDevice(identity.deviceId, keys.signingKey(), keys.agreementKey()); + for (const zone of zones) { + if (post.buildUpload(zone) === uploads.get(zone)) retainedBatches++; + const envelopes = JSON.parse(post.envelopesFor(zone, devices)) + .envelopes as PutEnvelope[]; + retainedKeyIds += envelopes.filter((envelope) => + oldKeyIds.get(zone)!.includes(envelope.key_id), + ).length; + peer.acceptEnvelopes( + JSON.stringify({ + envelopes: envelopes.map((envelope) => ({ + ...envelope, + sender_device_id: identity.deviceId, + created_at: "2026-11-11T00:00:00Z", + })), + }), + devices, + ); + const upload = JSON.parse(uploads.get(zone)!) as Upload; + peer.applyChanges( + zone, + JSON.stringify({ + token: "synthetic-local-proof", + more: false, + zone_deleted: false, + records: upload.changes.map((change) => ({ + record_id: change.record_id, + record_type: change.record_type, + version: 1, + ciphertext: change.ciphertext, + wrapped_key: change.wrapped_key, + author_device_id: identity.deviceId, + modified_at: "2026-11-11T00:00:00Z", + deleted: false, + delete_proof: null, + })), + }), + Date.now(), + ); + } + const core = peer.record("notes-local", "library") ?? "", + metadata = peer.record("notes-library-v1", "workspace") ?? ""; + recoverableMarkers += + Number(core.includes("ERASE-CURRENT-MARKER")) + + Number(metadata.includes("ERASE-HISTORY-MARKER")) + + Number(metadata.includes("ERASE-CATALOG-MARKER")); + const oldCollections = peer.record("notes-collections-v1", "workspace"); + recoverableMarkers += + Number(oldCollections?.includes("ERASE-CLIP-URI-MARKER") ?? false) + + Number(oldCollections?.includes("ERASE-CLIP-TEXT-MARKER") ?? false); + if (oldCollections) { + const recovered = JSON.parse(oldCollections).fields.collections.value; + if (recovered.journal) { + const opened = await unlockJournalEnvelope( + recovered.journal, + passphrase, + { accountId: identity.accountId, epoch: recovered.epoch }, + () => {}, + ); + recoverableMarkers += Number( + JSON.stringify(opened.data).includes("ERASE-JOURNAL-MARKER"), + ); + } + } + // Only counts are asserted/output. No keys, envelopes or full snapshots leave this test. + expect({ retainedBatches, retainedKeyIds, recoverableMarkers }).toEqual({ + retainedBatches: 0, + retainedKeyIds: 0, + recoverableMarkers: 0, + }); + lock(); + const account = createAccountService({ + engineUrl: "http://127.0.0.1:9999", + }); + expect(await account.current()).toEqual(identity); + } finally { + peer.free(); + post.free(); + peerKeys.free(); + keys.free(); + secret.fill(0); + } + }); +}); From 9e5e3e3939a640ffaba0844e77eb838036254747 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 22:10:28 +1100 Subject: [PATCH 2/2] test(clips): set fixture clock before opening storage --- web/tests/data/clips.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tests/data/clips.test.ts b/web/tests/data/clips.test.ts index b6d0be4..e86e520 100644 --- a/web/tests/data/clips.test.ts +++ b/web/tests/data/clips.test.ts @@ -269,9 +269,9 @@ it("checks canonical duplicates including archived clips, requires deliberate ne expect(again.snapshot.clips.items[1]!.id).not.toBe(item.id); }); it("uses admission time for Undo after slow hashing, never resets it on replay, and refuses stale/expired undo", async () => { - const { clips } = await open(); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-11-11T10:00:00Z")); + const { clips } = await open(); const intent = await clips.prepare(await clips.read(), create); vi.setSystemTime(Date.now() + 20000); const saved = await clips.commit(intent);