Files
relicario/docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md
adlee-was-taken bd13854f59 docs: vault tab + session timeout implementation plan
7 tasks: session timer, popup navigation, vault scaffold,
shared state host, device settings, router fix, manual testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 02:19:31 -04:00

1442 lines
42 KiB
Markdown

# Vault Tab UI + Session Timeout — Implementation Plan
> **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 a full "desktop-like" vault UI in a browser tab with sidebar+detail layout, plus configurable session timeout shared between popup and vault tab.
**Architecture:** New `vault.html` entry point with its own state management and hash-based routing, rendered as a sidebar+pane layout. Session timeout lives in the service worker via a new `session-timer.ts` module that resets on every message and broadcasts `session_expired` to all views. Both popup and vault tab import the same form/detail component renderers — vault passes its right pane element, popup passes its app element.
**Tech Stack:** TypeScript, webpack (new entry point), Chrome MV3 APIs (chrome.commands, chrome.runtime.sendMessage, chrome.storage.local)
---
### Task 1: Session Timer — Service Worker
**Files:**
- Create: `src/service-worker/session-timer.ts`
- Modify: `src/service-worker/index.ts`
- Modify: `src/service-worker/session.ts`
- Modify: `src/shared/messages.ts`
- Test: `src/service-worker/__tests__/session-timer.test.ts`
- [ ] **Step 1: Define session config types in shared/messages.ts**
Add the new message types and session config type. In `src/shared/messages.ts`, add to the `PopupMessage` union:
```typescript
| { type: 'get_session_config' }
| { type: 'update_session_config'; config: SessionTimeoutConfig }
```
Add the config type at the top of the file:
```typescript
export type SessionTimeoutConfig =
| { mode: 'inactivity'; minutes: number }
| { mode: 'every_time' };
```
Add both new types to the `POPUP_ONLY_TYPES` set.
- [ ] **Step 2: Write the failing test for session-timer.ts**
Create `src/service-worker/__tests__/session-timer.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resetTimer, stopTimer, getConfig, setConfig, onExpired } from '../session-timer';
describe('session-timer', () => {
beforeEach(() => {
vi.useFakeTimers();
stopTimer();
// Reset to default
setConfig({ mode: 'inactivity', minutes: 15 });
});
afterEach(() => {
vi.useRealTimers();
stopTimer();
});
it('fires callback after inactivity timeout', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
vi.advanceTimersByTime(15 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('resets the timer on each call to resetTimer', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
vi.advanceTimersByTime(14 * 60 * 1000);
resetTimer(); // reset before it fires
vi.advanceTimersByTime(14 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(1 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('does not fire when mode is every_time', () => {
const cb = vi.fn();
onExpired(cb);
setConfig({ mode: 'every_time' });
resetTimer();
vi.advanceTimersByTime(60 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
});
it('respects updated minutes', () => {
const cb = vi.fn();
onExpired(cb);
setConfig({ mode: 'inactivity', minutes: 5 });
resetTimer();
vi.advanceTimersByTime(5 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('getConfig returns current config', () => {
setConfig({ mode: 'inactivity', minutes: 30 });
expect(getConfig()).toEqual({ mode: 'inactivity', minutes: 30 });
});
it('stopTimer prevents firing', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
stopTimer();
vi.advanceTimersByTime(60 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer`
Expected: FAIL — module not found
- [ ] **Step 4: Implement session-timer.ts**
Create `src/service-worker/session-timer.ts`:
```typescript
import type { SessionTimeoutConfig } from '../shared/messages';
let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 };
let timerId: ReturnType<typeof setTimeout> | null = null;
let expiredCallback: (() => void) | null = null;
export function onExpired(cb: () => void): void {
expiredCallback = cb;
}
export function getConfig(): SessionTimeoutConfig {
return config;
}
export function setConfig(c: SessionTimeoutConfig): void {
config = c;
stopTimer();
}
export function resetTimer(): void {
stopTimer();
if (config.mode === 'every_time') return;
timerId = setTimeout(() => {
timerId = null;
expiredCallback?.();
}, config.minutes * 60 * 1000);
}
export function stopTimer(): void {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer`
Expected: all 6 tests PASS
- [ ] **Step 6: Wire timer into service worker index.ts**
In `src/service-worker/index.ts`, import the timer and wire it up:
```typescript
import { resetTimer, onExpired, setConfig, getConfig } from './session-timer';
import { clearCurrent } from './session';
```
In the message listener, after `state.wasm` is initialized and before `route()`, add timer reset:
```typescript
resetTimer();
```
Add the expiration handler at module scope (after `state` is defined):
```typescript
onExpired(() => {
clearCurrent();
state.manifest = null;
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
});
```
Load saved config from `chrome.storage.local` during init:
```typescript
chrome.storage.local.get('session_timeout').then((result) => {
if (result.session_timeout) setConfig(result.session_timeout);
});
```
- [ ] **Step 7: Add session config handlers to router/popup-only.ts**
In `src/service-worker/router/popup-only.ts`, add two new cases:
```typescript
case 'get_session_config': {
const { getConfig } = await import('../session-timer');
return { ok: true, data: { config: getConfig() } };
}
case 'update_session_config': {
const { setConfig, resetTimer } = await import('../session-timer');
const config = (msg as { config: SessionTimeoutConfig }).config;
setConfig(config);
resetTimer();
await chrome.storage.local.set({ session_timeout: config });
return { ok: true };
}
```
Import `SessionTimeoutConfig` from `../../shared/messages`.
- [ ] **Step 8: Build and run tests**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5 && bun test session-timer`
Expected: Build succeeds, tests pass.
- [ ] **Step 9: Commit**
```bash
git add src/service-worker/session-timer.ts src/service-worker/__tests__/session-timer.test.ts src/service-worker/index.ts src/service-worker/router/popup-only.ts src/shared/messages.ts
git commit -m "feat(ext/sw): add session inactivity timeout with configurable timer"
```
---
### Task 2: Popup Listens for Session Expiry + "Open Vault" Links
**Files:**
- Modify: `src/popup/popup.ts`
- Modify: `src/popup/components/unlock.ts`
- Modify: `src/popup/components/item-list.ts`
- [ ] **Step 1: Add session_expired listener to popup.ts**
In `src/popup/popup.ts`, in the `DOMContentLoaded` handler (after `sendMessage({ type: 'is_unlocked' })`), add a listener:
```typescript
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
currentState.view = 'locked';
currentState.error = null;
currentState.selectedItem = null;
currentState.selectedId = null;
render();
}
});
```
- [ ] **Step 2: Add openVaultTab helper to popup.ts**
Export a helper function in `src/popup/popup.ts`:
```typescript
export function openVaultTab(hash?: string): void {
const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : '');
chrome.tabs.create({ url });
}
```
- [ ] **Step 3: Add "Open vault" link to unlock screen**
In `src/popup/components/unlock.ts`, find the settings button at the bottom and add an "open vault" button next to it:
```html
<button class="btn" id="open-vault-btn">open vault</button>
```
Wire the listener:
```typescript
document.getElementById('open-vault-btn')?.addEventListener('click', () => openVaultTab());
```
Import `openVaultTab` from `../popup`.
- [ ] **Step 4: Add "Open vault" button and Shift+F to item list toolbar**
In `src/popup/components/item-list.ts`, add a small button to the toolbar row (after lock button):
```html
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)"></button>
```
Wire the listener:
```typescript
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
```
In `handleListKeydown`, add Shift+F handler before the existing key checks:
```typescript
if (e.key === 'F' && e.shiftKey) {
e.preventDefault();
openVaultTab();
return;
}
```
Import `openVaultTab` from `../popup`.
- [ ] **Step 5: Update popOutToTab to use vault.html**
In `src/popup/popup.ts`, change `popOutToTab()` to open `vault.html` with a hash:
```typescript
export function popOutToTab(): void {
const state = getState();
if (state.newType) {
openVaultTab(`add/${state.newType}`);
} else if (state.selectedId) {
openVaultTab(`${state.view}/${state.selectedId}`);
} else {
openVaultTab();
}
window.close();
}
```
- [ ] **Step 6: Build and smoke test**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5`
Expected: Build succeeds.
- [ ] **Step 7: Commit**
```bash
git add src/popup/popup.ts src/popup/components/unlock.ts src/popup/components/item-list.ts
git commit -m "feat(ext/popup): session expiry listener, open-vault links, Shift+F shortcut"
```
---
### Task 3: Webpack + Manifest + vault.html Scaffold
**Files:**
- Create: `src/vault/vault.html`
- Create: `src/vault/vault.css`
- Create: `src/vault/vault.ts`
- Modify: `webpack.config.js`
- Modify: `manifest.json`
- [ ] **Step 1: Create vault.html**
Create `src/vault/vault.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>relicario — vault</title>
<link rel="stylesheet" href="vault.css">
</head>
<body>
<div id="vault-app"></div>
<script src="vault.js"></script>
</body>
</html>
```
- [ ] **Step 2: Create vault.css with shared base styles and layout**
Create `src/vault/vault.css`. Copy the base styles (reset, colors, typography, scrollbar, buttons, inputs, form elements) from `src/popup/styles.css` — everything up through the component styles but NOT the popup-specific layout (body width/max-height). Then add the vault layout:
```css
/* ---- Vault layout ---- */
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.5;
margin: 0;
height: 100vh;
overflow: hidden;
}
#vault-app {
display: flex;
height: 100vh;
}
.vault-sidebar {
width: 260px;
min-width: 260px;
border-right: 1px solid #21262d;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.vault-sidebar__header {
padding: 12px 16px;
border-bottom: 1px solid #21262d;
display: flex;
align-items: center;
gap: 8px;
}
.vault-sidebar__search {
padding: 8px 12px;
border-bottom: 1px solid #21262d;
}
.vault-sidebar__search input {
width: 100%;
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
padding: 6px 10px;
font-size: 12px;
font-family: inherit;
}
.vault-sidebar__list {
flex: 1;
overflow-y: auto;
}
.vault-sidebar__nav {
border-top: 1px solid #21262d;
padding: 8px 0;
}
.vault-sidebar__nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
color: #8b949e;
font-size: 12px;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
font-family: inherit;
}
.vault-sidebar__nav-item:hover {
color: #c9d1d9;
background: #161b22;
}
.vault-pane {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.vault-pane--empty {
display: flex;
align-items: center;
justify-content: center;
color: #484f58;
font-size: 14px;
}
/* Sidebar item rows */
.vault-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
border-left: 2px solid transparent;
font-size: 12px;
}
.vault-entry:hover {
background: #161b22;
}
.vault-entry.selected {
background: #161b22;
border-left-color: #d2ab43;
}
.vault-entry__title {
color: #c9d1d9;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-entry__meta {
color: #484f58;
font-size: 11px;
}
/* Type group headers in sidebar */
.vault-group-header {
padding: 12px 16px 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #484f58;
}
```
Include the shared component styles the pane needs (form groups, buttons, field rows, detail view, generator panel, attachments, etc.) — copy these from `src/popup/styles.css` so the pane renders correctly. Omit the popup-specific `.search-bar`, `.keyhints`, `.entry-list`, `.entry-row` classes (the vault sidebar uses its own classes above).
- [ ] **Step 3: Create vault.ts scaffold with state management and hash routing**
Create `src/vault/vault.ts`:
```typescript
import type { Item, ItemId, ItemType, ManifestEntry, VaultSettings } from '../shared/types';
import type { Request, Response } from '../shared/messages';
// --- Messaging ---
function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
resolve(response);
});
});
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// --- State ---
type VaultView = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history';
interface VaultState {
view: VaultView;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
searchQuery: string;
newType: ItemType | null;
error: string | null;
loading: boolean;
vaultSettings: VaultSettings | null;
historyItemId: ItemId | null;
}
const state: VaultState = {
view: 'locked',
entries: [],
selectedId: null,
selectedItem: null,
searchQuery: '',
newType: null,
error: null,
loading: false,
vaultSettings: null,
historyItemId: null,
};
// --- Hash routing ---
function parseHash(): { view: string; param?: string } {
const hash = window.location.hash.slice(1); // remove '#'
if (!hash) return { view: 'list' };
const [view, param] = hash.split('/');
return { view, param };
}
function setHash(view: string, param?: string): void {
window.location.hash = param ? `${view}/${param}` : view;
}
// --- Render ---
function render(): void {
const app = document.getElementById('vault-app');
if (!app) return;
if (state.view === 'locked') {
renderLockScreen(app);
return;
}
renderShell(app);
}
function renderLockScreen(app: HTMLElement): void {
app.innerHTML = `
<div style="display:flex; align-items:center; justify-content:center; height:100vh; width:100%;">
<div style="text-align:center; width:320px;">
<img class="brand-logo" src="icons/icon128.png" alt="relicario">
<div class="brand" style="margin-bottom:4px;">relicario</div>
<div class="muted" style="margin-bottom:20px;">two-factor vault</div>
${state.error ? `<div class="error" style="margin-bottom:12px;">${escapeHtml(state.error)}</div>` : ''}
<input type="password" id="passphrase" placeholder="passphrase"
style="width:100%; margin-bottom:12px;">
<button class="btn btn-primary" id="unlock-btn" style="width:100%;">unlock</button>
</div>
</div>
`;
const input = document.getElementById('passphrase') as HTMLInputElement;
input?.focus();
const doUnlock = async (): Promise<void> => {
const passphrase = input.value;
if (!passphrase) return;
state.loading = true;
state.error = null;
const resp = await sendMessage({ type: 'unlock', passphrase });
state.loading = false;
if (resp.ok) {
await loadManifest();
} else {
state.error = resp.error ?? 'Unlock failed';
render();
}
};
document.getElementById('unlock-btn')?.addEventListener('click', doUnlock);
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doUnlock();
});
}
function renderShell(app: HTMLElement): void {
// Preserve sidebar if already rendered, only update pane
if (!app.querySelector('.vault-sidebar')) {
app.innerHTML = `
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<img src="icons/icon48.png" alt="" style="width:24px; height:24px;">
<span class="brand" style="font-size:13px;">relicario</span>
<span style="flex:1;"></span>
<button class="btn" id="vault-lock-btn" style="font-size:11px;">lock</button>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search...">
</div>
<div class="vault-sidebar__list" id="vault-item-list"></div>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item" data-nav="trash">🗑 trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">📱 devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
</div>
</div>
<div class="vault-pane vault-pane--empty" id="vault-pane">
select an item
</div>
`;
wireSidebar();
}
renderSidebarList();
renderPane();
}
function wireSidebar(): void {
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
searchInput?.addEventListener('input', () => {
state.searchQuery = searchInput.value;
renderSidebarList();
});
document.getElementById('vault-lock-btn')?.addEventListener('click', async () => {
await sendMessage({ type: 'lock' });
state.view = 'locked';
state.selectedItem = null;
state.selectedId = null;
state.entries = [];
render();
});
document.querySelectorAll<HTMLButtonElement>('[data-nav]').forEach((btn) => {
btn.addEventListener('click', () => {
const nav = btn.dataset.nav as VaultView;
state.view = nav;
state.selectedId = null;
state.selectedItem = null;
setHash(nav);
renderPane();
});
});
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) {
e.preventDefault();
searchInput?.focus();
}
});
}
function renderSidebarList(): void {
const listEl = document.getElementById('vault-item-list');
if (!listEl) return;
let filtered = state.entries;
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
filtered = filtered.filter(([, e]) =>
e.title.toLowerCase().includes(q) ||
(e.icon_hint ?? '').toLowerCase().includes(q) ||
e.tags.some(t => t.toLowerCase().includes(q))
);
}
// Group by type
const groups = new Map<string, Array<[ItemId, ManifestEntry]>>();
for (const entry of filtered) {
const type = entry[1].type;
if (!groups.has(type)) groups.set(type, []);
groups.get(type)!.push(entry);
}
const typeOrder: string[] = ['login', 'secure_note', 'identity', 'card', 'key', 'totp', 'document'];
const typeLabel: Record<string, string> = {
login: 'logins', secure_note: 'notes', identity: 'identities',
card: 'cards', key: 'keys', totp: 'totp', document: 'documents',
};
const typeIcon: Record<string, string> = {
login: '🔑', secure_note: '📝', identity: '🪪',
card: '💳', key: '🗝', totp: '⏱', document: '📄',
};
let html = '';
for (const type of typeOrder) {
const items = groups.get(type);
if (!items || items.length === 0) continue;
html += `<div class="vault-group-header">${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}</div>`;
for (const [id, e] of items) {
const sel = id === state.selectedId ? ' selected' : '';
html += `
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
<span class="vault-entry__meta">${escapeHtml(e.icon_hint ?? '')}</span>
</div>`;
}
}
if (!html) html = '<div style="padding:24px 16px; color:#484f58; text-align:center;">no items</div>';
listEl.innerHTML = html;
listEl.querySelectorAll<HTMLElement>('.vault-entry').forEach((el) => {
el.addEventListener('click', async () => {
const id = el.dataset.id!;
state.selectedId = id;
state.loading = true;
renderSidebarList(); // update selection highlight
const resp = await sendMessage({ type: 'get_item', id });
state.loading = false;
if (resp.ok) {
const data = resp.data as { item: Item };
state.selectedItem = data.item;
state.view = 'detail';
setHash('item', id);
renderPane();
}
});
});
}
function renderPane(): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
pane.className = 'vault-pane';
switch (state.view) {
case 'detail':
if (state.selectedItem) {
renderPaneDetail(pane, state.selectedItem);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
renderPaneAdd(pane);
break;
case 'edit':
renderPaneEdit(pane);
break;
case 'trash':
renderPaneSection(pane, 'trash');
break;
case 'devices':
renderPaneSection(pane, 'devices');
break;
case 'settings':
renderPaneSection(pane, 'settings');
break;
case 'settings-vault':
renderPaneSection(pane, 'settings-vault');
break;
case 'field-history':
renderPaneSection(pane, 'field-history');
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
}
// --- Pane renderers ---
// These import the existing popup component renderers and pass the pane element.
// The popup renderers write to whatever element they receive via `app`.
async function renderPaneDetail(pane: HTMLElement, item: Item): Promise<void> {
// Dynamic import to reuse popup detail renderers
const { renderItemDetail } = await import('../popup/components/item-detail');
renderItemDetail(pane);
}
function renderPaneAdd(pane: HTMLElement): void {
import('../popup/components/item-form').then(({ renderItemForm }) => {
renderItemForm(pane, 'add');
});
}
function renderPaneEdit(pane: HTMLElement): void {
import('../popup/components/item-form').then(({ renderItemForm }) => {
renderItemForm(pane, 'edit');
});
}
async function renderPaneSection(pane: HTMLElement, section: string): Promise<void> {
switch (section) {
case 'trash': {
const { renderTrash } = await import('../popup/components/trash');
renderTrash(pane);
break;
}
case 'devices': {
const { renderDevices } = await import('../popup/components/devices');
renderDevices(pane);
break;
}
case 'settings': {
const { renderSettings } = await import('../popup/components/settings');
renderSettings(pane);
break;
}
case 'settings-vault': {
const { renderVaultSettings } = await import('../popup/components/settings-vault');
renderVaultSettings(pane);
break;
}
case 'field-history': {
const { renderFieldHistory } = await import('../popup/components/field-history');
renderFieldHistory(pane);
break;
}
}
}
// --- Init ---
async function loadManifest(): Promise<void> {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
state.entries = data.items;
}
const vsResp = await sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
state.vaultSettings = (vsResp.data as { settings: VaultSettings }).settings;
}
state.view = 'list';
render();
// Handle deep link from hash
const { view, param } = parseHash();
if (view === 'item' && param) {
const resp = await sendMessage({ type: 'get_item', id: param });
if (resp.ok) {
state.selectedId = param;
state.selectedItem = (resp.data as { item: Item }).item;
state.view = 'detail';
renderSidebarList();
renderPane();
}
} else if (view === 'add' && param) {
state.newType = param as ItemType;
state.view = 'add';
renderPane();
} else if (view === 'trash' || view === 'devices' || view === 'settings') {
state.view = view as VaultView;
renderPane();
}
}
document.addEventListener('DOMContentLoaded', async () => {
const resp = await sendMessage({ type: 'is_unlocked' });
if (resp.ok && (resp.data as { unlocked: boolean }).unlocked) {
await loadManifest();
} else {
state.view = 'locked';
render();
}
});
// Listen for session expiry
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
state.view = 'locked';
state.error = null;
state.selectedItem = null;
state.selectedId = null;
state.entries = [];
render();
}
});
// Hash change navigation
window.addEventListener('hashchange', () => {
const { view, param } = parseHash();
if (view === 'trash' || view === 'devices' || view === 'settings') {
state.view = view as VaultView;
state.selectedId = null;
state.selectedItem = null;
renderPane();
}
});
```
- [ ] **Step 4: Add vault entry point to webpack.config.js**
In `webpack.config.js`, add to the `entry` object:
```javascript
vault: './src/vault/vault.ts',
```
In the CopyPlugin patterns array, add:
```javascript
{ from: 'src/vault/vault.html', to: 'vault.html' },
{ from: 'src/vault/vault.css', to: 'vault.css' },
```
- [ ] **Step 5: Update manifest.json with commands and vault.html access**
In `manifest.json`, add a `commands` section:
```json
"commands": {
"open-vault": {
"description": "Open relicario vault"
}
}
```
Note: no `suggested_key` — the user configures it in `chrome://extensions/shortcuts`.
Add the commands listener in `src/service-worker/index.ts`:
```typescript
chrome.commands.onCommand.addListener((command) => {
if (command === 'open-vault') {
chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') });
}
});
```
- [ ] **Step 6: Build and verify**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10`
Expected: Build succeeds with vault.js in output, vault.html and vault.css copied to dist/.
Verify files exist:
```bash
ls dist/vault.html dist/vault.css dist/vault.js
```
- [ ] **Step 7: Commit**
```bash
git add src/vault/ webpack.config.js manifest.json src/service-worker/index.ts
git commit -m "feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing"
```
---
### Task 4: Resolve Shared State — Vault Tab Uses Popup's State Functions
**Files:**
- Modify: `src/popup/popup.ts`
- Modify: `src/vault/vault.ts`
- Modify: `src/popup/components/item-detail.ts` (if it calls navigate/setState)
- Modify: any popup component that calls `navigate`, `setState`, `getState`, `sendMessage`
The popup components (item-detail, item-form, trash, devices, settings, etc.) all import from `../popup` (i.e., `src/popup/popup.ts`) for `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`. When the vault tab dynamically imports these components, those imports resolve to the popup's module — but in the vault's webpack bundle, `popup.ts` won't be initialized (no `DOMContentLoaded`, wrong state).
The fix: extract the shared functions into a new `src/shared/state.ts` that both popup and vault initialize with their own state/render callbacks.
- [ ] **Step 1: Create src/shared/state.ts**
```typescript
import type { Request, Response } from './messages';
export interface StateHost {
getState(): any;
setState(partial: any): void;
navigate(view: string, extras?: any): void;
sendMessage(request: Request): Promise<Response>;
escapeHtml(s: string): string;
popOutToTab(): void;
isInTab(): boolean;
openVaultTab(hash?: string): void;
}
let host: StateHost | null = null;
export function registerHost(h: StateHost): void { host = h; }
export function getState(): any {
if (!host) throw new Error('No state host registered');
return host.getState();
}
export function setState(partial: any): void {
if (!host) throw new Error('No state host registered');
host.setState(partial);
}
export function navigate(view: string, extras?: any): void {
if (!host) throw new Error('No state host registered');
host.navigate(view, extras);
}
export function sendMessage(request: Request): Promise<Response> {
if (!host) throw new Error('No state host registered');
return host.sendMessage(request);
}
export function escapeHtml(s: string): string {
if (!host) throw new Error('No state host registered');
return host.escapeHtml(s);
}
export function popOutToTab(): void {
if (!host) throw new Error('No state host registered');
host.popOutToTab();
}
export function isInTab(): boolean {
if (!host) return false;
return host.isInTab();
}
export function openVaultTab(hash?: string): void {
if (!host) throw new Error('No state host registered');
host.openVaultTab(hash);
}
```
- [ ] **Step 2: Update popup.ts to register as host**
In `src/popup/popup.ts`, at the end of the existing exports section (before the `DOMContentLoaded` listener), call `registerHost`:
```typescript
import { registerHost } from '../shared/state';
registerHost({
getState: () => currentState,
setState,
navigate,
sendMessage,
escapeHtml,
popOutToTab,
isInTab,
openVaultTab,
});
```
Keep the existing exports — the popup's own code can still import directly from `./popup`. The state host is for cross-bundle component access.
- [ ] **Step 3: Update vault.ts to register as host**
In `src/vault/vault.ts`, register its own state host before calling `render()`:
```typescript
import { registerHost } from '../shared/state';
registerHost({
getState: () => state,
setState: (partial: Partial<VaultState>) => {
Object.assign(state, partial);
render();
},
navigate: (view: string, extras?: Partial<VaultState>) => {
Object.assign(state, { view, error: null, loading: false, ...extras });
if (view === 'list') {
state.selectedId = null;
state.selectedItem = null;
}
setHash(view);
render();
},
sendMessage,
escapeHtml,
popOutToTab: () => {}, // no-op, already in tab
isInTab: () => true,
openVaultTab: () => {}, // no-op, already in vault
});
```
- [ ] **Step 4: Update all popup components to import from shared/state**
In every file under `src/popup/components/` that imports from `../popup` or `../../popup`, change the import to use `../../shared/state` (or `../shared/state` depending on depth).
Files to update (all imports of `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`):
- `src/popup/components/item-list.ts`
- `src/popup/components/item-detail.ts`
- `src/popup/components/item-form.ts`
- `src/popup/components/unlock.ts`
- `src/popup/components/settings.ts`
- `src/popup/components/settings-vault.ts`
- `src/popup/components/trash.ts`
- `src/popup/components/devices.ts`
- `src/popup/components/field-history.ts`
- `src/popup/components/fields.ts`
- `src/popup/components/generator-panel.ts`
- `src/popup/components/attachments-disclosure.ts`
- `src/popup/components/types/login.ts`
- `src/popup/components/types/secure-note.ts`
- `src/popup/components/types/identity.ts`
- `src/popup/components/types/card.ts`
- `src/popup/components/types/key.ts`
- `src/popup/components/types/totp.ts`
- `src/popup/components/types/document.ts`
For each file, replace:
```typescript
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup';
```
with:
```typescript
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
```
Adjust relative path depth accordingly (components in `types/` need `../../../shared/state`; components directly in `components/` need `../../shared/state`).
Note: `popup.ts` itself should NOT change its own internal function definitions — it still defines `setState`, `navigate`, etc. locally. It just also calls `registerHost` to expose them to the shared state module.
- [ ] **Step 4b: Update test mocks to match new import paths**
Test files under `src/popup/components/__tests__/` and `src/popup/components/types/__tests__/` mock `../../popup` or `../../../popup`. Update all `vi.mock('../../popup', ...)` calls to `vi.mock('../../shared/state', ...)` (and similarly for `../../../popup``../../../shared/state`). The mock shape stays the same — just the path changes.
Test files to update:
- `src/popup/components/__tests__/attachments-disclosure.test.ts`
- `src/popup/components/__tests__/devices.test.ts`
- `src/popup/components/__tests__/field-history.test.ts`
- `src/popup/components/__tests__/fields.test.ts`
- `src/popup/components/__tests__/generator-panel.test.ts`
- `src/popup/components/__tests__/sections-editor.test.ts`
- `src/popup/components/__tests__/sections-render.test.ts`
- `src/popup/components/__tests__/settings-vault.test.ts`
- `src/popup/components/__tests__/trash.test.ts`
- `src/popup/components/types/__tests__/card.save.test.ts`
- `src/popup/components/types/__tests__/document.save.test.ts`
- `src/popup/components/types/__tests__/identity.save.test.ts`
- `src/popup/components/types/__tests__/key.save.test.ts`
- `src/popup/components/types/__tests__/sections-save.test.ts`
- `src/popup/components/types/__tests__/secure-note.save.test.ts`
- `src/popup/components/types/__tests__/totp.save.test.ts`
- [ ] **Step 5: Update vault.ts pane renderers to drop dynamic import wrappers**
Since the components now import from `shared/state` (which vault.ts has registered as host), the vault can import them directly. Simplify the pane render functions in `vault.ts`:
```typescript
import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form';
import { renderTrash } from '../popup/components/trash';
import { renderDevices } from '../popup/components/devices';
import { renderSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory } from '../popup/components/field-history';
```
Replace the async pane renderers with direct calls:
```typescript
function renderPane(): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
pane.className = 'vault-pane';
switch (state.view) {
case 'detail':
if (state.selectedItem) {
renderItemDetail(pane);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
renderItemForm(pane, 'add');
break;
case 'edit':
renderItemForm(pane, 'edit');
break;
case 'trash':
renderTrash(pane);
break;
case 'devices':
renderDevices(pane);
break;
case 'settings':
renderSettings(pane);
break;
case 'settings-vault':
renderVaultSettingsView(pane);
break;
case 'field-history':
renderFieldHistory(pane);
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
}
```
Remove the old `renderPaneDetail`, `renderPaneAdd`, `renderPaneEdit`, `renderPaneSection` functions.
- [ ] **Step 6: Add "+ new" button to sidebar header**
In the sidebar header of `vault.ts`, add a new button:
```html
<button class="btn" id="vault-new-btn" style="font-size:11px;">+ new</button>
```
Wire it:
```typescript
document.getElementById('vault-new-btn')?.addEventListener('click', () => {
state.newType = null;
state.view = 'add';
setHash('add');
renderPane();
});
```
- [ ] **Step 7: Build and test**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10`
Expected: Build succeeds. Both popup.js and vault.js compile.
Run: `bun test 2>&1 | tail -20`
Expected: Existing tests still pass (components import from shared/state, which delegates to registered host — tests mock the functions they need).
- [ ] **Step 8: Commit**
```bash
git add src/shared/state.ts src/vault/vault.ts src/popup/popup.ts src/popup/components/
git commit -m "feat(ext): shared state host so vault tab reuses popup components"
```
---
### Task 5: Device Settings — Session Timeout Config UI
**Files:**
- Modify: `src/popup/components/settings.ts`
- Modify: `src/vault/vault.ts` (settings nav already wired)
- [ ] **Step 1: Read current settings.ts to understand structure**
Read `src/popup/components/settings.ts` to see how the existing settings view is structured and where to add device settings.
- [ ] **Step 2: Add session timeout UI to settings view**
In `src/popup/components/settings.ts`, in the `renderSettings` function, add a "device settings" section at the top (before vault settings link):
```html
<div class="settings-section">
<h4 class="settings-heading">device</h4>
<div class="form-group">
<label class="label">auto-lock</label>
<div class="inline-row" style="gap:8px;">
<select id="timeout-mode" style="flex:1;">
<option value="inactivity"${config.mode === 'inactivity' ? ' selected' : ''}>after inactivity</option>
<option value="every_time"${config.mode === 'every_time' ? ' selected' : ''}>every time</option>
</select>
<select id="timeout-minutes" style="width:80px;"${config.mode === 'every_time' ? ' disabled' : ''}>
${[5, 15, 30, 60].map(m => `<option value="${m}"${config.mode === 'inactivity' && config.minutes === m ? ' selected' : ''}>${m} min</option>`).join('')}
</select>
</div>
</div>
</div>
```
Fetch the current config on render:
```typescript
const configResp = await sendMessage({ type: 'get_session_config' });
const config = configResp.ok
? (configResp.data as { config: SessionTimeoutConfig }).config
: { mode: 'inactivity' as const, minutes: 15 };
```
Wire the change handlers:
```typescript
document.getElementById('timeout-mode')?.addEventListener('change', async (e) => {
const mode = (e.target as HTMLSelectElement).value;
const minutesSelect = document.getElementById('timeout-minutes') as HTMLSelectElement;
if (mode === 'every_time') {
minutesSelect.disabled = true;
await sendMessage({ type: 'update_session_config', config: { mode: 'every_time' } });
} else {
minutesSelect.disabled = false;
const minutes = parseInt(minutesSelect.value, 10);
await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } });
}
});
document.getElementById('timeout-minutes')?.addEventListener('change', async (e) => {
const minutes = parseInt((e.target as HTMLSelectElement).value, 10);
await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } });
});
```
Import `SessionTimeoutConfig` from `../../shared/messages`.
Note: `renderSettings` needs to become `async` for the `sendMessage` call. Update the function signature and the caller in popup.ts render switch.
- [ ] **Step 3: Build and test**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5`
Expected: Build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/popup/components/settings.ts src/popup/popup.ts
git commit -m "feat(ext/settings): add session timeout config to device settings"
```
---
### Task 6: Router — Allow vault.html as Trusted Sender
**Files:**
- Modify: `src/service-worker/router/index.ts`
- [ ] **Step 1: Update router to recognize vault.html**
In `src/service-worker/router/index.ts`, add vault URL detection:
```typescript
const vaultUrl = chrome.runtime.getURL('vault.html');
```
Update `isPopup` to include vault:
```typescript
const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl);
```
- [ ] **Step 2: Build**
Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5`
Expected: Build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/service-worker/router/index.ts
git commit -m "fix(ext/router): allow vault.html as trusted sender for popup-only messages"
```
---
### Task 7: Manual Browser Testing
- [ ] **Step 1: Build the extension**
```bash
cd /home/alee/Sources/relicario/extension && bun run build
```
- [ ] **Step 2: Reload extension in Chrome**
Go to `chrome://extensions`, click reload on relicario.
- [ ] **Step 3: Test popup basics**
- Open popup, verify search doesn't auto-focus
- Type `/` to focus search, verify text goes forward
- Use arrow keys and Enter to open an item
- Verify Shift+F opens vault tab
- Verify "⤴" button in toolbar opens vault tab
- [ ] **Step 4: Test vault tab**
- Verify vault tab shows lock screen if locked
- Unlock with passphrase
- Verify sidebar shows items grouped by type
- Click an item — detail appears in right pane
- Click "+ new" — type selection appears in pane
- Select a type — form appears in pane
- Click trash/devices/settings in sidebar nav
- Use `/` to focus sidebar search
- Verify URL hash updates as you navigate
- [ ] **Step 5: Test session timeout**
- Go to settings in either popup or vault tab
- Set timeout to 5 minutes
- Wait 5 minutes (or temporarily set to a short value for testing)
- Verify both popup and vault tab show lock screen
- [ ] **Step 6: Test popup → vault navigation**
- Open popup, view an item, click popout button → verify vault tab opens with that item selected
- Open popup, click "+ new", select card → verify vault tab opens with card form
- Open popup lock screen, click "open vault" → verify vault tab opens
- [ ] **Step 7: Fix any issues found, rebuild, retest, commit**