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

2306 lines
66 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
```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<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**
```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 <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):
```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<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`:
```typescript
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:
```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 <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`:
```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<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:
```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<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**
```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 <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:
```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<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:
```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<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**
```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 <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:
```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 <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`:
```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<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:
```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<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**
```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 <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`:
```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<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:
```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<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**
```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 <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`:
```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<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:
```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<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**
```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 <noreply@anthropic.com>
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
<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:
```typescript
<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:
```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 <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:
```typescript
<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:
```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 <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):
```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
<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:
```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 <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):
```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 `
<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:
```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 = `
<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:
```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 <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:
```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.