docs(specs): three architecture-review followup plans (security/CLI/extension)
Plan A (security & docs polish, S): SessionHandle impl Drop + JS .free() audit + recovery_qr.rs documentation + relay launcher dev-c expansion. Independent of B/C; ships first. Plan B (CLI restructure, M-L): split cli/main.rs (2641 LOC) into commands/ folder + prompt.rs + parse.rs; helpers::git_run captures stderr; Vault:: after_manifest_change centralizes the groups-cache discipline; canonical ParamsFile; batched purge; migrate parse_month_year/base32_decode_lenient/ guess_mime to relicario-core with WASM re-exports. Plan C (extension restructure, L): typed StateHost (precondition); extract service-worker/storage.ts; setup.ts SW migration via create_vault/ attach_vault messages + step-registry pattern; vault.ts split into shell/sidebar/list/drawer/form-wrapper with vault_locked channel unified through shared/state.ts; P2 cluster (timer reset, gitHost clear, teardown helper, allSettled, MutationObserver debounce); get_vault_status closes the relicario status parity gap. Cross-boundary cites verified: Plan B Phase 8 WASM exports are the seam Plan C consumes (deferred to a future plan); Plan A owns the .free() swallow removal that Plan C respects without redoing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
# Extension Restructure — Design
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Status:** Proposed
|
||||
**Source:** docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.4, P1.5, P1.6, P1.9) + folded P2s from DEV-C's extension sections + the `relicario status` parity gap
|
||||
**Effort estimate:** L
|
||||
|
||||
## Summary
|
||||
|
||||
This is the largest of the three architecture-review followups — multi-day to multi-week — and it eliminates the two steepest learning cliffs in the extension. After this plan ships, a tinkerer who opens `extension/src/setup/setup.ts` no longer sees WASM imported directly (it isn't the pattern; it was the exception); a tinkerer who opens `extension/src/vault/vault.ts` sees ~200 LOC of routing and state instead of 1027 LOC of shell + sidebar + list + drawer + form-wrapper inlined together; the `shared/state.ts` bridge between popup and vault tab becomes type-checked end to end; and the duplicated service-worker router helpers consolidate into one home each. As a side effect, the extension closes its last CLI-parity gap (`relicario status` → vault-sidebar status indicator).
|
||||
|
||||
## Findings addressed
|
||||
|
||||
- **P1.4** (DEV-C; `extension/src/setup/setup.ts:28-37`, `:1118-1120`, whole 1220-LOC file) — setup wizard imports `relicario-wasm` directly and orchestrates `unlock` / `embed_image_secret` / `register_device` / `manifest_encrypt` itself, bypassing the SW abstraction every other surface uses; ~400 LOC of crypto orchestration duplicated.
|
||||
- **P1.5** (DEV-C; `extension/src/vault/vault.ts:1-1027`) — single 1027-LOC file owns shell + hash routing + sidebar + list + drawer + type-picker + form-wrapper + deep-link routing + teardown. Contains the `vault_locked` RPC intercept (lines 47-74) and the drawer-state leak (lines 495-536, 648-695).
|
||||
- **P1.6** (DEV-C; `extension/src/shared/state.ts:10-35`) — `StateHost` contract is fully `any`-typed; `host` singleton has no double-registration guard.
|
||||
- **P1.9** (DEV-C; `extension/src/service-worker/router/popup-only.ts:687-703`, `:~169`; `extension/src/service-worker/router/content-callable.ts:187-205`, `:~169`) — `loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`, and `itemToManifestEntry` duplicated across both router files.
|
||||
- **P2 — inactivity-timer reset on content-callable messages** (DEV-C; `extension/src/service-worker/index.ts:76-78`) — active autofiller never opens popup, gets force-locked despite continuous use.
|
||||
- **P2 — `state.gitHost` clear on session expiry** (DEV-C; `extension/src/service-worker/index.ts:51-58`) — expiry callback clears manifest but leaves the cached git-host client.
|
||||
- **P2 — duplicated teardown helpers** (DEV-C; `extension/src/popup/components/settings.ts:56-65` and `settings-vault.ts:15-22`) — two near-identical cleanup paths in a regression class with known prior leaks.
|
||||
- **P2 — `Promise.all` without per-promise error handling** (DEV-C; `extension/src/popup/components/devices.ts:47-50`, `trash.ts:39-46`) — single rejected RPC fails the whole render.
|
||||
- **P2 — MutationObserver `scan()` not debounced** (DEV-C; `extension/src/content/detector.ts:96-103`) — SPA churn re-runs the full scan many times per second.
|
||||
- **CLI/extension parity gap — no equivalent to `relicario status`** (DEV-C parity table; PM kickoff) — extension surfaces nothing comparable to ahead/behind/lastSyncAt/pendingItems.
|
||||
|
||||
## Approach
|
||||
|
||||
Architectural shape: **types first, then extract, then split**. The extension already has a clean message-router/SW boundary; this plan finishes the job for the three surfaces that don't yet honor it (state.ts, setup.ts, vault.ts) and pulls duplicated SW helpers into one home.
|
||||
|
||||
### Post-split `extension/src/vault/`
|
||||
|
||||
```
|
||||
extension/src/vault/
|
||||
├── vault.html (unchanged)
|
||||
├── vault.css (unchanged)
|
||||
├── vault.ts (~200 LOC; routing + state only — owns
|
||||
│ hash parsing, RouterState, render() entry,
|
||||
│ imports the modules below)
|
||||
├── vault-shell.ts (DOM scaffolding, color-scheme apply,
|
||||
│ onMessage wiring, applyShellViewClass)
|
||||
├── vault-sidebar.ts (renderSidebarCategories, search input
|
||||
│ wiring with 50-100ms debounce, nav buttons,
|
||||
│ global keydown shortcuts)
|
||||
├── vault-list.ts (renderListPane, row rendering, type icons)
|
||||
├── vault-drawer.ts (openDrawer/closeDrawer, renderDrawer,
|
||||
│ drawer field grid, drawer event wiring;
|
||||
│ state.drawerOpen reset is owned here)
|
||||
├── vault-form-wrapper.ts (renderFormWrapped, sticky bar, header;
|
||||
│ __test__ export migrates with it)
|
||||
├── vault-status.ts (NEW — renders the get_vault_status indicator
|
||||
│ in the sidebar; phase 6)
|
||||
└── components/
|
||||
├── backup-panel.ts (unchanged)
|
||||
└── import-panel.ts (unchanged)
|
||||
```
|
||||
|
||||
Rationale: each module owns one concern that has its own teardown, its own DOM rectangle, and its own subset of `RouterState`. Adding a new pane view today is a 5-place edit (`teardownPaneComponents` lines 813-820 is the symptom DEV-C flagged); after the split, it is one new module + one entry in `vault.ts`'s render switch. The `vault_locked` RPC intercept at lines 47-74 lifts out entirely (see `shared/state.ts` below).
|
||||
|
||||
### Post-split `extension/src/service-worker/`
|
||||
|
||||
```
|
||||
extension/src/service-worker/
|
||||
├── index.ts (entry point — onMessage, initWasm,
|
||||
│ inactivity-timer wiring; phase 5 touches
|
||||
│ the content-callable timer-reset rule
|
||||
│ and the gitHost clear-on-expiry)
|
||||
├── session.ts (unchanged in scope; Plan A removes the
|
||||
│ try{free()} swallow at :26)
|
||||
├── session-timer.ts (unchanged; phase 5 documents the
|
||||
│ exclusion list inline)
|
||||
├── storage.ts (NEW — phase 2: loadDeviceSettings,
|
||||
│ loadBlacklist, saveBlacklist, plus the
|
||||
│ config + image-base64 + setup-state loaders
|
||||
│ if natural; both router files import here)
|
||||
├── vault.ts (gains: itemToManifestEntry (moved from
|
||||
│ both routers; phase 2), create_vault and
|
||||
│ attach_vault handlers (phase 3), and the
|
||||
│ get_vault_status handler (phase 6))
|
||||
├── git-host.ts (unchanged)
|
||||
├── github.ts / gitea.ts (unchanged)
|
||||
├── devices.ts (unchanged)
|
||||
└── router/
|
||||
├── index.ts (unchanged)
|
||||
├── popup-only.ts (imports from ../storage and ../vault;
|
||||
duplicated definitions deleted)
|
||||
└── content-callable.ts (imports from ../storage and ../vault;
|
||||
duplicated definitions deleted)
|
||||
```
|
||||
|
||||
Rationale: `service-worker/storage.ts` becomes the single source for `chrome.storage.local` reads/writes the SW does. `service-worker/vault.ts` already owns vault-tier WASM orchestration, so `itemToManifestEntry`, `create_vault`, `attach_vault`, and `get_vault_status` all belong there.
|
||||
|
||||
### `StateHost` interface contract (P1.6)
|
||||
|
||||
```ts
|
||||
// extension/src/shared/state.ts (post-rewrite)
|
||||
import type { Request, Response } from './messages';
|
||||
import type { PopupState, View } from '../popup/popup';
|
||||
|
||||
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<K extends keyof PopupState>(
|
||||
partial: Pick<PopupState, K> | Partial<PopupState>,
|
||||
): void {
|
||||
if (!host) throw new Error('No state host registered');
|
||||
host.setState(partial);
|
||||
}
|
||||
|
||||
// navigate / sendMessage / escapeHtml / popOutToTab / isInTab / openVaultTab
|
||||
// keep their generic-friendly signatures (View, Request, Response).
|
||||
//
|
||||
// vault_locked unification: shared/state.ts wraps sendMessage so a
|
||||
// `{ ok: false, error: 'vault_locked' }` response (for any request other
|
||||
// than is_unlocked / unlock) flips host state to locked + dispatches a
|
||||
// `session_expired`-equivalent event the popup also listens for.
|
||||
// Both popup.ts and vault.ts consume from this single channel — the
|
||||
// vault.ts:47-74 RPC intercept is removed in phase 4.
|
||||
```
|
||||
|
||||
Note `View` and `PopupState` are currently defined in `extension/src/popup/popup.ts`. To avoid a popup→shared circular import, they migrate to `extension/src/shared/types.ts` (or `extension/src/shared/popup-state.ts`) before `state.ts` re-imports them. This migration is part of phase 1.
|
||||
|
||||
### Setup wizard step-registry shape (P1.4)
|
||||
|
||||
```ts
|
||||
// extension/src/setup/setup.ts (post-rewrite)
|
||||
interface StepContext {
|
||||
state: WizardState;
|
||||
rerender: () => void;
|
||||
goto: (id: StepId) => void;
|
||||
}
|
||||
|
||||
interface SetupStep {
|
||||
id: StepId;
|
||||
/** Pure render — returns innerHTML for #setup-step-host. */
|
||||
render: (ctx: StepContext) => string;
|
||||
/** Wire DOM events; return a teardown the wizard runs on step change. */
|
||||
attach: (root: HTMLElement, ctx: StepContext) => () => void;
|
||||
}
|
||||
|
||||
type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
|
||||
|
||||
const STEPS: ReadonlyArray<SetupStep> = [
|
||||
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
|
||||
];
|
||||
|
||||
/** Cleared on `beforeunload` and on `goto('mode')`. */
|
||||
function clearWizardState(): void {
|
||||
// Best-effort wipe: zero-fill Uint8Arrays before drop where reachable;
|
||||
// null out passphrase + token strings (JS strings are GC-only — see Risks).
|
||||
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.
|
||||
// NOTE: setup no longer holds a SessionHandle (the SW does); the
|
||||
// phase 1 sweep deletes `verifiedHandle` from WizardState entirely.
|
||||
}
|
||||
```
|
||||
|
||||
`setup.ts` no longer imports `relicario-wasm` and no longer touches `wasm.lock` / `.free()`. The two new SW handlers do that work (see Plan A coordination below).
|
||||
|
||||
The 1220 LOC drops to ~500: the ~400 LOC of crypto orchestration (image-secret extract / embed, KDF gating, manifest_encrypt, attachment_encrypt) moves to the SW; the remaining UI logic compresses by ~300 LOC because the step-registry pattern collapses the six `renderStepN` / `attachStepN` pairs into six `SetupStep` objects.
|
||||
|
||||
### New SW message handlers
|
||||
|
||||
Added to `extension/src/shared/messages.ts`:
|
||||
|
||||
```ts
|
||||
| { 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' }
|
||||
```
|
||||
|
||||
Both `create_vault` and `attach_vault` are added to `POPUP_ONLY_TYPES`. `get_vault_status` joins the same set. Response shapes:
|
||||
|
||||
```ts
|
||||
export interface CreateVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { referenceImageBytes: Uint8Array; deviceName: string;
|
||||
recoveryQrAvailable: true };
|
||||
}
|
||||
export interface AttachVaultResponse extends Extract<Response, { ok: true }> {
|
||||
data: { deviceName: string };
|
||||
}
|
||||
export interface GetVaultStatusResponse extends Extract<Response, { ok: true }> {
|
||||
data: { ahead: number; behind: number; lastSyncAt: number | null;
|
||||
pendingItems: number };
|
||||
}
|
||||
```
|
||||
|
||||
The SW handlers live in `service-worker/vault.ts`. `create_vault` and `attach_vault` hold their own internal session reference for the duration of the operation — they do not depend on the user-facing inactivity timer (see Risks).
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Each phase keeps the existing vitest test suite green throughout. Regression budget per phase: **zero** test failures introduced; new tests added per phase (synthetic fixtures only, no binary blobs — `make_test_jpeg` style equivalents on the JS side).
|
||||
|
||||
### Phase 1 — `StateHost` typing + `__resetHostForTests` (P1.6)
|
||||
|
||||
- **Goal:** Make `extension/src/shared/state.ts` type-checked end to end so phases 3 and 4 can refactor against a real contract.
|
||||
- **Changes:**
|
||||
- Move `View` and `PopupState` from `extension/src/popup/popup.ts` to `extension/src/shared/types.ts` (or new `extension/src/shared/popup-state.ts`).
|
||||
- Rewrite `extension/src/shared/state.ts` to the snippet in **Approach**: typed `StateHost`, generic `getState`/`setState`, double-registration throw, `__resetHostForTests` export. No `any` in the public surface.
|
||||
- Sweep all callers of `getState()` / `setState()` / `navigate()`. Existing `as any` casts surface as TS errors and get fixed (typed access where the field is known; an explicit narrowing where it isn't).
|
||||
- The `vault_locked` channel collapse is **not** in this phase — the wrapper around `sendMessage` lands here as a no-op signature change; its body (the RPC intercept) lifts in phase 4.
|
||||
- **Tests:**
|
||||
- New `extension/src/shared/__tests__/state.test.ts` covering: register-then-getState round-trip; double-register throws; `__resetHostForTests` clears the singleton; `getState()` without a registered host throws.
|
||||
- Adjust any existing test that accidentally relied on a leaked host (none expected; the existing suites already register-then-tear-down per-test).
|
||||
- **Effort:** S-M
|
||||
- **Depends on:** none
|
||||
|
||||
### Phase 2 — Extract `service-worker/storage.ts` + move `itemToManifestEntry` (P1.9)
|
||||
|
||||
- **Goal:** Eliminate the duplicated SW helpers so blacklist mutations and manifest-projection refactors happen in one place.
|
||||
- **Changes:**
|
||||
- Create `extension/src/service-worker/storage.ts` exporting `loadDeviceSettings`, `saveDeviceSettings`, `loadBlacklist`, `saveBlacklist`. Migrate the bodies from `extension/src/service-worker/router/popup-only.ts:687-703` (and `saveDeviceSettings` from the same file) and delete the `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` definitions in `extension/src/service-worker/router/content-callable.ts:187-205`.
|
||||
- Move `itemToManifestEntry` from both router files (`popup-only.ts:707` and `content-callable.ts:169`) into `extension/src/service-worker/vault.ts` as a named export. Both routers import it from there.
|
||||
- Optionally also fold `loadConfig` / `loadImageBase64` / `loadSetupState` into `storage.ts` since they're chrome.storage.local readers; keep the boundary clean.
|
||||
- **Tests:**
|
||||
- New `extension/src/service-worker/__tests__/storage.test.ts` covering load/save round-trips for each helper, default-value fallback when the key is absent.
|
||||
- The existing `extension/src/service-worker/router/__tests__/router.test.ts` keeps passing; the dispatch behavior is unchanged.
|
||||
- **Effort:** S
|
||||
- **Depends on:** none (independent of phase 1; can ship parallel)
|
||||
|
||||
### Phase 3 — Setup wizard SW migration + step registry (P1.4)
|
||||
|
||||
- **Goal:** Setup wizard becomes UI-only. SW owns `create_vault` and `attach_vault` end to end. Wizard restructures to a step-registry pattern. Sensitive material clears on abandon.
|
||||
- **Changes:**
|
||||
- Add `create_vault` and `attach_vault` types to `extension/src/shared/messages.ts` (request union, response interfaces, capability set).
|
||||
- Implement handlers in `extension/src/service-worker/vault.ts`. Each handler:
|
||||
1. Computes salt + params, calls `unlock` (create) or verifies (attach), calls `embed_image_secret` / `extract_image_secret`, calls `register_device`, calls `manifest_encrypt` for the empty manifest, writes the resulting bytes to the configured remote via the existing `git-host` abstraction.
|
||||
2. Holds its own `SessionHandle` for the duration. On success, transitions the SW into the unlocked state (replaces the popup-driven `unlock` path's outcome); on failure, calls `wasm.lock(handle)` then `.free()` (see Plan A coordination).
|
||||
3. Returns the reference image bytes (`create_vault`) or just the device name (`attach_vault`) so the wizard's "Done" step can offer a download.
|
||||
- Rewrite `extension/src/setup/setup.ts`:
|
||||
- Delete the WASM dynamic-import block at lines 28-37; delete the `loadWasm()` helper; delete the `wasm` module variable; delete `verifiedHandle` from `WizardState`.
|
||||
- Convert each of the six `renderStepN` / `attachStepN` pairs into a `SetupStep` object (`modeStep`, `hostStep`, `connectionStep`, `vaultStep`, `deviceStep`, `doneStep`) per the step-registry snippet in **Approach**. The wizard's render loop becomes `STEPS[state.step].render(ctx)` + `STEPS[state.step].attach(root, ctx)`.
|
||||
- Replace direct WASM orchestration in the vault step with `sendMessage({ type: 'create_vault', ... })` / `sendMessage({ type: 'attach_vault', ... })`.
|
||||
- Add `clearWizardState()` per the snippet; bind to `window.addEventListener('beforeunload', clearWizardState)` and call from the `goto('mode')` path.
|
||||
- Update `extension/src/wasm.d.ts` only if the new SW handlers need a WASM entry point that isn't already declared (verify by reading `extension/src/service-worker/vault.ts` — they likely don't, since `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt` are already declared; the SW just orchestrates them). If no new WASM entry, this file is **not touched** by Plan C.
|
||||
- The `recovery_qr_generated_at` direct chrome.storage.local write at `setup.ts:1056-1062` is **out of scope** per the kickoff (defer to a P3 cleanup) — it stays as-is in this phase.
|
||||
- **Tests:**
|
||||
- Update `extension/src/setup/__tests__/setup.test.ts` to assert on the step registry shape (each `SetupStep.id`, `render` returns non-empty HTML, `attach` returns a callable teardown).
|
||||
- New SW-side test in `extension/src/service-worker/__tests__/vault.test.ts` (or extend an existing one) covering `create_vault` happy path with a stubbed `git-host` and the WASM stub from `__stubs__/relicario_wasm.stub.ts` (round out the stub for `embed_image_secret`, `register_device`, `manifest_encrypt` if they aren't there yet — DEV-C P2 noted only 7 of ~25 are stubbed).
|
||||
- New test for `clearWizardState`: simulate `beforeunload`, assert `Uint8Array` contents are zero-filled.
|
||||
- **Effort:** L
|
||||
- **Depends on:** Phase 1 (the wizard's UI uses `getState`/`setState` against a typed `StateHost`)
|
||||
|
||||
### Phase 4 — Split `vault.ts` + lift `vault_locked` channel (P1.5)
|
||||
|
||||
- **Goal:** `extension/src/vault/vault.ts` shrinks to ~200 LOC of routing + state. Each pane concern lives in its own module. The `vault_locked` RPC intercept disappears from vault.ts and runs in `shared/state.ts` instead.
|
||||
- **Changes:**
|
||||
- Create the five new files (`vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`) per the directory tree in **Approach**. Migrate the corresponding code blocks from the existing `vault.ts` into them. Each module exports a `render*` function plus, where stateful, an explicit `teardown()`.
|
||||
- In `vault.ts`, retain only: `RouterState` declaration, hash parsing (`parseHash`, `setHash`), `loadManifest`, the `render()` entry point, the `renderPane()` switch, and the imports that wire the modules together.
|
||||
- In `vault-drawer.ts`: include an `ensureDrawerClosedForRoute(route)` helper that the `renderPane` switch calls before any non-list view. This implements the P2 fix for `vault.ts:495-536` (drawer state no longer leaks across navigation).
|
||||
- In `vault-sidebar.ts`: wrap the search input handler in a 50-100ms debounce (DEV-C P2 — `vault.ts:648-695`).
|
||||
- Lift the `vault_locked` RPC intercept (`vault.ts:47-74`) into the `sendMessage` wrapper in `extension/src/shared/state.ts` (the wrapper signature landed in phase 1; phase 4 fills the body). Both popup and vault now consume the same `session_expired`-equivalent flow. **Migration discipline:** keep both signals firing for one merge cycle (the SW continues to dispatch `session_expired` and the wrapper still fires the intercept) until both surfaces have been verified as consuming from `shared/state.ts`; then collapse.
|
||||
- **Tests:**
|
||||
- Existing `extension/src/vault/__tests__/form-wrapper.test.ts` and `sidebar-glyphs.test.ts` continue to pass (the symbols they import move from `vault.ts` to `vault-form-wrapper.ts` / `vault-sidebar.ts`; update import paths).
|
||||
- New `extension/src/vault/__tests__/drawer-state.test.ts` covering: drawer auto-closes when navigating from list to trash/devices/settings.
|
||||
- New `extension/src/shared/__tests__/state-vault-locked.test.ts` covering: a `{ ok: false, error: 'vault_locked' }` response (for a request other than `is_unlocked`/`unlock`) flips host state to locked.
|
||||
- **Effort:** M
|
||||
- **Depends on:** Phase 1 (uses the typed `StateHost` surface and the `sendMessage` wrapper)
|
||||
|
||||
### Phase 5 — Extension P2 cluster
|
||||
|
||||
- **Goal:** Sweep five small extension P2s that share the same "small fix, real correctness win" shape.
|
||||
- **Changes:**
|
||||
- **Inactivity timer reset on content-callable messages** (`extension/src/service-worker/index.ts:76-78`): invert the current condition. Reset on **all** messages except a small documented exclusion set (`get_autofill_candidates` is the only known read-only content call). Define `READ_ONLY_CONTENT_CALLABLE` in `service-worker/session-timer.ts` with a doc comment listing each excluded type and the rationale; `index.ts` consults that set.
|
||||
- **`state.gitHost` clear on session expiry** (`extension/src/service-worker/index.ts:51-58`): in the `sessionTimer.onExpired` callback, also set `state.gitHost = null`. The initializer rebuilds it on demand.
|
||||
- **Teardown helper extraction** (`extension/src/popup/components/settings.ts:56-65` and `settings-vault.ts:15-22`): extract `teardownSettingsCommon()` exported from `settings.ts` (or a new `settings-shared.ts`); both `settings.ts:teardownSettings` and `settings-vault.ts:teardown` call it. Single source for the closeGeneratorPanel + activeKeyHandler removal pattern.
|
||||
- **`Promise.allSettled` in devices/trash** (`extension/src/popup/components/devices.ts:47-50`, `trash.ts:39-46`): swap `Promise.all` for `Promise.allSettled`; render each settled response defensively (`.status === 'fulfilled' && r.value.ok`); fall back to "couldn't load" copy per failed slot.
|
||||
- **MutationObserver debounce** (`extension/src/content/detector.ts:96-103`): wrap the existing `() => scan()` callback in a 200ms trailing-edge debounce (or `requestIdleCallback` if available; `setTimeout(..., 200)` fallback). Reset on every observer fire.
|
||||
- **Tests:**
|
||||
- Existing `extension/src/service-worker/__tests__/session-timer.test.ts` extended with a "popup-only message resets, content-callable message does not reset (except listed exclusions)" case.
|
||||
- Existing `extension/src/popup/components/__tests__/devices.test.ts` and `trash.test.ts` extended with a "one RPC fails, the other still renders" case.
|
||||
- Existing `extension/src/popup/components/__tests__/settings.test.ts` and `settings-vault.test.ts` extended to confirm both call paths invoke `teardownSettingsCommon`.
|
||||
- **Effort:** M
|
||||
- **Depends on:** none (all five are independent of phases 1-4)
|
||||
|
||||
### Phase 6 — `get_vault_status` SW message + vault-sidebar status indicator
|
||||
|
||||
- **Goal:** Close the last CLI/extension parity gap (`relicario status` ↔ extension status indicator).
|
||||
- **Changes:**
|
||||
- Add `{ type: 'get_vault_status' }` to `extension/src/shared/messages.ts` and to `POPUP_ONLY_TYPES`. Add `GetVaultStatusResponse` per the **Approach** snippet.
|
||||
- Implement the handler in `extension/src/service-worker/vault.ts`. It returns `{ ahead, behind, lastSyncAt, pendingItems }` from cached state on `state.gitHost` (no actual sync). A "last sync" timestamp is recorded in `service-worker/index.ts` (or `state.gitHost`) on each successful `sync` handler return.
|
||||
- Create `extension/src/vault/vault-status.ts` rendering a small indicator in the sidebar footer: glyph + "in sync" / "N ahead" / "N pending" / "last sync 2m ago". Polls on sidebar mount and on a manual refresh button (NOT every render — see Risks).
|
||||
- **Tests:**
|
||||
- New `extension/src/service-worker/__tests__/vault-status.test.ts` covering the four state combinations and the no-sync invariant (handler does not touch the network).
|
||||
- New `extension/src/vault/__tests__/status-indicator.test.ts` for the renderer.
|
||||
- **Effort:** S-M
|
||||
- **Depends on:** Phase 4 (the indicator lives inside the new `vault-sidebar.ts` boundary)
|
||||
|
||||
### Future / deferred (Plan B coordination)
|
||||
|
||||
Plan B (CLI restructure) migrates `parse_month_year`, `base32_decode_lenient`, and `guess_mime` from the CLI into `relicario-core`, then re-exports them through `relicario-wasm`. Once those WASM exports land, the extension can consume them via new SW message handlers (e.g. `parse_month_year`, `decode_totp_secret`, `guess_mime_for_filename`) — this is a natural follow-up and explicitly **deferred to a future plan**. The seam to consume them is `extension/src/service-worker/vault.ts` (or a new `service-worker/parse.ts`) plus a new entry in `extension/src/wasm.d.ts`. Plan C does **not** design those handlers in detail.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **State.ts typing changes ripple.** Every consumer of `getState`/`setState` becomes type-checked; existing `as any` casts will surface as TS errors. *Mitigation:* phase 1 includes a sweep + targeted TS error fix as part of the phase, not as a follow-up. Run `tsc --noEmit` per file class to triage; expect ~15-30 errors clustered in `popup/components/*.ts` and `vault/*.ts`.
|
||||
- **Setup-via-SW migration changes the crypto state machine.** Today setup orchestrates WASM directly; after phase 3, the SW owns vault creation. If the SW's user-facing inactivity timer fires mid-creation, the user could lose progress. *Mitigation:* design `create_vault` / `attach_vault` to be transactional from the SW's perspective — they hold their own internal session reference for the duration of the operation and do not consult or reset the user-facing inactivity timer until they return successfully. Document this contract in the handler header comments.
|
||||
- **`vault.ts` split + `vault_locked` channel unification.** The popup currently uses `session_expired` event; vault tab uses RPC intercept. Unifying onto one channel means popup behavior must continue to work after phase 4. *Mitigation:* keep both signals firing during phase 4 (the SW continues to dispatch `session_expired`; the new wrapper in `shared/state.ts` also fires the intercept); collapse only after both surfaces are verified to consume from `shared/state.ts` in the same merge cycle. Add a regression test asserting the popup's lock screen renders on `session_expired` and the vault tab's lock screen renders on the SW response intercept.
|
||||
- **`.free()` callsite policy.** Plan A (security/docs polish) handles the Rust-side `impl Drop for SessionHandle` and removes the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`. Plan C does **not** redo that work, but wherever this refactor *moves* a `.free()` callsite — most notably during the phase 3 setup-to-SW migration where `setup.ts`'s `verifiedHandle` retires and the new `create_vault` / `attach_vault` handlers acquire their own handles — the new location must call `wasm.lock(handle)` first regardless of whether Plan A's Rust-side `impl Drop` lands. Cite Plan A as the source of the policy.
|
||||
- **WASM boundary coordination.** Plan B (CLI restructure) will touch `extension/src/wasm.d.ts` for the new parser exports (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) once they land in `relicario-wasm`. Plan C should **not** touch `extension/src/wasm.d.ts` unless `create_vault` / `attach_vault` need WASM entry points that aren't already declared (verify by reading `service-worker/vault.ts`; the SW already orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt`, so likely no new entries needed). If both plans must touch `wasm.d.ts`, sequence Plan B's edits first and rebase Plan C on top.
|
||||
- **`clearWizardState()` semantics.** Clearing on `beforeunload` is best-effort in browsers (the event can be skipped if the tab crashes or is killed). JS strings (passphrase, API token) are also GC-only — there is no `Zeroize` for them. *Mitigation:* explicit zero-fill of `Uint8Array` fields where possible (carrier image bytes, reference image bytes); document the best-effort contract in the function header comment; do not over-promise in user docs. The same caveat already applies to existing strings in `WizardState` today, so this is a maintenance of the existing contract, not a regression.
|
||||
- **`get_vault_status` design.** Needs to call into the git-host abstraction without triggering an actual sync. *Mitigation:* cache the last-sync state in `state.gitHost` (add `lastSyncAt: number | null`, `ahead: number`, `behind: number` fields populated by the existing `sync` handler) and have `get_vault_status` read those cached values; the sidebar indicator polls on mount + on a manual refresh button rather than on every render. Any UI element that wants live status calls `sync` explicitly.
|
||||
- **Phase ordering risk.** Phase 1 is the blocker — phases 3 and 4 both depend on the typed `StateHost`. If phase 1 takes longer than estimated, run phase 2 (independent) and phase 5 (independent) in parallel to avoid total stall.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Plan C does NOT touch:
|
||||
|
||||
- Anything in **Plan A** (security/docs polish): Rust `impl Drop for SessionHandle`, the `service-worker/session.ts:26` swallow removal, `.free()` callsite audit, `recovery_qr.rs` documentation, server hardening, env-var audit.
|
||||
- Anything in **Plan B** (CLI restructure): `cli/main.rs` split, git-shell error UX helper, `parse_month_year`/`base32_decode_lenient`/`guess_mime` migration to core (Plan C only consumes them, deferred to a future phase).
|
||||
- Extension P3s: form-header `isInTab()` redundancy, popup.ts `isInTab()` heuristic, item-form.ts `renderComingSoon` dead code, `types/login.ts` size, `vault.ts:18-26` backup-panel comment, capture/detector/fill username-finder dedup, capture submit-button hook scope, setup.ts passphrase-score `-1` sentinel, `setup.ts:1056-1062` chrome.storage bypass, `setup.ts:1-7` "5-step" header comment, glyphs.ts partial adoption, `types.ts` `TotpKind` flat-union refactor, `totp-tools.ts:39-46` swallowed rejections, generator-panel cleanup idempotence guard, `item-list.ts` popover listener reuse path, popup `popup.ts:178-181` unconditional teardowns.
|
||||
- Other CLI/extension parity items: per-attachment `delete_attachment` SW message (P3), `list --tag` filter doc note (P3) — only `get_vault_status` is in scope.
|
||||
- Cross-cutting items not explicitly listed: `chrome.storage.local` direct reads outside the setup migration (e.g. `settings-security.ts:112-113`), `bun test` runner doc note, manifest version sync.
|
||||
- The full P2/P3 tail outside the items folded above.
|
||||
- The 8 "Open architectural decisions" from the synthesis appendix.
|
||||
- WASM JS-naming snake_case → camelCase decision (defer to a separate plan).
|
||||
- Anything touching the in-flight uncommitted v0.5.x work (vault.ts +151/-99, vault.css +238/-99, manifest version, glyphs additions). Plan C executes against committed `main` post-061facd.
|
||||
|
||||
## Done criteria
|
||||
|
||||
A reviewer confirms the plan shipped by checking each item:
|
||||
|
||||
- [ ] `extension/src/shared/state.ts` `StateHost` interface defines every field; no `any` in the public surface.
|
||||
- [ ] `getState` returns `PopupState`; `setState` is generic over `keyof PopupState`.
|
||||
- [ ] `registerHost` throws on second registration; `__resetHostForTests` is exported and used in vitest setup.
|
||||
- [ ] `extension/src/service-worker/router/popup-only.ts` and `content-callable.ts` no longer contain `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` definitions (only imports from `service-worker/storage.ts`).
|
||||
- [ ] `itemToManifestEntry` defined once in `extension/src/service-worker/vault.ts`, imported by both router files.
|
||||
- [ ] `extension/src/setup/setup.ts` LOC ≤ ~500.
|
||||
- [ ] `extension/src/setup/setup.ts` does not import `relicario-wasm` (no dynamic import, no `loadWasm`, no module-level `wasm` binding).
|
||||
- [ ] SW handles `create_vault` and `attach_vault` messages (entries in `messages.ts`, `POPUP_ONLY_TYPES`, and `service-worker/vault.ts`).
|
||||
- [ ] `clearWizardState()` exists, is bound to `beforeunload`, and is called from the `goto('mode')` path; sensitive `Uint8Array` fields are zero-filled.
|
||||
- [ ] `extension/src/vault/vault.ts` is split into `vault.ts` + `vault-shell.ts` + `vault-sidebar.ts` + `vault-list.ts` + `vault-drawer.ts` + `vault-form-wrapper.ts`; `vault.ts` is ~200 LOC of routing + state only.
|
||||
- [ ] Single `vault_locked` channel: the RPC intercept is in `extension/src/shared/state.ts`'s `sendMessage` wrapper; `vault.ts` no longer contains the intercept block at the old lines 47-74.
|
||||
- [ ] Drawer auto-closes on navigation to non-list views (`state.drawerOpen = false` reset in `renderPane` / `ensureDrawerClosedForRoute`).
|
||||
- [ ] Sidebar search input is debounced (50-100ms).
|
||||
- [ ] Inactivity timer resets on **all** messages except a documented exclusion set (currently `get_autofill_candidates`); the exclusion set is defined and commented in `service-worker/session-timer.ts`.
|
||||
- [ ] `state.gitHost = null` runs alongside `state.manifest = null` in the `sessionTimer.onExpired` callback.
|
||||
- [ ] One `teardownSettingsCommon` helper exists; both `settings.ts` and `settings-vault.ts` call it.
|
||||
- [ ] `devices.ts` and `trash.ts` use `Promise.allSettled` and render each settled response defensively.
|
||||
- [ ] `extension/src/content/detector.ts` MutationObserver `scan()` is debounced (200ms or `requestIdleCallback`).
|
||||
- [ ] `get_vault_status` SW message and response interface exist; vault sidebar renders an indicator on mount and on manual refresh.
|
||||
- [ ] All existing vitest tests under `extension/src/**/__tests__/` pass; new tests added per phase pass.
|
||||
- [ ] No `extension/src/wasm.d.ts` change introduced by Plan C unless coordinated with Plan B (verify the file's diff is empty post-merge, or coordinated explicitly with Plan B's parser exports).
|
||||
- [ ] Every `.free()` callsite moved by this plan is preceded by `wasm.lock(handle)` (per Plan A's policy, regardless of whether Plan A's Rust `impl Drop` has landed).
|
||||
Reference in New Issue
Block a user