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>
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 importsrelicario-wasmdirectly and orchestratesunlock/embed_image_secret/register_device/manifest_encryptitself, 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 thevault_lockedRPC 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) —StateHostcontract is fullyany-typed;hostsingleton 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, anditemToManifestEntryduplicated 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.gitHostclear 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-65andsettings-vault.ts:15-22) — two near-identical cleanup paths in a regression class with known prior leaks. - P2 —
Promise.allwithout 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.tstype-checked end to end so phases 3 and 4 can refactor against a real contract. - Changes:
- Move
ViewandPopupStatefromextension/src/popup/popup.tstoextension/src/shared/types.ts(or newextension/src/shared/popup-state.ts). - Rewrite
extension/src/shared/state.tsto the snippet in Approach: typedStateHost, genericgetState/setState, double-registration throw,__resetHostForTestsexport. Noanyin the public surface. - Sweep all callers of
getState()/setState()/navigate(). Existingas anycasts surface as TS errors and get fixed (typed access where the field is known; an explicit narrowing where it isn't). - The
vault_lockedchannel collapse is not in this phase — the wrapper aroundsendMessagelands here as a no-op signature change; its body (the RPC intercept) lifts in phase 4.
- Move
- Tests:
- New
extension/src/shared/__tests__/state.test.tscovering: register-then-getState round-trip; double-register throws;__resetHostForTestsclears 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).
- New
- 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.tsexportingloadDeviceSettings,saveDeviceSettings,loadBlacklist,saveBlacklist. Migrate the bodies fromextension/src/service-worker/router/popup-only.ts:687-703(andsaveDeviceSettingsfrom the same file) and delete theloadDeviceSettings/loadBlacklist/saveBlacklistdefinitions inextension/src/service-worker/router/content-callable.ts:187-205. - Move
itemToManifestEntryfrom both router files (popup-only.ts:707andcontent-callable.ts:169) intoextension/src/service-worker/vault.tsas a named export. Both routers import it from there. - Optionally also fold
loadConfig/loadImageBase64/loadSetupStateintostorage.tssince they're chrome.storage.local readers; keep the boundary clean.
- Create
- Tests:
- New
extension/src/service-worker/__tests__/storage.test.tscovering 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.tskeeps passing; the dispatch behavior is unchanged.
- New
- 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_vaultandattach_vaultend to end. Wizard restructures to a step-registry pattern. Sensitive material clears on abandon. - Changes:
- Add
create_vaultandattach_vaulttypes toextension/src/shared/messages.ts(request union, response interfaces, capability set). - Implement handlers in
extension/src/service-worker/vault.ts. Each handler:- Computes salt + params, calls
unlock(create) or verifies (attach), callsembed_image_secret/extract_image_secret, callsregister_device, callsmanifest_encryptfor the empty manifest, writes the resulting bytes to the configured remote via the existinggit-hostabstraction. - Holds its own
SessionHandlefor the duration. On success, transitions the SW into the unlocked state (replaces the popup-drivenunlockpath's outcome); on failure, callswasm.lock(handle)then.free()(see Plan A coordination). - Returns the reference image bytes (
create_vault) or just the device name (attach_vault) so the wizard's "Done" step can offer a download.
- Computes salt + params, calls
- Rewrite
extension/src/setup/setup.ts:- Delete the WASM dynamic-import block at lines 28-37; delete the
loadWasm()helper; delete thewasmmodule variable; deleteverifiedHandlefromWizardState. - Convert each of the six
renderStepN/attachStepNpairs into aSetupStepobject (modeStep,hostStep,connectionStep,vaultStep,deviceStep,doneStep) per the step-registry snippet in Approach. The wizard's render loop becomesSTEPS[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 towindow.addEventListener('beforeunload', clearWizardState)and call from thegoto('mode')path.
- Delete the WASM dynamic-import block at lines 28-37; delete the
- Update
extension/src/wasm.d.tsonly if the new SW handlers need a WASM entry point that isn't already declared (verify by readingextension/src/service-worker/vault.ts— they likely don't, sinceunlock/embed_image_secret/register_device/manifest_encryptare already declared; the SW just orchestrates them). If no new WASM entry, this file is not touched by Plan C. - The
recovery_qr_generated_atdirect chrome.storage.local write atsetup.ts:1056-1062is out of scope per the kickoff (defer to a P3 cleanup) — it stays as-is in this phase.
- Add
- Tests:
- Update
extension/src/setup/__tests__/setup.test.tsto assert on the step registry shape (eachSetupStep.id,renderreturns non-empty HTML,attachreturns a callable teardown). - New SW-side test in
extension/src/service-worker/__tests__/vault.test.ts(or extend an existing one) coveringcreate_vaulthappy path with a stubbedgit-hostand the WASM stub from__stubs__/relicario_wasm.stub.ts(round out the stub forembed_image_secret,register_device,manifest_encryptif they aren't there yet — DEV-C P2 noted only 7 of ~25 are stubbed). - New test for
clearWizardState: simulatebeforeunload, assertUint8Arraycontents are zero-filled.
- Update
- Effort: L
- Depends on: Phase 1 (the wizard's UI uses
getState/setStateagainst a typedStateHost)
Phase 4 — Split vault.ts + lift vault_locked channel (P1.5)
- Goal:
extension/src/vault/vault.tsshrinks to ~200 LOC of routing + state. Each pane concern lives in its own module. Thevault_lockedRPC intercept disappears from vault.ts and runs inshared/state.tsinstead. - 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 existingvault.tsinto them. Each module exports arender*function plus, where stateful, an explicitteardown(). - In
vault.ts, retain only:RouterStatedeclaration, hash parsing (parseHash,setHash),loadManifest, therender()entry point, therenderPane()switch, and the imports that wire the modules together. - In
vault-drawer.ts: include anensureDrawerClosedForRoute(route)helper that therenderPaneswitch calls before any non-list view. This implements the P2 fix forvault.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_lockedRPC intercept (vault.ts:47-74) into thesendMessagewrapper inextension/src/shared/state.ts(the wrapper signature landed in phase 1; phase 4 fills the body). Both popup and vault now consume the samesession_expired-equivalent flow. Migration discipline: keep both signals firing for one merge cycle (the SW continues to dispatchsession_expiredand the wrapper still fires the intercept) until both surfaces have been verified as consuming fromshared/state.ts; then collapse.
- Create the five new files (
- Tests:
- Existing
extension/src/vault/__tests__/form-wrapper.test.tsandsidebar-glyphs.test.tscontinue to pass (the symbols they import move fromvault.tstovault-form-wrapper.ts/vault-sidebar.ts; update import paths). - New
extension/src/vault/__tests__/drawer-state.test.tscovering: drawer auto-closes when navigating from list to trash/devices/settings. - New
extension/src/shared/__tests__/state-vault-locked.test.tscovering: a{ ok: false, error: 'vault_locked' }response (for a request other thanis_unlocked/unlock) flips host state to locked.
- Existing
- Effort: M
- Depends on: Phase 1 (uses the typed
StateHostsurface and thesendMessagewrapper)
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_candidatesis the only known read-only content call). DefineREAD_ONLY_CONTENT_CALLABLEinservice-worker/session-timer.tswith a doc comment listing each excluded type and the rationale;index.tsconsults that set. state.gitHostclear on session expiry (extension/src/service-worker/index.ts:51-58): in thesessionTimer.onExpiredcallback, also setstate.gitHost = null. The initializer rebuilds it on demand.- Teardown helper extraction (
extension/src/popup/components/settings.ts:56-65andsettings-vault.ts:15-22): extractteardownSettingsCommon()exported fromsettings.ts(or a newsettings-shared.ts); bothsettings.ts:teardownSettingsandsettings-vault.ts:teardowncall it. Single source for the closeGeneratorPanel + activeKeyHandler removal pattern. Promise.allSettledin devices/trash (extension/src/popup/components/devices.ts:47-50,trash.ts:39-46): swapPromise.allforPromise.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 (orrequestIdleCallbackif available;setTimeout(..., 200)fallback). Reset on every observer fire.
- Inactivity timer reset on content-callable messages (
- Tests:
- Existing
extension/src/service-worker/__tests__/session-timer.test.tsextended with a "popup-only message resets, content-callable message does not reset (except listed exclusions)" case. - Existing
extension/src/popup/components/__tests__/devices.test.tsandtrash.test.tsextended with a "one RPC fails, the other still renders" case. - Existing
extension/src/popup/components/__tests__/settings.test.tsandsettings-vault.test.tsextended to confirm both call paths invoketeardownSettingsCommon.
- Existing
- 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' }toextension/src/shared/messages.tsand toPOPUP_ONLY_TYPES. AddGetVaultStatusResponseper the Approach snippet. - Implement the handler in
extension/src/service-worker/vault.ts. It returns{ ahead, behind, lastSyncAt, pendingItems }from cached state onstate.gitHost(no actual sync). A "last sync" timestamp is recorded inservice-worker/index.ts(orstate.gitHost) on each successfulsynchandler return. - Create
extension/src/vault/vault-status.tsrendering 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).
- Add
- Tests:
- New
extension/src/service-worker/__tests__/vault-status.test.tscovering the four state combinations and the no-sync invariant (handler does not touch the network). - New
extension/src/vault/__tests__/status-indicator.test.tsfor the renderer.
- New
- Effort: S-M
- Depends on: Phase 4 (the indicator lives inside the new
vault-sidebar.tsboundary)
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/setStatebecomes type-checked; existingas anycasts 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. Runtsc --noEmitper file class to triage; expect ~15-30 errors clustered inpopup/components/*.tsandvault/*.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_vaultto 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.tssplit +vault_lockedchannel unification. The popup currently usessession_expiredevent; 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 dispatchsession_expired; the new wrapper inshared/state.tsalso fires the intercept); collapse only after both surfaces are verified to consume fromshared/state.tsin the same merge cycle. Add a regression test asserting the popup's lock screen renders onsession_expiredand the vault tab's lock screen renders on the SW response intercept..free()callsite policy. Plan A (security/docs polish) handles the Rust-sideimpl Drop for SessionHandleand removes thetry { current.free() }swallow atextension/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 wheresetup.ts'sverifiedHandleretires and the newcreate_vault/attach_vaulthandlers acquire their own handles — the new location must callwasm.lock(handle)first regardless of whether Plan A's Rust-sideimpl Droplands. Cite Plan A as the source of the policy.- WASM boundary coordination. Plan B (CLI restructure) will touch
extension/src/wasm.d.tsfor the new parser exports (parse_month_year,base32_decode_lenient,guess_mime) once they land inrelicario-wasm. Plan C should not touchextension/src/wasm.d.tsunlesscreate_vault/attach_vaultneed WASM entry points that aren't already declared (verify by readingservice-worker/vault.ts; the SW already orchestratesunlock/embed_image_secret/register_device/manifest_encrypt, so likely no new entries needed). If both plans must touchwasm.d.ts, sequence Plan B's edits first and rebase Plan C on top. clearWizardState()semantics. Clearing onbeforeunloadis 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 noZeroizefor them. Mitigation: explicit zero-fill ofUint8Arrayfields 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 inWizardStatetoday, so this is a maintenance of the existing contract, not a regression.get_vault_statusdesign. Needs to call into the git-host abstraction without triggering an actual sync. Mitigation: cache the last-sync state instate.gitHost(addlastSyncAt: number | null,ahead: number,behind: numberfields populated by the existingsynchandler) and haveget_vault_statusread 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 callssyncexplicitly.- 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, theservice-worker/session.ts:26swallow removal,.free()callsite audit,recovery_qr.rsdocumentation, server hardening, env-var audit. - Anything in Plan B (CLI restructure):
cli/main.rssplit, git-shell error UX helper,parse_month_year/base32_decode_lenient/guess_mimemigration to core (Plan C only consumes them, deferred to a future phase). - Extension P3s: form-header
isInTab()redundancy, popup.tsisInTab()heuristic, item-form.tsrenderComingSoondead code,types/login.tssize,vault.ts:18-26backup-panel comment, capture/detector/fill username-finder dedup, capture submit-button hook scope, setup.ts passphrase-score-1sentinel,setup.ts:1056-1062chrome.storage bypass,setup.ts:1-7"5-step" header comment, glyphs.ts partial adoption,types.tsTotpKindflat-union refactor,totp-tools.ts:39-46swallowed rejections, generator-panel cleanup idempotence guard,item-list.tspopover listener reuse path, popuppopup.ts:178-181unconditional teardowns. - Other CLI/extension parity items: per-attachment
delete_attachmentSW message (P3),list --tagfilter doc note (P3) — onlyget_vault_statusis in scope. - Cross-cutting items not explicitly listed:
chrome.storage.localdirect reads outside the setup migration (e.g.settings-security.ts:112-113),bun testrunner 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
mainpost-061facd.
Done criteria
A reviewer confirms the plan shipped by checking each item:
extension/src/shared/state.tsStateHostinterface defines every field; noanyin the public surface.getStatereturnsPopupState;setStateis generic overkeyof PopupState.registerHostthrows on second registration;__resetHostForTestsis exported and used in vitest setup.extension/src/service-worker/router/popup-only.tsandcontent-callable.tsno longer containloadDeviceSettings/loadBlacklist/saveBlacklistdefinitions (only imports fromservice-worker/storage.ts).itemToManifestEntrydefined once inextension/src/service-worker/vault.ts, imported by both router files.extension/src/setup/setup.tsLOC ≤ ~500.extension/src/setup/setup.tsdoes not importrelicario-wasm(no dynamic import, noloadWasm, no module-levelwasmbinding).- SW handles
create_vaultandattach_vaultmessages (entries inmessages.ts,POPUP_ONLY_TYPES, andservice-worker/vault.ts). clearWizardState()exists, is bound tobeforeunload, and is called from thegoto('mode')path; sensitiveUint8Arrayfields are zero-filled.extension/src/vault/vault.tsis split intovault.ts+vault-shell.ts+vault-sidebar.ts+vault-list.ts+vault-drawer.ts+vault-form-wrapper.ts;vault.tsis ~200 LOC of routing + state only.- Single
vault_lockedchannel: the RPC intercept is inextension/src/shared/state.ts'ssendMessagewrapper;vault.tsno longer contains the intercept block at the old lines 47-74. - Drawer auto-closes on navigation to non-list views (
state.drawerOpen = falsereset inrenderPane/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 inservice-worker/session-timer.ts. state.gitHost = nullruns alongsidestate.manifest = nullin thesessionTimer.onExpiredcallback.- One
teardownSettingsCommonhelper exists; bothsettings.tsandsettings-vault.tscall it. devices.tsandtrash.tsusePromise.allSettledand render each settled response defensively.extension/src/content/detector.tsMutationObserverscan()is debounced (200ms orrequestIdleCallback).get_vault_statusSW 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.tschange 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 bywasm.lock(handle)(per Plan A's policy, regardless of whether Plan A's Rustimpl Drophas landed).