# Local-First Personalization Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Give PrivaTools users an encrypted device-local password vault, named Bates counters, a shared asset library, and per-tool defaults — with a `/my-stuff` management page — without accounts, a server, or any privacy-policy change. **Architecture:** A new `src/lib/localStore/` package wraps IndexedDB for anything binary or secret (vault, assets, counters) and reuses the existing synchronous `localStorage` layer for tool defaults. Passwords are encrypted with AES-GCM under a **non-extractable** WebCrypto key that can never leave the device, and are trialled entirely client-side with `pdfjs-dist`, so the vault never increases what crosses the network. **Tech Stack:** TypeScript, React 18, Vite, Vitest + jsdom + @testing-library/react, `pdfjs-dist` (already a dependency), `fake-indexeddb` (new dev dependency, test-only). --- ## Deviation from the spec (recorded) Spec §3 places tool defaults in the IndexedDB `kv` store. **This plan keeps tool defaults in the existing synchronous `localStorage` layer instead.** Reason: `useFormPersist` hydrates state *synchronously* in a `useRef` initializer specifically so a tool never renders defaults and then flickers into restored values (`useFormPersist.ts:42-54`). IndexedDB is async-only, so moving defaults there would reintroduce exactly that flicker on all 104 tools. Counters, vault, and assets stay in IndexedDB — none of them are read during first paint. Spec §3 and §6.2 are amended in Task 0 to match. --- ## File structure | File | Responsibility | |---|---| | `src/lib/localStore/db.ts` | IndexedDB open/upgrade + get/put/del/keys/values/clear/destroy, with an in-memory fallback | | `src/lib/localStore/crypto.ts` | Non-extractable AES-GCM key lifecycle; encrypt/decrypt strings | | `src/lib/localStore/vault.ts` | Password entries: add, list, reveal, candidates-by-recency, markUsed, delete | | `src/lib/localStore/counters.ts` | Named Bates counters + active selection | | `src/lib/localStore/assets.ts` | Binary assets with per-item and total quotas | | `src/lib/localStore/defaults.ts` | Tool-defaults index (which slugs are customized) over `persistence.ts` | | `src/lib/localStore/migrate.ts` | One-way import of `ESignUI`'s raw signature key | | `src/lib/localStore/inventory.ts` | Counts/sizes for `/my-stuff`, export, erase-everything | | `src/lib/localStore/index.ts` | Public barrel | | `src/hooks/useToolDefaults.ts` | Drop-in `useFormPersist` replacement that also registers the slug | | `src/hooks/usePdfPasswordTrial.ts` | Client-side pdf.js trial against the vault | | `src/hooks/useAsset.ts` | Read/write a named asset from a tool UI | | `src/pages/MyStuffPage.tsx` | `/my-stuff` management page | --- ## Task 0: Test environment + spec amendment **Files:** - Modify: `frontend/package.json` - Modify: `frontend/src/test/setup.ts` - Modify: `docs/superpowers/specs/2026-08-21-local-first-personalization-design.md` - [ ] **Step 1: Install the test-only IndexedDB polyfill** ```bash cd frontend && npm install --save-dev fake-indexeddb ``` - [ ] **Step 2: Wire polyfills into the test setup** Append to `frontend/src/test/setup.ts`: ```ts // IndexedDB — jsdom ships none. localStore tests need a real implementation. import "fake-indexeddb/auto"; // WebCrypto — jsdom defines `crypto` without `subtle`. Bind Node's webcrypto so // the non-extractable-key path is exercised for real rather than mocked. import { webcrypto } from "node:crypto"; if (!globalThis.crypto?.subtle) { Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true, writable: true, }); } ``` - [ ] **Step 3: Verify the polyfills load** Create `frontend/src/test/env.test.ts`: ```ts import { describe, it, expect } from "vitest"; describe("test environment", () => { it("provides IndexedDB", () => { expect(typeof indexedDB).toBe("object"); expect(indexedDB).not.toBeNull(); }); it("provides WebCrypto subtle", () => { expect(globalThis.crypto.subtle).toBeDefined(); expect(typeof globalThis.crypto.subtle.generateKey).toBe("function"); }); it("can generate a non-extractable AES-GCM key", async () => { const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); expect(key.extractable).toBe(false); await expect(crypto.subtle.exportKey("raw", key)).rejects.toThrow(); }); }); ``` Run: `cd frontend && npx vitest run src/test/env.test.ts` Expected: 3 passed. - [ ] **Step 4: Amend the spec to match this plan's storage split** In `docs/superpowers/specs/2026-08-21-local-first-personalization-design.md`, in the §3 storage-schema table, change the `kv` row description from `counters, tool defaults, meta, schema version` to `counters, active-counter id, customized-slug index, schema version`. In §6.2, after the first paragraph add: ```markdown Tool defaults remain on the existing synchronous `localStorage` layer rather than IndexedDB. `useFormPersist` hydrates synchronously by design so a tool never renders defaults and then flickers into restored values; IndexedDB is async-only and would reintroduce that flicker across all 104 tools. Only the *index* of which slugs are customized lives in IndexedDB, for `/my-stuff`. ``` - [ ] **Step 5: Commit** ```bash git add frontend/package.json frontend/package-lock.json frontend/src/test/setup.ts frontend/src/test/env.test.ts docs/superpowers/specs/2026-08-21-local-first-personalization-design.md git commit -m "test: add fake-indexeddb + webcrypto polyfills for localStore Also amends the 0.5 spec: tool defaults stay on the synchronous localStorage layer to avoid a first-paint flicker on all 104 tools." ``` --- ## Task 1: IndexedDB wrapper **Files:** - Create: `frontend/src/lib/localStore/db.ts` - Test: `frontend/src/lib/localStore/db.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/db.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; beforeEach(async () => { await db.destroy(); }); describe("localStore/db", () => { it("reports availability", () => { expect(db.isAvailable()).toBe(true); }); it("round-trips a value", async () => { await db.put("kv", "greeting", { hello: "world" }); expect(await db.get<{ hello: string }>("kv", "greeting")).toEqual({ hello: "world" }); }); it("returns undefined for a missing key", async () => { expect(await db.get("kv", "nope")).toBeUndefined(); }); it("overwrites on repeated put", async () => { await db.put("kv", "n", 1); await db.put("kv", "n", 2); expect(await db.get("kv", "n")).toBe(2); }); it("deletes a key", async () => { await db.put("kv", "gone", true); await db.del("kv", "gone"); expect(await db.get("kv", "gone")).toBeUndefined(); }); it("lists keys and values for one store only", async () => { await db.put("kv", "a", 1); await db.put("kv", "b", 2); await db.put("vault", "v1", { secret: true }); expect((await db.keys("kv")).sort()).toEqual(["a", "b"]); expect((await db.values("kv")).sort()).toEqual([1, 2]); expect(await db.keys("vault")).toEqual(["v1"]); }); it("clears one store without touching another", async () => { await db.put("kv", "a", 1); await db.put("vault", "v1", 1); await db.clear("kv"); expect(await db.keys("kv")).toEqual([]); expect(await db.keys("vault")).toEqual(["v1"]); }); it("destroy removes everything", async () => { await db.put("kv", "a", 1); await db.destroy(); expect(await db.keys("kv")).toEqual([]); }); it("stores structured-cloneable values including Blob", async () => { const blob = new Blob(["hi"], { type: "text/plain" }); await db.put("assets", "b", blob); const back = await db.get("assets", "b"); expect(back).toBeInstanceOf(Blob); expect(await back!.text()).toBe("hi"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/db.test.ts` Expected: FAIL — `Failed to resolve import "./db"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/db.ts`: ```ts /** * Minimal IndexedDB wrapper for the local-first personalization store. * * Deliberately hand-rolled rather than pulling in `idb`: we need six * operations, the repo runs a bundle-size gate (`npm run check:bundle`) and an * SRI step, and a dependency for this much surface isn't worth the cost. * * Degradation: when IndexedDB is unavailable (private mode, some embedded * webviews, SSR) every operation transparently falls back to an in-memory Map * for the session. Callers never see an error — personalization simply doesn't * survive a reload, which is strictly better than a crashed tool page. */ const DB_NAME = "privatools"; const DB_VERSION = 1; export const STORES = ["secrets", "vault", "assets", "kv"] as const; export type StoreName = (typeof STORES)[number]; let dbPromise: Promise | null = null; const memory = new Map>(); function memStore(store: StoreName): Map { let m = memory.get(store); if (!m) { m = new Map(); memory.set(store, m); } return m; } export function isAvailable(): boolean { try { return typeof indexedDB !== "undefined" && indexedDB !== null; } catch { return false; } } function open(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const d = req.result; for (const s of STORES) { if (!d.objectStoreNames.contains(s)) d.createObjectStore(s); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); req.onblocked = () => reject(new Error("indexeddb blocked")); }); return dbPromise; } async function run( store: StoreName, mode: IDBTransactionMode, fn: (os: IDBObjectStore) => IDBRequest, ): Promise { const d = await open(); return new Promise((resolve, reject) => { const tx = d.transaction(store, mode); const req = fn(tx.objectStore(store)); req.onsuccess = () => resolve(req.result as T); req.onerror = () => reject(req.error); }); } export async function get(store: StoreName, key: string): Promise { if (!isAvailable()) return memStore(store).get(key) as T | undefined; try { return await run(store, "readonly", (os) => os.get(key)); } catch { return memStore(store).get(key) as T | undefined; } } export async function put(store: StoreName, key: string, value: unknown): Promise { if (!isAvailable()) { memStore(store).set(key, value); return; } try { await run(store, "readwrite", (os) => os.put(value, key)); } catch { memStore(store).set(key, value); } } export async function del(store: StoreName, key: string): Promise { memStore(store).delete(key); if (!isAvailable()) return; try { await run(store, "readwrite", (os) => os.delete(key)); } catch { /* already removed from the memory mirror */ } } export async function keys(store: StoreName): Promise { if (!isAvailable()) return [...memStore(store).keys()]; try { const k = await run(store, "readonly", (os) => os.getAllKeys()); return k.map(String); } catch { return [...memStore(store).keys()]; } } export async function values(store: StoreName): Promise { if (!isAvailable()) return [...memStore(store).values()] as T[]; try { return await run(store, "readonly", (os) => os.getAll()); } catch { return [...memStore(store).values()] as T[]; } } export async function clear(store: StoreName): Promise { memStore(store).clear(); if (!isAvailable()) return; try { await run(store, "readwrite", (os) => os.clear()); } catch { /* memory mirror already cleared */ } } /** Delete the whole database. Used by "Erase everything" and by tests. */ export async function destroy(): Promise { memory.clear(); if (!isAvailable()) return; if (dbPromise) { try { (await dbPromise).close(); } catch { /* already closed */ } dbPromise = null; } await new Promise((resolve) => { const req = indexedDB.deleteDatabase(DB_NAME); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/db.test.ts` Expected: 8 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/db.ts frontend/src/lib/localStore/db.test.ts git commit -m "feat(localStore): IndexedDB wrapper with in-memory degradation" ``` --- ## Task 2: Non-extractable crypto **Files:** - Create: `frontend/src/lib/localStore/crypto.ts` - Test: `frontend/src/lib/localStore/crypto.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/crypto.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as c from "./crypto"; beforeEach(async () => { await db.destroy(); c._resetForTests(); }); describe("localStore/crypto", () => { it("reports WebCrypto availability", () => { expect(c.hasWebCrypto()).toBe(true); }); it("generates a key that cannot be exported", async () => { const key = await c.getOrCreateKey(); expect(key.extractable).toBe(false); await expect(crypto.subtle.exportKey("raw", key)).rejects.toThrow(); }); it("reuses the same key across calls", async () => { const a = await c.getOrCreateKey(); c._resetForTests(); const b = await c.getOrCreateKey(); const enc = await c.encryptString("hello", a); expect(await c.decryptString(enc, b)).toBe("hello"); }); it("round-trips a string", async () => { const enc = await c.encryptString("hunter2"); expect(await c.decryptString(enc)).toBe("hunter2"); }); it("does not store the plaintext", async () => { const enc = await c.encryptString("hunter2"); const bytes = new Uint8Array(enc.ct); expect(new TextDecoder().decode(bytes)).not.toContain("hunter2"); }); it("uses a fresh IV per encryption", async () => { const a = await c.encryptString("same"); const b = await c.encryptString("same"); expect(Array.from(a.iv)).not.toEqual(Array.from(b.iv)); }); it("fails cleanly when decrypting with the wrong key", async () => { const enc = await c.encryptString("secret"); const other = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); await expect(c.decryptString(enc, other as CryptoKey)).rejects.toThrow(); }); it("round-trips unicode", async () => { const enc = await c.encryptString("пароль-密码-🔐"); expect(await c.decryptString(enc)).toBe("пароль-密码-🔐"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/crypto.test.ts` Expected: FAIL — `Failed to resolve import "./crypto"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/crypto.ts`: ```ts /** * Vault encryption. * * Passwords are encrypted with AES-GCM under a key generated as * NON-EXTRACTABLE (`extractable: false`). The browser will hand the page a * handle to the key and will encrypt/decrypt with it, but `exportKey` rejects — * the raw bytes can never be read out, copied to another device, or exfiltrated * by a script that dumps storage. * * What this does NOT protect against: script execution on our own origin (XSS). * A compromised page can ask the key to decrypt just as legitimate code does. * The real control there is the nonce-based CSP. `/my-stuff` says so plainly * rather than overclaiming. */ import * as db from "./db"; const KEY_ID = "vault-key"; const IV_BYTES = 12; // AES-GCM standard nonce length export interface Encrypted { iv: Uint8Array; ct: ArrayBuffer; } let cached: Promise | null = null; export function hasWebCrypto(): boolean { try { return typeof globalThis.crypto?.subtle?.generateKey === "function"; } catch { return false; } } /** Test hook — drops the in-process cache so a fresh read from IndexedDB happens. */ export function _resetForTests(): void { cached = null; } export function getOrCreateKey(): Promise { if (cached) return cached; cached = (async () => { const existing = await db.get("secrets", KEY_ID); // A structured-clone round-trip preserves `extractable: false`. if (existing && typeof (existing as CryptoKey).type === "string") return existing; const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); await db.put("secrets", KEY_ID, key); return key; })(); return cached; } export async function encryptString(plain: string, key?: CryptoKey): Promise { const k = key ?? (await getOrCreateKey()); const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, k, new TextEncoder().encode(plain), ); return { iv, ct }; } export async function decryptString(enc: Encrypted, key?: CryptoKey): Promise { const k = key ?? (await getOrCreateKey()); const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: enc.iv }, k, enc.ct); return new TextDecoder().decode(plain); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/crypto.test.ts` Expected: 8 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/crypto.ts frontend/src/lib/localStore/crypto.test.ts git commit -m "feat(localStore): AES-GCM under a non-extractable WebCrypto key" ``` --- ## Task 3: Password vault **Files:** - Create: `frontend/src/lib/localStore/vault.ts` - Test: `frontend/src/lib/localStore/vault.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/vault.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as crypt from "./crypto"; import * as vault from "./vault"; beforeEach(async () => { await db.destroy(); crypt._resetForTests(); }); describe("localStore/vault", () => { it("adds and lists an entry without exposing the password", async () => { const meta = await vault.addPassword("work docs", "hunter2"); expect(meta.label).toBe("work docs"); expect(JSON.stringify(meta)).not.toContain("hunter2"); const list = await vault.listEntries(); expect(list).toHaveLength(1); expect(JSON.stringify(list)).not.toContain("hunter2"); }); it("stores ciphertext, never plaintext, at rest", async () => { await vault.addPassword("work", "hunter2"); const raw = await db.values>("vault"); const dump = raw.map((r) => new TextDecoder().decode(new Uint8Array(r.ct as ArrayBuffer))).join(""); expect(dump).not.toContain("hunter2"); }); it("reveals the password on explicit request", async () => { const meta = await vault.addPassword("work", "hunter2"); expect(await vault.revealPassword(meta.id)).toBe("hunter2"); }); it("rejects an empty password", async () => { await expect(vault.addPassword("x", "")).rejects.toThrow(/empty/i); }); it("deduplicates by password, keeping the newer label", async () => { await vault.addPassword("old label", "same"); await vault.addPassword("new label", "same"); const list = await vault.listEntries(); expect(list).toHaveLength(1); expect(list[0].label).toBe("new label"); }); it("orders candidates most-recently-used first", async () => { const a = await vault.addPassword("a", "pw-a"); const b = await vault.addPassword("b", "pw-b"); await vault.markUsed(a.id); const cands = await vault.candidatesByRecency(); expect(cands.map((c) => c.password)).toEqual(["pw-a", "pw-b"]); expect(b.id).toBeTruthy(); }); it("bumps lastUsedAt and useCount only via markUsed", async () => { const m = await vault.addPassword("a", "pw"); expect(m.useCount).toBe(0); await vault.markUsed(m.id); const [after] = await vault.listEntries(); expect(after.useCount).toBe(1); expect(after.lastUsedAt).toBeGreaterThanOrEqual(m.lastUsedAt); }); it("deletes one entry", async () => { const a = await vault.addPassword("a", "pw-a"); await vault.addPassword("b", "pw-b"); await vault.deleteEntry(a.id); const list = await vault.listEntries(); expect(list.map((e) => e.label)).toEqual(["b"]); }); it("clears the whole vault", async () => { await vault.addPassword("a", "pw-a"); await vault.clearVault(); expect(await vault.listEntries()).toEqual([]); }); it("reports entries it cannot decrypt instead of throwing", async () => { await vault.addPassword("good", "pw"); await db.put("vault", "corrupt", { id: "corrupt", label: "broken", iv: new Uint8Array(12), ct: new Uint8Array([1, 2, 3]).buffer, createdAt: Date.now(), lastUsedAt: 0, useCount: 0, }); const cands = await vault.candidatesByRecency(); expect(cands.map((c) => c.password)).toEqual(["pw"]); expect(await vault.unreadableCount()).toBe(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/vault.test.ts` Expected: FAIL — `Failed to resolve import "./vault"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/vault.ts`: ```ts /** * Device-local password vault. * * Entries hold only ciphertext. `label` is a user-supplied nickname and is * stored in plaintext — the UI warns against putting the password in it. * * Candidate ordering is most-recently-used first, which is the best available * heuristic without fingerprinting documents. We deliberately do NOT store a * hash of the PDF to remember which password opened it: that would be a privacy * regression (a stable identifier for a user's documents) for a marginal * speed-up on a loop that runs in milliseconds. */ import * as db from "./db"; import { decryptString, encryptString, type Encrypted } from "./crypto"; export interface VaultEntry extends Encrypted { id: string; label: string; createdAt: number; lastUsedAt: number; useCount: number; } export type VaultEntryMeta = Omit; function toMeta(e: VaultEntry): VaultEntryMeta { const { iv: _iv, ct: _ct, ...meta } = e; return meta; } function newId(): string { return crypto.randomUUID(); } async function all(): Promise { return await db.values("vault"); } /** Decrypt every entry, skipping any that fail. Returns the readable ones. */ async function readable(): Promise<{ entry: VaultEntry; password: string }[]> { const out: { entry: VaultEntry; password: string }[] = []; for (const entry of await all()) { try { out.push({ entry, password: await decryptString({ iv: entry.iv, ct: entry.ct }) }); } catch { /* corrupt or key-mismatched — surfaced via unreadableCount() */ } } return out; } export async function addPassword(label: string, password: string): Promise { if (!password) throw new Error("Password cannot be empty"); // Dedupe: the same password saved twice would be tried twice for no gain. // Keep the newer label so a re-save can rename. for (const { entry, password: existing } of await readable()) { if (existing === password) { const updated: VaultEntry = { ...entry, label: label || entry.label }; await db.put("vault", updated.id, updated); return toMeta(updated); } } const enc = await encryptString(password); const entry: VaultEntry = { id: newId(), label: label || "Untitled", iv: enc.iv, ct: enc.ct, createdAt: Date.now(), lastUsedAt: 0, useCount: 0, }; await db.put("vault", entry.id, entry); return toMeta(entry); } /** Metadata only, most-recently-used first. Never returns passwords. */ export async function listEntries(): Promise { const entries = await all(); entries.sort((a, b) => b.lastUsedAt - a.lastUsedAt || b.createdAt - a.createdAt); return entries.map(toMeta); } export async function revealPassword(id: string): Promise { const entry = await db.get("vault", id); if (!entry) throw new Error("No such vault entry"); return decryptString({ iv: entry.iv, ct: entry.ct }); } /** Decrypted candidates for a trial run, most-recently-used first. */ export async function candidatesByRecency(): Promise<{ id: string; password: string }[]> { const items = await readable(); items.sort( (a, b) => b.entry.lastUsedAt - a.entry.lastUsedAt || b.entry.createdAt - a.entry.createdAt, ); return items.map(({ entry, password }) => ({ id: entry.id, password })); } export async function unreadableCount(): Promise { return (await all()).length - (await readable()).length; } export async function markUsed(id: string): Promise { const entry = await db.get("vault", id); if (!entry) return; await db.put("vault", id, { ...entry, lastUsedAt: Date.now(), useCount: entry.useCount + 1, }); } export async function deleteEntry(id: string): Promise { await db.del("vault", id); } export async function clearVault(): Promise { await db.clear("vault"); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/vault.test.ts` Expected: 10 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/vault.ts frontend/src/lib/localStore/vault.test.ts git commit -m "feat(localStore): encrypted password vault with recency-ordered candidates" ``` --- ## Task 4: Named Bates counters **Files:** - Create: `frontend/src/lib/localStore/counters.ts` - Test: `frontend/src/lib/localStore/counters.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/counters.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as counters from "./counters"; beforeEach(async () => { await db.destroy(); }); describe("localStore/counters", () => { it("creates a counter with defaults", async () => { const c = await counters.createCounter({ name: "Smith v. Acme" }); expect(c.name).toBe("Smith v. Acme"); expect(c.next).toBe(1); expect(c.digits).toBe(6); expect(c.position).toBe("bottom-right"); }); it("lists counters newest-updated first", async () => { const a = await counters.createCounter({ name: "A" }); await counters.createCounter({ name: "B" }); await counters.updateCounter(a.id, { prefix: "A-" }); expect((await counters.listCounters()).map((c) => c.name)).toEqual(["A", "B"]); }); it("advances by the number of pages stamped", async () => { const c = await counters.createCounter({ name: "M", prefix: "M-", next: 400 }); const after = await counters.advanceCounter(c.id, 12); expect(after.next).toBe(412); }); it("keeps counters independent", async () => { const a = await counters.createCounter({ name: "A", next: 10 }); const b = await counters.createCounter({ name: "B", next: 500 }); await counters.advanceCounter(a.id, 5); expect((await counters.getCounter(b.id))!.next).toBe(500); }); it("allows manual correction of next", async () => { const c = await counters.createCounter({ name: "M", next: 100 }); const after = await counters.updateCounter(c.id, { next: 250 }); expect(after.next).toBe(250); }); it("rejects a negative or zero advance", async () => { const c = await counters.createCounter({ name: "M" }); await expect(counters.advanceCounter(c.id, 0)).rejects.toThrow(/pages/i); await expect(counters.advanceCounter(c.id, -3)).rejects.toThrow(/pages/i); }); it("tracks the active counter", async () => { const c = await counters.createCounter({ name: "M" }); expect(await counters.getActiveCounterId()).toBe(c.id); const d = await counters.createCounter({ name: "N" }); await counters.setActiveCounterId(d.id); expect(await counters.getActiveCounterId()).toBe(d.id); }); it("clears the active id when the active counter is deleted", async () => { const c = await counters.createCounter({ name: "M" }); await counters.deleteCounter(c.id); expect(await counters.getActiveCounterId()).toBeNull(); }); it("formats the next label", async () => { const c = await counters.createCounter({ name: "M", prefix: "SMITH-", digits: 6, next: 412 }); expect(counters.formatNext(c)).toBe("SMITH-000412"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/counters.test.ts` Expected: FAIL — `Failed to resolve import "./counters"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/counters.ts`: ```ts /** * Named Bates counters. * * Discovery numbering is continuous *per matter* and must never be shared * across matters, so a single global counter silently corrupts numbering the * moment someone works two cases. Each counter is independent; one is active * at a time. * * `advanceCounter` is called only after a confirmed successful stamp. Gaps in a * Bates sequence are a real problem in litigation, so we never advance * optimistically. */ import * as db from "./db"; const ACTIVE_KEY = "bates:active"; const PREFIX = "bates:counter:"; export interface BatesCounter { id: string; name: string; prefix: string; digits: number; position: string; next: number; updatedAt: number; } export interface CounterInput { name: string; prefix?: string; digits?: number; position?: string; next?: number; } function key(id: string): string { return PREFIX + id; } export function formatNext(c: BatesCounter): string { return `${c.prefix}${String(c.next).padStart(c.digits, "0")}`; } export async function listCounters(): Promise { const all = await db.values("kv"); return all .filter((v): v is BatesCounter => !!v && typeof (v as BatesCounter).next === "number") .sort((a, b) => b.updatedAt - a.updatedAt); } export async function getCounter(id: string): Promise { return db.get("kv", key(id)); } export async function createCounter(input: CounterInput): Promise { const c: BatesCounter = { id: crypto.randomUUID(), name: input.name || "Untitled matter", prefix: input.prefix ?? "", digits: input.digits ?? 6, position: input.position ?? "bottom-right", next: input.next ?? 1, updatedAt: Date.now(), }; await db.put("kv", key(c.id), c); if (!(await getActiveCounterId())) await setActiveCounterId(c.id); return c; } export async function updateCounter( id: string, patch: Partial>, ): Promise { const existing = await getCounter(id); if (!existing) throw new Error("No such counter"); const updated: BatesCounter = { ...existing, ...patch, id, updatedAt: Date.now() }; await db.put("kv", key(id), updated); return updated; } /** Advance after a CONFIRMED successful stamp. Never call optimistically. */ export async function advanceCounter(id: string, pages: number): Promise { if (!Number.isFinite(pages) || pages <= 0) { throw new Error("pages must be a positive number"); } const existing = await getCounter(id); if (!existing) throw new Error("No such counter"); return updateCounter(id, { next: existing.next + Math.floor(pages) }); } export async function deleteCounter(id: string): Promise { await db.del("kv", key(id)); if ((await getActiveCounterId()) === id) await db.del("kv", ACTIVE_KEY); } export async function getActiveCounterId(): Promise { return (await db.get("kv", ACTIVE_KEY)) ?? null; } export async function setActiveCounterId(id: string): Promise { await db.put("kv", ACTIVE_KEY, id); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/counters.test.ts` Expected: 9 passed. Note: `listCounters` filters `kv` values by shape because the `kv` store also holds the active-counter id (a string) and the customized-slug index (an array). The `next` type-check discriminates counters from both. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/counters.ts frontend/src/lib/localStore/counters.test.ts git commit -m "feat(localStore): named Bates counters, advance only on success" ``` --- ## Task 5: Asset library **Files:** - Create: `frontend/src/lib/localStore/assets.ts` - Test: `frontend/src/lib/localStore/assets.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/assets.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as assets from "./assets"; const blobOf = (n: number, type = "image/png") => new Blob([new Uint8Array(n)], { type }); beforeEach(async () => { await db.destroy(); }); describe("localStore/assets", () => { it("round-trips a blob", async () => { const meta = await assets.putAsset("signature", "mine.png", blobOf(10)); expect(meta.kind).toBe("signature"); expect(meta.bytes).toBe(10); const back = await assets.getAssetBlob(meta.id); expect(back).toBeInstanceOf(Blob); expect(back!.size).toBe(10); }); it("lists all assets and filters by kind", async () => { await assets.putAsset("signature", "s.png", blobOf(4)); await assets.putAsset("logo", "l.png", blobOf(4)); expect(await assets.listAssets()).toHaveLength(2); expect((await assets.listAssets("logo")).map((a) => a.name)).toEqual(["l.png"]); }); it("rejects an asset over the per-item cap", async () => { await expect( assets.putAsset("logo", "big.png", blobOf(assets.MAX_ASSET_BYTES + 1)), ).rejects.toThrow(/too large/i); }); it("rejects a write that would exceed the total cap", async () => { const chunk = assets.MAX_ASSET_BYTES; for (let i = 0; i < 5; i++) { await assets.putAsset("logo", `l${i}.png`, blobOf(chunk)); } expect(await assets.totalAssetBytes()).toBe(assets.MAX_TOTAL_BYTES); await expect(assets.putAsset("logo", "one-more.png", blobOf(1))).rejects.toThrow( /storage is full/i, ); }); it("rejects an empty blob", async () => { await expect(assets.putAsset("logo", "empty.png", blobOf(0))).rejects.toThrow(/empty/i); }); it("deletes an asset and frees its budget", async () => { const m = await assets.putAsset("logo", "l.png", blobOf(100)); await assets.deleteAsset(m.id); expect(await assets.listAssets()).toEqual([]); expect(await assets.totalAssetBytes()).toBe(0); }); it("replaces the single asset of a singleton kind", async () => { await assets.putAsset("signature", "old.png", blobOf(10)); await assets.putAsset("signature", "new.png", blobOf(20)); const list = await assets.listAssets("signature"); expect(list).toHaveLength(1); expect(list[0].name).toBe("new.png"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/assets.test.ts` Expected: FAIL — `Failed to resolve import "./assets"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/assets.ts`: ```ts /** * Shared binary assets — signature, logo, watermark, letterhead, stamp. * * Generalizes what ESignUI already did for signatures alone (a raw * localStorage data-URL key) into a typed store the watermark, stamp, and * header-footer tools can all read from. * * Quotas are explicit and produce a clear error rather than a silent write * failure: running out of storage mid-save with no message is worse than a * refusal that says why. */ import * as db from "./db"; export type AssetKind = "signature" | "logo" | "watermark" | "letterhead" | "stamp"; /** Kinds where a second asset replaces the first rather than accumulating. */ const SINGLETON_KINDS: ReadonlySet = new Set(["signature", "letterhead"]); export const MAX_ASSET_BYTES = 5 * 1024 * 1024; export const MAX_TOTAL_BYTES = 25 * 1024 * 1024; export interface AssetMeta { id: string; kind: AssetKind; name: string; mime: string; bytes: number; createdAt: number; } interface AssetRecord extends AssetMeta { blob: Blob; } function toMeta(r: AssetRecord): AssetMeta { const { blob: _blob, ...meta } = r; return meta; } async function records(): Promise { return db.values("assets"); } export async function totalAssetBytes(): Promise { return (await records()).reduce((sum, r) => sum + r.bytes, 0); } export async function putAsset(kind: AssetKind, name: string, blob: Blob): Promise { if (blob.size === 0) throw new Error("That file is empty."); if (blob.size > MAX_ASSET_BYTES) { throw new Error( `That file is too large — the limit is ${Math.round(MAX_ASSET_BYTES / 1024 / 1024)} MB per item.`, ); } const existing = await records(); const replacing = SINGLETON_KINDS.has(kind) ? existing.filter((r) => r.kind === kind) : []; const freed = replacing.reduce((sum, r) => sum + r.bytes, 0); const used = existing.reduce((sum, r) => sum + r.bytes, 0) - freed; if (used + blob.size > MAX_TOTAL_BYTES) { throw new Error( `Your device storage is full — the limit is ${Math.round(MAX_TOTAL_BYTES / 1024 / 1024)} MB total. Delete something first.`, ); } for (const r of replacing) await db.del("assets", r.id); const record: AssetRecord = { id: crypto.randomUUID(), kind, name: name || "untitled", mime: blob.type || "application/octet-stream", bytes: blob.size, createdAt: Date.now(), blob, }; await db.put("assets", record.id, record); return toMeta(record); } export async function listAssets(kind?: AssetKind): Promise { const all = await records(); return all .filter((r) => !kind || r.kind === kind) .sort((a, b) => b.createdAt - a.createdAt) .map(toMeta); } export async function getAssetBlob(id: string): Promise { return (await db.get("assets", id))?.blob; } export async function deleteAsset(id: string): Promise { await db.del("assets", id); } export async function clearAssets(): Promise { await db.clear("assets"); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/assets.test.ts` Expected: 7 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/assets.ts frontend/src/lib/localStore/assets.test.ts git commit -m "feat(localStore): binary asset library with explicit quotas" ``` --- ## Task 6: Tool-defaults index **Files:** - Create: `frontend/src/lib/localStore/defaults.ts` - Test: `frontend/src/lib/localStore/defaults.test.ts` The values themselves stay in `persistence.ts` (synchronous, no first-paint flicker). This module tracks *which* slugs are customized so `/my-stuff` can list and clear them. - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/defaults.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as defaults from "./defaults"; import { loadPersisted, savePersisted } from "@/lib/persistence"; beforeEach(async () => { await db.destroy(); localStorage.clear(); }); describe("localStore/defaults", () => { it("starts with no customized slugs", async () => { expect(await defaults.customizedSlugs()).toEqual([]); }); it("registers a slug once", async () => { await defaults.registerCustomized("compress"); await defaults.registerCustomized("compress"); expect(await defaults.customizedSlugs()).toEqual(["compress"]); }); it("unregisters a slug", async () => { await defaults.registerCustomized("compress"); await defaults.registerCustomized("bates"); await defaults.unregisterCustomized("compress"); expect(await defaults.customizedSlugs()).toEqual(["bates"]); }); it("clearSlug removes both the value and the registration", async () => { savePersisted("compress", { level: "extreme" }); await defaults.registerCustomized("compress"); await defaults.clearSlug("compress"); expect(loadPersisted("compress")).toBeNull(); expect(await defaults.customizedSlugs()).toEqual([]); }); it("clearAll removes every registered slug's value", async () => { savePersisted("compress", { level: "extreme" }); savePersisted("bates", { prefix: "X-" }); await defaults.registerCustomized("compress"); await defaults.registerCustomized("bates"); await defaults.clearAll(); expect(loadPersisted("compress")).toBeNull(); expect(loadPersisted("bates")).toBeNull(); expect(await defaults.customizedSlugs()).toEqual([]); }); it("exports the customized values as a plain object", async () => { savePersisted("compress", { level: "extreme" }); await defaults.registerCustomized("compress"); expect(await defaults.exportDefaults()).toEqual({ compress: { level: "extreme" } }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/defaults.test.ts` Expected: FAIL — `Failed to resolve import "./defaults"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/defaults.ts`: ```ts /** * Index of which tool slugs have customized defaults. * * The values live in `lib/persistence.ts` (synchronous localStorage) so tools * hydrate without a first-paint flicker. Only the index lives here, so * `/my-stuff` can answer "which tools have I customized?" and clear them. */ import * as db from "./db"; import { clearPersisted, loadPersisted } from "@/lib/persistence"; const INDEX_KEY = "defaults:slugs"; export async function customizedSlugs(): Promise { return (await db.get("kv", INDEX_KEY)) ?? []; } export async function registerCustomized(slug: string): Promise { const slugs = await customizedSlugs(); if (slugs.includes(slug)) return; await db.put("kv", INDEX_KEY, [...slugs, slug]); } export async function unregisterCustomized(slug: string): Promise { const slugs = await customizedSlugs(); if (!slugs.includes(slug)) return; await db.put( "kv", INDEX_KEY, slugs.filter((s) => s !== slug), ); } export async function clearSlug(slug: string): Promise { clearPersisted(slug); await unregisterCustomized(slug); } export async function clearAll(): Promise { for (const slug of await customizedSlugs()) clearPersisted(slug); await db.del("kv", INDEX_KEY); } export async function exportDefaults(): Promise> { const out: Record = {}; for (const slug of await customizedSlugs()) { const value = loadPersisted(slug); if (value !== null) out[slug] = value; } return out; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/defaults.test.ts` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/localStore/defaults.ts frontend/src/lib/localStore/defaults.test.ts git commit -m "feat(localStore): index of customized tool slugs" ``` --- ## Task 7: Inventory, export, erase **Files:** - Create: `frontend/src/lib/localStore/inventory.ts` - Create: `frontend/src/lib/localStore/index.ts` - Test: `frontend/src/lib/localStore/inventory.test.ts` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/inventory.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as crypt from "./crypto"; import * as vault from "./vault"; import * as assets from "./assets"; import * as counters from "./counters"; import * as defaults from "./defaults"; import { eraseEverything, exportSetup, inventory } from "./inventory"; import { savePersisted } from "@/lib/persistence"; beforeEach(async () => { await db.destroy(); crypt._resetForTests(); localStorage.clear(); }); describe("localStore/inventory", () => { it("reports an empty store", async () => { const inv = await inventory(); expect(inv.vault.count).toBe(0); expect(inv.assets.count).toBe(0); expect(inv.assets.bytes).toBe(0); expect(inv.counters.count).toBe(0); expect(inv.defaults.count).toBe(0); }); it("counts everything stored", async () => { await vault.addPassword("a", "pw"); await assets.putAsset("logo", "l.png", new Blob([new Uint8Array(64)])); await counters.createCounter({ name: "M" }); savePersisted("compress", { level: "extreme" }); await defaults.registerCustomized("compress"); const inv = await inventory(); expect(inv.vault.count).toBe(1); expect(inv.assets.count).toBe(1); expect(inv.assets.bytes).toBe(64); expect(inv.counters.count).toBe(1); expect(inv.defaults.count).toBe(1); }); it("export excludes the vault entirely", async () => { await vault.addPassword("secret label", "hunter2"); await counters.createCounter({ name: "M", prefix: "M-" }); savePersisted("compress", { level: "extreme" }); await defaults.registerCustomized("compress"); const text = await (await exportSetup()).text(); expect(text).not.toContain("hunter2"); expect(text).not.toContain("secret label"); expect(text).toContain("M-"); expect(text).toContain("extreme"); const parsed = JSON.parse(text); expect(parsed.vault).toBeUndefined(); expect(parsed.note).toMatch(/vault/i); }); it("eraseEverything leaves nothing behind", async () => { await vault.addPassword("a", "pw"); await assets.putAsset("logo", "l.png", new Blob([new Uint8Array(8)])); await counters.createCounter({ name: "M" }); savePersisted("compress", { level: "extreme" }); await defaults.registerCustomized("compress"); localStorage.setItem("privatools_form_other", "x"); await eraseEverything(); const inv = await inventory(); expect(inv.vault.count).toBe(0); expect(inv.assets.count).toBe(0); expect(inv.counters.count).toBe(0); expect(inv.defaults.count).toBe(0); expect(localStorage.getItem("privatools_form_other")).toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/inventory.test.ts` Expected: FAIL — `Failed to resolve import "./inventory"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/inventory.ts`: ```ts /** * What's stored on this device — powers /my-stuff. * * `exportSetup` deliberately omits the vault: its key is non-extractable, so * the passwords cannot be re-imported anywhere anyway. The export says so * rather than silently producing an incomplete backup. */ import * as db from "./db"; import * as assets from "./assets"; import * as counters from "./counters"; import * as defaults from "./defaults"; import * as vault from "./vault"; const LS_PREFIX = "privatools"; export interface Inventory { vault: { count: number; unreadable: number }; assets: { count: number; bytes: number }; counters: { count: number; activeLabel: string | null }; defaults: { count: number; slugs: string[] }; available: { indexedDb: boolean; webCrypto: boolean }; } export async function inventory(): Promise { const [entries, unreadable, assetList, bytes, counterList, activeId, slugs] = await Promise.all([ vault.listEntries(), vault.unreadableCount(), assets.listAssets(), assets.totalAssetBytes(), counters.listCounters(), counters.getActiveCounterId(), defaults.customizedSlugs(), ]); const active = counterList.find((c) => c.id === activeId) ?? null; return { vault: { count: entries.length, unreadable }, assets: { count: assetList.length, bytes }, counters: { count: counterList.length, activeLabel: active ? counters.formatNext(active) : null, }, defaults: { count: slugs.length, slugs }, available: { indexedDb: db.isAvailable(), webCrypto: (await import("./crypto")).hasWebCrypto(), }, }; } export async function exportSetup(): Promise { const payload = { version: 1, exportedAt: new Date().toISOString(), note: "Your password vault is NOT included. Its encryption key is non-extractable by design and cannot leave the device it was created on.", counters: await counters.listCounters(), activeCounterId: await counters.getActiveCounterId(), assets: await assets.listAssets(), defaults: await defaults.exportDefaults(), }; return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); } /** Nuke everything this site stored on the device. */ export async function eraseEverything(): Promise { await db.destroy(); try { const doomed: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(LS_PREFIX)) doomed.push(k); } for (const k of doomed) localStorage.removeItem(k); } catch { /* storage disabled — nothing was persisted anyway */ } } ``` `frontend/src/lib/localStore/index.ts`: ```ts export * as db from "./db"; export * as vault from "./vault"; export * as assets from "./assets"; export * as counters from "./counters"; export * as toolDefaults from "./defaults"; export { inventory, exportSetup, eraseEverything, type Inventory } from "./inventory"; export { hasWebCrypto } from "./crypto"; ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/inventory.test.ts` Expected: 4 passed. - [ ] **Step 5: Run the whole suite and commit** ```bash cd frontend && npx vitest run git add frontend/src/lib/localStore/inventory.ts frontend/src/lib/localStore/index.ts frontend/src/lib/localStore/inventory.test.ts git commit -m "feat(localStore): inventory, vault-excluding export, erase-everything" ``` --- ## Task 8: Client-side password trial **Files:** - Create: `frontend/src/lib/pdfPassword.ts` - Test: `frontend/src/lib/pdfPassword.test.ts` Pure logic, no React — the hook in Task 9 wraps it. `tryPassword` is injected so tests never load the real pdf.js worker. - [ ] **Step 1: Write the failing test** `frontend/src/lib/pdfPassword.test.ts`: ```ts import { describe, it, expect, beforeEach, vi } from "vitest"; import * as db from "./localStore/db"; import * as crypt from "./localStore/crypto"; import * as vault from "./localStore/vault"; import { trialVaultPasswords } from "./pdfPassword"; beforeEach(async () => { await db.destroy(); crypt._resetForTests(); }); describe("trialVaultPasswords", () => { it("returns notNeeded when the document opens without a password", async () => { const open = vi.fn().mockResolvedValue("ok"); const res = await trialVaultPasswords(new Uint8Array([1]), open); expect(res).toEqual({ status: "notNeeded" }); expect(open).toHaveBeenCalledTimes(1); }); it("finds the matching password and reports how many were tried", async () => { await vault.addPassword("wrong", "aaa"); await vault.addPassword("right", "bbb"); const b = (await vault.listEntries()).find((e) => e.label === "right")!; const open = vi.fn(async (_data: Uint8Array, password?: string) => { if (!password) throw Object.assign(new Error("need"), { name: "PasswordException" }); if (password !== "bbb") throw Object.assign(new Error("bad"), { name: "PasswordException" }); return "ok"; }); const res = await trialVaultPasswords(new Uint8Array([1]), open); expect(res).toEqual({ status: "unlocked", password: "bbb", entryId: b.id, tried: 2 }); }); it("marks the winning entry as used", async () => { await vault.addPassword("right", "bbb"); const open = vi.fn(async (_d: Uint8Array, password?: string) => { if (password !== "bbb") throw Object.assign(new Error("x"), { name: "PasswordException" }); return "ok"; }); await trialVaultPasswords(new Uint8Array([1]), open); expect((await vault.listEntries())[0].useCount).toBe(1); }); it("reports needed when no saved password fits", async () => { await vault.addPassword("a", "aaa"); const open = vi.fn(async () => { throw Object.assign(new Error("x"), { name: "PasswordException" }); }); expect(await trialVaultPasswords(new Uint8Array([1]), open)).toEqual({ status: "needed", tried: 1, }); }); it("reports needed with tried=0 when the vault is empty", async () => { const open = vi.fn(async () => { throw Object.assign(new Error("x"), { name: "PasswordException" }); }); expect(await trialVaultPasswords(new Uint8Array([1]), open)).toEqual({ status: "needed", tried: 0, }); }); it("propagates a non-password error instead of masking it as needed", async () => { const open = vi.fn(async () => { throw Object.assign(new Error("corrupt"), { name: "InvalidPDFException" }); }); await expect(trialVaultPasswords(new Uint8Array([1]), open)).rejects.toThrow(/corrupt/); }); it("never performs a network request", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); await vault.addPassword("a", "aaa"); const open = vi.fn(async (_d: Uint8Array, password?: string) => { if (password !== "aaa") throw Object.assign(new Error("x"), { name: "PasswordException" }); return "ok"; }); await trialVaultPasswords(new Uint8Array([1]), open); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/pdfPassword.test.ts` Expected: FAIL — `Failed to resolve import "./pdfPassword"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/pdfPassword.ts`: ```ts /** * Client-side PDF password trial. * * This is the security property the whole vault design rests on: candidate * passwords are tested locally with pdf.js, so only a password that actually * works is ever sent to the server — exactly what would have crossed the wire * if the user had typed it. Wrong candidates never leave the browser. * * If anyone ever refactors this to ask the server "does this password work?", * the "never performs a network request" test must fail loudly. * * Limitation: pdf.js can only trial the USER password (the one that blocks * opening). An OWNER password merely restricts permissions, and pdf.js opens * such a file with an empty user password, so it cannot verify one. Tools that * take an owner password offer autofill from the vault instead of a trial. */ import * as vault from "./localStore/vault"; /** Opens a PDF, resolving on success and throwing PasswordException otherwise. */ export type OpenPdf = (data: Uint8Array, password?: string) => Promise; export type TrialResult = | { status: "notNeeded" } | { status: "unlocked"; password: string; entryId: string; tried: number } | { status: "needed"; tried: number }; function isPasswordError(err: unknown): boolean { const name = (err as { name?: string } | null)?.name; return name === "PasswordException"; } export async function trialVaultPasswords( data: Uint8Array, open: OpenPdf, ): Promise { try { await open(data); return { status: "notNeeded" }; } catch (err) { if (!isPasswordError(err)) throw err; } const candidates = await vault.candidatesByRecency(); let tried = 0; for (const candidate of candidates) { tried++; try { await open(data, candidate.password); await vault.markUsed(candidate.id); return { status: "unlocked", password: candidate.password, entryId: candidate.id, tried, }; } catch (err) { if (!isPasswordError(err)) throw err; } } return { status: "needed", tried }; } /** Real pdf.js opener. Lazily imported so the worker only loads when needed. */ export async function makePdfJsOpener(): Promise { const pdfjsLib = await import("pdfjs-dist"); const workerSrc = (await import("pdfjs-dist/build/pdf.worker.mjs?url")).default; pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; return async (data: Uint8Array, password?: string) => { // pdf.js transfers the buffer, so hand it a copy per attempt. const task = pdfjsLib.getDocument({ data: data.slice(), password }); const doc = await task.promise; doc.destroy(); return doc; }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/pdfPassword.test.ts` Expected: 7 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/lib/pdfPassword.ts frontend/src/lib/pdfPassword.test.ts git commit -m "feat: client-side PDF password trial against the vault Wrong candidates never leave the browser; only a working password is sent onward. Asserted by a test that fails if a network call appears." ``` --- ## Task 9: `useToolDefaults` hook **Files:** - Create: `frontend/src/hooks/useToolDefaults.ts` - Test: `frontend/src/hooks/useToolDefaults.test.tsx` Signature-identical to `useFormPersist` so per-tool adoption is a one-line change. - [ ] **Step 1: Write the failing test** `frontend/src/hooks/useToolDefaults.test.tsx`: ```tsx import { describe, it, expect, beforeEach } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import * as db from "@/lib/localStore/db"; import * as defaults from "@/lib/localStore/defaults"; import { useToolDefaults } from "./useToolDefaults"; const DEFAULTS = { level: "recommended", quality: 75 }; beforeEach(async () => { await db.destroy(); localStorage.clear(); }); describe("useToolDefaults", () => { it("starts at defaults with restored=false", () => { const { result } = renderHook(() => useToolDefaults("compress", DEFAULTS)); expect(result.current[0]).toEqual(DEFAULTS); expect(result.current[2].restored).toBe(false); }); it("registers the slug once a non-default value is written", async () => { const { result } = renderHook(() => useToolDefaults("compress", DEFAULTS)); act(() => result.current[1]({ level: "extreme", quality: 40 })); await waitFor(async () => { expect(await defaults.customizedSlugs()).toEqual(["compress"]); }); }); it("does not register when the value equals defaults", async () => { const { result } = renderHook(() => useToolDefaults("compress", DEFAULTS)); act(() => result.current[1]({ ...DEFAULTS })); await new Promise((r) => setTimeout(r, 600)); expect(await defaults.customizedSlugs()).toEqual([]); }); it("rehydrates synchronously on remount", async () => { const first = renderHook(() => useToolDefaults("compress", DEFAULTS)); act(() => first.result.current[1]({ level: "extreme", quality: 40 })); await new Promise((r) => setTimeout(r, 600)); first.unmount(); const second = renderHook(() => useToolDefaults("compress", DEFAULTS)); expect(second.result.current[0]).toEqual({ level: "extreme", quality: 40 }); expect(second.result.current[2].restored).toBe(true); }); it("reset clears the value and the registration", async () => { const { result } = renderHook(() => useToolDefaults("compress", DEFAULTS)); act(() => result.current[1]({ level: "extreme", quality: 40 })); await new Promise((r) => setTimeout(r, 600)); await act(async () => { result.current[2].reset(); }); expect(result.current[0]).toEqual(DEFAULTS); await waitFor(async () => { expect(await defaults.customizedSlugs()).toEqual([]); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/hooks/useToolDefaults.test.tsx` Expected: FAIL — `Failed to resolve import "./useToolDefaults"`. - [ ] **Step 3: Write the implementation** `frontend/src/hooks/useToolDefaults.ts`: ```ts /** * useToolDefaults — remembered per-tool settings. * * Signature-identical to `useFormPersist`, so adopting it in a tool is a * one-line change. It adds one thing: registering the slug in the localStore * index so `/my-stuff` can show "6 tools customized" and clear them. * * Values stay in synchronous localStorage on purpose — see the storage-split * note in the 0.5 design spec. Only the index is async. */ import { useCallback, useEffect, useRef } from "react"; import { useFormPersist, type UseFormPersistResult } from "./useFormPersist"; import { registerCustomized, unregisterCustomized } from "@/lib/localStore/defaults"; import { shallowEqual } from "@/lib/persistence"; export function useToolDefaults>( slug: string, defaults: T, ): [T, React.Dispatch>, UseFormPersistResult] { const [state, setState, api] = useFormPersist(slug, defaults); // Mirror the persistence layer's own rule: a value equal to defaults isn't // "customized", so it shouldn't appear in /my-stuff either. const lastRegistered = useRef(null); useEffect(() => { const customized = !shallowEqual( state as Record, defaults as Record, ); if (lastRegistered.current === customized) return; lastRegistered.current = customized; void (customized ? registerCustomized(slug) : unregisterCustomized(slug)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, slug]); const reset = useCallback(() => { api.reset(); lastRegistered.current = false; void unregisterCustomized(slug); }, [api, slug]); return [state, setState, { ...api, reset }]; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/hooks/useToolDefaults.test.tsx` Expected: 5 passed. - [ ] **Step 5: Commit** ```bash git add frontend/src/hooks/useToolDefaults.ts frontend/src/hooks/useToolDefaults.test.tsx git commit -m "feat(hooks): useToolDefaults — useFormPersist plus /my-stuff registration" ``` --- ## Task 10: ESignUI signature migration **Files:** - Create: `frontend/src/lib/localStore/migrate.ts` - Test: `frontend/src/lib/localStore/migrate.test.ts` - Modify: `frontend/src/components/tool-ui/ESignUI.tsx` - [ ] **Step 1: Write the failing test** `frontend/src/lib/localStore/migrate.test.ts`: ```ts import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./db"; import * as assets from "./assets"; import { LEGACY_SIG_KEY, migrateLegacyKeys } from "./migrate"; const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; beforeEach(async () => { await db.destroy(); localStorage.clear(); }); describe("migrateLegacyKeys", () => { it("does nothing when there is no legacy signature", async () => { await migrateLegacyKeys(); expect(await assets.listAssets("signature")).toEqual([]); }); it("moves a legacy signature into the asset store and removes the raw key", async () => { localStorage.setItem(LEGACY_SIG_KEY, PNG_DATA_URL); await migrateLegacyKeys(); const sigs = await assets.listAssets("signature"); expect(sigs).toHaveLength(1); expect(sigs[0].mime).toBe("image/png"); expect(localStorage.getItem(LEGACY_SIG_KEY)).toBeNull(); }); it("is idempotent", async () => { localStorage.setItem(LEGACY_SIG_KEY, PNG_DATA_URL); await migrateLegacyKeys(); await migrateLegacyKeys(); expect(await assets.listAssets("signature")).toHaveLength(1); }); it("ignores a malformed legacy value and clears it", async () => { localStorage.setItem(LEGACY_SIG_KEY, "not-a-data-url"); await migrateLegacyKeys(); expect(await assets.listAssets("signature")).toEqual([]); expect(localStorage.getItem(LEGACY_SIG_KEY)).toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/lib/localStore/migrate.test.ts` Expected: FAIL — `Failed to resolve import "./migrate"`. - [ ] **Step 3: Write the implementation** `frontend/src/lib/localStore/migrate.ts`: ```ts /** * One-way import of storage written before the localStore existed. * * ESignUI saved the user's signature as a raw data-URL under its own * localStorage key, bypassing the persistence layer. Move it into the typed * asset store so the signature is visible in /my-stuff and reusable by the * watermark and stamp tools, then drop the raw key. * * Safe to call on every boot: it is idempotent and cheap. */ import * as assets from "./assets"; export const LEGACY_SIG_KEY = "privatools_esign_signature"; function dataUrlToBlob(dataUrl: string): Blob | null { const match = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl); if (!match) return null; try { const [, mime, b64] = match; const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new Blob([bytes], { type: mime }); } catch { return null; } } export async function migrateLegacyKeys(): Promise { let legacy: string | null = null; try { legacy = localStorage.getItem(LEGACY_SIG_KEY); } catch { return; } if (!legacy) return; const blob = dataUrlToBlob(legacy); if (blob && blob.size > 0 && (await assets.listAssets("signature")).length === 0) { try { await assets.putAsset("signature", "signature.png", blob); } catch { /* over quota — drop the legacy key anyway rather than retrying forever */ } } try { localStorage.removeItem(LEGACY_SIG_KEY); } catch { /* storage disabled */ } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/lib/localStore/migrate.test.ts` Expected: 4 passed. - [ ] **Step 5: Confirm the legacy key name matches ESignUI** Run: `cd frontend && grep -n "SIG_STORAGE_KEY" src/components/tool-ui/ESignUI.tsx` If the constant's value differs from `privatools_esign_signature`, update `LEGACY_SIG_KEY` in `migrate.ts` to the real value and re-run the test. - [ ] **Step 6: Commit** ```bash git add frontend/src/lib/localStore/migrate.ts frontend/src/lib/localStore/migrate.test.ts git commit -m "feat(localStore): migrate ESignUI's raw signature key into the asset store" ``` --- ## Task 11: `/my-stuff` page **Files:** - Create: `frontend/src/pages/MyStuffPage.tsx` - Modify: `frontend/src/App.tsx` (add the route) - Test: `frontend/src/pages/MyStuffPage.test.tsx` - [ ] **Step 1: Write the failing test** `frontend/src/pages/MyStuffPage.test.tsx`: ```tsx import { describe, it, expect, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import * as db from "@/lib/localStore/db"; import * as crypt from "@/lib/localStore/crypto"; import * as vault from "@/lib/localStore/vault"; import * as counters from "@/lib/localStore/counters"; import MyStuffPage from "./MyStuffPage"; const renderPage = () => render( , ); beforeEach(async () => { await db.destroy(); crypt._resetForTests(); localStorage.clear(); }); describe("MyStuffPage", () => { it("shows an empty state", async () => { renderPage(); expect(await screen.findByText(/nothing stored on this device/i)).toBeInTheDocument(); }); it("lists what is stored", async () => { await vault.addPassword("work docs", "hunter2"); await counters.createCounter({ name: "Smith v. Acme", prefix: "SMITH-", next: 412 }); renderPage(); expect(await screen.findByText(/1 password/i)).toBeInTheDocument(); expect(await screen.findByText("SMITH-000412")).toBeInTheDocument(); }); it("never renders a stored password", async () => { await vault.addPassword("work docs", "hunter2"); renderPage(); await screen.findByText(/1 password/i); expect(document.body.textContent).not.toContain("hunter2"); }); it("erases everything when confirmed", async () => { await vault.addPassword("work docs", "hunter2"); renderPage(); await screen.findByText(/1 password/i); await userEvent.click(screen.getByRole("button", { name: /erase everything/i })); await userEvent.click(await screen.findByRole("button", { name: /^yes, erase/i })); await waitFor(async () => { expect(await vault.listEntries()).toEqual([]); }); }); it("states that storage is device-local", async () => { renderPage(); expect(await screen.findByText(/stored on this device only/i)).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/pages/MyStuffPage.test.tsx` Expected: FAIL — `Failed to resolve import "./MyStuffPage"`. - [ ] **Step 3: Install the user-event dependency if missing** Run: `cd frontend && npm ls @testing-library/user-event || npm install --save-dev @testing-library/user-event` - [ ] **Step 4: Write the implementation** Build `frontend/src/pages/MyStuffPage.tsx` following the layout in spec §6.3. It must: - call `inventory()` on mount and render counts for vault, assets, counters, defaults; - render `Nothing stored on this device yet.` when every count is zero; - render the heading `My Stuff` and the subtitle `Stored on this device only`; - render `{n} password{s}` for the vault, and the active counter's `formatNext(...)` label; - never render a decrypted password — reveal is a per-entry action, not part of the initial render; - offer `Export my setup` (calls `exportSetup()` and triggers a download) with a visible note that the vault is excluded; - offer `Erase everything`, which opens a confirmation with a `Yes, erase` button that calls `eraseEverything()` and refreshes the inventory; - show a warning row when `inventory().vault.unreadable > 0` offering a vault purge; - show a notice when `available.indexedDb` is false (nothing will persist) or `available.webCrypto` is false (vault unavailable); - state plainly that the vault protects against casual access, not against a compromised page — no overclaiming. Use the existing UI primitives in `@/components/ui/` and match the styling conventions of `PrivacyPage.tsx`. - [ ] **Step 5: Add the route** In `frontend/src/App.tsx`, next to the existing `/pipeline` route (line ~175), add: ```tsx )} /> ``` Import it the same lazy way sibling pages are imported in that file. - [ ] **Step 6: Mark the route noindex** In `backend/app/seo_meta.py`, add `/my-stuff` to the same set that keeps utility routes out of the index, so it is not crawled. Verify with: Run: `cd /Users/lakshya/projects/priva-tool/.claude/worktrees/dazzling-swartz-82749f && grep -n "noindex" backend/app/seo_meta.py | head` - [ ] **Step 7: Run tests** Run: `cd frontend && npx vitest run src/pages/MyStuffPage.test.tsx` Expected: 5 passed. - [ ] **Step 8: Commit** ```bash git add frontend/src/pages/MyStuffPage.tsx frontend/src/pages/MyStuffPage.test.tsx frontend/src/App.tsx backend/app/seo_meta.py git commit -m "feat: /my-stuff — see and erase everything stored on this device" ``` --- ## Task 12: Wire the vault into UnlockUI **Files:** - Create: `frontend/src/hooks/usePdfPasswordTrial.ts` - Modify: `frontend/src/components/tool-ui/UnlockUI.tsx` - Test: `frontend/src/hooks/usePdfPasswordTrial.test.tsx` - [ ] **Step 1: Write the failing test** `frontend/src/hooks/usePdfPasswordTrial.test.tsx`: ```tsx import { describe, it, expect, beforeEach, vi } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import * as db from "@/lib/localStore/db"; import * as crypt from "@/lib/localStore/crypto"; import * as vault from "@/lib/localStore/vault"; import { usePdfPasswordTrial } from "./usePdfPasswordTrial"; beforeEach(async () => { await db.destroy(); crypt._resetForTests(); }); const fileOf = (bytes = [1, 2, 3]) => new File([new Uint8Array(bytes)], "doc.pdf", { type: "application/pdf" }); describe("usePdfPasswordTrial", () => { it("starts idle", () => { const { result } = renderHook(() => usePdfPasswordTrial()); expect(result.current.state.status).toBe("idle"); }); it("reports notNeeded for an unencrypted file", async () => { const open = vi.fn().mockResolvedValue("ok"); const { result } = renderHook(() => usePdfPasswordTrial(async () => open)); await act(async () => { await result.current.run(fileOf()); }); await waitFor(() => expect(result.current.state.status).toBe("notNeeded")); }); it("surfaces a visible trying state, then unlocked", async () => { await vault.addPassword("a", "pw"); const seen: string[] = []; const open = vi.fn(async (_d: Uint8Array, password?: string) => { if (password !== "pw") throw Object.assign(new Error("x"), { name: "PasswordException" }); return "ok"; }); const { result } = renderHook(() => usePdfPasswordTrial(async () => open)); await act(async () => { const p = result.current.run(fileOf()); seen.push(result.current.state.status); await p; }); await waitFor(() => expect(result.current.state.status).toBe("unlocked")); expect(seen).toContain("trying"); if (result.current.state.status === "unlocked") { expect(result.current.state.password).toBe("pw"); } }); it("reports needed when nothing fits", async () => { await vault.addPassword("a", "nope"); const open = vi.fn(async () => { throw Object.assign(new Error("x"), { name: "PasswordException" }); }); const { result } = renderHook(() => usePdfPasswordTrial(async () => open)); await act(async () => { await result.current.run(fileOf()); }); await waitFor(() => expect(result.current.state.status).toBe("needed")); }); it("resets back to idle", async () => { const open = vi.fn().mockResolvedValue("ok"); const { result } = renderHook(() => usePdfPasswordTrial(async () => open)); await act(async () => { await result.current.run(fileOf()); }); act(() => result.current.reset()); expect(result.current.state.status).toBe("idle"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/hooks/usePdfPasswordTrial.test.tsx` Expected: FAIL — `Failed to resolve import "./usePdfPasswordTrial"`. - [ ] **Step 3: Write the implementation** `frontend/src/hooks/usePdfPasswordTrial.ts`: ```ts /** * React wrapper around trialVaultPasswords. * * The `trying` state is intentionally observable: the user always sees that * stored credentials were used, rather than passwords being applied invisibly. */ import { useCallback, useState } from "react"; import { makePdfJsOpener, trialVaultPasswords, type OpenPdf, } from "@/lib/pdfPassword"; export type TrialState = | { status: "idle" } | { status: "trying"; total: number } | { status: "notNeeded" } | { status: "unlocked"; password: string; entryId: string; tried: number } | { status: "needed"; tried: number } | { status: "error"; message: string }; export function usePdfPasswordTrial(makeOpener: () => Promise = makePdfJsOpener) { const [state, setState] = useState({ status: "idle" }); const run = useCallback( async (file: File) => { setState({ status: "trying", total: 0 }); try { const data = new Uint8Array(await file.arrayBuffer()); const open = await makeOpener(); const result = await trialVaultPasswords(data, open); setState(result as TrialState); return result; } catch (err) { const message = err instanceof Error ? err.message : "Could not read that PDF."; setState({ status: "error", message }); return { status: "error" as const, message }; } }, [makeOpener], ); const reset = useCallback(() => setState({ status: "idle" }), []); return { state, run, reset }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/hooks/usePdfPasswordTrial.test.tsx` Expected: 5 passed. - [ ] **Step 5: Wire it into UnlockUI** In `frontend/src/components/tool-ui/UnlockUI.tsx`, when a file is selected: 1. call `run(file)`; 2. while `state.status === "trying"`, render `Encrypted PDF — trying your saved passwords…`; 3. on `unlocked`, prefill the password field with `state.password` and show `Unlocked with a saved password`; 4. on `needed`, leave the field empty and show `None of your saved passwords fit — enter one below`; 5. after a successful unlock with a password the user typed, show a `Save this password?` control that calls `vault.addPassword(label, password)`; 6. on `notNeeded`, behave exactly as today. - [ ] **Step 6: Run the full suite** Run: `cd frontend && npx vitest run` Expected: all green. - [ ] **Step 7: Commit** ```bash git add frontend/src/hooks/usePdfPasswordTrial.ts frontend/src/hooks/usePdfPasswordTrial.test.tsx frontend/src/components/tool-ui/UnlockUI.tsx git commit -m "feat(unlock): try saved passwords locally before prompting" ``` --- ## Task 13: Vault autofill for ProtectUI and PermissionsUI **Files:** - Modify: `frontend/src/components/tool-ui/ProtectUI.tsx` - Modify: `frontend/src/components/tool-ui/PermissionsUI.tsx` - Create: `frontend/src/components/VaultPasswordPicker.tsx` - Test: `frontend/src/components/VaultPasswordPicker.test.tsx` These take an **owner** password, which pdf.js cannot verify, so they get autofill rather than a trial. - [ ] **Step 1: Write the failing test** `frontend/src/components/VaultPasswordPicker.test.tsx`: ```tsx import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as db from "@/lib/localStore/db"; import * as crypt from "@/lib/localStore/crypto"; import * as vault from "@/lib/localStore/vault"; import { VaultPasswordPicker } from "./VaultPasswordPicker"; beforeEach(async () => { await db.destroy(); crypt._resetForTests(); }); describe("VaultPasswordPicker", () => { it("renders nothing when the vault is empty", async () => { const { container } = render(); await new Promise((r) => setTimeout(r, 0)); expect(container).toBeEmptyDOMElement(); }); it("lists labels but never passwords", async () => { await vault.addPassword("work docs", "hunter2"); render(); expect(await screen.findByText("work docs")).toBeInTheDocument(); expect(document.body.textContent).not.toContain("hunter2"); }); it("passes the decrypted password to onPick only when chosen", async () => { await vault.addPassword("work docs", "hunter2"); const onPick = vi.fn(); render(); await userEvent.click(await screen.findByText("work docs")); expect(onPick).toHaveBeenCalledWith("hunter2"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/components/VaultPasswordPicker.test.tsx` Expected: FAIL — `Failed to resolve import "./VaultPasswordPicker"`. - [ ] **Step 3: Write the implementation** `frontend/src/components/VaultPasswordPicker.tsx`: ```tsx /** * Pick a saved password by label. * * Used by tools that take an OWNER password. pdf.js cannot verify an owner * password (it opens such files with an empty user password), so these tools * offer autofill rather than the automatic trial UnlockUI gets. The UI says so * instead of silently behaving differently. * * Renders nothing when the vault is empty — no empty-state noise on a tool page. */ import { useEffect, useState } from "react"; import * as vault from "@/lib/localStore/vault"; export function VaultPasswordPicker({ onPick }: { onPick: (password: string) => void }) { const [entries, setEntries] = useState([]); useEffect(() => { let alive = true; void vault.listEntries().then((e) => { if (alive) setEntries(e); }); return () => { alive = false; }; }, []); if (entries.length === 0) return null; return (
Use a saved password: {entries.map((e) => ( ))}
); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd frontend && npx vitest run src/components/VaultPasswordPicker.test.tsx` Expected: 3 passed. - [ ] **Step 5: Mount it in both tools** In `ProtectUI.tsx` and `PermissionsUI.tsx`, render `` directly beneath the password input, and add a "Save this password" control after a successful run that calls `vault.addPassword(label, password)`. - [ ] **Step 6: Commit** ```bash git add frontend/src/components/VaultPasswordPicker.tsx frontend/src/components/VaultPasswordPicker.test.tsx frontend/src/components/tool-ui/ProtectUI.tsx frontend/src/components/tool-ui/PermissionsUI.tsx git commit -m "feat(protect,permissions): autofill owner passwords from the vault" ``` --- ## Task 14: Named counters in BatesUI **Files:** - Modify: `frontend/src/components/tool-ui/BatesUI.tsx` - Test: `frontend/src/components/tool-ui/BatesUI.counters.test.tsx` `BatesUI` currently uses `useFormPersist("bates", BATES_DEFAULTS)` with `{prefix, startNumber, digits, position}` and documents a multi-file caveat: each PDF restarts at `start_number`. Named counters fix the cross-session half of that. - [ ] **Step 1: Write the failing test** `frontend/src/components/tool-ui/BatesUI.counters.test.tsx`: ```tsx import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import * as db from "@/lib/localStore/db"; import * as counters from "@/lib/localStore/counters"; import { BatesUI } from "./BatesUI"; beforeEach(async () => { await db.destroy(); localStorage.clear(); }); describe("BatesUI counters", () => { it("offers to create a counter when none exist", async () => { render(); expect(await screen.findByText(/new matter/i)).toBeInTheDocument(); }); it("shows the active counter's next number", async () => { await counters.createCounter({ name: "Smith v. Acme", prefix: "SMITH-", next: 412 }); render(); expect(await screen.findByText("SMITH-000412")).toBeInTheDocument(); }); it("seeds the form from the active counter", async () => { await counters.createCounter({ name: "Smith v. Acme", prefix: "SMITH-", digits: 6, next: 412, position: "top-left", }); render(); const prefix = (await screen.findByLabelText(/prefix/i)) as HTMLInputElement; expect(prefix.value).toBe("SMITH-"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd frontend && npx vitest run src/components/tool-ui/BatesUI.counters.test.tsx` Expected: FAIL — no counter UI rendered. - [ ] **Step 3: Implement** In `BatesUI.tsx`: 1. load `listCounters()` and `getActiveCounterId()` on mount; 2. render a matter selector above the settings — each counter shows `name` and `formatNext(counter)`; include a `New matter` action that calls `createCounter({ name })`; 3. when a counter is active, seed `prefix`, `digits`, `position`, and `startNumber` (from `counter.next`) into the existing `config` state, and persist edits back via `updateCounter`; 4. give the prefix input an accessible label (`