diff --git a/extension/src/shared/__tests__/state-vault-locked.test.ts b/extension/src/shared/__tests__/state-vault-locked.test.ts new file mode 100644 index 0000000..16003ad --- /dev/null +++ b/extension/src/shared/__tests__/state-vault-locked.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerHost, __resetHostForTests, sendMessage } from '../state'; +import type { StateHost } from '../state'; +import type { Response } from '../messages'; + +function makeHost(response: { ok: boolean; error?: string }): StateHost { + return { + getState: () => ({ view: 'list' } as never), + setState: vi.fn(), + navigate: vi.fn(), + sendMessage: vi.fn().mockResolvedValue(response as Response), + escapeHtml: (s) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), + }; +} + +describe('shared/state sendMessage vault_locked intercept', () => { + beforeEach(() => __resetHostForTests()); + + it('navigates to the lock screen on a vault_locked response', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).toHaveBeenCalledWith( + 'locked', + expect.objectContaining({ error: expect.any(String) }), + ); + }); + + it('does NOT intercept the unlock request itself', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'unlock', passphrase: 'x' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does NOT intercept is_unlocked', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + await sendMessage({ type: 'is_unlocked' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does not intercept a successful response', async () => { + const host = makeHost({ ok: true }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does not intercept a non-vault_locked error', async () => { + const host = makeHost({ ok: false, error: 'something_else' }); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('returns the response unchanged', async () => { + const host = makeHost({ ok: false, error: 'vault_locked' }); + registerHost(host); + const resp = await sendMessage({ type: 'list_items' }); + expect(resp).toEqual({ ok: false, error: 'vault_locked' }); + }); +}); diff --git a/extension/src/shared/state.ts b/extension/src/shared/state.ts index 30deeaf..68dfa04 100644 --- a/extension/src/shared/state.ts +++ b/extension/src/shared/state.ts @@ -50,13 +50,33 @@ export function navigate(view: View, extras?: Partial): void { host.navigate(view, extras); } +// Requests that must NOT trigger the lock screen on a vault_locked response: +// they run during cold start / unlock, before a session exists, so a +// vault_locked here is expected rather than a lost session. +const VAULT_LOCKED_BYPASS: ReadonlySet = new Set([ + 'unlock', 'is_unlocked', +]); + /** - * Phase 4 will add a vault_locked intercept here. For now, this is a pure - * pass-through so the signature is stable for Phase 4 to fill. + * Dispatches a request to the service worker and intercepts the `vault_locked` + * response. MV3 evicts the service worker after ~30s idle, wiping the in-memory + * session/manifest; the next RPC comes back `vault_locked`. Any surface (popup + * or vault tab) that gets that on a non-bypassed request treats it as "session + * lost" and navigates to the lock screen so the user can re-enter their + * passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4 + * so both surfaces share one channel. */ export async function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); - return host.sendMessage(request); + const response = await host.sendMessage(request); + if ( + !response.ok && + response.error === 'vault_locked' && + !VAULT_LOCKED_BYPASS.has(request.type) + ) { + host.navigate('locked', { error: 'Session expired — please unlock again.' }); + } + return response; } export function escapeHtml(s: string): string { diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index f07434c..82b11e1 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -5,7 +5,7 @@ /// vault tab's pane area. import type { Request, Response } from '../shared/messages'; -import { registerHost } from '../shared/state'; +import { registerHost, sendMessage } from '../shared/state'; import { type ErrorCta } from '../shared/error-copy'; import { type VaultController, type VaultState, type VaultView, @@ -27,32 +27,12 @@ import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vaul // Helpers // --------------------------------------------------------------------------- -function sendMessage(request: Request): Promise { +// Plain transport to the service worker, registered as the host's sendMessage. +// The shared sendMessage() wrapper (shared/state.ts) layers the session-lost +// → lock-screen intercept on top of this for every UI RPC. +function postToServiceWorker(request: Request): Promise { return new Promise((resolve) => { - chrome.runtime.sendMessage(request, (response: Response) => { - // MV3 service workers are evicted after ~30s idle, which wipes the - // in-memory session/manifest/gitHost. The fullscreen tab stays open - // and has no signal that the SW restarted — the next RPC just comes - // back `vault_locked`. Treat that as "session lost" and force the - // lock screen so the user can re-enter their passphrase. Skip for - // is_unlocked / unlock themselves to avoid loops on cold start. - if ( - response && - !response.ok && - response.error === 'vault_locked' && - request.type !== 'is_unlocked' && - request.type !== 'unlock' && - state.unlocked - ) { - state.unlocked = false; - state.selectedId = null; - state.selectedItem = null; - state.entries = []; - state.error = 'Session expired — please unlock again.'; - render(ctx); - } - resolve(response); - }); + chrome.runtime.sendMessage(request, (response: Response) => resolve(response)); }); } @@ -115,6 +95,17 @@ registerHost({ renderPane(ctx); }, navigate: (view, extras) => { + if (view === 'locked') { + // Session lost (SW evicted mid-session). The vault shows its lock + // screen off state.unlocked, so flip it and drop the in-memory data. + state.unlocked = false; + state.selectedId = null; + state.selectedItem = null; + state.entries = []; + state.error = (extras?.error as string | undefined) ?? null; + render(ctx); + return; + } Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass(ctx); @@ -122,7 +113,7 @@ registerHost({ if (state.view === 'list') renderListPane(ctx); renderPane(ctx); }, - sendMessage, + sendMessage: postToServiceWorker, escapeHtml, popOutToTab: () => {}, isInTab: () => true,