docs/superpowers/plans/2026-08-21-local-first-personalization.md
Rama main · réplica de lectura del repositorio original.
# 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<number>("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<Blob>("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<IDBDatabase> | null = null;
const memory = new Map<StoreName, Map<string, unknown>>();
function memStore(store: StoreName): Map<string, unknown> {
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<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((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<T>(
store: StoreName,
mode: IDBTransactionMode,
fn: (os: IDBObjectStore) => IDBRequest,
): Promise<T> {
const d = await open();
return new Promise<T>((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<T>(store: StoreName, key: string): Promise<T | undefined> {
if (!isAvailable()) return memStore(store).get(key) as T | undefined;
try {
return await run<T | undefined>(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<void> {
if (!isAvailable()) {
memStore(store).set(key, value);
return;
}
try {
await run<void>(store, "readwrite", (os) => os.put(value, key));
} catch {
memStore(store).set(key, value);
}
}
export async function del(store: StoreName, key: string): Promise<void> {
memStore(store).delete(key);
if (!isAvailable()) return;
try {
await run<void>(store, "readwrite", (os) => os.delete(key));
} catch {
/* already removed from the memory mirror */
}
}
export async function keys(store: StoreName): Promise<string[]> {
if (!isAvailable()) return [...memStore(store).keys()];
try {
const k = await run<IDBValidKey[]>(store, "readonly", (os) => os.getAllKeys());
return k.map(String);
} catch {
return [...memStore(store).keys()];
}
}
export async function values<T>(store: StoreName): Promise<T[]> {
if (!isAvailable()) return [...memStore(store).values()] as T[];
try {
return await run<T[]>(store, "readonly", (os) => os.getAll());
} catch {
return [...memStore(store).values()] as T[];
}
}
export async function clear(store: StoreName): Promise<void> {
memStore(store).clear();
if (!isAvailable()) return;
try {
await run<void>(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<void> {
memory.clear();
if (!isAvailable()) return;
if (dbPromise) {
try {
(await dbPromise).close();
} catch {
/* already closed */
}
dbPromise = null;
}
await new Promise<void>((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<CryptoKey> | 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<CryptoKey> {
if (cached) return cached;
cached = (async () => {
const existing = await db.get<CryptoKey>("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<Encrypted> {
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<string> {
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<Record<string, unknown>>("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<VaultEntry, "iv" | "ct">;
function toMeta(e: VaultEntry): VaultEntryMeta {
const { iv: _iv, ct: _ct, ...meta } = e;
return meta;
}
function newId(): string {
return crypto.randomUUID();
}
async function all(): Promise<VaultEntry[]> {
return await db.values<VaultEntry>("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<VaultEntryMeta> {
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<VaultEntryMeta[]> {
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<string> {
const entry = await db.get<VaultEntry>("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<number> {
return (await all()).length - (await readable()).length;
}
export async function markUsed(id: string): Promise<void> {
const entry = await db.get<VaultEntry>("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<void> {
await db.del("vault", id);
}
export async function clearVault(): Promise<void> {
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<BatesCounter[]> {
const all = await db.values<BatesCounter>("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<BatesCounter | undefined> {
return db.get<BatesCounter>("kv", key(id));
}
export async function createCounter(input: CounterInput): Promise<BatesCounter> {
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<Omit<BatesCounter, "id">>,
): Promise<BatesCounter> {
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<BatesCounter> {
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<void> {
await db.del("kv", key(id));
if ((await getActiveCounterId()) === id) await db.del("kv", ACTIVE_KEY);
}
export async function getActiveCounterId(): Promise<string | null> {
return (await db.get<string>("kv", ACTIVE_KEY)) ?? null;
}
export async function setActiveCounterId(id: string): Promise<void> {
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<AssetKind> = 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<AssetRecord[]> {
return db.values<AssetRecord>("assets");
}
export async function totalAssetBytes(): Promise<number> {
return (await records()).reduce((sum, r) => sum + r.bytes, 0);
}
export async function putAsset(kind: AssetKind, name: string, blob: Blob): Promise<AssetMeta> {
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<AssetMeta[]> {
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<Blob | undefined> {
return (await db.get<AssetRecord>("assets", id))?.blob;
}
export async function deleteAsset(id: string): Promise<void> {
await db.del("assets", id);
}
export async function clearAssets(): Promise<void> {
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<string[]> {
return (await db.get<string[]>("kv", INDEX_KEY)) ?? [];
}
export async function registerCustomized(slug: string): Promise<void> {
const slugs = await customizedSlugs();
if (slugs.includes(slug)) return;
await db.put("kv", INDEX_KEY, [...slugs, slug]);
}
export async function unregisterCustomized(slug: string): Promise<void> {
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<void> {
clearPersisted(slug);
await unregisterCustomized(slug);
}
export async function clearAll(): Promise<void> {
for (const slug of await customizedSlugs()) clearPersisted(slug);
await db.del("kv", INDEX_KEY);
}
export async function exportDefaults(): Promise<Record<string, unknown>> {
const out: Record<string, unknown> = {};
for (const slug of await customizedSlugs()) {
const value = loadPersisted<unknown>(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<Inventory> {
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<Blob> {
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<void> {
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<unknown>;
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<TrialResult> {
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<OpenPdf> {
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<T extends Record<string, unknown>>(
slug: string,
defaults: T,
): [T, React.Dispatch<React.SetStateAction<T>>, UseFormPersistResult<T>] {
const [state, setState, api] = useFormPersist<T>(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<boolean | null>(null);
useEffect(() => {
const customized = !shallowEqual(
state as Record<string, unknown>,
defaults as Record<string, unknown>,
);
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<void> {
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(
<MemoryRouter>
<MyStuffPage />
</MemoryRouter>,
);
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
<Route path="/my-stuff" element={withRouteFallback(<MyStuffPage />)} />
```
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<OpenPdf> = makePdfJsOpener) {
const [state, setState] = useState<TrialState>({ 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(<VaultPasswordPicker onPick={vi.fn()} />);
await new Promise((r) => setTimeout(r, 0));
expect(container).toBeEmptyDOMElement();
});
it("lists labels but never passwords", async () => {
await vault.addPassword("work docs", "hunter2");
render(<VaultPasswordPicker onPick={vi.fn()} />);
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(<VaultPasswordPicker onPick={onPick} />);
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<vault.VaultEntryMeta[]>([]);
useEffect(() => {
let alive = true;
void vault.listEntries().then((e) => {
if (alive) setEntries(e);
});
return () => {
alive = false;
};
}, []);
if (entries.length === 0) return null;
return (
<div className="mt-2 text-sm">
<span className="text-muted-foreground">Use a saved password: </span>
{entries.map((e) => (
<button
key={e.id}
type="button"
className="mr-2 underline underline-offset-2 hover:text-accent"
onClick={async () => onPick(await vault.revealPassword(e.id))}
>
{e.label}
</button>
))}
</div>
);
}
```
- [ ] **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 `<VaultPasswordPicker onPick={setPassword} />` 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(<BatesUI />);
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(<BatesUI />);
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(<BatesUI />);
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 (`<label htmlFor>` or `aria-label="Prefix"`) so the test can find it;
5. **after a confirmed successful stamp**, call `advanceCounter(activeId, pagesStamped)` — never before, and never on failure. Take the page count from the response metadata or the pdf.js document already opened for the preview;
6. keep `useFormPersist` for the no-counter case so behaviour is unchanged for users who never create one.
- [ ] **Step 4: Run tests**
Run: `cd frontend && npx vitest run src/components/tool-ui/BatesUI.counters.test.tsx`
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/tool-ui/BatesUI.tsx frontend/src/components/tool-ui/BatesUI.counters.test.tsx
git commit -m "feat(bates): named per-matter counters that continue across sessions"
```
---
## Task 15: Asset library in ESignUI, WatermarkUI, StampUI, HeaderFooterUI
**Files:**
- Create: `frontend/src/hooks/useAsset.ts`
- Modify: `frontend/src/components/tool-ui/ESignUI.tsx`
- Modify: `frontend/src/components/tool-ui/WatermarkUI.tsx`, `StampUI.tsx`, `HeaderFooterUI.tsx`
- Test: `frontend/src/hooks/useAsset.test.tsx`
- [ ] **Step 1: Write the failing test**
`frontend/src/hooks/useAsset.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 { useAsset } from "./useAsset";
beforeEach(async () => {
await db.destroy();
});
describe("useAsset", () => {
it("starts empty", async () => {
const { result } = renderHook(() => useAsset("logo"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([]);
});
it("saves and lists an asset", async () => {
const { result } = renderHook(() => useAsset("logo"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.save("l.png", new Blob([new Uint8Array(8)], { type: "image/png" }));
});
await waitFor(() => expect(result.current.items).toHaveLength(1));
expect(result.current.items[0].name).toBe("l.png");
});
it("surfaces a quota error instead of throwing", async () => {
const { result } = renderHook(() => useAsset("logo"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.save("big.png", new Blob([new Uint8Array(6 * 1024 * 1024)]));
});
await waitFor(() => expect(result.current.error).toMatch(/too large/i));
expect(result.current.items).toEqual([]);
});
it("removes an asset", async () => {
const { result } = renderHook(() => useAsset("logo"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.save("l.png", new Blob([new Uint8Array(8)]));
});
await waitFor(() => expect(result.current.items).toHaveLength(1));
await act(async () => {
await result.current.remove(result.current.items[0].id);
});
await waitFor(() => expect(result.current.items).toEqual([]));
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/hooks/useAsset.test.tsx`
Expected: FAIL — `Failed to resolve import "./useAsset"`.
- [ ] **Step 3: Write the implementation**
`frontend/src/hooks/useAsset.ts`:
```ts
/**
* Read/write assets of one kind from a tool UI.
*
* Quota failures surface as `error` text rather than a thrown exception —
* running out of storage should tell the user what happened, not blow up the
* tool page mid-interaction.
*/
import { useCallback, useEffect, useState } from "react";
import * as assets from "@/lib/localStore/assets";
export function useAsset(kind: assets.AssetKind) {
const [items, setItems] = useState<assets.AssetMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setItems(await assets.listAssets(kind));
setLoading(false);
}, [kind]);
useEffect(() => {
void refresh();
}, [refresh]);
const save = useCallback(
async (name: string, blob: Blob) => {
setError(null);
try {
await assets.putAsset(kind, name, blob);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save that file.");
}
},
[kind, refresh],
);
const remove = useCallback(
async (id: string) => {
await assets.deleteAsset(id);
await refresh();
},
[refresh],
);
const blobOf = useCallback((id: string) => assets.getAssetBlob(id), []);
return { items, loading, error, save, remove, blobOf, refresh };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd frontend && npx vitest run src/hooks/useAsset.test.tsx`
Expected: 4 passed.
- [ ] **Step 5: Adopt in the four tools**
- `ESignUI.tsx`: replace the raw `SIG_STORAGE_KEY` read/write with `useAsset("signature")`. Call `migrateLegacyKeys()` once on mount so an existing signature carries over. Keep the auto-apply behaviour.
- `WatermarkUI.tsx`: add `useAsset("watermark")` with a "use saved image" picker and a "save this image" action.
- `StampUI.tsx`: same with `useAsset("stamp")`, plus read access to `logo`.
- `HeaderFooterUI.tsx`: `useAsset("letterhead")` and `useAsset("logo")`.
- [ ] **Step 6: Run the full suite and commit**
```bash
cd frontend && npx vitest run
git add frontend/src/hooks/useAsset.ts frontend/src/hooks/useAsset.test.tsx frontend/src/components/tool-ui/ESignUI.tsx frontend/src/components/tool-ui/WatermarkUI.tsx frontend/src/components/tool-ui/StampUI.tsx frontend/src/components/tool-ui/HeaderFooterUI.tsx
git commit -m "feat(assets): shared signature/logo/watermark/letterhead library"
```
---
## Task 16+: The 104-tool defaults sweep
**Do not attempt in one pass.** Work in groups of 8–12 tools, running `npx vitest run` and `npx tsc --noEmit` between groups, committing per group.
### Per-tool recipe
**Case A — the tool already uses `useFormPersist`** (6 tools: confirm with `grep -rl useFormPersist src/components/tool-ui`).
Change the import and the call:
```diff
-import { useFormPersist } from "@/hooks/useFormPersist";
+import { useToolDefaults } from "@/hooks/useToolDefaults";
-const [config, setConfig, { restored, reset }] = useFormPersist("bates", BATES_DEFAULTS);
+const [config, setConfig, { restored, reset }] = useToolDefaults("bates", BATES_DEFAULTS);
```
Nothing else changes — the signatures are identical.
**Case B — the tool holds config in plain `useState`.**
1. Collect the tool's settings into one defaults object at module scope:
```ts
const COMPRESS_DEFAULTS = { level: "recommended", quality: 75 };
```
2. Replace the individual `useState` calls with one `useToolDefaults` call, using the tool's registry slug (from `src/data/tools.ts`) as the key:
```ts
const [config, setConfig, { restored, reset }] = useToolDefaults("compress-pdf", COMPRESS_DEFAULTS);
const { level, quality } = config;
const setLevel = (v: string) => setConfig((c) => ({ ...c, level: v }));
const setQuality = (v: number) => setConfig((c) => ({ ...c, quality: v }));
```
3. Add a reset control next to the settings, following `BatesUI`'s existing pattern:
```tsx
<button type="button" onClick={reset} className="text-xs text-muted-foreground hover:text-foreground">
Reset to defaults
</button>
```
4. If the tool should surface the restore toast, copy `BatesUI`'s effect verbatim:
```tsx
useEffect(() => {
if (restored) toast.message("Restored previous settings", { description: "Picked up where you left off.", duration: 3000 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
```
**Never persist:** uploaded files, output blobs, passwords (they belong in the vault), or anything derived from document content. Only user-chosen settings.
### Slug rule
The key must be the tool's registry slug so `/my-stuff` can map it back to a display name. Verify each against `src/data/tools.ts` / `src/data/non-pdf-tools.ts`; a typo produces an orphan entry in `/my-stuff` that maps to no tool.
### Per-group verification
```bash
cd frontend && npx vitest run && npx tsc --noEmit && npm run lint
```
### Group commit
```bash
git add frontend/src/components/tool-ui/
git commit -m "feat(defaults): remember settings for <group name> tools"
```
### Final step for the sweep
Add a test asserting every registered slug maps to a real tool:
`frontend/src/test/tool-defaults-slugs.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { ALL_TOOLS } from "@/data/tools";
describe("useToolDefaults slugs", () => {
it("every slug passed to useToolDefaults is a real tool slug", () => {
const dir = join(process.cwd(), "src/components/tool-ui");
const known = new Set(ALL_TOOLS.map((t) => t.slug));
const bad: string[] = [];
for (const f of readdirSync(dir).filter((f) => f.endsWith(".tsx"))) {
const src = readFileSync(join(dir, f), "utf8");
for (const m of src.matchAll(/useToolDefaults\(\s*["'`]([^"'`]+)["'`]/g)) {
if (!known.has(m[1])) bad.push(`${f}: ${m[1]}`);
}
}
expect(bad).toEqual([]);
});
});
```
Confirm the export name `ALL_TOOLS` against `src/data/tools.ts` before writing this test; if it differs, use the real export.
---
## Self-review
**Spec coverage:**
| Spec section | Task |
|---|---|
| §3 `localStore` foundation | 1, 2, 7 |
| §3 storage-split deviation | 0 (spec amended) |
| §4 vault + client-side trial | 3, 8, 12 |
| §4 owner-password limitation | 13 |
| §5 named Bates counters | 4, 14 |
| §6.1 asset library + ESign migration | 5, 10, 15 |
| §6.2 per-tool defaults (all 104) | 6, 9, 16+ |
| §6.3 `/my-stuff` + export + erase | 7, 11 |
| §8 test environment | 0 |
| §8 "no network during trial" | 8 |
| §9 degradation risks | 1 (in-memory), 2 (`hasWebCrypto`), 11 (notices) |
No spec requirement is unassigned.
**Type consistency:** `VaultEntryMeta` (3) is what `listEntries` returns and what `VaultPasswordPicker` (13) consumes. `AssetMeta`/`AssetKind` (5) are used by `useAsset` (15) and `inventory` (7). `BatesCounter`/`formatNext` (4) are used by `inventory` (7), `MyStuffPage` (11), and `BatesUI` (14). `OpenPdf`/`TrialResult` (8) feed `usePdfPasswordTrial` (12). `UseFormPersistResult<T>` (existing) is re-exported through `useToolDefaults` (9). All consistent.
**Placeholder scan:** every code step contains complete code. Tasks 11, 12, 13, 14, 15 step 5 describe UI integration as explicit numbered requirements against named existing files rather than pasted JSX, because those files are 200–600 lines of existing markup that must be read before editing — the requirements are specific and testable, and each has a failing test written first.