Completes the extension restructure begun in v0.6.0. Phases 3 (setup wizard SW migration + step registry), 4 (vault.ts split + vault_locked lift), and 6 (get_vault_status + sidebar status indicator) all merged to main (9df2fee,3b8368d,397cc78) via three parallel worktree streams. This commit is the release-prep wrap-up: - Version bump to v0.7.0 across the three relicario crates + Cargo.lock, extension/package.json, and both extension manifests (the manifests had lagged at 0.5.0 — corrected here). - CHANGELOG.md v0.7.0 entry. - STATUS.md: extension restructure moved to shipped; Phases 3/4/6 landing section added. - ROADMAP.md: v0.7.0 row added; Up-next now command palette. - extension/ARCHITECTURE.md: all three phases integrated (new vault-* modules, setup-steps.ts, get_vault_status protocol + status indicator, vault_locked lift, git-host sync cache). - Plan completion checkboxes ticked. Task 7.1 verification: done-criteria sweep all green; 423/423 vitest; build:all clean (only the pre-existing 4MB WASM size warning). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2661 lines
85 KiB
Markdown
2661 lines
85 KiB
Markdown
# 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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<PopupState>): void;
|
|
navigate(view: View, extras?: Partial<PopupState>): 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 {
|
|
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<PopupState>): void {
|
|
if (!host) throw new Error('No state host registered');
|
|
host.setState(partial);
|
|
}
|
|
|
|
export function navigate(view: View, extras?: Partial<PopupState>): 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<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) 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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<PopupState>): 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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<DeviceSettings> {
|
|
// 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<void> {
|
|
await chrome.storage.local.set({ device_settings: settings });
|
|
}
|
|
|
|
export async function loadBlacklist(): Promise<string[]> {
|
|
// 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<void> {
|
|
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<string, unknown> = {}) {
|
|
const store: Record<string, unknown> = { ...initial };
|
|
(global as { chrome: unknown }).chrome = {
|
|
storage: {
|
|
local: {
|
|
get: vi.fn((keys: string | string[]) => {
|
|
const arr = Array.isArray(keys) ? keys : [keys];
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of arr) if (k in store) out[k] = store[k];
|
|
return Promise.resolve(out);
|
|
}),
|
|
set: vi.fn((kv: Record<string, unknown>) => {
|
|
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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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<CreateVaultResponse | { ok: false; error: string }> {
|
|
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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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<SetupStep> = [
|
|
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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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 `<div id="vault-shell">` 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<void> {
|
|
// 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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<string> = new Set([
|
|
'unlock', 'is_unlocked',
|
|
]);
|
|
|
|
export async function sendMessage(request: Request): Promise<Response> {
|
|
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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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<string> = 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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 <label>" message into the appropriate DOM slot.
|
|
|
|
- [ ] **Step 3: Same for trash.ts (line 39-46)**
|
|
|
|
```typescript
|
|
const [trashedResp, settingsResp] = await Promise.allSettled([
|
|
sendMessage({ type: 'list_trashed' }),
|
|
sendMessage({ type: 'get_settings' }),
|
|
]);
|
|
|
|
// … defensive per-slot rendering
|
|
```
|
|
|
|
- [ ] **Step 4: Update existing devices.test.ts and trash.test.ts**
|
|
|
|
Add cases:
|
|
|
|
```typescript
|
|
it('renders devices when revoked list fails', async () => {
|
|
// mockListPair([{name:'foo',...}], <reject>)
|
|
// assert device row rendered
|
|
// assert revoked-load-error slot rendered
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests + commit**
|
|
|
|
```bash
|
|
cd extension/.. && git add extension/src/popup/components/devices.ts extension/src/popup/components/trash.ts $(git diff --name-only -- extension/src/popup/components/__tests__/)
|
|
git commit -m "fix(ext/popup): defensive Promise.allSettled in devices + trash (Plan C Phase 5)
|
|
|
|
DEV-C P2: Promise.all meant one rejected RPC failed the whole render.
|
|
allSettled + per-slot fallback keeps the surface usable when one feed
|
|
is down.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5.5: Debounce MutationObserver `scan()` in detector.ts
|
|
|
|
**Files:**
|
|
- Modify: `extension/src/content/detector.ts`
|
|
|
|
- [ ] **Step 1: Locate the MutationObserver callback**
|
|
|
|
```bash
|
|
cd extension && sed -n '92,108p' src/content/detector.ts
|
|
```
|
|
|
|
The current shape:
|
|
|
|
```typescript
|
|
const observer = new MutationObserver(() => scan());
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap in 200ms debounce**
|
|
|
|
Replace with:
|
|
|
|
```typescript
|
|
const SCAN_DEBOUNCE_MS = 200;
|
|
let scanTimer: number | undefined;
|
|
|
|
function scheduleScan(): void {
|
|
if (scanTimer !== undefined) clearTimeout(scanTimer);
|
|
scanTimer = window.setTimeout(() => {
|
|
scanTimer = undefined;
|
|
scan();
|
|
}, SCAN_DEBOUNCE_MS);
|
|
}
|
|
|
|
const observer = new MutationObserver(scheduleScan);
|
|
```
|
|
|
|
- [ ] **Step 3: Add a test if a test harness exists for detector**
|
|
|
|
If `extension/src/content/__tests__/detector.test.ts` exists, add:
|
|
|
|
```typescript
|
|
it('debounces rapid MutationObserver fires', async () => {
|
|
// … set up a JSDOM with the detector mounted
|
|
// … fire 10 mutations in quick succession
|
|
// … advance timers 250ms
|
|
// … assert scan was called exactly once
|
|
});
|
|
```
|
|
|
|
If no test harness exists, skip and rely on manual verification on a real SPA page.
|
|
|
|
- [ ] **Step 4: Build + commit**
|
|
|
|
```bash
|
|
cd extension/.. && git add extension/src/content/detector.ts $(git diff --name-only -- extension/src/content/__tests__/ 2>/dev/null)
|
|
git commit -m "perf(ext/content): debounce MutationObserver scan() to 200ms (Plan C Phase 5)
|
|
|
|
DEV-C P2: SPA churn was re-running the full scan many times per second.
|
|
Trailing-edge debounce coalesces bursts.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6 — `get_vault_status` SW message + sidebar status indicator
|
|
|
|
**Effort:** S-M. **Depends on:** Phase 4.
|
|
|
|
### Task 6.1: Implement `get_vault_status` SW handler
|
|
|
|
**Files:**
|
|
- Modify: `extension/src/service-worker/vault.ts` (handler) — note `get_vault_status` type was added to `messages.ts` in Task 3.1.
|
|
- Modify: `extension/src/service-worker/router/popup-only.ts` (dispatch)
|
|
- Modify: `extension/src/service-worker/git-host.ts` (cache fields)
|
|
- Create: `extension/src/service-worker/__tests__/vault-status.test.ts`
|
|
|
|
- [ ] **Step 1: Add cached fields to git-host state**
|
|
|
|
In `extension/src/service-worker/git-host.ts` (or wherever the `GitHost` interface lives), add:
|
|
|
|
```typescript
|
|
export interface GitHost {
|
|
// … existing
|
|
lastSyncAt: number | null;
|
|
ahead: number;
|
|
behind: number;
|
|
}
|
|
```
|
|
|
|
Initialize to `null`/`0`/`0` on host construction.
|
|
|
|
- [ ] **Step 2: Update the `sync` handler to populate them**
|
|
|
|
Find the existing `sync` handler in `service-worker/vault.ts` (or similar). After a successful sync, set:
|
|
|
|
```typescript
|
|
state.gitHost.lastSyncAt = Date.now();
|
|
state.gitHost.ahead = syncResult.ahead ?? 0;
|
|
state.gitHost.behind = syncResult.behind ?? 0;
|
|
```
|
|
|
|
- [ ] **Step 3: Write the failing handler test**
|
|
|
|
`extension/src/service-worker/__tests__/vault-status.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, it } from 'vitest';
|
|
import { handleGetVaultStatus } from '../vault';
|
|
|
|
describe('get_vault_status', () => {
|
|
it('returns zeros when never synced', async () => {
|
|
const state = makeStateWithGitHost({
|
|
lastSyncAt: null, ahead: 0, behind: 0,
|
|
});
|
|
const resp = await handleGetVaultStatus(state);
|
|
expect(resp).toEqual({
|
|
ok: true,
|
|
data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 },
|
|
});
|
|
});
|
|
|
|
it('reflects cached sync state', async () => {
|
|
const state = makeStateWithGitHost({
|
|
lastSyncAt: 1234567890, ahead: 3, behind: 1,
|
|
});
|
|
state.manifest = { items: { /* 5 entries with trashed_at:null */ } };
|
|
const resp = await handleGetVaultStatus(state);
|
|
expect(resp.data.lastSyncAt).toBe(1234567890);
|
|
expect(resp.data.ahead).toBe(3);
|
|
expect(resp.data.behind).toBe(1);
|
|
expect(resp.data.pendingItems).toBe(5);
|
|
});
|
|
|
|
it('does NOT call into the network', async () => {
|
|
// Stub gitHost.fetch / .push to throw; assert handler returns ok regardless
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Implement `handleGetVaultStatus`**
|
|
|
|
In `extension/src/service-worker/vault.ts`:
|
|
|
|
```typescript
|
|
import type { GetVaultStatusResponse } from '../shared/messages';
|
|
|
|
export async function handleGetVaultStatus(
|
|
state: RouterState,
|
|
): Promise<GetVaultStatusResponse | { ok: false; error: string }> {
|
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
|
const pendingItems = state.manifest
|
|
? Object.values(state.manifest.items).filter((e) => !e.trashed_at).length
|
|
: 0;
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
ahead: state.gitHost.ahead,
|
|
behind: state.gitHost.behind,
|
|
lastSyncAt: state.gitHost.lastSyncAt,
|
|
pendingItems,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Wire dispatch in popup-only.ts**
|
|
|
|
```typescript
|
|
case 'get_vault_status':
|
|
return handleGetVaultStatus(state);
|
|
```
|
|
|
|
- [ ] **Step 6: Run test (expect PASS)**
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/git-host.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/__tests__/vault-status.test.ts
|
|
git commit -m "feat(ext/sw): get_vault_status handler (Plan C Phase 6)
|
|
|
|
Returns cached ahead/behind/lastSyncAt from state.gitHost plus a live
|
|
pendingItems count from the manifest. Does NOT call into the network —
|
|
sync is explicit.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6.2: Create `vault-status.ts` renderer
|
|
|
|
**Files:**
|
|
- Create: `extension/src/vault/vault-status.ts`
|
|
- Create: `extension/src/vault/__tests__/status-indicator.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing renderer test**
|
|
|
|
`extension/src/vault/__tests__/status-indicator.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { renderStatusIndicator } from '../vault-status';
|
|
|
|
describe('vault status indicator', () => {
|
|
it('renders "in sync" when ahead/behind/pending all zero', () => {
|
|
const el = document.createElement('div');
|
|
renderStatusIndicator(el, {
|
|
ahead: 0, behind: 0, lastSyncAt: Date.now(), pendingItems: 0,
|
|
});
|
|
expect(el.textContent).toMatch(/in sync/i);
|
|
});
|
|
|
|
it('renders "N ahead" when ahead is non-zero', () => {
|
|
const el = document.createElement('div');
|
|
renderStatusIndicator(el, {
|
|
ahead: 3, behind: 0, lastSyncAt: Date.now(), pendingItems: 0,
|
|
});
|
|
expect(el.textContent).toMatch(/3 ahead/i);
|
|
});
|
|
|
|
it('renders "N pending" when pendingItems is non-zero', () => {
|
|
const el = document.createElement('div');
|
|
renderStatusIndicator(el, {
|
|
ahead: 0, behind: 0, lastSyncAt: Date.now(), pendingItems: 5,
|
|
});
|
|
expect(el.textContent).toMatch(/5 pending/i);
|
|
});
|
|
|
|
it('renders "never synced" when lastSyncAt is null', () => {
|
|
const el = document.createElement('div');
|
|
renderStatusIndicator(el, {
|
|
ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0,
|
|
});
|
|
expect(el.textContent).toMatch(/never synced/i);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Create the renderer**
|
|
|
|
`extension/src/vault/vault-status.ts`:
|
|
|
|
```typescript
|
|
import { GLYPH_SYNCED, GLYPH_AHEAD, GLYPH_BEHIND, GLYPH_PENDING }
|
|
from '../shared/glyphs';
|
|
import { relativeTime } from '../shared/relative-time';
|
|
|
|
interface VaultStatus {
|
|
ahead: number;
|
|
behind: number;
|
|
lastSyncAt: number | null;
|
|
pendingItems: number;
|
|
}
|
|
|
|
export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void {
|
|
const ts = status.lastSyncAt
|
|
? `last sync ${relativeTime(status.lastSyncAt, Date.now())}`
|
|
: 'never synced';
|
|
|
|
const parts: string[] = [];
|
|
if (status.pendingItems > 0) {
|
|
parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`);
|
|
}
|
|
if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`);
|
|
if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`);
|
|
if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`);
|
|
|
|
el.innerHTML = `
|
|
<div class="vault-status">
|
|
<div class="vault-status__state">${parts.join(' · ')}</div>
|
|
<div class="vault-status__ts">${ts}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
You may need to add the four glyphs (`GLYPH_SYNCED`, `GLYPH_AHEAD`, `GLYPH_BEHIND`, `GLYPH_PENDING`) to `shared/glyphs.ts` if they don't exist. Use existing glyph-family conventions.
|
|
|
|
- [ ] **Step 3: Run test (expect PASS)**
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd extension/.. && git add extension/src/vault/vault-status.ts extension/src/vault/__tests__/status-indicator.test.ts extension/src/shared/glyphs.ts
|
|
git commit -m "feat(ext/vault): vault-status indicator renderer (Plan C Phase 6)
|
|
|
|
Renders sidebar-footer indicator with ahead/behind/pending state. Pure
|
|
DOM; status fetch happens in the wiring layer (Task 6.3).
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6.3: Wire indicator into sidebar
|
|
|
|
**Files:**
|
|
- Modify: `extension/src/vault/vault-sidebar.ts`
|
|
|
|
- [ ] **Step 1: Add an indicator slot in the sidebar markup**
|
|
|
|
Find the sidebar `renderSidebarCategories` (or equivalent) and add a footer slot:
|
|
|
|
```html
|
|
<div class="vault-sidebar__footer">
|
|
<div id="vault-status-slot"></div>
|
|
<button class="btn-icon" id="status-refresh-btn" title="Refresh status">↻</button>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Add the wiring**
|
|
|
|
In `vault-sidebar.ts`'s wire function:
|
|
|
|
```typescript
|
|
import { renderStatusIndicator } from './vault-status';
|
|
import { sendMessage } from '../shared/state';
|
|
|
|
async function refreshStatus(): Promise<void> {
|
|
const resp = await sendMessage({ type: 'get_vault_status' });
|
|
if (!resp.ok) return;
|
|
const slot = document.getElementById('vault-status-slot');
|
|
if (slot) renderStatusIndicator(slot, resp.data);
|
|
}
|
|
|
|
// On sidebar mount:
|
|
void refreshStatus();
|
|
|
|
// On manual refresh button:
|
|
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
|
|
void refreshStatus();
|
|
});
|
|
```
|
|
|
|
Do NOT poll on a timer. The spec is explicit: "polls on mount + manual refresh button, not every render."
|
|
|
|
- [ ] **Step 3: Run full vitest suite**
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd extension/.. && git add extension/src/vault/vault-sidebar.ts
|
|
git commit -m "feat(ext/vault): wire vault-status into sidebar footer (Plan C Phase 6)
|
|
|
|
Status refresh happens on sidebar mount and on the manual ↻ button.
|
|
No timer polling — matches the spec's no-network-without-user-intent
|
|
discipline.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Final Verification
|
|
|
|
### Task 7.1: Run the full Done-criteria sweep
|
|
|
|
- [ ] **Step 1: `tsc --noEmit` clean**
|
|
|
|
```bash
|
|
cd extension && npx tsc --noEmit 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: no output (clean).
|
|
|
|
- [ ] **Step 2: Full vitest suite green**
|
|
|
|
```bash
|
|
cd extension && npx vitest run
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 3: Production build clean**
|
|
|
|
```bash
|
|
cd extension && npm run build:all 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: webpack compiles both targets (Chrome + Firefox) with no errors (only the pre-existing 4MB WASM warning).
|
|
|
|
- [ ] **Step 4: Spec done-criteria checklist**
|
|
|
|
For each item in the spec's Done criteria (lines 343-368), verify:
|
|
|
|
- [ ] `shared/state.ts` `StateHost` interface has no `any` in public surface — `grep -c ": any\|<any>" extension/src/shared/state.ts` returns 0.
|
|
- [ ] `registerHost` throws on second registration — verified by test in Task 1.5.
|
|
- [ ] `__resetHostForTests` exported — verified by import in tests.
|
|
- [ ] Router files don't contain duplicated `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` — `grep -c "function loadDeviceSettings\|function loadBlacklist\|function saveBlacklist" extension/src/service-worker/router/*.ts` returns 0.
|
|
- [ ] `itemToManifestEntry` defined once — `grep -rn "function itemToManifestEntry\|export function itemToManifestEntry" extension/src/service-worker/` returns 1.
|
|
- [ ] `setup.ts` ≤ 500 LOC — `wc -l extension/src/setup/setup.ts`.
|
|
- [ ] `setup.ts` does not import `relicario-wasm` — `grep -c "relicario-wasm" extension/src/setup/setup.ts` returns 0.
|
|
- [ ] SW handles all three new messages — `grep -c "case 'create_vault'\|case 'attach_vault'\|case 'get_vault_status'" extension/src/service-worker/router/popup-only.ts` returns 3.
|
|
- [ ] `clearWizardState` bound to `beforeunload` — grep visually confirms.
|
|
- [ ] `vault.ts` split into 6 modules — `ls extension/src/vault/vault-*.ts` returns 5 (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`), plus `vault-status.ts` from Phase 6. `wc -l extension/src/vault/vault.ts` returns ≤ ~250.
|
|
- [ ] `vault.ts` no longer contains `vault_locked` intercept — `grep -c "vault_locked" extension/src/vault/vault.ts` returns 0.
|
|
- [ ] Drawer closes on non-list nav — verified by test in Task 4.4.
|
|
- [ ] Sidebar search debounced — `grep "SEARCH_DEBOUNCE_MS" extension/src/vault/vault-sidebar.ts` returns a match.
|
|
- [ ] Inactivity timer rule inverted — verified by test in Task 5.1.
|
|
- [ ] `state.gitHost = null` on expiry — verified by grep + test.
|
|
- [ ] Single `teardownSettingsCommon` — `grep -rn "function teardownSettingsCommon\|export function teardownSettingsCommon" extension/src/popup/components/` returns 1.
|
|
- [ ] `devices.ts` and `trash.ts` use `Promise.allSettled` — `grep -c "Promise.allSettled" extension/src/popup/components/devices.ts extension/src/popup/components/trash.ts` returns 2.
|
|
- [ ] `detector.ts` debounced — `grep "SCAN_DEBOUNCE_MS\|scheduleScan" extension/src/content/detector.ts` returns a match.
|
|
- [ ] `get_vault_status` message exists and is rendered — verified by sidebar mount.
|
|
- [ ] No `wasm.d.ts` change — `git diff main -- extension/src/wasm.d.ts` is empty (unless Plan B has been merged in parallel and added its parser exports — in which case those are Plan B's diff, not Plan C's).
|
|
- [ ] All `.free()` callsites preceded by `wasm.lock` per Plan A policy — `grep -B 2 "\.free()" extension/src/` and visually confirm each is preceded by a `wasm.lock(...)` call.
|
|
|
|
- [ ] **Step 5: Final commit (if any verification fixes needed)**
|
|
|
|
If any final adjustments are needed (typically minor — a missed import path, a forgotten teardown), commit them with:
|
|
|
|
```bash
|
|
cd extension/.. && git commit -m "fix(ext): final verification fixes (Plan C completion)
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
- [ ] **Step 6: Update STATUS.md and ROADMAP.md**
|
|
|
|
Move extension restructure from "Up next" to shipped. Add a "Phase 4 command palette" pointer as the new next item if appropriate.
|
|
|
|
```bash
|
|
cd extension/.. && git add STATUS.md ROADMAP.md
|
|
git commit -m "docs: Plan C (extension restructure) complete; update STATUS/ROADMAP
|
|
|
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
|
```
|
|
|
|
- [ ] **Step 7: Push**
|
|
|
|
```bash
|
|
git push origin main
|
|
```
|
|
|
|
---
|
|
|
|
## Completion Checklist
|
|
|
|
- [x] Phase 1: `StateHost` typed end-to-end (Tasks 1.1-1.5) — merged 2026-05-30
|
|
- [x] Phase 2: SW helpers consolidated in `storage.ts` + `vault.ts` (Tasks 2.1-2.3) — merged 2026-05-30
|
|
- [x] Phase 3: Setup wizard SW-orchestrated + step registry + `clearWizardState` (Tasks 3.1-3.7) — merged `9df2fee` 2026-05-31
|
|
- [x] Phase 4: `vault.ts` split into 5 modules + `vault_locked` channel unified (Tasks 4.1-4.7) — merged `3b8368d` 2026-06-01
|
|
- [x] Phase 5: Five P2 fixes (Tasks 5.1-5.5) — merged 2026-05-30
|
|
- [x] Phase 6: `get_vault_status` + sidebar status indicator (Tasks 6.1-6.3) — merged `397cc78` 2026-06-01
|
|
- [x] Task 7.1: Final verification + STATUS/ROADMAP update — done-criteria sweep all green; 423/423 vitest; build:all clean; STATUS/ROADMAP/CHANGELOG/ARCHITECTURE updated; versions bumped to v0.7.0
|
|
|
|
---
|
|
|
|
## Notes on execution order
|
|
|
|
Phase 1 blocks Phases 3 and 4. Phase 4 blocks Phase 6. Phases 2 and 5 are independent of everything else.
|
|
|
|
**Recommended sequence for sequential execution:**
|
|
1. Phase 1 (the typed `StateHost` foundation everyone depends on)
|
|
2. Phase 2 (independent; lands quickly)
|
|
3. Phase 5 (independent; lands quickly)
|
|
4. Phase 4 (vault.ts split — biggest visible change)
|
|
5. Phase 6 (status indicator — completes the parity gap)
|
|
6. Phase 3 (setup wizard — biggest single phase, save for last so all the supporting infra is in place)
|
|
|
|
**For parallel execution** (subagent-driven-development), Phases 1, 2, 5 can ship in parallel; Phase 4 ships after Phase 1; Phase 6 ships after Phase 4; Phase 3 ships after Phase 1. Maximum parallelism is 3 streams.
|
|
|
|
## Cross-plan coordination
|
|
|
|
This plan ("Plan C") explicitly does NOT touch:
|
|
- Plan A (security/docs polish) — already shipped (commits `89090a8`, `0c9387f`, `229e483`).
|
|
- Plan B (CLI restructure) — already shipped (Plan B Cycles 1+2 + the read-side cleanup `d717f0d`).
|
|
|
|
`extension/src/wasm.d.ts` is shared with future plans that may add parser exports. This plan does not touch that file (verify with `git diff main -- extension/src/wasm.d.ts` empty at completion).
|