Four features completing Plan 1C: device ed25519 keypair registration during setup wizard, device management UI, trash view with restore/purge (including orphan blob cleanup), per-item field history view, and per-attachment size cap setting in vault settings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
Plan 1C-γ₂: Device registration + Trash + Field history + Attachment caps — design
Date: 2026-04-26 Scope: Add device registration during setup, device management UI, trash view with restore/purge (including orphan blob cleanup), per-item field history view, and a single attachment-cap setting in vault settings.
Goal
The Rust core already supports soft-delete/restore (Item::soft_delete, Item::restore, Item::is_trashed), field history capture (auto-tracked for Password/Concealed/Totp fields in Item::field_history), and attachment caps (VaultSettings::attachment_caps). The CLI has device management via ed25519 keypairs (device add/list/revoke). What's missing is the extension surface: a way to register the extension as a device, view/revoke devices, browse and act on trashed items, view password history, and configure the attachment size limit.
γ₂ completes Plan 1C by exposing these already-implemented core capabilities in the extension UI.
Non-goals
- Commit signing with device key — keypair is generated and stored for future use, but no operations are signed yet.
- Bulk trash operations (select-all, empty-selected) — single-item restore + "empty all" only.
- Field history editing/deletion — view-only.
- Manual orphan blob purge button — orphans are cleaned automatically when emptying trash.
- Exposing all four attachment caps — only
per_attachment_max_bytesis user-configurable; others use sensible defaults.
Visual identity
Device name step in setup wizard
After passphrase + reference image, a new step appears:
Name this device
This helps you identify which devices have access to your vault.
┌─────────────────────────────────┐
│ Chrome on Linux │
└─────────────────────────────────┘
[ continue ]
- Auto-suggested default:
"{browser} on {platform}"(e.g., "Chrome on Linux", "Firefox on macOS") - User can edit the name or accept the default
- "Continue" generates keypair, stores private key locally, commits pubkey to
devices.json
Device management screen
Entry point: "Devices" link in popup navigation (gear icon row alongside Settings).
← back devices
┌─────────────────────────────────┐
│ Chrome on Linux ← you │
│ added 3d ago │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Firefox on MacBook revoke │
│ added 2w ago │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ CLI revoke │
│ added 1mo ago │
└─────────────────────────────────┘
- "← you" badge on current device (matched via
device_nameinchrome.storage.local) - Current device row has no revoke button (can't revoke self)
- Revoke shows confirm: "Revoke {name}? This device will no longer be authorized."
- Commits
"device: revoke {name}"on confirm
Unregistered device banner: If device_private_key is missing from local storage but vault exists, show:
⚠ This device is not registered
[ Register this device ]
Trash screen
Entry point: "Trash" link in popup navigation.
← back trash
3 items · oldest auto-purges in 45d
┌─────────────────────────────────┐
│ 🔑 Old Bank Login │
│ trashed 2d ago restore │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 📝 Temp Note │
│ trashed 5d ago restore │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 💳 Expired Card │
│ trashed 12d ago restore │
└─────────────────────────────────┘
[ empty trash ]
- List from manifest where
trashed_at != null, sorted newest-trashed first - Type icon + title per row (same as main item list)
- "restore" clears
trashed_at, updates manifest, commits - Header shows count + days until oldest item auto-purges (based on
trash_retention) - "empty trash" confirms: "Permanently delete 3 items? This cannot be undone."
- Empty trash also scans for orphan blobs (attachments not referenced by any item) and deletes them
- Single commit for the whole operation:
"trash: purge N items + M orphan blobs" - Empty state: "Trash is empty"
Field history screen
Entry point: "View history" link on item detail (only shown if field_history is non-empty).
← back to item password history
GitHub Login
┌─────────────────────────────────┐
│ •••••••••••• current │
│ set 2d ago [ 📋 ] │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ •••••••••••• │
│ changed 3w ago [ 📋 ] │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ •••••••••••• │
│ changed 2mo ago [ 📋 ] │
└─────────────────────────────────┘
- Shows history for all tracked fields (Password, Concealed, Totp)
- Current value comes from the item's field itself (not
field_history), marked "current", timestamp = item'smodified - Historical values come from
field_historyentries - Values masked by default; click row to reveal
- Copy button per entry
- Sorted newest-first (current always first)
- If multiple tracked fields exist, group by field name with section headers
Attachment caps in vault settings
New section after "autofill origins" in vault settings:
attachments
max file size [ 10 MB ▾ ]
- Dropdown with presets: 5 MB, 10 MB (default), 25 MB, 50 MB
- Updates
vault_settings.attachment_caps.per_attachment_max_bytes - Other caps remain at defaults:
per_item_max_count: 20,per_vault_soft_cap_bytes: 100MB,per_vault_hard_cap_bytes: 500MB
Architecture
Layer 1: WASM bindings
New exports in relicario-wasm:
/// Generate an ed25519 keypair for device registration.
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
#[wasm_bindgen]
pub fn generate_device_keypair() -> String;
/// Extract field history from a decrypted item.
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
/// where current_value is the field's present value (for the "current" row in UI)
/// and entries are historical values from field_history.
#[wasm_bindgen]
pub fn get_field_history(item_json: &str) -> String;
The ed25519-dalek crate is already a dependency (used by CLI). WASM feature gate may be needed.
Layer 2: Shared types
extension/src/shared/types.ts:
export interface Device {
name: string;
public_key: string; // hex-encoded ed25519 pubkey
added_at: number; // unix timestamp
}
export interface FieldHistoryEntry {
value: string;
changed_at: number;
}
export interface FieldHistory {
field_id: string;
field_name: string;
current_value: string; // present value of the field
entries: FieldHistoryEntry[]; // historical values
}
extension/src/shared/messages.ts — new message types:
| Message | Direction | Purpose |
|---|---|---|
list_devices |
popup → SW | Get Device[] from devices.json |
add_device |
popup → SW | Register new device (name, pubkey) |
revoke_device |
popup → SW | Remove device by name |
list_trashed |
popup → SW | Get manifest entries where trashed_at != null |
restore_item |
popup → SW | Clear trashed_at on item, update manifest |
purge_item |
popup → SW | Permanently delete single trashed item |
purge_all_trash |
popup → SW | Delete all trashed items + orphan blobs |
get_field_history |
popup → SW | Get history for an item |
Layer 3: Service worker
extension/src/service-worker/devices.ts (NEW):
export async function readDevices(gitHost: GitHost): Promise<Device[]>;
export async function writeDevices(gitHost: GitHost, devices: Device[], message: string): Promise<void>;
export async function addDevice(gitHost: GitHost, device: Device): Promise<void>;
export async function revokeDevice(gitHost: GitHost, name: string): Promise<void>;
Reads/writes .relicario/devices.json in the vault repo.
extension/src/service-worker/vault.ts — new functions:
export async function listTrashed(manifest: Manifest): ManifestEntry[];
export async function restoreItem(gitHost: GitHost, session: SessionHandle, itemId: string): Promise<void>;
export async function purgeItem(gitHost: GitHost, itemId: string): Promise<void>;
export async function purgeAllTrash(gitHost: GitHost, session: SessionHandle, manifest: Manifest): Promise<{ itemCount: number, orphanCount: number }>;
Orphan blob scan algorithm:
- Collect all
AttachmentRef.idvalues from all non-trashed items →Set<string> referenced - List files in
attachments/directory →Set<string> existing - Orphans =
existing - referenced - Delete each orphan blob file
- Return count for commit message
Router handlers in popup-only.ts:
- All 8 message types get handlers
- Standard sender check (popup-only)
- Return
{ ok: true, data: ... }or{ ok: false, error: '...', detail: '...' }
Layer 4: Popup
New screens:
extension/src/popup/components/trash.ts— trash list viewextension/src/popup/components/devices.ts— device management viewextension/src/popup/components/field-history.ts— per-item history view
Modified screens:
extension/src/popup/components/setup-wizard.ts— add device name step after reference imageextension/src/popup/components/settings-vault.ts— add attachment caps sectionextension/src/popup/components/item-detail.ts— add "View history" link if history existsextension/src/popup/popup.ts— add navigation targets for trash, devices, field-history
Navigation state:
type Screen =
| 'unlock' | 'setup' | 'list' | 'detail' | 'form' | 'settings' | 'vault-settings'
| 'trash' | 'devices' | 'field-history'; // ← new
interface State {
// existing fields...
historyItemId?: string; // for field-history screen
}
Device name step flow:
- After reference image step, show device name input
- On continue:
- Call WASM
generate_device_keypair()→{ public_key_hex, private_key_base64 } - Store in
chrome.storage.local:device_name,device_private_key - Send
add_devicemessage to SW with name + pubkey - SW writes to
devices.json, commits
- Call WASM
- Proceed to vault creation/unlock
Unregistered device detection:
On unlock success, popup checks:
- If
device_private_keymissing from local storage AND vault hasdevices.jsonwith entries → show "not registered" banner - Banner click triggers device registration flow (same as setup, but without passphrase/image steps)
Testing strategy
Unit tests (vitest + happy-dom)
Service worker tests:
devices.test.ts: add/list/revoke, duplicate name rejection, JSON formattrash.test.ts: listTrashed filter, restoreItem clears timestamp, purgeItem deletes files, orphan scan logic
Popup tests:
trash.test.ts: renders trashed items, restore button, empty trash confirmdevices.test.ts: renders device list, "you" indicator, revoke confirmfield-history.test.ts: renders entries, mask/reveal toggle, copy buttonsetup-wizard.test.ts: device name step appears, defaults correctly
Router tests:
- Extend
router.test.tswith cases for all 8 new message types - Sender check verification (reject non-popup callers)
Manual browser test matrix
| # | Test | Chrome | Firefox |
|---|---|---|---|
| 1 | Setup wizard shows device name step | ||
| 2 | Device name defaults to "Chrome on Linux" (or similar) | ||
| 3 | Device list shows "← you" on current device | ||
| 4 | Revoke other device works, confirms | ||
| 5 | Trash item from detail view | ||
| 6 | Trash view shows trashed items | ||
| 7 | Restore from trash returns item to list | ||
| 8 | Empty trash purges items + orphan blobs | ||
| 9 | Field history shows after password edit | ||
| 10 | History values masked, click to reveal | ||
| 11 | Attachment cap dropdown in vault settings | ||
| 12 | Cap change persists across unlock cycles |
File changes
Rust (WASM)
| File | Change |
|---|---|
crates/relicario-wasm/src/lib.rs |
Add generate_device_keypair, get_field_history |
crates/relicario-wasm/Cargo.toml |
Ensure ed25519-dalek features for WASM |
Extension — shared
| File | Change |
|---|---|
extension/src/shared/types.ts |
Add Device, FieldHistoryEntry, FieldHistory |
extension/src/shared/messages.ts |
Add 8 message types |
Extension — service worker
| File | Change |
|---|---|
extension/src/service-worker/devices.ts |
NEW — device CRUD |
extension/src/service-worker/vault.ts |
Add trash/restore/purge functions |
extension/src/service-worker/router/popup-only.ts |
Add 8 handlers |
extension/src/service-worker/__tests__/devices.test.ts |
NEW |
extension/src/service-worker/__tests__/trash.test.ts |
NEW |
extension/src/service-worker/router/__tests__/router.test.ts |
Extend with new handlers |
Extension — popup
| File | Change |
|---|---|
extension/src/popup/components/trash.ts |
NEW |
extension/src/popup/components/devices.ts |
NEW |
extension/src/popup/components/field-history.ts |
NEW |
extension/src/popup/components/setup-wizard.ts |
Add device name step |
extension/src/popup/components/settings-vault.ts |
Add attachment caps section |
extension/src/popup/components/item-detail.ts |
Add "View history" link |
extension/src/popup/popup.ts |
Add navigation targets |
extension/src/popup/styles.css |
New styles for trash, devices, history |
extension/src/popup/components/__tests__/trash.test.ts |
NEW |
extension/src/popup/components/__tests__/devices.test.ts |
NEW |
extension/src/popup/components/__tests__/field-history.test.ts |
NEW |
Sequencing
Bottom-up by layer, with setup wizard changes near the end:
- WASM bindings —
generate_device_keypair,get_field_history - Shared types —
Device,FieldHistory*, message types - SW devices —
devices.ts+ handlers + tests - SW trash — trash functions in
vault.ts+ handlers + tests - SW field history — handler (uses WASM binding) + tests
- Popup trash screen —
trash.ts+ styles + tests - Popup devices screen —
devices.ts+ styles + tests - Popup field history screen —
field-history.ts+ tests - Popup item-detail — "View history" link
- Popup vault-settings — attachment caps section
- Popup navigation — wire trash + devices entry points
- Setup wizard — device name step (atomic, riskiest change last)
- Manual browser testing — Chrome + Firefox matrix
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.