docs(spec): Plan 1C-γ₂ — device registration + trash + field history + attachment caps
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>
This commit is contained in:
@@ -0,0 +1,395 @@
|
|||||||
|
# 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_bytes` is 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_name` in `chrome.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's `modified`
|
||||||
|
- **Historical values** come from `field_history` entries
|
||||||
|
- 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`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 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`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:**
|
||||||
|
|
||||||
|
1. Collect all `AttachmentRef.id` values from all non-trashed items → `Set<string> referenced`
|
||||||
|
2. List files in `attachments/` directory → `Set<string> existing`
|
||||||
|
3. Orphans = `existing - referenced`
|
||||||
|
4. Delete each orphan blob file
|
||||||
|
5. 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 view
|
||||||
|
- `extension/src/popup/components/devices.ts` — device management view
|
||||||
|
- `extension/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 image
|
||||||
|
- `extension/src/popup/components/settings-vault.ts` — add attachment caps section
|
||||||
|
- `extension/src/popup/components/item-detail.ts` — add "View history" link if history exists
|
||||||
|
- `extension/src/popup/popup.ts` — add navigation targets for trash, devices, field-history
|
||||||
|
|
||||||
|
**Navigation state:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:**
|
||||||
|
|
||||||
|
1. After reference image step, show device name input
|
||||||
|
2. 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_device` message to SW with name + pubkey
|
||||||
|
- SW writes to `devices.json`, commits
|
||||||
|
3. Proceed to vault creation/unlock
|
||||||
|
|
||||||
|
**Unregistered device detection:**
|
||||||
|
|
||||||
|
On unlock success, popup checks:
|
||||||
|
- If `device_private_key` missing from local storage AND vault has `devices.json` with 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 format
|
||||||
|
- `trash.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 confirm
|
||||||
|
- `devices.test.ts`: renders device list, "you" indicator, revoke confirm
|
||||||
|
- `field-history.test.ts`: renders entries, mask/reveal toggle, copy button
|
||||||
|
- `setup-wizard.test.ts`: device name step appears, defaults correctly
|
||||||
|
|
||||||
|
**Router tests:**
|
||||||
|
|
||||||
|
- Extend `router.test.ts` with 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:
|
||||||
|
|
||||||
|
1. **WASM bindings** — `generate_device_keypair`, `get_field_history`
|
||||||
|
2. **Shared types** — `Device`, `FieldHistory*`, message types
|
||||||
|
3. **SW devices** — `devices.ts` + handlers + tests
|
||||||
|
4. **SW trash** — trash functions in `vault.ts` + handlers + tests
|
||||||
|
5. **SW field history** — handler (uses WASM binding) + tests
|
||||||
|
6. **Popup trash screen** — `trash.ts` + styles + tests
|
||||||
|
7. **Popup devices screen** — `devices.ts` + styles + tests
|
||||||
|
8. **Popup field history screen** — `field-history.ts` + tests
|
||||||
|
9. **Popup item-detail** — "View history" link
|
||||||
|
10. **Popup vault-settings** — attachment caps section
|
||||||
|
11. **Popup navigation** — wire trash + devices entry points
|
||||||
|
12. **Setup wizard** — device name step (atomic, riskiest change last)
|
||||||
|
13. **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.
|
||||||
Reference in New Issue
Block a user