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>
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— addgenerate_device_keypair,get_field_history(Task 1)
Shared types & messages:
extension/src/shared/types.ts— addDevice,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— addlistTrashed,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_keypairto 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_historyto 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 §ion.fields {
if tracked_kinds.contains(&field.kind) {
if let Some(entries) = item.field_history.get(&field.id) {
if !entries.is_empty() {
let current = match &field.value {
relicario_core::FieldValue::Password(v) => v.expose_secret().to_string(),
relicario_core::FieldValue::Concealed(v) => v.expose_secret().to_string(),
_ => String::new(),
};
results.push(serde_json::json!({
"field_id": field.id.as_str(),
"field_name": &field.label,
"current_value": current,
"entries": entries.iter().map(|e| serde_json::json!({
"value": e.value,
"changed_at": e.replaced_at,
})).collect::<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
)"
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:
<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.tsorsettings.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
- Open
chrome://extensions - Enable Developer mode
- Load unpacked from
extension/dist
- Step 3: Test setup wizard device step (new vault)
- Click extension icon → should open setup.html
- Complete steps 1-3 (host, connection, create vault)
- New step 4: Device name input should appear with default like "Chrome on Linux"
- Edit name or accept default → Continue
- Step 5 (finish) should appear
- Download reference image, save config to extension
- Step 4: Test device list
- Unlock vault
- Settings → Devices
- Should show current device with "← you" indicator
- Should not have revoke button on current device
- Step 5: Test trash flow
- Create a test login item
- Open item detail → Edit → Trash
- Settings → Trash → should see trashed item
- Click restore → item should return to main list
- Trash again → Empty trash → item should be permanently deleted
- Step 6: Test field history
- Create login item with password
- Edit item, change password, save
- Open item detail → "View history" button should appear
- Click it → should see current + previous password (masked)
- Click entry to reveal
- Copy button should work
- Step 7: Test attachment cap setting
- Settings → Vault Settings
- Should see "max file size" dropdown under "attachments"
- Change value, save
- 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.