# Extension Restructure 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:** Eliminate the two steepest learning cliffs in the extension — the 1027-LOC `vault.ts` monolith and `setup.ts`'s direct WASM orchestration — and close the last CLI/extension parity gap (`relicario status`) by introducing 6 new modules, 3 new SW message handlers, and typed `StateHost`. **Architecture:** Types first, then extract, then split. Phase 1 lays the typed `StateHost` foundation that phases 3 and 4 build on. Phase 2 deduplicates SW router helpers into `storage.ts` and `vault.ts`. Phase 3 moves all setup-wizard crypto orchestration into the SW. Phase 4 splits `vault.ts` into 5 focused modules and lifts the `vault_locked` channel into `shared/state.ts`. Phase 5 sweeps 5 small P2 fixes. Phase 6 adds the `get_vault_status` parity feature. **Tech Stack:** TypeScript, vitest + happy-dom, webpack, Rust core via WASM. No new runtime dependencies. **Spec:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` --- ## File Structure ### Created - `extension/src/shared/popup-state.ts` — `View` + `PopupState` types (moved from `popup/popup.ts`). - `extension/src/shared/__tests__/state.test.ts` — `StateHost` registration / getState / setState / `__resetHostForTests` coverage. - `extension/src/shared/__tests__/state-vault-locked.test.ts` — `vault_locked` channel intercept coverage. - `extension/src/service-worker/storage.ts` — `loadDeviceSettings` / `saveDeviceSettings` / `loadBlacklist` / `saveBlacklist`. - `extension/src/service-worker/__tests__/storage.test.ts` — storage round-trip coverage. - `extension/src/service-worker/__tests__/vault.test.ts` (if absent) — `create_vault`, `attach_vault`, `get_vault_status` handler coverage. - `extension/src/service-worker/__tests__/vault-status.test.ts` — status handler coverage. - `extension/src/vault/vault-shell.ts` — DOM scaffolding, color-scheme apply, onMessage wiring. - `extension/src/vault/vault-sidebar.ts` — sidebar categories, debounced search, nav buttons, status slot wiring. - `extension/src/vault/vault-list.ts` — list pane rendering and row rendering. - `extension/src/vault/vault-drawer.ts` — drawer open/close/render + `ensureDrawerClosedForRoute`. - `extension/src/vault/vault-form-wrapper.ts` — `renderFormWrapped` + sticky bar + header. - `extension/src/vault/vault-status.ts` — sidebar-footer status indicator (Phase 6). - `extension/src/vault/__tests__/drawer-state.test.ts` — drawer auto-close on navigation. - `extension/src/vault/__tests__/status-indicator.test.ts` — sidebar status renderer. ### Modified - `extension/src/shared/state.ts` — typed `StateHost`, double-registration guard, `__resetHostForTests`, `sendMessage` wrapper (Phase 1 lays the wrapper; Phase 4 fills the `vault_locked` body). - `extension/src/shared/messages.ts` — add `create_vault`, `attach_vault`, `get_vault_status` (Phase 3 + 6). - `extension/src/shared/types.ts` — re-export `View` / `PopupState` from `popup-state.ts` for compatibility (or leave the import paths and skip the re-export — see Task 1.1). - `extension/src/popup/popup.ts` — drop `View` + `PopupState` definitions (now in `shared/popup-state.ts`); import them instead. - `extension/src/service-worker/router/popup-only.ts` — delete duplicated `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` / `itemToManifestEntry`; import from `storage.ts` / `vault.ts`. - `extension/src/service-worker/router/content-callable.ts` — same deduplication. - `extension/src/service-worker/vault.ts` — gains `itemToManifestEntry` export + 3 new handlers (`create_vault`, `attach_vault`, `get_vault_status`) + cached sync state. - `extension/src/service-worker/index.ts` — invert inactivity-timer reset rule (Phase 5); clear `state.gitHost` on session expiry. - `extension/src/service-worker/session-timer.ts` — define `READ_ONLY_CONTENT_CALLABLE` exclusion set with doc comment. - `extension/src/setup/setup.ts` — delete WASM dynamic-import + `loadWasm` + module `wasm` binding + `verifiedHandle`; convert `renderStepN`/`attachStepN` pairs to `SetupStep` step-registry objects; add `clearWizardState()`. - `extension/src/setup/__tests__/setup.test.ts` — assert step-registry shape. - `extension/src/vault/vault.ts` — trim to ~200 LOC of routing + state; delete `vault_locked` RPC intercept (lifted to `shared/state.ts`). - `extension/src/popup/components/settings.ts` and `settings-vault.ts` — extract `teardownSettingsCommon`; both call it. - `extension/src/popup/components/devices.ts` and `trash.ts` — switch `Promise.all` to `Promise.allSettled` with per-slot fallback. - `extension/src/content/detector.ts` — debounce MutationObserver `scan()`. - `extension/src/__stubs__/relicario_wasm.stub.ts` — round out missing entries needed by the new SW handler tests (per DEV-C P2 note that only ~7 of ~25 are stubbed). ### Untouched - `extension/src/wasm.d.ts` (no new WASM entry points needed; verify in Phase 3 Task 3.2). - `relicario-core` / `relicario-cli` / `relicario-wasm` Rust crates. - `extension/src/vault/vault.html` / `vault.css`. - All Plan A (security/docs polish) territory. - All Plan B (CLI restructure) territory. --- ## Phase 1 — `StateHost` typing + `__resetHostForTests` (P1.6) **Effort:** S-M. **Depends on:** none. **Blocks:** Phases 3, 4. ### Task 1.1: Move `View` and `PopupState` to `shared/popup-state.ts` **Files:** - Create: `extension/src/shared/popup-state.ts` - Modify: `extension/src/popup/popup.ts` (drop definitions, import from new location) - Modify: any callers that import `View` / `PopupState` from `popup/popup.ts` - [ ] **Step 1: Identify all importers** ```bash cd extension && grep -rn "from '\.\./popup/popup'\|from '\.\./\.\./popup/popup'\|from '\./popup/popup'" src/ | grep -i "View\|PopupState" ``` Record the list. Most are in `src/popup/components/*.ts` and `src/vault/vault.ts`. - [ ] **Step 2: Read `popup.ts` to find the current `View` and `PopupState` definitions** ```bash cd extension && grep -n "export type View\|export interface PopupState\|export type PopupState" src/popup/popup.ts ``` Copy the type and interface bodies verbatim. - [ ] **Step 3: Create `shared/popup-state.ts` with the copied types** `extension/src/shared/popup-state.ts`: ```typescript // State shared between popup and vault surfaces. Kept here (not in popup/) so // shared/state.ts can import without creating a popup→shared circular dep. import type { ItemId, ManifestEntry, ItemType } from './types'; export type View = | 'unlock' | 'list' | 'detail' | 'add' | 'edit' | 'history' | 'settings' | 'devices' | 'trash' | 'backup' | 'import'; export interface PopupState { view: View; loading: boolean; error: string | null; entries: Array<[ItemId, ManifestEntry]>; selectedItem: import('./types').Item | null; searchQuery: string; newType: ItemType | null; // … copy every field from popup.ts's current PopupState exactly } ``` **Important:** copy the existing `PopupState` body field-for-field; do not invent fields. If the popup currently has narrower or wider types, preserve them. Use a brief comment if a field's purpose is non-obvious (mirror existing comments). - [ ] **Step 4: Update `popup/popup.ts` to import the moved types** Replace the original `export type View = …` and `export interface PopupState { … }` blocks in `extension/src/popup/popup.ts` with: ```typescript export type { View, PopupState } from '../shared/popup-state'; ``` (Re-export keeps existing consumers happy without a wider sweep in this task; Phase 1 Task 1.4 sweeps them.) - [ ] **Step 5: Build to verify** ```bash cd extension && npx tsc --noEmit 2>&1 | tail -20 ``` Expected: clean compile (the re-export means existing import paths still resolve). - [ ] **Step 6: Run vitest** ```bash cd extension && npx vitest run ``` Expected: all current tests pass (the type relocation is no-behavior-change). - [ ] **Step 7: Commit** ```bash cd extension/.. && git add extension/src/shared/popup-state.ts extension/src/popup/popup.ts git commit -m "refactor(ext/shared): move View + PopupState to shared/popup-state.ts Foundation for Plan C Phase 1: shared/state.ts (next task) needs to import PopupState without creating a popup→shared circular dep. popup.ts now re-exports from the new location so existing callers don't break in this task. Task 1.4 will sweep them onto the canonical import path. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 1.2: Rewrite `shared/state.ts` with typed `StateHost` + double-register guard + `__resetHostForTests` **Files:** - Modify: `extension/src/shared/state.ts` - [ ] **Step 1: Read current state.ts** ```bash cd extension && cat src/shared/state.ts ``` Record the current shape (functions, the `host` singleton, any inline types). - [ ] **Step 2: Rewrite `state.ts` with the typed contract** Replace the entire file with: ```typescript // extension/src/shared/state.ts // // Single channel for popup and vault-tab UI to read/write app state and // dispatch messages to the service worker. Two registered hosts (popup, // vault tab) implement StateHost; each surface calls registerHost(this) at // boot. // // The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts // into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature; // the body is a thin pass-through until Phase 4. import type { Request, Response } from './messages'; import type { PopupState, View } from './popup-state'; export interface StateHost { getState(): PopupState; setState(partial: Partial): void; navigate(view: View, extras?: Partial): void; sendMessage(request: Request): Promise; escapeHtml(s: string): string; popOutToTab(): void; isInTab(): boolean; openVaultTab(hash?: string): void; } let host: StateHost | null = null; export function registerHost(h: StateHost): void { if (host) throw new Error('state host already registered'); host = h; } /** Test-only — vitest beforeEach() calls this to break inter-test leakage. */ export function __resetHostForTests(): void { host = null; } export function getState(): PopupState { if (!host) throw new Error('No state host registered'); return host.getState(); } export function setState(partial: Partial): void { if (!host) throw new Error('No state host registered'); host.setState(partial); } export function navigate(view: View, extras?: Partial): void { if (!host) throw new Error('No state host registered'); host.navigate(view, extras); } /** * Phase 4 will add a vault_locked intercept here. For now, this is a pure * pass-through so the signature is stable for Phase 4 to fill. */ export async function sendMessage(request: Request): Promise { 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) throw new Error('No state host registered'); return host.isInTab(); } export function openVaultTab(hash?: string): void { if (!host) throw new Error('No state host registered'); host.openVaultTab(hash); } ``` - [ ] **Step 3: Build to verify** ```bash cd extension && npx tsc --noEmit 2>&1 | tail -30 ``` Expected: TS errors will surface in callers that previously relied on `any`-typed access. Record the error list (it will be the work surface for Task 1.4). - [ ] **Step 4: Commit (skip if step 3 shows errors)** If step 3 is clean, commit. If there are errors, hold the commit until Task 1.4 fixes the callers — bundle them into one commit there. ```bash cd extension/.. && git add extension/src/shared/state.ts # Hold commit until callers compile clean (Task 1.4) ``` --- ### Task 1.3: Sweep `as any` casts on `getState`/`setState`/`navigate` call sites **Files:** - Modify: every caller of `getState`/`setState`/`navigate`/`sendMessage`/`escapeHtml`/`popOutToTab`/`isInTab`/`openVaultTab` from `shared/state` that has a TS error after Task 1.2. - [ ] **Step 1: Get the TS error list** ```bash cd extension && npx tsc --noEmit 2>&1 | grep "error TS" | head -50 ``` Expected: 15-30 errors in `popup/components/*.ts` and `vault/vault.ts`. - [ ] **Step 2: Fix each error** For each error, the fix pattern is: - `(state as any).field` → `state.field` (the field exists in `PopupState`; remove the cast). - `setState({ field: value } as any)` → `setState({ field: value })` (type the field correctly). - `setState({ foo: x } as unknown as PopupState)` → `setState({ foo: x })` (if `foo` is in `PopupState`). - If a caller is using a field that genuinely isn't in `PopupState`, add it to `PopupState` in `shared/popup-state.ts` with a comment justifying its addition. Do not introduce new `as any` casts. The goal is removing them, not relocating. - [ ] **Step 3: Re-run tsc** ```bash cd extension && npx tsc --noEmit 2>&1 | tail -10 ``` Expected: clean. - [ ] **Step 4: Run vitest** ```bash cd extension && npx vitest run ``` Expected: all tests pass. - [ ] **Step 5: Commit (bundle with Task 1.2 if held)** ```bash cd extension/.. && git add extension/src/shared/state.ts $(git diff --name-only -- extension/src/) git commit -m "refactor(ext/shared): typed StateHost + sweep as-any casts (Plan C Phase 1) Replaces the previously any-typed StateHost contract with a typed interface. Adds double-registration guard and __resetHostForTests for vitest. sendMessage wrapper is currently a pass-through; Phase 4 will fill its body with the vault_locked intercept lifted from vault.ts. Sweeps callers that relied on as-any to access typed PopupState fields. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 1.4: Sweep imports of `View` and `PopupState` to canonical path **Files:** - Modify: every caller still importing `View` / `PopupState` from `popup/popup.ts`. - [ ] **Step 1: Find remaining importers** ```bash cd extension && grep -rn "from '\.\./popup/popup'\|from '\.\./\.\./popup/popup'" src/ | grep -E "View|PopupState" ``` - [ ] **Step 2: Rewrite each import** For each match, change: ```typescript import type { View, PopupState } from '../popup/popup'; ``` To: ```typescript import type { View, PopupState } from '../shared/popup-state'; ``` (Adjust the relative path per the file location.) - [ ] **Step 3: Remove the re-export from `popup/popup.ts`** Delete the `export type { View, PopupState } from '../shared/popup-state';` line added in Task 1.1. - [ ] **Step 4: Build + test** ```bash cd extension && npx tsc --noEmit && npx vitest run ``` Both should be clean. - [ ] **Step 5: Commit** ```bash cd extension/.. && git add $(git diff --name-only -- extension/src/) extension/src/popup/popup.ts git commit -m "refactor(ext): sweep View/PopupState imports to shared/popup-state (Plan C Phase 1) Removes the re-export shim from popup/popup.ts now that all callers point at the canonical shared/popup-state. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 1.5: Add `state.test.ts` coverage **Files:** - Create: `extension/src/shared/__tests__/state.test.ts` - [ ] **Step 1: Write the test file** `extension/src/shared/__tests__/state.test.ts`: ```typescript import { beforeEach, describe, expect, it, vi } from 'vitest'; import { registerHost, __resetHostForTests, getState, setState, navigate, sendMessage, } from '../state'; import type { StateHost } from '../state'; import type { PopupState } from '../popup-state'; function makeHost(initial?: Partial): StateHost { let state: PopupState = { view: 'list', loading: false, error: null, entries: [], selectedItem: null, searchQuery: '', newType: null, ...initial, } as PopupState; return { getState: () => state, setState: (partial) => { state = { ...state, ...partial }; }, navigate: vi.fn(), sendMessage: vi.fn().mockResolvedValue({ ok: true }), escapeHtml: (s) => s, popOutToTab: vi.fn(), isInTab: () => false, openVaultTab: vi.fn(), }; } describe('shared/state', () => { beforeEach(() => { __resetHostForTests(); }); it('register-then-getState round-trips', () => { const host = makeHost({ view: 'detail' }); registerHost(host); expect(getState().view).toBe('detail'); }); it('double-register throws', () => { registerHost(makeHost()); expect(() => registerHost(makeHost())).toThrow(/already registered/); }); it('__resetHostForTests clears the singleton', () => { registerHost(makeHost()); __resetHostForTests(); expect(() => getState()).toThrow(/No state host/); }); it('getState without host throws', () => { expect(() => getState()).toThrow(/No state host/); }); it('setState merges partial state', () => { const host = makeHost(); registerHost(host); setState({ loading: true }); expect(getState().loading).toBe(true); }); it('navigate delegates to host', () => { const host = makeHost(); registerHost(host); navigate('settings'); expect(host.navigate).toHaveBeenCalledWith('settings', undefined); }); it('sendMessage delegates to host', async () => { const host = makeHost(); registerHost(host); const resp = await sendMessage({ type: 'is_unlocked' }); expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' }); expect(resp).toEqual({ ok: true }); }); }); ``` - [ ] **Step 2: Run the test** ```bash cd extension && npx vitest run src/shared/__tests__/state.test.ts ``` Expected: 7 passed. - [ ] **Step 3: Commit** ```bash cd extension/.. && git add extension/src/shared/__tests__/state.test.ts git commit -m "test(ext/shared): cover StateHost registration + reset (Plan C Phase 1) Co-Authored-By: Claude Opus 4.7 " ``` --- ## Phase 2 — Extract `service-worker/storage.ts` + move `itemToManifestEntry` (P1.9) **Effort:** S. **Depends on:** none (parallel with Phase 1). **Blocks:** none. ### Task 2.1: Create `service-worker/storage.ts` **Files:** - Create: `extension/src/service-worker/storage.ts` - Read: `extension/src/service-worker/router/popup-only.ts:687-703` - Read: `extension/src/service-worker/router/content-callable.ts:187-205` - [ ] **Step 1: Read the existing helper bodies** ```bash cd extension && grep -A 20 "^function loadDeviceSettings\|^function saveDeviceSettings\|^function loadBlacklist\|^function saveBlacklist" src/service-worker/router/popup-only.ts src/service-worker/router/content-callable.ts ``` Record the four function bodies. They should be identical or near-identical between the two router files (that's the point of P1.9). - [ ] **Step 2: Create `storage.ts`** `extension/src/service-worker/storage.ts`: ```typescript // Single home for chrome.storage.local reads/writes done by the service // worker. Both router files (popup-only.ts and content-callable.ts) import // from here — the duplicated definitions in those files lift out as part // of Plan C Phase 2. import type { DeviceSettings } from '../shared/types'; const DEFAULT_DEVICE_SETTINGS: DeviceSettings = { captureEnabled: false, captureStyle: 'bar', }; export async function loadDeviceSettings(): Promise { // Copy the body from popup-only.ts's loadDeviceSettings verbatim, then // replace any inline default with DEFAULT_DEVICE_SETTINGS above. const stored = await chrome.storage.local.get(['device_settings']); return (stored.device_settings as DeviceSettings | undefined) ?? DEFAULT_DEVICE_SETTINGS; } export async function saveDeviceSettings(settings: DeviceSettings): Promise { await chrome.storage.local.set({ device_settings: settings }); } export async function loadBlacklist(): Promise { // Copy the body from popup-only.ts's loadBlacklist verbatim. const stored = await chrome.storage.local.get(['capture_blacklist']); return (stored.capture_blacklist as string[] | undefined) ?? []; } export async function saveBlacklist(hosts: string[]): Promise { await chrome.storage.local.set({ capture_blacklist: hosts }); } ``` **Important:** Step 1 produced the actual bodies. Use those verbatim. The snippets above are placeholders illustrating the shape; if the real bodies have different defaults / serialization, preserve them exactly. - [ ] **Step 3: Run vitest (verify the file imports cleanly)** ```bash cd extension && npx vitest run ``` Expected: all existing tests pass (no behavior change yet — storage.ts is unused so far). - [ ] **Step 4: Hold commit until Task 2.3 deduplicates the routers** --- ### Task 2.2: Move `itemToManifestEntry` to `service-worker/vault.ts` **Files:** - Modify: `extension/src/service-worker/vault.ts` - Read: `extension/src/service-worker/router/popup-only.ts:707` (locate `itemToManifestEntry`) - Read: `extension/src/service-worker/router/content-callable.ts:169` (locate the duplicate) - [ ] **Step 1: Read both definitions and confirm they match** ```bash cd extension && grep -A 25 "function itemToManifestEntry" src/service-worker/router/popup-only.ts src/service-worker/router/content-callable.ts ``` If they're identical, proceed. If they differ, log the difference and pick the more recent / more correct version (typically `popup-only.ts`'s, since that file gets more attention). - [ ] **Step 2: Add the function to `service-worker/vault.ts` as a named export** Append to `extension/src/service-worker/vault.ts`: ```typescript import type { Item, ManifestEntry } from '../shared/types'; /** * Project a decrypted Item into its ManifestEntry shape for browse-without- * decrypt views. Both router files use this; defined here (the SW's * vault-orchestration home) instead of duplicated in each router. */ export function itemToManifestEntry(item: Item): ManifestEntry { // Paste the body from popup-only.ts:707 verbatim. return { id: item.id, title: item.title, type: item.r#type, // … etc. — paste exact body }; } ``` - [ ] **Step 3: Hold commit until Task 2.3 cleans up the duplicates** --- ### Task 2.3: Dedupe router files + add `storage.test.ts` **Files:** - Modify: `extension/src/service-worker/router/popup-only.ts` - Modify: `extension/src/service-worker/router/content-callable.ts` - Create: `extension/src/service-worker/__tests__/storage.test.ts` - [ ] **Step 1: Replace inline functions with imports in popup-only.ts** In `extension/src/service-worker/router/popup-only.ts`: - Delete the `function loadDeviceSettings`, `function saveDeviceSettings`, `function loadBlacklist`, `function saveBlacklist`, `function itemToManifestEntry` definitions. - Add at the top of the file: ```typescript import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; import { itemToManifestEntry } from '../vault'; ``` - [ ] **Step 2: Same for content-callable.ts** In `extension/src/service-worker/router/content-callable.ts`: - Delete the same five definitions. - Add the same imports. - [ ] **Step 3: Write `storage.test.ts`** `extension/src/service-worker/__tests__/storage.test.ts`: ```typescript import { beforeEach, describe, expect, it, vi } from 'vitest'; import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; function mockChromeStorage(initial: Record = {}) { const store: Record = { ...initial }; (global as { chrome: unknown }).chrome = { storage: { local: { get: vi.fn((keys: string | string[]) => { const arr = Array.isArray(keys) ? keys : [keys]; const out: Record = {}; for (const k of arr) if (k in store) out[k] = store[k]; return Promise.resolve(out); }), set: vi.fn((kv: Record) => { Object.assign(store, kv); return Promise.resolve(); }), }, }, } as never; return store; } describe('service-worker/storage', () => { beforeEach(() => { mockChromeStorage(); }); it('loadDeviceSettings returns default when storage is empty', async () => { const s = await loadDeviceSettings(); expect(s.captureEnabled).toBe(false); expect(s.captureStyle).toBe('bar'); }); it('loadDeviceSettings returns stored value', async () => { mockChromeStorage({ device_settings: { captureEnabled: true, captureStyle: 'toast' } }); const s = await loadDeviceSettings(); expect(s.captureEnabled).toBe(true); expect(s.captureStyle).toBe('toast'); }); it('saveDeviceSettings persists', async () => { const store = mockChromeStorage(); await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' }); expect(store.device_settings).toEqual({ captureEnabled: true, captureStyle: 'bar' }); }); it('loadBlacklist returns empty array by default', async () => { expect(await loadBlacklist()).toEqual([]); }); it('saveBlacklist / loadBlacklist round-trips', async () => { await saveBlacklist(['example.com', 'evil.test']); expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']); }); }); ``` - [ ] **Step 4: Run tests** ```bash cd extension && npx vitest run src/service-worker/__tests__/storage.test.ts ``` Expected: 5 passed. - [ ] **Step 5: Run full suite** ```bash cd extension && npx vitest run ``` Expected: all tests pass (the router tests still pass because they exercise dispatch behavior, which is unchanged). - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/service-worker/storage.ts extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/router/content-callable.ts extension/src/service-worker/__tests__/storage.test.ts git commit -m "refactor(ext/sw): extract storage.ts + move itemToManifestEntry (Plan C Phase 2) P1.9: loadDeviceSettings / loadBlacklist / saveBlacklist / saveDeviceSettings + itemToManifestEntry were duplicated across popup-only.ts and content-callable.ts. Lifts the four storage helpers into service-worker/ storage.ts and itemToManifestEntry into service-worker/vault.ts. Both router files now import from one home each. Adds storage.test.ts covering round-trips and defaults. Co-Authored-By: Claude Opus 4.7 " ``` --- ## Phase 3 — Setup wizard SW migration + step registry (P1.4) **Effort:** L. **Depends on:** Phase 1. ### Task 3.1: Add `create_vault` / `attach_vault` / `get_vault_status` to messages.ts **Files:** - Modify: `extension/src/shared/messages.ts` - [ ] **Step 1: Read current Request union and POPUP_ONLY_TYPES set** ```bash cd extension && grep -n "POPUP_ONLY_TYPES\|export type Request" src/shared/messages.ts | head -10 ``` - [ ] **Step 2: Add the three new request shapes** In `extension/src/shared/messages.ts`, add to the `Request` union: ```typescript | { type: 'create_vault'; config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string } | { type: 'attach_vault'; config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' } ``` (`VaultConfig` is presumably already defined in `shared/types.ts` or similar — find and import if needed.) - [ ] **Step 3: Add the response interfaces** Below the existing response interfaces, add: ```typescript export interface CreateVaultResponse { ok: true; data: { referenceImageBytes: Uint8Array; deviceName: string; recoveryQrAvailable: true; }; } export interface AttachVaultResponse { ok: true; data: { deviceName: string }; } export interface GetVaultStatusResponse { ok: true; data: { ahead: number; behind: number; lastSyncAt: number | null; pendingItems: number; }; } ``` - [ ] **Step 4: Add the three types to `POPUP_ONLY_TYPES`** Find the set and add: ```typescript 'create_vault', 'attach_vault', 'get_vault_status', ``` - [ ] **Step 5: Build to verify** ```bash cd extension && npx tsc --noEmit 2>&1 | tail -10 ``` Expected: clean (the new types aren't consumed yet). - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/shared/messages.ts git commit -m "feat(ext/messages): add create_vault, attach_vault, get_vault_status (Plan C Phase 3 prep) Adds the request shapes + response interfaces. POPUP_ONLY_TYPES set grows by three. SW handlers in service-worker/vault.ts land in the next tasks. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 3.2: Implement `create_vault` SW handler **Files:** - Modify: `extension/src/service-worker/vault.ts` - Modify: `extension/src/service-worker/router/popup-only.ts` (dispatch entry) - Modify: `extension/src/__stubs__/relicario_wasm.stub.ts` (round out missing entries needed by the test) - Create: `extension/src/service-worker/__tests__/vault.test.ts` (if absent — check first) - [ ] **Step 1: Read the existing setup.ts crypto orchestration** ```bash cd extension && grep -n "embed_image_secret\|register_device\|manifest_encrypt" src/setup/setup.ts | head -10 ``` Record the call sequence: the spec says it's `unlock` → `embed_image_secret` → `register_device` → `manifest_encrypt` then push to git host. - [ ] **Step 2: Check whether vault.test.ts already exists** ```bash cd extension && ls src/service-worker/__tests__/ ``` If `vault.test.ts` is absent, create it in Step 5 of this task; otherwise add a `describe('create_vault', ...)` block. - [ ] **Step 3: Write the failing test first** `extension/src/service-worker/__tests__/vault.test.ts` (or append a describe block): ```typescript import { beforeEach, describe, expect, it, vi } from 'vitest'; // Will need: // - mock chrome.storage.local // - stub the GitHost via a fake implementation // - extend relicario_wasm.stub.ts to return realistic values for unlock, // embed_image_secret, register_device, manifest_encrypt // - call route({ type: 'create_vault', ... }, makeState(), popupSender) // - assert the response shape: { ok: true, data: { referenceImageBytes, deviceName, recoveryQrAvailable } } describe('create_vault SW handler', () => { beforeEach(() => { // chrome.* mock, gitHost mock, wasm stub }); it('runs the create flow end-to-end and returns reference image bytes', async () => { // … set up state with a stubbed gitHost that captures putBlob calls // … set up wasm stub: unlock → handle, embed_image_secret → bytes, // register_device → keypair, manifest_encrypt → bytes // … call the create_vault handler // … assert response.data.referenceImageBytes is a Uint8Array // … assert gitHost.putBlob was called with manifest.enc + params.json }); it('returns ok:false with a useful error when image embedding fails', async () => { // … wasm stub embed_image_secret throws // … assert response.ok === false and error string is descriptive }); }); ``` - [ ] **Step 4: Run the test (expect FAIL — handler not implemented)** ```bash cd extension && npx vitest run src/service-worker/__tests__/vault.test.ts ``` Expected: FAIL (no handler dispatched). - [ ] **Step 5: Implement the handler in `service-worker/vault.ts`** Add to `extension/src/service-worker/vault.ts`: ```typescript import type { CreateVaultResponse } from '../shared/messages'; /** * Creates a new vault end-to-end inside the SW. Holds its own SessionHandle * for the duration; does not depend on the user-facing inactivity timer. * On success, transitions the SW into the unlocked state (the SessionHandle * stays alive until the SW receives a lock message or the timer expires). * On failure, the handle is locked (zeroize key) then freed. * * Cite: docs/superpowers/specs/2026-05-04-extension-restructure-design.md * phase 3 (P1.4) for the rationale on moving this out of setup.ts. */ export async function handleCreateVault( msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string }, state: RouterState, ): Promise { let handle: SessionHandle | null = null; try { // 1. Embed the random secret into the carrier image. const carrierBytes = new Uint8Array(msg.carrierImageBytes); const referenceImageBytes = state.wasm.embed_image_secret(carrierBytes); // 2. Derive Argon2id params; compute master key via unlock. const params = state.wasm.default_vault_settings_json(); // … (orchestration body — paste from setup.ts:NNN-NNN with adaptation) // 3. Register the device, get the device keypair. const keys = state.wasm.register_device(msg.deviceName) as { signing_public_key: string; deploy_public_key: string; }; // 4. Encrypt the empty manifest + push to remote. const manifestBytes = state.wasm.manifest_encrypt(emptyManifestJson, handle); await state.gitHost!.putBlob('manifest.enc', manifestBytes); // … push params.json, devices.json, etc. via gitHost // 5. Transition SW into unlocked state. state.sessionHandle = handle; handle = null; // ownership transferred — don't lock-and-free in finally return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true, }, }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } finally { // Per Plan A: lock then free if we still own the handle. if (handle) { try { state.wasm.lock(handle); } catch { /* lock already happened */ } handle.free(); } } } ``` **Important:** the orchestration body (steps 1-4) requires copying the exact sequence from `setup.ts`. Read `setup.ts` carefully to mirror the existing logic — do not invent new steps. The plan can't show every line here because the setup.ts body is the source of truth. - [ ] **Step 6: Wire the dispatch in `service-worker/router/popup-only.ts`** Add a case to the router's switch: ```typescript case 'create_vault': return handleCreateVault(msg, state); ``` Import `handleCreateVault` from `../vault`. - [ ] **Step 7: Round out the WASM stub for the test** In `extension/src/__stubs__/relicario_wasm.stub.ts`, ensure the four functions used by the handler return usable values when `state.wasm.X = ...` is overridden in the test. Add stub entries that throw a clear "not mocked" message: ```typescript export const default_vault_settings_json = (): string => '{}'; export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); }; // (register_device and manifest_encrypt already in stub per Task 1) ``` - [ ] **Step 8: Run the test (expect PASS)** ```bash cd extension && npx vitest run src/service-worker/__tests__/vault.test.ts ``` Expected: both cases pass. - [ ] **Step 9: Commit** ```bash cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/__stubs__/relicario_wasm.stub.ts extension/src/service-worker/__tests__/vault.test.ts git commit -m "feat(ext/sw): create_vault handler (Plan C Phase 3) Lifts the create-vault orchestration out of setup.ts into the SW. The handler holds its own SessionHandle for the duration; on success the handle transitions to SW-owned (unlocked state). On failure, the handle is locked then freed per Plan A's policy. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 3.3: Implement `attach_vault` SW handler **Files:** - Modify: `extension/src/service-worker/vault.ts` - Modify: `extension/src/service-worker/router/popup-only.ts` - Modify: `extension/src/service-worker/__tests__/vault.test.ts` - [ ] **Step 1: Add the attach test block** In `extension/src/service-worker/__tests__/vault.test.ts`: ```typescript describe('attach_vault SW handler', () => { it('extracts image secret, derives key, registers device', async () => { // … wasm stub: extract_image_secret → bytes, unlock → handle, // register_device → keypair // … call attach_vault handler with a reference JPEG // … assert response.data.deviceName matches input }); }); ``` - [ ] **Step 2: Run (expect FAIL)** - [ ] **Step 3: Implement `handleAttachVault` in `service-worker/vault.ts`** Same shape as `handleCreateVault` but the crypto sequence is: 1. `extract_image_secret(referenceImageBytes)` → 32-byte secret 2. `unlock(passphrase, image_secret, params)` → SessionHandle 3. `register_device(deviceName)` → keypair 4. Persist device.json to gitHost. Refer to `setup.ts` for the exact sequence currently used in the attach flow. - [ ] **Step 4: Wire dispatch in popup-only.ts** ```typescript case 'attach_vault': return handleAttachVault(msg, state); ``` - [ ] **Step 5: Run test (expect PASS)** - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/__tests__/vault.test.ts git commit -m "feat(ext/sw): attach_vault handler (Plan C Phase 3) Same shape as create_vault: SW owns image-secret extract + unlock + register_device + device.json persist. setup.ts will call this in place of its current direct WASM orchestration in Task 3.5. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 3.4: Delete WASM imports + `loadWasm` + `verifiedHandle` from setup.ts **Files:** - Modify: `extension/src/setup/setup.ts` - [ ] **Step 1: Locate the WASM imports** ```bash cd extension && grep -n "from 'relicario-wasm'\|loadWasm\|verifiedHandle" src/setup/setup.ts | head -10 ``` - [ ] **Step 2: Delete the dynamic import block at lines 28-37** Remove the `loadWasm()` helper and the module-level `wasm` variable. Setup no longer talks to WASM directly. - [ ] **Step 3: Delete `verifiedHandle` from `WizardState`** Find the `WizardState` interface in setup.ts and remove the `verifiedHandle?: SessionHandle | null` field. - [ ] **Step 4: Remove the `SessionHandle` import** The `import type { SessionHandle } from 'relicario-wasm';` line goes too. - [ ] **Step 5: Build (expect errors)** ```bash cd extension && npx tsc --noEmit 2>&1 | grep "setup" | head -10 ``` Expected: errors where `wasm.X(...)` is called inline. These get fixed in Task 3.5. - [ ] **Step 6: Hold commit until Task 3.5 makes the file compile** --- ### Task 3.5: Replace WASM calls with `sendMessage(create_vault / attach_vault)` + step registry **Files:** - Modify: `extension/src/setup/setup.ts` - [ ] **Step 1: Find the `wasm.X(...)` callsites** ```bash cd extension && grep -n "wasm\." src/setup/setup.ts | head -20 ``` There should be ~10-15 sites across the create flow, the attach flow, and the device-register step. - [ ] **Step 2: Replace the create-vault sequence with one `sendMessage`** Find the block (likely in `attachStep3New` or similar) that calls `wasm.embed_image_secret`, `wasm.unlock`, `wasm.register_device`, `wasm.manifest_encrypt`. Replace with: ```typescript const resp = await sendMessage({ type: 'create_vault', config: state.vaultConfig!, passphrase: state.passphrase!, carrierImageBytes: state.carrierImageBytes!.buffer, deviceName: state.deviceName!, }); if (!resp.ok) { state.error = resp.error; return rerender(); } state.referenceImageBytes = new Uint8Array(resp.data.referenceImageBytes); ``` - [ ] **Step 3: Replace the attach-vault sequence** Same pattern with `{ type: 'attach_vault', ... }`. - [ ] **Step 4: Convert `renderStep*` / `attachStep*` pairs to `SetupStep` objects** Define the contract at the top of setup.ts: ```typescript interface StepContext { state: WizardState; rerender: () => void; goto: (id: StepId) => void; } interface SetupStep { id: StepId; render: (ctx: StepContext) => string; attach: (root: HTMLElement, ctx: StepContext) => () => void; } type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; ``` Then for each existing `renderStepN` + `attachStepN` pair: ```typescript const modeStep: SetupStep = { id: 'mode', render: (ctx) => { /* paste renderStep0 body */ }, attach: (root, ctx) => { /* paste attachStep0 body; return teardown */ }, }; // … hostStep, connectionStep, vaultStep, deviceStep, doneStep ``` Then the `STEPS` array + the wizard's main render loop: ```typescript const STEPS: ReadonlyArray = [ modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, ]; function rerender() { const ctx = { state, rerender, goto }; const step = STEPS.find((s) => s.id === state.currentStep)!; app.innerHTML = step.render(ctx); const teardown = step.attach(app, ctx); // … record teardown for cleanup on next rerender } ``` The wizard's existing init code calls `rerender()` once on boot. - [ ] **Step 5: Build to verify** ```bash cd extension && npx tsc --noEmit 2>&1 | grep "setup" | head -10 ``` Expected: clean (or close to it — fix any remaining errors). - [ ] **Step 6: Run existing setup tests (expect FAIL — they assert on old shape)** ```bash cd extension && npx vitest run src/setup/__tests__/setup.test.ts ``` Expected: failures. These get fixed in Task 3.7. - [ ] **Step 7: Hold commit until Task 3.7 updates tests** --- ### Task 3.6: Add `clearWizardState` + `beforeunload` binding **Files:** - Modify: `extension/src/setup/setup.ts` - [ ] **Step 1: Add the function** In setup.ts: ```typescript /** * Best-effort wipe of sensitive material when the wizard is abandoned. * Browsers may skip beforeunload if the tab crashes or is killed; JS * strings (passphrase, API token) are also GC-only. Zero-fills the * reachable Uint8Array fields; strings are nulled out but cannot be * truly zeroized. */ function clearWizardState(): void { if (state.carrierImageBytes) state.carrierImageBytes.fill(0); if (state.referenceImageBytes) state.referenceImageBytes.fill(0); if (state.referenceImageBytesAttach) state.referenceImageBytesAttach.fill(0); // Reset every field of `state` to its initial value. state.passphrase = ''; state.apiToken = ''; // … etc. — null/zero every field WizardState exposes } ``` - [ ] **Step 2: Bind to beforeunload** In the wizard's bootstrap code: ```typescript window.addEventListener('beforeunload', clearWizardState); ``` - [ ] **Step 3: Call from `goto('mode')`** In the `goto()` function: ```typescript function goto(id: StepId): void { if (id === 'mode') clearWizardState(); state.currentStep = id; rerender(); } ``` - [ ] **Step 4: Hold commit (bundle with Task 3.7)** --- ### Task 3.7: Update setup tests + add `clearWizardState` test **Files:** - Modify: `extension/src/setup/__tests__/setup.test.ts` - [ ] **Step 1: Read existing setup tests** ```bash cd extension && cat src/setup/__tests__/setup.test.ts ``` Determine what's asserted today and what needs to change. - [ ] **Step 2: Rewrite assertions against the step-registry shape** Tests that previously poked at `renderStep0` / `attachStep0` should now assert on the `STEPS` array's shape: ```typescript import { STEPS } from '../setup'; describe('setup step registry', () => { it('has the six expected steps in order', () => { expect(STEPS.map((s) => s.id)).toEqual([ 'mode', 'host', 'connection', 'vault', 'device', 'done', ]); }); it('each step renders non-empty HTML and returns a teardown', () => { for (const step of STEPS) { const html = step.render({ state: makeState(), rerender: vi.fn(), goto: vi.fn() }); expect(html.length).toBeGreaterThan(0); // attach test deferred — would need a DOM container } }); }); ``` You'll need to `export { STEPS }` from `setup.ts` (or `export const __test__ = { STEPS, clearWizardState }` for test-only exposure if you don't want to expose to production). - [ ] **Step 3: Add `clearWizardState` test** ```typescript describe('clearWizardState', () => { it('zero-fills Uint8Array fields', () => { const state = makeWizardState({ carrierImageBytes: new Uint8Array([1, 2, 3, 4]), }); __test__.clearWizardState(state); expect(Array.from(state.carrierImageBytes!)).toEqual([0, 0, 0, 0]); }); }); ``` - [ ] **Step 4: Run tests** ```bash cd extension && npx vitest run src/setup/__tests__/setup.test.ts ``` Expected: all pass. - [ ] **Step 5: Run full suite to ensure no other surface broke** ```bash cd extension && npx vitest run ``` - [ ] **Step 6: Commit Tasks 3.4-3.7 as one cohesive commit** ```bash cd extension/.. && git add extension/src/setup/setup.ts extension/src/setup/__tests__/setup.test.ts git commit -m "refactor(ext/setup): SW migration + step registry + clearWizardState (Plan C Phase 3) Removes setup.ts's direct WASM orchestration entirely. The wizard now calls sendMessage({ type: 'create_vault' | 'attach_vault' }) for the crypto work. The six renderStepN/attachStepN pairs collapse into the SetupStep registry. clearWizardState() wipes sensitive Uint8Array fields on beforeunload and on goto('mode'). setup.ts drops from ~1220 LOC to ~500 LOC. Co-Authored-By: Claude Opus 4.7 " ``` --- ## Phase 4 — Split `vault.ts` + lift `vault_locked` channel (P1.5) **Effort:** M. **Depends on:** Phase 1. ### Task 4.1: Create `vault-shell.ts` **Files:** - Create: `extension/src/vault/vault-shell.ts` - Read: `extension/src/vault/vault.ts` (current ~1037 LOC) - [ ] **Step 1: Identify the shell concerns in current vault.ts** The shell owns: - DOM scaffolding (the `
` skeleton). - Color-scheme application (reads `chrome.storage.sync.password_display_scheme` and applies CSS variables). - `chrome.runtime.onMessage` wiring (for `session_expired` and similar SW push events). - `applyShellViewClass` (sets `data-view` attribute on the shell). Grep for each concern in current vault.ts: ```bash cd extension && grep -n "applyShellViewClass\|onMessage\|password_display_scheme\|vault-shell" src/vault/vault.ts | head -10 ``` - [ ] **Step 2: Create the file with the migrated functions** `extension/src/vault/vault-shell.ts`: ```typescript // DOM scaffolding + color-scheme + onMessage wiring for the vault tab. // Migrated from vault.ts as part of Plan C Phase 4. import { applyColorScheme } from '../shared/password-coloring'; export function renderShell(app: HTMLElement): void { // Paste the shell-scaffolding innerHTML from vault.ts:NNN-NNN app.innerHTML = `…`; } export function applyShellViewClass(view: string): void { // Paste from vault.ts:NNN-NNN } export function wireShellMessageListener(onSessionExpired: () => void): () => void { const handler = (msg: { type: string }) => { if (msg?.type === 'session_expired') onSessionExpired(); }; chrome.runtime.onMessage.addListener(handler); return () => chrome.runtime.onMessage.removeListener(handler); } // Color-scheme apply on boot export async function applyVaultColorScheme(): Promise { // Paste from vault.ts:NNN-NNN } ``` - [ ] **Step 3: Remove the migrated code from vault.ts** Delete the corresponding lines, leaving import + call sites in vault.ts. - [ ] **Step 4: Build to verify** ```bash cd extension && npx tsc --noEmit ``` - [ ] **Step 5: Run vitest** ```bash cd extension && npx vitest run ``` Expected: all tests pass (no behavior change). - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/vault/vault-shell.ts extension/src/vault/vault.ts git commit -m "refactor(ext/vault): extract vault-shell.ts (Plan C Phase 4) DOM scaffolding, color-scheme apply, onMessage wiring all live in their own module. vault.ts shrinks by ~80 LOC. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.2: Create `vault-sidebar.ts` **Files:** - Create: `extension/src/vault/vault-sidebar.ts` - Modify: `extension/src/vault/vault.ts` - [ ] **Step 1: Identify sidebar concerns** The sidebar owns: - `renderSidebarCategories` (the type-category nav rendering). - Search input handling (currently NOT debounced — DEV-C P2). - Bottom nav buttons (add, trash, devices, settings, lock). - Global keydown shortcuts (search focus on `/`, etc.). ```bash cd extension && grep -n "renderSidebarCategories\|sidebar-search\|vault-sidebar__\|keydown" src/vault/vault.ts | head -15 ``` - [ ] **Step 2: Create the file with debounced search** `extension/src/vault/vault-sidebar.ts`: ```typescript // Sidebar — categories nav, search input with 80ms debounce, bottom nav. // Migrated from vault.ts as part of Plan C Phase 4. Adds the debounce per // DEV-C P2 (vault.ts:648-695 ran the full filter on every keystroke). const SEARCH_DEBOUNCE_MS = 80; export function renderSidebarCategories(/* args */): void { // Paste from vault.ts:NNN-NNN } export function wireSidebar(/* args */): () => void { // Paste from vault.ts:NNN-NNN, with the search input wrapped in debounce: const searchEl = document.getElementById('vault-search') as HTMLInputElement | null; let searchTimer: number | undefined; searchEl?.addEventListener('input', () => { if (searchTimer !== undefined) clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { onSearchChange(searchEl.value); }, SEARCH_DEBOUNCE_MS); }); // … bottom nav buttons, keydown shortcuts (unchanged) return function teardownSidebar() { if (searchTimer !== undefined) clearTimeout(searchTimer); // … other teardown }; } ``` - [ ] **Step 3: Remove the migrated code from vault.ts** - [ ] **Step 4: Build + test** ```bash cd extension && npx tsc --noEmit && npx vitest run ``` - [ ] **Step 5: Commit** ```bash cd extension/.. && git add extension/src/vault/vault-sidebar.ts extension/src/vault/vault.ts git commit -m "refactor(ext/vault): extract vault-sidebar.ts with debounced search (Plan C Phase 4) Migrates sidebar concerns out of vault.ts. The search input gets an 80ms trailing-edge debounce (P2 fix — was firing on every keystroke). Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.3: Create `vault-list.ts` **Files:** - Create: `extension/src/vault/vault-list.ts` - Modify: `extension/src/vault/vault.ts` - [ ] **Step 1: Identify list concerns** The list pane owns: - `renderListPane(entries, activeSelection)` (the row rendering). - Per-type glyph icon mapping (from `shared/glyphs.ts`). - Row click handler (opens the drawer — but the drawer handler lives in vault-drawer.ts, so the list dispatches an event or calls a passed-in callback). - [ ] **Step 2: Create the file** `extension/src/vault/vault-list.ts`: ```typescript import { GLYPH_LOGIN, /* … all per-type glyphs */ } from '../shared/glyphs'; import type { ItemId, ManifestEntry, ItemType } from '../shared/types'; export function renderListPane( pane: HTMLElement, entries: Array<[ItemId, ManifestEntry]>, activeSelection: ItemId | null, onRowClick: (id: ItemId) => void, ): void { // Paste from vault.ts:NNN-NNN } function glyphForType(t: ItemType): string { switch (t) { case 'login': return GLYPH_LOGIN; // … } } ``` - [ ] **Step 3: Remove from vault.ts, build, test, commit** ```bash cd extension/.. && git add extension/src/vault/vault-list.ts extension/src/vault/vault.ts git commit -m "refactor(ext/vault): extract vault-list.ts (Plan C Phase 4) Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.4: Create `vault-drawer.ts` with `ensureDrawerClosedForRoute` **Files:** - Create: `extension/src/vault/vault-drawer.ts` - Create: `extension/src/vault/__tests__/drawer-state.test.ts` - Modify: `extension/src/vault/vault.ts` - [ ] **Step 1: Identify drawer concerns** The drawer owns: - `openDrawer(itemId)` / `closeDrawer()`. - `renderDrawer(item)` (the 2-column field grid). - Drawer event wiring (esc to close, ✕ button). - `state.drawerOpen` reset (the P2 leak fix lives here). - [ ] **Step 2: Write the drawer-state test first** `extension/src/vault/__tests__/drawer-state.test.ts`: ```typescript import { describe, expect, it } from 'vitest'; import { ensureDrawerClosedForRoute } from '../vault-drawer'; describe('ensureDrawerClosedForRoute', () => { it('closes the drawer when navigating to trash', () => { const state = { drawerOpen: true, selectedItem: 'abc' as never }; ensureDrawerClosedForRoute(state, { view: 'trash' }); expect(state.drawerOpen).toBe(false); }); it('leaves drawer open when navigating between list and detail', () => { const state = { drawerOpen: true, selectedItem: 'abc' as never }; ensureDrawerClosedForRoute(state, { view: 'detail' }); expect(state.drawerOpen).toBe(true); }); it('does nothing when drawer was already closed', () => { const state = { drawerOpen: false, selectedItem: null }; ensureDrawerClosedForRoute(state, { view: 'devices' }); expect(state.drawerOpen).toBe(false); }); }); ``` - [ ] **Step 3: Run test (expect FAIL — function doesn't exist)** ```bash cd extension && npx vitest run src/vault/__tests__/drawer-state.test.ts ``` - [ ] **Step 4: Create the file** `extension/src/vault/vault-drawer.ts`: ```typescript import type { RouterState, Route } from './vault'; export function openDrawer(/* args */): void { /* paste */ } export function closeDrawer(state: RouterState): void { state.drawerOpen = false; // … DOM updates } export function renderDrawer(/* args */): void { /* paste */ } /** * The renderPane switch calls this before any non-list view to prevent * drawer state leaking across navigation (P2 fix for vault.ts:495-536). */ export function ensureDrawerClosedForRoute( state: RouterState, route: Route, ): void { const drawerKeepingViews = new Set(['list', 'detail']); if (!drawerKeepingViews.has(route.view)) { state.drawerOpen = false; } } ``` - [ ] **Step 5: Wire `ensureDrawerClosedForRoute` into vault.ts's renderPane switch** In vault.ts: ```typescript function renderPane(): void { const route = parseHash(); ensureDrawerClosedForRoute(state, route); // … existing switch } ``` - [ ] **Step 6: Run test (expect PASS)** - [ ] **Step 7: Run full suite** - [ ] **Step 8: Commit** ```bash cd extension/.. && git add extension/src/vault/vault-drawer.ts extension/src/vault/__tests__/drawer-state.test.ts extension/src/vault/vault.ts git commit -m "refactor(ext/vault): extract vault-drawer.ts + ensureDrawerClosedForRoute (Plan C Phase 4) P2 fix: drawer state no longer leaks across navigation (vault.ts:495-536 called out the bug). ensureDrawerClosedForRoute runs in the renderPane switch before any non-list view. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.5: Create `vault-form-wrapper.ts` **Files:** - Create: `extension/src/vault/vault-form-wrapper.ts` - Modify: `extension/src/vault/vault.ts` - Modify: `extension/src/vault/__tests__/form-wrapper.test.ts` (update import path) - [ ] **Step 1: Identify form-wrapper concerns** The form-wrapper owns: - `renderFormWrapped(app, mode)` (already at vault.ts:761 after Plan B Phase 2B). - The `__test__` export. - The sticky save bar + header + dirty-state wiring. - [ ] **Step 2: Move the code** Cut the entire `renderFormWrapped` function + the `__test__` export from vault.ts and paste into `extension/src/vault/vault-form-wrapper.ts`. Adjust imports as needed. - [ ] **Step 3: Update form-wrapper test import** In `extension/src/vault/__tests__/form-wrapper.test.ts`, change: ```typescript import { __test__ } from '../vault'; ``` To: ```typescript import { __test__ } from '../vault-form-wrapper'; ``` - [ ] **Step 4: Build + test** - [ ] **Step 5: Commit** ```bash cd extension/.. && git add extension/src/vault/vault-form-wrapper.ts extension/src/vault/vault.ts extension/src/vault/__tests__/form-wrapper.test.ts git commit -m "refactor(ext/vault): extract vault-form-wrapper.ts (Plan C Phase 4) Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.6: Trim `vault.ts` to ~200 LOC **Files:** - Modify: `extension/src/vault/vault.ts` - [ ] **Step 1: Measure current LOC** ```bash cd extension && wc -l src/vault/vault.ts ``` Expected: ~700-800 LOC after Tasks 4.1-4.5 (started at 1037). - [ ] **Step 2: Identify what should remain** The retained content per the spec: - `RouterState` declaration. - Hash parsing (`parseHash`, `setHash`). - `loadManifest`. - `render()` entry point. - `renderPane()` switch. - The imports that wire the modules. Anything else gets pushed into the right module from Tasks 4.1-4.5. - [ ] **Step 3: Sweep remaining inline code into modules** For each remaining block of inline code in vault.ts that isn't routing/state, identify its home: - Component lifecycle helpers (`teardownPaneComponents`) → keep in vault.ts since they coordinate across modules. - The `vault_locked` RPC intercept at vault.ts:47-74 → lift in Task 4.7; not this task. - Anything else: relocate. - [ ] **Step 4: Verify the target LOC** ```bash cd extension && wc -l src/vault/vault.ts ``` Target: ≤ ~250 LOC (spec says "~200 LOC of routing + state"). - [ ] **Step 5: Build + test** - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/vault/vault.ts git commit -m "refactor(ext/vault): trim vault.ts to routing + state (Plan C Phase 4) Final pass after Tasks 4.1-4.5. vault.ts now owns RouterState, hash parsing, loadManifest, the render() entry, and the renderPane switch only. All pane-specific logic moved to the per-concern modules. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4.7: Lift `vault_locked` RPC intercept into `shared/state.ts` **Files:** - Modify: `extension/src/shared/state.ts` - Modify: `extension/src/vault/vault.ts` (delete the old intercept) - Create: `extension/src/shared/__tests__/state-vault-locked.test.ts` - [ ] **Step 1: Read the current intercept** ```bash cd extension && sed -n '47,74p' src/vault/vault.ts ``` Record the body. It typically wraps `sendMessage` and on `{ ok: false, error: 'vault_locked' }` flips local state to a locked screen. - [ ] **Step 2: Write the failing test** `extension/src/shared/__tests__/state-vault-locked.test.ts`: ```typescript import { beforeEach, describe, expect, it, vi } from 'vitest'; import { registerHost, __resetHostForTests, sendMessage, } from '../state'; import type { StateHost } from '../state'; function makeHost(): StateHost { return { getState: () => ({ view: 'list', loading: false, error: null, entries: [], selectedItem: null, searchQuery: '', newType: null } as never), setState: vi.fn(), navigate: vi.fn(), sendMessage: vi.fn().mockResolvedValue({ ok: false, error: 'vault_locked' }), escapeHtml: (s) => s, popOutToTab: vi.fn(), isInTab: () => false, openVaultTab: vi.fn(), }; } describe('state.sendMessage vault_locked intercept', () => { beforeEach(() => __resetHostForTests()); it('flips host state to unlock view on vault_locked response', async () => { const host = makeHost(); registerHost(host); await sendMessage({ type: 'list_items' }); expect(host.navigate).toHaveBeenCalledWith('unlock', expect.anything()); }); it('does NOT intercept on the unlock request itself', async () => { const host = makeHost(); registerHost(host); await sendMessage({ type: 'unlock', passphrase: 'x' }); expect(host.navigate).not.toHaveBeenCalled(); }); it('does NOT intercept on is_unlocked', async () => { const host = makeHost(); registerHost(host); await sendMessage({ type: 'is_unlocked' }); expect(host.navigate).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 3: Run test (expect FAIL — wrapper is a pass-through from Phase 1)** - [ ] **Step 4: Fill the wrapper body in `shared/state.ts`** Replace the pass-through `sendMessage` in `shared/state.ts`: ```typescript const VAULT_LOCKED_INTERCEPT_BYPASS: ReadonlySet = new Set([ 'unlock', 'is_unlocked', ]); export async function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); const resp = await host.sendMessage(request); if (!resp.ok && resp.error === 'vault_locked' && !VAULT_LOCKED_INTERCEPT_BYPASS.has(request.type)) { host.navigate('unlock', { error: 'Vault locked. Re-enter passphrase.' }); } return resp; } ``` - [ ] **Step 5: Delete the old intercept from vault.ts** Remove lines 47-74 (or whatever range now corresponds after Tasks 4.1-4.6). - [ ] **Step 6: Run the new test (expect PASS)** - [ ] **Step 7: Run full suite** Expected: all pass, including popup's existing `session_expired` handling (since the SW still dispatches that event for legacy consumers; the wrapper is the new path). - [ ] **Step 8: Commit** ```bash cd extension/.. && git add extension/src/shared/state.ts extension/src/vault/vault.ts extension/src/shared/__tests__/state-vault-locked.test.ts git commit -m "refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4) Both popup and vault tab now consume vault_locked through the sendMessage wrapper instead of duplicate channels (popup had session_expired event listener; vault had inline RPC intercept). The SW still dispatches session_expired for the popup's existing listener; this is the migration cycle the spec calls out. Co-Authored-By: Claude Opus 4.7 " ``` --- ## Phase 5 — Extension P2 cluster **Effort:** M. **Depends on:** none. ### Task 5.1: Invert inactivity-timer reset rule **Files:** - Modify: `extension/src/service-worker/index.ts` - Modify: `extension/src/service-worker/session-timer.ts` - [ ] **Step 1: Locate the current rule** ```bash cd extension && sed -n '70,85p' src/service-worker/index.ts ``` The current behavior is "reset on popup-only messages." We want "reset on all messages except a documented exclusion set." - [ ] **Step 2: Define the exclusion set in `session-timer.ts`** Append to `extension/src/service-worker/session-timer.ts`: ```typescript /** * Content-callable message types that should NOT reset the inactivity timer. * * Rationale: a content script reading available autofill candidates is a * passive query — it shouldn't keep the vault alive indefinitely while the * user isn't actually interacting with it. * * Today this is the only known passive read; if a future content message * is also passive, add it here with a one-line justification. */ export const READ_ONLY_CONTENT_CALLABLE: ReadonlySet = new Set([ 'get_autofill_candidates', ]); ``` - [ ] **Step 3: Invert the rule in `index.ts`** Change the existing block (lines ~70-85) from "reset only if popup-only" to: ```typescript import { READ_ONLY_CONTENT_CALLABLE } from './session-timer'; // In the onMessage handler, after route dispatch: if (!READ_ONLY_CONTENT_CALLABLE.has(msg.type)) { sessionTimer.reset(); } ``` - [ ] **Step 4: Update existing session-timer test** In `extension/src/service-worker/__tests__/session-timer.test.ts`, add: ```typescript it('popup-only message resets the timer', async () => { // route({ type: 'list_items' }, popupSender) // assert sessionTimer.reset was called }); it('content-callable get_autofill_candidates does NOT reset', async () => { // route({ type: 'get_autofill_candidates' }, contentSender) // assert sessionTimer.reset was NOT called }); it('content-callable capture_save_login DOES reset', async () => { // route({ type: 'capture_save_login', ... }, contentSender) // assert sessionTimer.reset was called (write op = active use) }); ``` - [ ] **Step 5: Run tests** - [ ] **Step 6: Commit** ```bash cd extension/.. && git add extension/src/service-worker/index.ts extension/src/service-worker/session-timer.ts extension/src/service-worker/__tests__/session-timer.test.ts git commit -m "fix(ext/sw): inactivity timer resets on all non-passive messages (Plan C Phase 5) DEV-C P2: an active autofiller never opens the popup, so under the old rule it got force-locked despite continuous use. Inverts the rule: reset on all messages except a documented exclusion set (only get_autofill_candidates today). Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 5.2: Clear `state.gitHost` on session expiry **Files:** - Modify: `extension/src/service-worker/index.ts` - [ ] **Step 1: Locate `sessionTimer.onExpired` callback** ```bash cd extension && sed -n '48,62p' src/service-worker/index.ts ``` - [ ] **Step 2: Add `state.gitHost = null`** In the onExpired callback, alongside the existing `state.manifest = null`: ```typescript sessionTimer.onExpired(() => { state.manifest = null; state.gitHost = null; // Plan C Phase 5: don't leak the cached client clearCurrent(); // … existing notifications }); ``` - [ ] **Step 3: Add or extend a test** In an appropriate test file: ```typescript it('session expiry clears state.gitHost', async () => { state.gitHost = makeFakeGitHost(); // trigger expiry expect(state.gitHost).toBeNull(); }); ``` - [ ] **Step 4: Run + commit** ```bash cd extension/.. && git add extension/src/service-worker/index.ts git commit -m "fix(ext/sw): clear state.gitHost on session expiry (Plan C Phase 5) DEV-C P2: expiry cleared manifest but left the cached git-host client. The initializer rebuilds gitHost on demand, so clearing here is safe. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 5.3: Extract `teardownSettingsCommon` **Files:** - Modify: `extension/src/popup/components/settings.ts` - Modify: `extension/src/popup/components/settings-vault.ts` - [ ] **Step 1: Read both teardown bodies** ```bash cd extension && sed -n '56,65p' src/popup/components/settings.ts sed -n '15,22p' src/popup/components/settings-vault.ts ``` Identify the common steps: `closeGeneratorPanel()`, `document.removeEventListener('keydown', activeKeyHandler)`, etc. - [ ] **Step 2: Add `teardownSettingsCommon` to `settings.ts`** ```typescript /** * Common cleanup invoked by both the device-settings teardown * (settings.ts) and the vault-settings teardown (settings-vault.ts). * Centralized to avoid the "regression class with known prior leaks" * DEV-C P2 flagged. */ export function teardownSettingsCommon(): void { closeGeneratorPanel(); if (activeKeyHandler) { document.removeEventListener('keydown', activeKeyHandler); activeKeyHandler = null; } } ``` - [ ] **Step 3: Replace inline cleanup in both files** In `settings.ts`'s existing teardown: ```typescript export function teardownSettings(): void { teardownSettingsCommon(); // … any settings.ts-specific cleanup } ``` In `settings-vault.ts`: ```typescript import { teardownSettingsCommon } from './settings'; export function teardown(): void { teardownSettingsCommon(); // … any settings-vault.ts-specific cleanup } ``` - [ ] **Step 4: Build + test (existing tests should still pass)** - [ ] **Step 5: Commit** ```bash cd extension/.. && git add extension/src/popup/components/settings.ts extension/src/popup/components/settings-vault.ts git commit -m "refactor(ext/popup): extract teardownSettingsCommon (Plan C Phase 5) DEV-C P2: settings.ts:56-65 and settings-vault.ts:15-22 had near- identical cleanup paths. Single source for closeGeneratorPanel + activeKeyHandler removal. Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 5.4: Switch `Promise.all` to `Promise.allSettled` in devices / trash **Files:** - Modify: `extension/src/popup/components/devices.ts` - Modify: `extension/src/popup/components/trash.ts` - [ ] **Step 1: Locate the `Promise.all` calls** ```bash cd extension && grep -n "Promise.all\b" src/popup/components/devices.ts src/popup/components/trash.ts ``` - [ ] **Step 2: Replace in devices.ts** Find the `Promise.all([sendMessage({type:'list_devices'}), sendMessage({type:'list_revoked'})])` call (around line 47-50 per the spec). Replace with: ```typescript const [devicesResp, revokedResp] = await Promise.allSettled([ sendMessage({ type: 'list_devices' }), sendMessage({ type: 'list_revoked' }), ]); const devices = devicesResp.status === 'fulfilled' && devicesResp.value.ok ? (devicesResp.value.data as { devices: Device[] }).devices : (renderLoadErrorSlot('devices'), []); const revoked = revokedResp.status === 'fulfilled' && revokedResp.value.ok ? (revokedResp.value.data as { revoked: RevokedEntry[] }).revoked : (renderLoadErrorSlot('revoked'), []); ``` Add a `renderLoadErrorSlot(label: string): void` helper that inserts a "couldn't load