Files
relicario/docs/superpowers/specs/2026-05-04-extension-restructure-design.md
adlee-was-taken 4f7ab91f14 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>
2026-05-05 20:02:40 -04:00

34 KiB

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)

// 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)

// 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:

| { 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:

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).