# 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.