Files
relicario/docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md
adlee-was-taken af050f176c docs(plan): Plan 1C-γ₂ — device registration + trash + history + caps
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 <noreply@anthropic.com>
2026-04-26 15:39:19 -04:00

66 KiB

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]:

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:

use ed25519_dalek::SigningKey;
use base64::Engine;

Then append this function after rate_passphrase:

/// Generate an ed25519 keypair for device registration.
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
#[wasm_bindgen]
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
    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:

hex = "0.4"
  • Step 4: Add rand crate with getrandom backend

Edit crates/relicario-wasm/Cargo.toml — add:

rand = "0.8"
  • Step 5: Add get_field_history to lib.rs

Append after generate_device_keypair:

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<JsValue, JsError> {
    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::<Vec<_>>(),
                }));
            }
        }
    }
    
    // Check section fields for tracked kinds
    for section in &item.sections {
        for field in &section.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::<Vec<_>>(),
                        }));
                    }
                }
            }
        }
    }
    
    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
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 <noreply@anthropic.com>
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):

// --- 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):

  | { 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:

export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = 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:

export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
  data: { devices: Device[] };
}

export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
  data: { items: Array<[ItemId, ManifestEntry]> };
}

export interface PurgeAllTrashResponse extends Extract<Response, { ok: true }> {
  data: { itemCount: number; orphanCount: number };
}

export interface FieldHistoryResponse extends Extract<Response, { ok: true }> {
  data: { history: FieldHistoryView[] };
}
  • Step 5: Add Device import to messages.ts

Edit extension/src/shared/messages.ts — update the import at the top:

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
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 <noreply@anthropic.com>
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:

/// 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<Device[]> {
  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<void> {
  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<void> {
  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<void> {
  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:

import * as devices from '../devices';

Then add these cases to the switch statement before the default/closing brace:

    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:

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
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 <noreply@anthropic.com>
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:

// --- 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<void> {
  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<string[]> {
  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<string>();
  
  // 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<string>();
  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:

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:

    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:

import { describe, expect, it } from 'vitest';
import { listTrashed } from '../vault';
import type { Manifest } from '../../shared/types';

function makeManifest(items: Record<string, { trashed_at?: number }>): 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
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 <noreply@anthropic.com>
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:

    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
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 <noreply@anthropic.com>
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:

/// 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<string, string> = {
  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<void> {
  const state = getState();
  
  // Fetch trashed items
  const resp = await sendMessage({ type: 'list_trashed' });
  if (!resp.ok) {
    app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
    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 = `
    <div class="pad">
      <div class="trash-header">
        <button class="btn" id="back-btn">← back</button>
        <h3 style="margin:0;">trash</h3>
      </div>
      ${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
      ${items.length === 0
        ? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
        : items.map(([id, entry]) => `
          <div class="trash-row" data-id="${escapeHtml(id)}">
            <span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
            <div class="trash-row__info">
              <span class="trash-row__title">${escapeHtml(entry.title)}</span>
              <span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
            </div>
            <button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
          </div>
        `).join('')}
      ${items.length > 0 ? `
        <div style="margin-top:16px;text-align:center;">
          <button class="btn danger" id="empty-trash-btn">empty trash</button>
        </div>
      ` : ''}
    </div>
  `;

  // Wire handlers
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));

  document.querySelectorAll<HTMLButtonElement>('[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:

/* --- 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:

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      data: { items: [] },
    });

    await renderTrash(app);
    app.querySelector<HTMLButtonElement>('#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
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 <noreply@anthropic.com>
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:

/// 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<void> {
  // 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 = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
    return;
  }
  
  const devices = (resp.data as { devices: Device[] }).devices;
  const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);

  app.innerHTML = `
    <div class="pad">
      <div class="devices-header">
        <button class="btn" id="back-btn">← back</button>
        <h3 style="margin:0;">devices</h3>
      </div>
      ${!isRegistered ? `
        <div class="device-banner">
          <span>⚠ This device is not registered</span>
          <button class="btn btn-primary" id="register-btn">Register this device</button>
        </div>
      ` : ''}
      ${devices.length === 0
        ? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
        : devices.map((d) => {
            const isCurrentDevice = d.name === currentDeviceName;
            return `
              <div class="device-row">
                <div class="device-row__info">
                  <span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
                  <span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
                </div>
                ${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
              </div>
            `;
          }).join('')}
    </div>
  `;

  // 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<HTMLButtonElement>('[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:

/* --- 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:

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
    (sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      data: { devices: [] },
    });

    await renderDevices(app);
    app.querySelector<HTMLButtonElement>('#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
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 <noreply@anthropic.com>
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:

/// 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<string>();

export function teardown(): void {
  revealedSet.clear();
}

export async function renderFieldHistory(app: HTMLElement): Promise<void> {
  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 = `<div class="pad"><p class="error">Failed to load history</p></div>`;
    return;
  }
  
  const history = (resp.data as { history: FieldHistoryView[] }).history;
  
  if (history.length === 0) {
    app.innerHTML = `
      <div class="pad">
        <div class="history-header">
          <button class="btn" id="back-btn">← back to item</button>
          <h3 style="margin:0;">password history</h3>
        </div>
        <p class="muted" style="text-align:center;margin-top:32px;">No history available</p>
      </div>
    `;
    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 `
      <div class="history-entry" data-entry="${escapeHtml(entryKey)}">
        <div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
        <div class="history-entry__meta">
          ${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
          <span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
        </div>
        <button class="history-entry__copy" data-copy="${escapeHtml(value)}" title="Copy">📋</button>
      </div>
    `;
  }

  let content = '';
  for (const field of history) {
    if (history.length > 1) {
      content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
    }
    // 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 = `
    <div class="pad">
      <div class="history-header">
        <button class="btn" id="back-btn">← back to item</button>
        <h3 style="margin:0;">password history</h3>
      </div>
      <div class="history-item-title">${escapeHtml(item.title)}</div>
      ${content}
    </div>
  `;

  // Wire handlers
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('detail'));

  // Toggle reveal on click
  document.querySelectorAll<HTMLElement>('.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<HTMLButtonElement>('[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:

/* --- 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:

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      data: { history: [] },
    });

    await renderFieldHistory(app);
    app.querySelector<HTMLButtonElement>('#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
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 <noreply@anthropic.com>
EOF
)"

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:

      <div class="form-actions" style="margin-top:14px;">
        <button class="btn" id="back-btn">back</button>
        <button class="btn" id="edit-btn">edit</button>

Replace with:

      <div class="form-actions" style="margin-top:14px;">
        <button class="btn" id="back-btn">back</button>
        ${Object.keys(item.field_history).length > 0 ? '<button class="btn" id="history-btn">view history</button>' : ''}
        <button class="btn" id="edit-btn">edit</button>
  • 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:

  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:

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
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 <noreply@anthropic.com>
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:

        <div class="settings-section">
          <div class="settings-section__title">attachments</div>
          <div class="settings-row">
            <span class="settings-row__label">max file size</span>
            <select id="attachment-cap">
              <option value="5242880">5 MB</option>
              <option value="10485760">10 MB</option>
              <option value="26214400">25 MB</option>
              <option value="52428800">50 MB</option>
            </select>
          </div>
        </div>
  • Step 2: Set initial value for attachment cap

In the rerender() function, after setting trash/history retention values, add:

    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:

    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
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 <noreply@anthropic.com>
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):

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:

  historyItemId: string | null;

And in the initial state object:

  historyItemId: null,
  • Step 3: Add imports for new screens

Add imports at the top:

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:

    case 'trash':
      renderTrash(app);
      break;
    case 'devices':
      renderDevices(app);
      break;
    case 'field-history':
      renderFieldHistory(app);
      break;
  • Step 5: Add teardown calls

Import teardown functions:

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):

  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:

        <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
        <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>

Wire the handlers:

  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
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 <noreply@anthropic.com>
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):

  deviceName: string;

And in the initial state object:

  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:

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 `
    <div class="wizard-step">
      <h3>name this device</h3>
      <p class="muted" style="margin-bottom:12px;">
        This helps you identify which devices have access to your vault.
      </p>
      <div class="form-group">
        <label class="label" for="device-name">device name</label>
        <input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux">
      </div>
      <div class="form-actions">
        <button class="btn" id="back-btn">back</button>
        <button class="btn btn-primary" id="next-btn">continue</button>
      </div>
    </div>
  `;
}

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:

      state.step = 4;

to:

      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:

        // 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:

  const progressHtml = `
    <div class="progress-bar">
      <div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
      <div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
      <div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
      <div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
      <div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
    </div>
  `;

And update the switch statement:

  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:

  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
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 <noreply@anthropic.com>
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:

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.