From af050f176c7ce38ef2a35c93253876194a89b9b5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 26 Apr 2026 15:39:19 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Plan=201C-=CE=B3=E2=82=82=20?= =?UTF-8?q?=E2=80=94=20device=20registration=20+=20trash=20+=20history=20+?= =?UTF-8?q?=20caps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 tasks, bottom-up layering: 1. WASM bindings (generate_device_keypair, get_field_history) 2. Shared types + messages 3-5. Service worker handlers (devices, trash, field history) 6-8. Popup screens (trash, devices, field-history) 9. Item detail "View history" link 10. Vault settings attachment cap 11. Popup navigation wiring 12. Setup wizard device name step 13. Manual browser testing Co-Authored-By: Claude Opus 4.5 --- ...026-04-26-relicario-extension-1c-gamma2.md | 2305 +++++++++++++++++ 1 file changed, 2305 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md diff --git a/docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md b/docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md new file mode 100644 index 0000000..3bc9b9c --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md @@ -0,0 +1,2305 @@ +# Plan 1C-γ₂ Implementation Plan — Device registration + Trash + Field history + Attachment caps + +> **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:** Add device registration during setup wizard, device management UI, trash view with restore/purge (including orphan blob cleanup), per-item field history view, and attachment-cap configuration in vault settings. + +**Architecture:** Bottom-up by layer. WASM bindings first (ed25519 keypair generation, field history extraction), then shared types, then service worker handlers, then popup screens. Setup wizard changes come last as the riskiest change. + +**Tech Stack:** TypeScript, vitest + happy-dom (popup tests), webpack, Rust core via WASM. ed25519-dalek for device keypairs (already in relicario-core). + +**Spec:** `docs/superpowers/specs/2026-04-26-relicario-extension-1c-gamma2-design.md` + +--- + +## File overview + +**WASM:** +- `crates/relicario-wasm/src/lib.rs` — add `generate_device_keypair`, `get_field_history` (Task 1) + +**Shared types & messages:** +- `extension/src/shared/types.ts` — add `Device`, `FieldHistory`, `FieldHistoryViewEntry` (Task 2) +- `extension/src/shared/messages.ts` — add 8 message types (Task 2) + +**Service worker:** +- `extension/src/service-worker/devices.ts` — NEW: device CRUD helpers (Task 3) +- `extension/src/service-worker/vault.ts` — add `listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash` (Task 4) +- `extension/src/service-worker/router/popup-only.ts` — add handlers for all 8 messages (Tasks 3, 4, 5) + +**Popup:** +- `extension/src/popup/components/trash.ts` — NEW (Task 6) +- `extension/src/popup/components/devices.ts` — NEW (Task 7) +- `extension/src/popup/components/field-history.ts` — NEW (Task 8) +- `extension/src/popup/components/types/*.ts` — add "View history" link to detail views (Task 9) +- `extension/src/popup/components/settings-vault.ts` — add attachment caps section (Task 10) +- `extension/src/popup/popup.ts` — add navigation for trash, devices, field-history (Task 11) +- `extension/src/setup/setup.ts` — add device name step (Task 12) + +Working dir: `/home/alee/Sources/relicario`. Branch: main. Direct-to-main per project convention. Do NOT push. + +--- + +## Task 1: WASM bindings — `generate_device_keypair` + `get_field_history` + +**Files:** +- Modify: `crates/relicario-wasm/Cargo.toml` +- Modify: `crates/relicario-wasm/src/lib.rs` + +- [ ] **Step 1: Add ed25519-dalek + base64 to WASM Cargo.toml** + +Edit `crates/relicario-wasm/Cargo.toml` — add after the `getrandom` line in `[dependencies]`: + +```toml +ed25519-dalek = { version = "2", features = ["rand_core"] } +base64 = "0.22" +``` + +- [ ] **Step 2: Add `generate_device_keypair` to lib.rs** + +Edit `crates/relicario-wasm/src/lib.rs` — add these imports near the top after the existing `use` statements: + +```rust +use ed25519_dalek::SigningKey; +use base64::Engine; +``` + +Then append this function after `rate_passphrase`: + +```rust +/// Generate an ed25519 keypair for device registration. +/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." } +#[wasm_bindgen] +pub fn generate_device_keypair() -> Result { + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::generate(&mut rng); + let verifying_key = signing_key.verifying_key(); + + let public_hex = hex::encode(verifying_key.as_bytes()); + let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes()); + + js_value_for(&serde_json::json!({ + "public_key_hex": public_hex, + "private_key_base64": private_b64, + })) +} +``` + +- [ ] **Step 3: Add hex crate to Cargo.toml** + +The `hex` crate is needed. Edit `crates/relicario-wasm/Cargo.toml` — add: + +```toml +hex = "0.4" +``` + +- [ ] **Step 4: Add rand crate with getrandom backend** + +Edit `crates/relicario-wasm/Cargo.toml` — add: + +```toml +rand = "0.8" +``` + +- [ ] **Step 5: Add `get_field_history` to lib.rs** + +Append after `generate_device_keypair`: + +```rust +use relicario_core::{Item, FieldKind}; + +/// Extract field history from a decrypted item JSON. +/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] } +#[wasm_bindgen] +pub fn get_field_history(item_json: &str) -> Result { + let item: Item = serde_json::from_str(item_json) + .map_err(|e| JsError::new(&format!("item json: {e}")))?; + + let mut results = Vec::new(); + + // Collect tracked fields from core + sections + let tracked_kinds = [FieldKind::Password, FieldKind::Concealed, FieldKind::Totp]; + + // Check core fields based on item type + if let Some(password) = match &item.core { + relicario_core::ItemCore::Login(c) => c.password.as_ref(), + _ => None, + } { + // Find the field_id for password in field_history + for (field_id, entries) in &item.field_history { + // Password field in LoginCore — check if history exists + if !entries.is_empty() { + results.push(serde_json::json!({ + "field_id": field_id.as_str(), + "field_name": "password", + "current_value": password, + "entries": entries.iter().map(|e| serde_json::json!({ + "value": e.value, + "changed_at": e.replaced_at, + })).collect::>(), + })); + } + } + } + + // Check section fields for tracked kinds + for section in &item.sections { + for field in §ion.fields { + if tracked_kinds.contains(&field.kind) { + if let Some(entries) = item.field_history.get(&field.id) { + if !entries.is_empty() { + let current = match &field.value { + relicario_core::FieldValue::Password(v) => v.expose_secret().to_string(), + relicario_core::FieldValue::Concealed(v) => v.expose_secret().to_string(), + _ => String::new(), + }; + results.push(serde_json::json!({ + "field_id": field.id.as_str(), + "field_name": &field.label, + "current_value": current, + "entries": entries.iter().map(|e| serde_json::json!({ + "value": e.value, + "changed_at": e.replaced_at, + })).collect::>(), + })); + } + } + } + } + } + + js_value_for(&results) +} +``` + +- [ ] **Step 6: Verify WASM build** + +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10` + +Expected: Build succeeds (warnings OK). + +- [ ] **Step 7: Rebuild WASM bundle for extension** + +Run: `cd extension && bun run build:wasm 2>&1 | tail -5` + +(If `build:wasm` doesn't exist, run: `wasm-pack build ../crates/relicario-wasm --target web --out-dir ../extension/src/wasm`) + +- [ ] **Step 8: Commit** + +```bash +cd /home/alee/Sources/relicario +git add crates/relicario-wasm/ +git commit -m "$(cat <<'EOF' +feat(wasm): add generate_device_keypair + get_field_history bindings + +generate_device_keypair returns ed25519 keypair as JSON with hex pubkey +and base64 private key. get_field_history extracts tracked field history +from a decrypted item for the popup's history view. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 2: Shared types + message types + +**Files:** +- Modify: `extension/src/shared/types.ts` +- Modify: `extension/src/shared/messages.ts` + +- [ ] **Step 1: Add Device type to types.ts** + +Edit `extension/src/shared/types.ts` — add after the `AttachmentSummary` interface (around line 145): + +```typescript +// --- Devices --- + +export interface Device { + name: string; + public_key: string; // hex-encoded ed25519 pubkey + added_at: number; // unix timestamp +} + +// --- Field history view --- + +export interface FieldHistoryViewEntry { + value: string; + changed_at: number; +} + +export interface FieldHistoryView { + field_id: string; + field_name: string; + current_value: string; + entries: FieldHistoryViewEntry[]; +} +``` + +- [ ] **Step 2: Add message types to messages.ts** + +Edit `extension/src/shared/messages.ts` — add to the `PopupMessage` union (after `download_attachment`): + +```typescript + | { type: 'list_devices' } + | { type: 'add_device'; name: string; public_key: string } + | { type: 'revoke_device'; name: string } + | { type: 'list_trashed' } + | { type: 'restore_item'; id: ItemId } + | { type: 'purge_item'; id: ItemId } + | { type: 'purge_all_trash' } + | { type: 'get_field_history'; id: ItemId }; +``` + +- [ ] **Step 3: Add to POPUP_ONLY_TYPES set** + +Edit `extension/src/shared/messages.ts` — find `POPUP_ONLY_TYPES` and add the new types: + +```typescript +export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ + 'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item', + 'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state', + 'save_setup', 'rate_passphrase', 'generate_password', 'generate_passphrase', + 'fill_credentials', + 'ack_autofill_origin', 'get_settings', 'update_settings', + 'get_vault_settings', 'update_vault_settings', 'get_blacklist', + 'remove_blacklist', 'upload_attachment', 'download_attachment', + 'list_devices', 'add_device', 'revoke_device', + 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', + 'get_field_history', +] as PopupMessage['type'][]); +``` + +- [ ] **Step 4: Add response type helpers** + +Edit `extension/src/shared/messages.ts` — add after `DownloadAttachmentResponse`: + +```typescript +export interface ListDevicesResponse extends Extract { + data: { devices: Device[] }; +} + +export interface ListTrashedResponse extends Extract { + data: { items: Array<[ItemId, ManifestEntry]> }; +} + +export interface PurgeAllTrashResponse extends Extract { + data: { itemCount: number; orphanCount: number }; +} + +export interface FieldHistoryResponse extends Extract { + data: { history: FieldHistoryView[] }; +} +``` + +- [ ] **Step 5: Add Device import to messages.ts** + +Edit `extension/src/shared/messages.ts` — update the import at the top: + +```typescript +import type { + Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, + DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device, + FieldHistoryView, +} from './types'; +``` + +- [ ] **Step 6: Verify type-check** + +Run: `cd extension && bunx tsc --noEmit 2>&1 | tail -5` + +Expected: Zero errors. + +- [ ] **Step 7: Run vitest** + +Run: `cd extension && bun run test 2>&1 | tail -3` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/shared/types.ts extension/src/shared/messages.ts +git commit -m "$(cat <<'EOF' +feat(ext/shared): add Device + FieldHistory types + 8 new message types + +Device: name, public_key (hex), added_at. +FieldHistoryView: field_id, field_name, current_value, entries[]. +Messages: list_devices, add_device, revoke_device, list_trashed, +restore_item, purge_item, purge_all_trash, get_field_history. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 3: Service worker — devices.ts + handlers + +**Files:** +- Create: `extension/src/service-worker/devices.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Create: `extension/src/service-worker/__tests__/devices.test.ts` + +- [ ] **Step 1: Create devices.ts** + +Create `extension/src/service-worker/devices.ts`: + +```typescript +/// Device management — reads/writes .relicario/devices.json + +import type { GitHost } from './git-host'; +import type { Device } from '../shared/types'; + +const DEVICES_PATH = '.relicario/devices.json'; + +interface DevicesFile { + devices: Device[]; +} + +export async function readDevices(gitHost: GitHost): Promise { + try { + const raw = await gitHost.readFile(DEVICES_PATH); + const text = new TextDecoder().decode(raw); + const parsed: DevicesFile = JSON.parse(text); + return parsed.devices ?? []; + } catch { + return []; + } +} + +export async function writeDevices( + gitHost: GitHost, + devices: Device[], + message: string, +): Promise { + const content: DevicesFile = { devices }; + const bytes = new TextEncoder().encode(JSON.stringify(content, null, 2)); + await gitHost.writeFile(DEVICES_PATH, bytes, message); +} + +export async function addDevice( + gitHost: GitHost, + device: Device, +): Promise { + const existing = await readDevices(gitHost); + if (existing.some((d) => d.name === device.name)) { + throw new Error(`device '${device.name}' already exists`); + } + existing.push(device); + await writeDevices(gitHost, existing, `device: add ${device.name}`); +} + +export async function revokeDevice( + gitHost: GitHost, + name: string, +): Promise { + const existing = await readDevices(gitHost); + const filtered = existing.filter((d) => d.name !== name); + if (filtered.length === existing.length) { + throw new Error(`device '${name}' not found`); + } + await writeDevices(gitHost, filtered, `device: revoke ${name}`); +} +``` + +- [ ] **Step 2: Add device handlers to popup-only.ts** + +Edit `extension/src/service-worker/router/popup-only.ts` — add import at top: + +```typescript +import * as devices from '../devices'; +``` + +Then add these cases to the switch statement before the default/closing brace: + +```typescript + case 'list_devices': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const list = await devices.readDevices(state.gitHost); + return { ok: true, data: { devices: list } }; + } + + case 'add_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const device = { + name: msg.name, + public_key: msg.public_key, + added_at: Math.floor(Date.now() / 1000), + }; + await devices.addDevice(state.gitHost, device); + return { ok: true }; + } + + case 'revoke_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + await devices.revokeDevice(state.gitHost, msg.name); + return { ok: true }; + } +``` + +- [ ] **Step 3: Create devices.test.ts** + +Create `extension/src/service-worker/__tests__/devices.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readDevices, addDevice, revokeDevice } from '../devices'; +import type { GitHost } from '../git-host'; + +function makeGitHost(devicesJson = '{"devices":[]}'): GitHost { + let stored = devicesJson; + return { + readFile: vi.fn().mockImplementation(async () => new TextEncoder().encode(stored)), + writeFile: vi.fn().mockImplementation(async (_p, bytes) => { stored = new TextDecoder().decode(bytes); }), + deleteFile: vi.fn(), + listDir: vi.fn(), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }; +} + +describe('devices', () => { + it('readDevices returns empty array when file missing', async () => { + const host = makeGitHost(); + (host.readFile as ReturnType).mockRejectedValueOnce(new Error('404')); + const result = await readDevices(host); + expect(result).toEqual([]); + }); + + it('readDevices parses existing devices', async () => { + const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"abc123","added_at":1000}]}'); + const result = await readDevices(host); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('CLI'); + }); + + it('addDevice appends to list', async () => { + const host = makeGitHost(); + await addDevice(host, { name: 'Chrome', public_key: 'def456', added_at: 2000 }); + expect(host.writeFile).toHaveBeenCalled(); + const written = (host.writeFile as ReturnType).mock.calls[0][1]; + const parsed = JSON.parse(new TextDecoder().decode(written)); + expect(parsed.devices).toHaveLength(1); + expect(parsed.devices[0].name).toBe('Chrome'); + }); + + it('addDevice rejects duplicate name', async () => { + const host = makeGitHost('{"devices":[{"name":"Chrome","public_key":"abc","added_at":1000}]}'); + await expect(addDevice(host, { name: 'Chrome', public_key: 'xyz', added_at: 2000 })) + .rejects.toThrow(/already exists/); + }); + + it('revokeDevice removes by name', async () => { + const host = makeGitHost('{"devices":[{"name":"CLI","public_key":"a","added_at":1},{"name":"Chrome","public_key":"b","added_at":2}]}'); + await revokeDevice(host, 'CLI'); + const written = (host.writeFile as ReturnType).mock.calls[0][1]; + const parsed = JSON.parse(new TextDecoder().decode(written)); + expect(parsed.devices).toHaveLength(1); + expect(parsed.devices[0].name).toBe('Chrome'); + }); + + it('revokeDevice throws if not found', async () => { + const host = makeGitHost(); + await expect(revokeDevice(host, 'nonexistent')).rejects.toThrow(/not found/); + }); +}); +``` + +- [ ] **Step 4: Run device tests** + +Run: `cd extension && bun run test src/service-worker/__tests__/devices.test.ts 2>&1 | tail -5` + +Expected: 6 tests pass. + +- [ ] **Step 5: Verify type-check** + +Run: `cd extension && bunx tsc --noEmit 2>&1 | tail -3` + +Expected: Zero errors. + +- [ ] **Step 6: Run all tests** + +Run: `cd extension && bun run test 2>&1 | tail -3` + +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/devices.ts \ + extension/src/service-worker/__tests__/devices.test.ts \ + extension/src/service-worker/router/popup-only.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): device management — devices.ts + router handlers + +Adds readDevices, addDevice, revokeDevice helpers that read/write +.relicario/devices.json. Router handlers: list_devices, add_device, +revoke_device. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 4: Service worker — trash helpers + handlers + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Create: `extension/src/service-worker/__tests__/trash.test.ts` + +- [ ] **Step 1: Add trash helpers to vault.ts** + +Edit `extension/src/service-worker/vault.ts` — add these functions after the existing exports: + +```typescript +// --- Trash operations --- + +export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> { + return Object.entries(manifest.items) + .filter(([, entry]) => entry.trashed_at != null) + .sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0)); +} + +export async function restoreItem( + git: GitHost, + handle: SessionHandle, + manifest: Manifest, + itemId: ItemId, +): Promise { + const item = await fetchAndDecryptItem(git, handle, itemId); + const now = Math.floor(Date.now() / 1000); + const restored: Item = { ...item, trashed_at: undefined, modified: now }; + await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`); + manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now }; + await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`); +} + +export async function purgeItem( + git: GitHost, + itemId: ItemId, + manifest: Manifest, +): Promise { + const entry = manifest.items[itemId]; + const deletedBlobs: string[] = []; + + // Delete attachments + for (const att of entry?.attachment_summaries ?? []) { + try { + await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`); + deletedBlobs.push(att.id); + } catch { /* blob may not exist */ } + } + + // Delete item file + await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`); + + // Update manifest + delete manifest.items[itemId]; + + return deletedBlobs; +} + +export async function purgeAllTrash( + git: GitHost, + handle: SessionHandle, + manifest: Manifest, +): Promise<{ itemCount: number; orphanCount: number }> { + const trashed = listTrashed(manifest); + const allDeletedBlobs = new Set(); + + // Purge each trashed item + for (const [id] of trashed) { + const deleted = await purgeItem(git, id, manifest); + deleted.forEach((b) => allDeletedBlobs.add(b)); + } + + // Collect all referenced attachment IDs from remaining items + const referenced = new Set(); + for (const entry of Object.values(manifest.items)) { + for (const att of entry.attachment_summaries ?? []) { + referenced.add(att.id); + } + } + + // Scan for orphan blobs + let orphanCount = 0; + try { + const blobFiles = await git.listDir('attachments'); + for (const filename of blobFiles) { + const id = filename.replace(/\.bin$/, ''); + if (!referenced.has(id) && !allDeletedBlobs.has(id)) { + try { + await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`); + orphanCount++; + } catch { /* ignore */ } + } + } + } catch { /* attachments dir may not exist */ } + + // Write manifest once + if (trashed.length > 0 || orphanCount > 0) { + await encryptAndWriteManifest( + git, handle, manifest, + `trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`, + ); + } + + return { itemCount: trashed.length, orphanCount }; +} +``` + +- [ ] **Step 2: Update vault.ts imports** + +Edit `extension/src/service-worker/vault.ts` — ensure `Item` is imported: + +```typescript +import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; +``` + +- [ ] **Step 3: Add trash handlers to popup-only.ts** + +Edit `extension/src/service-worker/router/popup-only.ts` — add these cases: + +```typescript + case 'list_trashed': { + if (!state.manifest) return { ok: false, error: 'vault_locked' }; + const items = vault.listTrashed(state.manifest); + return { ok: true, data: { items } }; + } + + case 'restore_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id); + return { ok: true }; + } + + case 'purge_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.purgeItem(state.gitHost, msg.id, state.manifest); + await vault.encryptAndWriteManifest( + state.gitHost, handle, state.manifest, + `manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`, + ); + return { ok: true }; + } + + case 'purge_all_trash': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest); + return { ok: true, data: result }; + } +``` + +- [ ] **Step 4: Create trash.test.ts** + +Create `extension/src/service-worker/__tests__/trash.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { listTrashed } from '../vault'; +import type { Manifest } from '../../shared/types'; + +function makeManifest(items: Record): Manifest { + const manifest: Manifest = { schema_version: 2, items: {} }; + for (const [id, { trashed_at }] of Object.entries(items)) { + manifest.items[id] = { + id, + type: 'login', + title: `Item ${id}`, + tags: [], + favorite: false, + modified: 1000, + trashed_at, + attachment_summaries: [], + }; + } + return manifest; +} + +describe('listTrashed', () => { + it('returns empty array when no trashed items', () => { + const manifest = makeManifest({ a: {}, b: {} }); + expect(listTrashed(manifest)).toEqual([]); + }); + + it('filters to only trashed items', () => { + const manifest = makeManifest({ + a: {}, + b: { trashed_at: 1000 }, + c: { trashed_at: 2000 }, + }); + const result = listTrashed(manifest); + expect(result).toHaveLength(2); + expect(result.map(([id]) => id)).toEqual(['c', 'b']); // sorted newest first + }); + + it('sorts by trashed_at descending', () => { + const manifest = makeManifest({ + old: { trashed_at: 100 }, + mid: { trashed_at: 500 }, + new: { trashed_at: 900 }, + }); + const result = listTrashed(manifest); + expect(result.map(([id]) => id)).toEqual(['new', 'mid', 'old']); + }); +}); +``` + +- [ ] **Step 5: Run trash tests** + +Run: `cd extension && bun run test src/service-worker/__tests__/trash.test.ts 2>&1 | tail -5` + +Expected: 3 tests pass. + +- [ ] **Step 6: Verify type-check + run all tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/vault.ts \ + extension/src/service-worker/__tests__/trash.test.ts \ + extension/src/service-worker/router/popup-only.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): trash operations — listTrashed, restoreItem, purgeItem, purgeAllTrash + +listTrashed filters manifest for trashed_at != null, sorted newest-first. +restoreItem clears trashed_at. purgeItem deletes item + attachments. +purgeAllTrash also scans for orphan blobs in attachments/ directory. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 5: Service worker — field history handler + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts` + +- [ ] **Step 1: Add get_field_history handler** + +Edit `extension/src/service-worker/router/popup-only.ts` — add this case: + +```typescript + case 'get_field_history': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + const history = state.wasm.get_field_history(JSON.stringify(item)); + return { ok: true, data: { history } }; + } +``` + +- [ ] **Step 2: Verify type-check + run tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 3: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/service-worker/router/popup-only.ts +git commit -m "$(cat <<'EOF' +feat(ext/sw): get_field_history handler + +Decrypts item and calls WASM get_field_history to extract tracked +field history for the popup's history view. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 6: Popup — trash screen + +**Files:** +- Create: `extension/src/popup/components/trash.ts` +- Modify: `extension/src/popup/styles.css` +- Create: `extension/src/popup/components/__tests__/trash.test.ts` + +- [ ] **Step 1: Create trash.ts** + +Create `extension/src/popup/components/trash.ts`: + +```typescript +/// Trash view — lists soft-deleted items with restore/purge actions. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types'; + +const TYPE_ICONS: Record = { + login: '🔑', secure_note: '📝', identity: '👤', card: '💳', + key: '🔐', document: '📄', totp: '⏱️', +}; + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function daysUntilPurge(trashedAt: number, retention: VaultSettings['trash_retention']): number | null { + if (retention.kind === 'forever') return null; + const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400); + return Math.max(0, retention.value - trashedDaysAgo); +} + +export function teardown(): void { + // No cleanup needed +} + +export async function renderTrash(app: HTMLElement): Promise { + const state = getState(); + + // Fetch trashed items + const resp = await sendMessage({ type: 'list_trashed' }); + if (!resp.ok) { + app.innerHTML = `

Failed to load trash

`; + return; + } + + const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items; + const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 }; + + // Calculate days until oldest auto-purges + let oldestPurgeDays: number | null = null; + if (items.length > 0 && retention.kind === 'days') { + const oldest = items[items.length - 1][1]; + oldestPurgeDays = daysUntilPurge(oldest.trashed_at ?? 0, retention); + } + + const headerInfo = items.length === 0 + ? '' + : oldestPurgeDays !== null + ? `${items.length} item${items.length === 1 ? '' : 's'} · oldest auto-purges in ${oldestPurgeDays}d` + : `${items.length} item${items.length === 1 ? '' : 's'}`; + + app.innerHTML = ` +
+
+ +

trash

+
+ ${headerInfo ? `

${escapeHtml(headerInfo)}

` : ''} + ${items.length === 0 + ? `

Trash is empty

` + : items.map(([id, entry]) => ` +
+ ${TYPE_ICONS[entry.type] ?? '📦'} +
+ ${escapeHtml(entry.title)} + trashed ${relativeTime(entry.trashed_at ?? 0)} +
+ +
+ `).join('')} + ${items.length > 0 ? ` +
+ +
+ ` : ''} +
+ `; + + // Wire handlers + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + + document.querySelectorAll('[data-restore]').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.restore; + if (!id) return; + btn.disabled = true; + btn.textContent = '...'; + const result = await sendMessage({ type: 'restore_item', id }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); + }); + + document.getElementById('empty-trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) { + return; + } + const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'deleting...'; + const result = await sendMessage({ type: 'purge_all_trash' }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); +} +``` + +- [ ] **Step 2: Add trash styles to styles.css** + +Edit `extension/src/popup/styles.css` — add at the end: + +```css +/* --- Trash view --- */ + +.trash-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.trash-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 4px; + background: #161b22; + margin-bottom: 6px; +} + +.trash-row__icon { + font-size: 16px; + flex-shrink: 0; +} + +.trash-row__info { + flex: 1; + min-width: 0; +} + +.trash-row__title { + display: block; + font-size: 13px; + color: #c9d1d9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trash-row__meta { + font-size: 11px; + color: #8b949e; +} + +.trash-row__restore { + font-size: 11px; + padding: 4px 8px; + background: #238636; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.trash-row__restore:hover { + background: #2ea043; +} + +.trash-row__restore:disabled { + opacity: 0.5; + cursor: default; +} +``` + +- [ ] **Step 3: Create trash.test.ts** + +Create `extension/src/popup/components/__tests__/trash.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderTrash } from '../trash'; + +// Mock popup module +vi.mock('../../popup', () => ({ + getState: vi.fn(() => ({ + vaultSettings: { trash_retention: { kind: 'days', value: 30 } }, + })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, +})); + +import { sendMessage, navigate } from '../../popup'; + +describe('trash view', () => { + let app: HTMLElement; + + beforeEach(() => { + app = document.createElement('div'); + vi.clearAllMocks(); + }); + + it('renders empty state when no trashed items', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { items: [] }, + }); + + await renderTrash(app); + + expect(app.innerHTML).toContain('Trash is empty'); + expect(app.querySelector('#empty-trash-btn')).toBeNull(); + }); + + it('renders trashed items with restore buttons', async () => { + const now = Math.floor(Date.now() / 1000); + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + items: [ + ['id1', { id: 'id1', type: 'login', title: 'Test Login', trashed_at: now - 3600, tags: [], favorite: false, modified: now, attachment_summaries: [] }], + ], + }, + }); + + await renderTrash(app); + + expect(app.innerHTML).toContain('Test Login'); + expect(app.innerHTML).toContain('restore'); + expect(app.querySelector('#empty-trash-btn')).not.toBeNull(); + }); + + it('back button navigates to list', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { items: [] }, + }); + + await renderTrash(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('list'); + }); +}); +``` + +- [ ] **Step 4: Run trash tests** + +Run: `cd extension && bun run test src/popup/components/__tests__/trash.test.ts 2>&1 | tail -5` + +Expected: 3 tests pass. + +- [ ] **Step 5: Verify type-check + run all tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/trash.ts \ + extension/src/popup/components/__tests__/trash.test.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): trash view — list trashed items with restore/purge + +Shows trashed items sorted newest-first with restore buttons. +Empty trash button purges all items + orphan blobs. Header shows +count and days until oldest auto-purges. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 7: Popup — devices screen + +**Files:** +- Create: `extension/src/popup/components/devices.ts` +- Modify: `extension/src/popup/styles.css` +- Create: `extension/src/popup/components/__tests__/devices.test.ts` + +- [ ] **Step 1: Create devices.ts** + +Create `extension/src/popup/components/devices.ts`: + +```typescript +/// Device management view — list devices with revoke actions. + +import { setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { Device } from '../../shared/types'; + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`; + return `${Math.floor(diff / 2592000)}mo ago`; +} + +export function teardown(): void { + // No cleanup needed +} + +export async function renderDevices(app: HTMLElement): Promise { + // Get current device name from local storage + const stored = await chrome.storage.local.get(['device_name']); + const currentDeviceName: string | undefined = stored.device_name; + + // Fetch device list + const resp = await sendMessage({ type: 'list_devices' }); + if (!resp.ok) { + app.innerHTML = `

Failed to load devices

`; + return; + } + + const devices = (resp.data as { devices: Device[] }).devices; + const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName); + + app.innerHTML = ` +
+
+ +

devices

+
+ ${!isRegistered ? ` +
+ ⚠ This device is not registered + +
+ ` : ''} + ${devices.length === 0 + ? `

No devices registered

` + : devices.map((d) => { + const isCurrentDevice = d.name === currentDeviceName; + return ` +
+
+ ${escapeHtml(d.name)}${isCurrentDevice ? ' ← you' : ''} + added ${relativeTime(d.added_at)} +
+ ${isCurrentDevice ? '' : ``} +
+ `; + }).join('')} +
+ `; + + // Wire handlers + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + + document.getElementById('register-btn')?.addEventListener('click', async () => { + // Generate keypair and register + // This would need WASM access - for now, redirect to a registration flow + // The full implementation happens in Task 12 (setup wizard integration) + setState({ error: 'Device registration from here is not yet implemented. Use setup wizard.' }); + }); + + document.querySelectorAll('[data-revoke]').forEach((btn) => { + btn.addEventListener('click', async () => { + const name = btn.dataset.revoke; + if (!name) return; + if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return; + + btn.disabled = true; + btn.textContent = '...'; + const result = await sendMessage({ type: 'revoke_device', name }); + if (result.ok) { + renderDevices(app); + } else { + setState({ error: result.error }); + } + }); + }); +} +``` + +- [ ] **Step 2: Add device styles to styles.css** + +Edit `extension/src/popup/styles.css` — add at the end: + +```css +/* --- Devices view --- */ + +.devices-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.device-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px; + background: #3d1f00; + border: 1px solid #9e6a03; + border-radius: 4px; + margin-bottom: 12px; + font-size: 12px; + color: #f0c674; +} + +.device-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + border-radius: 4px; + background: #161b22; + margin-bottom: 6px; +} + +.device-row__info { + flex: 1; + min-width: 0; +} + +.device-row__name { + display: block; + font-size: 13px; + color: #c9d1d9; +} + +.device-row__you { + font-size: 11px; + color: #58a6ff; +} + +.device-row__meta { + font-size: 11px; + color: #8b949e; +} + +.device-row__revoke { + font-size: 11px; + padding: 4px 8px; + background: #da3633; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.device-row__revoke:hover { + background: #f85149; +} + +.device-row__revoke:disabled { + opacity: 0.5; + cursor: default; +} +``` + +- [ ] **Step 3: Create devices.test.ts** + +Create `extension/src/popup/components/__tests__/devices.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderDevices } from '../devices'; + +// Mock chrome.storage.local +// @ts-expect-error test harness +globalThis.chrome = { + storage: { + local: { + get: vi.fn().mockResolvedValue({ device_name: 'Chrome on Linux' }), + }, + }, +}; + +// Mock popup module +vi.mock('../../popup', () => ({ + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, +})); + +import { sendMessage, navigate } from '../../popup'; + +describe('devices view', () => { + let app: HTMLElement; + + beforeEach(() => { + app = document.createElement('div'); + vi.clearAllMocks(); + }); + + it('renders empty state when no devices', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { devices: [] }, + }); + + await renderDevices(app); + + expect(app.innerHTML).toContain('No devices registered'); + }); + + it('renders devices with "you" indicator on current device', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + devices: [ + { name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 }, + { name: 'CLI', public_key: 'def', added_at: 500 }, + ], + }, + }); + + await renderDevices(app); + + expect(app.innerHTML).toContain('Chrome on Linux'); + expect(app.innerHTML).toContain('← you'); + expect(app.innerHTML).toContain('CLI'); + // Current device should not have revoke button + const rows = app.querySelectorAll('.device-row'); + expect(rows[0].querySelector('[data-revoke]')).toBeNull(); + expect(rows[1].querySelector('[data-revoke]')).not.toBeNull(); + }); + + it('shows unregistered banner when current device not in list', async () => { + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }], + }, + }); + + await renderDevices(app); + + expect(app.innerHTML).toContain('This device is not registered'); + }); + + it('back button navigates to list', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { devices: [] }, + }); + + await renderDevices(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('list'); + }); +}); +``` + +- [ ] **Step 4: Run devices tests** + +Run: `cd extension && bun run test src/popup/components/__tests__/devices.test.ts 2>&1 | tail -5` + +Expected: 4 tests pass. + +- [ ] **Step 5: Verify type-check + run all tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/devices.ts \ + extension/src/popup/components/__tests__/devices.test.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): devices view — list devices with revoke actions + +Shows registered devices with "← you" indicator on current device. +Revoke button on other devices. Unregistered banner if current +device not in list. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 8: Popup — field history screen + +**Files:** +- Create: `extension/src/popup/components/field-history.ts` +- Modify: `extension/src/popup/styles.css` +- Create: `extension/src/popup/components/__tests__/field-history.test.ts` + +- [ ] **Step 1: Create field-history.ts** + +Create `extension/src/popup/components/field-history.ts`: + +```typescript +/// Field history view — shows password/concealed field history for an item. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { FieldHistoryView } from '../../shared/types'; + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`; + return `${Math.floor(diff / 2592000)}mo ago`; +} + +const revealedSet = new Set(); + +export function teardown(): void { + revealedSet.clear(); +} + +export async function renderFieldHistory(app: HTMLElement): Promise { + const state = getState(); + const itemId = state.historyItemId; + const item = state.selectedItem; + + if (!itemId || !item) { + navigate('list'); + return; + } + + // Fetch field history + const resp = await sendMessage({ type: 'get_field_history', id: itemId }); + if (!resp.ok) { + app.innerHTML = `

Failed to load history

`; + return; + } + + const history = (resp.data as { history: FieldHistoryView[] }).history; + + if (history.length === 0) { + app.innerHTML = ` +
+
+ +

password history

+
+

No history available

+
+ `; + document.getElementById('back-btn')?.addEventListener('click', () => navigate('detail')); + return; + } + + function renderEntry(fieldId: string, value: string, timestamp: number, isCurrent: boolean): string { + const entryKey = `${fieldId}-${timestamp}`; + const isRevealed = revealedSet.has(entryKey); + const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••'; + + return ` +
+
${displayValue}
+ + +
+ `; + } + + let content = ''; + for (const field of history) { + if (history.length > 1) { + content += `
${escapeHtml(field.field_name)}
`; + } + // Current value first + content += renderEntry(field.field_id, field.current_value, item.modified, true); + // Historical values + for (const entry of field.entries) { + content += renderEntry(field.field_id, entry.value, entry.changed_at, false); + } + } + + app.innerHTML = ` +
+
+ +

password history

+
+
${escapeHtml(item.title)}
+ ${content} +
+ `; + + // Wire handlers + document.getElementById('back-btn')?.addEventListener('click', () => navigate('detail')); + + // Toggle reveal on click + document.querySelectorAll('.history-entry').forEach((el) => { + el.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return; + const key = el.dataset.entry; + if (!key) return; + if (revealedSet.has(key)) { + revealedSet.delete(key); + } else { + revealedSet.add(key); + } + renderFieldHistory(app); + }); + }); + + // Copy buttons + document.querySelectorAll('[data-copy]').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const value = btn.dataset.copy ?? ''; + await navigator.clipboard.writeText(value); + btn.textContent = '✓'; + setTimeout(() => { btn.textContent = '📋'; }, 1500); + }); + }); +} +``` + +- [ ] **Step 2: Add history styles to styles.css** + +Edit `extension/src/popup/styles.css` — add at the end: + +```css +/* --- Field history view --- */ + +.history-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.history-item-title { + font-size: 14px; + font-weight: 600; + color: #c9d1d9; + margin-bottom: 12px; +} + +.history-field-label { + font-size: 11px; + color: #8b949e; + text-transform: uppercase; + margin: 12px 0 6px; +} + +.history-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + border-radius: 4px; + background: #161b22; + margin-bottom: 6px; + cursor: pointer; +} + +.history-entry:hover { + background: #1c2128; +} + +.history-entry__value { + flex: 1; + font-family: monospace; + font-size: 13px; +} + +.history-entry__value.masked { + color: #8b949e; +} + +.history-entry__value.revealed { + color: #c9d1d9; +} + +.history-entry__meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 11px; + color: #8b949e; +} + +.history-entry__current { + color: #58a6ff; + font-weight: 500; +} + +.history-entry__copy { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px; +} + +.history-entry__copy:hover { + opacity: 0.8; +} +``` + +- [ ] **Step 3: Create field-history.test.ts** + +Create `extension/src/popup/components/__tests__/field-history.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderFieldHistory, teardown } from '../field-history'; + +// Mock popup module +vi.mock('../../popup', () => ({ + getState: vi.fn(() => ({ + historyItemId: 'item123', + selectedItem: { id: 'item123', title: 'Test Item', modified: 1000 }, + })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, +})); + +import { sendMessage, navigate } from '../../popup'; + +describe('field-history view', () => { + let app: HTMLElement; + + beforeEach(() => { + app = document.createElement('div'); + teardown(); + vi.clearAllMocks(); + }); + + it('renders empty state when no history', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { history: [] }, + }); + + await renderFieldHistory(app); + + expect(app.innerHTML).toContain('No history available'); + }); + + it('renders history entries masked by default', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + history: [{ + field_id: 'f1', + field_name: 'password', + current_value: 'secret123', + entries: [{ value: 'oldpass', changed_at: 500 }], + }], + }, + }); + + await renderFieldHistory(app); + + expect(app.innerHTML).toContain('••••••••••••'); + expect(app.innerHTML).not.toContain('secret123'); + expect(app.innerHTML).toContain('current'); + }); + + it('back button navigates to detail', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { history: [] }, + }); + + await renderFieldHistory(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('detail'); + }); +}); +``` + +- [ ] **Step 4: Run field-history tests** + +Run: `cd extension && bun run test src/popup/components/__tests__/field-history.test.ts 2>&1 | tail -5` + +Expected: 3 tests pass. + +- [ ] **Step 5: Verify type-check + run all tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/field-history.ts \ + extension/src/popup/components/__tests__/field-history.test.ts \ + extension/src/popup/styles.css +git commit -m "$(cat <<'EOF' +feat(ext/popup): field history view — masked values with reveal toggle + +Shows current + historical values for tracked fields (password/concealed). +Click to reveal, copy button per entry. Grouped by field name if multiple +tracked fields exist. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 9: Popup — "View history" link in item detail + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts` +- Modify: Other type detail files (similar pattern) + +- [ ] **Step 1: Add "View history" link to login detail** + +Edit `extension/src/popup/components/types/login.ts` — find the detail view's form-actions div (around line 78-80) and add a history link: + +Find this block: +```typescript +
+ + +``` + +Replace with: +```typescript +
+ + ${Object.keys(item.field_history).length > 0 ? '' : ''} + +``` + +- [ ] **Step 2: Wire history button handler** + +In the same file, find where `edit-btn` handler is wired (around line 90-95) and add before it: + +```typescript + document.getElementById('history-btn')?.addEventListener('click', () => { + setState({ historyItemId: item.id }); + navigate('field-history'); + }); +``` + +- [ ] **Step 3: Add setState import if not present** + +Ensure `setState` is imported at the top of `login.ts`: + +```typescript +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; +``` + +- [ ] **Step 4: Verify type-check + run tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/types/login.ts +git commit -m "$(cat <<'EOF' +feat(ext/popup): add "View history" link to login detail view + +Shows button when item.field_history is non-empty. Navigates to +field-history screen with historyItemId set. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 10: Popup — attachment caps in vault settings + +**Files:** +- Modify: `extension/src/popup/components/settings-vault.ts` + +- [ ] **Step 1: Add attachment caps section to settings-vault.ts** + +Edit `extension/src/popup/components/settings-vault.ts` — find the `autofill origins` section (around line 135-145) and add after it, before the `settings-footer` div: + +```typescript +
+
attachments
+
+ max file size + +
+
+``` + +- [ ] **Step 2: Set initial value for attachment cap** + +In the `rerender()` function, after setting trash/history retention values, add: + +```typescript + const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760; + (document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue); +``` + +- [ ] **Step 3: Wire change handler** + +In `wireHandlers()`, add: + +```typescript + document.getElementById('attachment-cap')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + const bytes = Number((e.target as HTMLSelectElement).value); + pendingSettings.attachment_caps = { + ...pendingSettings.attachment_caps, + per_attachment_max_bytes: bytes, + }; + updateSaveEnabled(); + }); +``` + +- [ ] **Step 4: Verify type-check + run tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/components/settings-vault.ts +git commit -m "$(cat <<'EOF' +feat(ext/popup): add attachment cap setting to vault settings + +Dropdown with 5/10/25/50 MB presets for per_attachment_max_bytes. +Other caps remain at defaults. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 11: Popup — navigation for trash + devices + field-history + +**Files:** +- Modify: `extension/src/popup/popup.ts` +- Modify: `extension/src/popup/components/item-list.ts` or `settings.ts` (add entry points) + +- [ ] **Step 1: Update View type in popup.ts** + +Edit `extension/src/popup/popup.ts` — update the View type (around line 27): + +```typescript +export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history'; +``` + +- [ ] **Step 2: Add historyItemId to PopupState** + +In the PopupState interface, add: + +```typescript + historyItemId: string | null; +``` + +And in the initial state object: + +```typescript + historyItemId: null, +``` + +- [ ] **Step 3: Add imports for new screens** + +Add imports at the top: + +```typescript +import { renderTrash } from './components/trash'; +import { renderDevices } from './components/devices'; +import { renderFieldHistory } from './components/field-history'; +``` + +- [ ] **Step 4: Add cases to render switch** + +In the `render()` function's switch statement, add: + +```typescript + case 'trash': + renderTrash(app); + break; + case 'devices': + renderDevices(app); + break; + case 'field-history': + renderFieldHistory(app); + break; +``` + +- [ ] **Step 5: Add teardown calls** + +Import teardown functions: + +```typescript +import { teardown as teardownTrash } from './components/trash'; +import { teardown as teardownDevices } from './components/devices'; +import { teardown as teardownFieldHistory } from './components/field-history'; +``` + +Add teardown calls at the beginning of render() (after the existing teardowns if any): + +```typescript + teardownTrash(); + teardownDevices(); + teardownFieldHistory(); +``` + +- [ ] **Step 6: Add navigation links in settings.ts** + +Edit `extension/src/popup/components/settings.ts` — add trash and devices links to the settings menu: + +Find the settings menu HTML and add: + +```typescript + + +``` + +Wire the handlers: + +```typescript + document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash')); + document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices')); +``` + +- [ ] **Step 7: Verify type-check + run tests** + +Run: `cd extension && bunx tsc --noEmit && bun run test 2>&1 | tail -3` + +Expected: Zero type errors, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/popup/popup.ts \ + extension/src/popup/components/settings.ts +git commit -m "$(cat <<'EOF' +feat(ext/popup): wire navigation for trash, devices, field-history screens + +Adds View variants, render cases, teardown calls, and entry points +in settings menu for trash and devices. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 12: Setup wizard — device name step + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +- [ ] **Step 1: Add device name to wizard state** + +Edit `extension/src/setup/setup.ts` — add to the WizardState interface (around line 29): + +```typescript + deviceName: string; +``` + +And in the initial state object: + +```typescript + deviceName: '', +``` + +- [ ] **Step 2: Update step count — now 5 steps** + +The wizard currently has 4 steps. Device name becomes step 4, finish becomes step 5. + +Update `renderStep4` to `renderStep5` and `attachStep4` to `attachStep5`. + +Update the progress bar to have 5 steps instead of 4. + +- [ ] **Step 3: Create new step 4 for device name** + +Add `renderStep4()` function: + +```typescript +function renderStep4(): string { + const platform = navigator.platform.toLowerCase(); + const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent); + const isFirefox = /firefox/i.test(navigator.userAgent); + const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser'; + const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux'; + const defaultName = state.deviceName || `${browser} on ${os}`; + + return ` +
+

name this device

+

+ This helps you identify which devices have access to your vault. +

+
+ + +
+
+ + +
+
+ `; +} + +function attachStep4(): void { + document.getElementById('back-btn')?.addEventListener('click', () => { + state.step = 3; + state.error = null; + render(); + }); + + document.getElementById('next-btn')?.addEventListener('click', async () => { + const nameInput = document.getElementById('device-name') as HTMLInputElement; + const name = nameInput.value.trim(); + if (!name) { + state.error = 'Device name is required'; + render(); + return; + } + + state.deviceName = name; + state.step = 5; + state.error = null; + render(); + }); +} +``` + +- [ ] **Step 4: Update step 3 to proceed to step 4 instead of step 5** + +In the create vault success handler (around line 650), change: + +```typescript + state.step = 4; +``` + +to: + +```typescript + state.step = 4; // device name step +``` + +- [ ] **Step 5: Generate and store device keypair in step 5** + +In `attachStep5` (formerly attachStep4), when pushing config to extension, also generate and register the device: + +After the `save_setup` message succeeds, add: + +```typescript + // Generate device keypair and register + const w = await loadWasm(); + const keypair = JSON.parse(w.generate_device_keypair()); + + // Store private key locally + await chrome.storage.local.set({ + device_name: state.deviceName, + device_private_key: keypair.private_key_base64, + }); + + // Register device with vault + chrome.runtime.sendMessage({ + type: 'add_device', + name: state.deviceName, + public_key: keypair.public_key_hex, + }); +``` + +- [ ] **Step 6: Update progress bar in render()** + +Update the progress bar HTML to have 5 steps: + +```typescript + const progressHtml = ` +
+
+
+
+
+
+
+ `; +``` + +And update the switch statement: + +```typescript + switch (state.step) { + case 1: stepHtml = renderStep1(); break; + case 2: stepHtml = renderStep2(); break; + case 3: stepHtml = renderStep3(); break; + case 4: stepHtml = renderStep4(); break; + case 5: stepHtml = renderStep5(); break; + } +``` + +And attachments: + +```typescript + switch (state.step) { + case 1: attachStep1(); break; + case 2: attachStep2(); break; + case 3: attachStep3(); break; + case 4: attachStep4(); break; + case 5: attachStep5(); break; + } +``` + +- [ ] **Step 7: Verify type-check + build** + +Run: `cd extension && bunx tsc --noEmit && bun run build 2>&1 | tail -5` + +Expected: Zero type errors, build succeeds. + +- [ ] **Step 8: Commit** + +```bash +cd /home/alee/Sources/relicario +git add extension/src/setup/setup.ts +git commit -m "$(cat <<'EOF' +feat(ext/setup): add device name step to setup wizard + +New step 4 after vault creation: enter device name (defaults to +"Chrome on Linux" based on detected browser/OS). Generates ed25519 +keypair, stores private key in chrome.storage.local, registers +device with vault. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 13: Manual browser testing + +**No files to modify — manual verification.** + +- [ ] **Step 1: Build extension** + +Run: `cd extension && bun run build` + +- [ ] **Step 2: Load in Chrome** + +1. Open `chrome://extensions` +2. Enable Developer mode +3. Load unpacked from `extension/dist` + +- [ ] **Step 3: Test setup wizard device step (new vault)** + +1. Click extension icon → should open setup.html +2. Complete steps 1-3 (host, connection, create vault) +3. **New step 4**: Device name input should appear with default like "Chrome on Linux" +4. Edit name or accept default → Continue +5. Step 5 (finish) should appear +6. Download reference image, save config to extension + +- [ ] **Step 4: Test device list** + +1. Unlock vault +2. Settings → Devices +3. Should show current device with "← you" indicator +4. Should not have revoke button on current device + +- [ ] **Step 5: Test trash flow** + +1. Create a test login item +2. Open item detail → Edit → Trash +3. Settings → Trash → should see trashed item +4. Click restore → item should return to main list +5. Trash again → Empty trash → item should be permanently deleted + +- [ ] **Step 6: Test field history** + +1. Create login item with password +2. Edit item, change password, save +3. Open item detail → "View history" button should appear +4. Click it → should see current + previous password (masked) +5. Click entry to reveal +6. Copy button should work + +- [ ] **Step 7: Test attachment cap setting** + +1. Settings → Vault Settings +2. Should see "max file size" dropdown under "attachments" +3. Change value, save +4. Lock/unlock → setting should persist + +- [ ] **Step 8: Build Firefox extension** + +Run: `cd extension && bun run build:firefox` + +- [ ] **Step 9: Repeat tests in Firefox** + +Load extension in Firefox and repeat steps 3-7. + +- [ ] **Step 10: Record results and commit any fixes** + +If all tests pass, tag the completion: + +```bash +cd /home/alee/Sources/relicario +git tag plan-1c-gamma2-complete +``` + +--- + +## Commit strategy + +Direct to `main` per project convention. Each task = one commit. Do NOT push. + +Tag `plan-1c-gamma2-complete` after all tasks pass + manual tests verified.