From 86621f075f2fb1318b4d8fea5e480cf5b7bb6281 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 02:24:26 -0400 Subject: [PATCH] feat(ext/sw): add session inactivity timer with configurable timeout Implements a service-worker-side session timer that locks the vault after a configurable period of inactivity (default 15 min). Supports two modes: 'inactivity' (timer-based) and 'every_time' (no timer). Config persists via chrome.storage.local and is exposed through get_session_config / update_session_config popup messages. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/session-timer.test.ts | 99 +++++++++++++++++++ extension/src/service-worker/index.ts | 22 +++++ .../src/service-worker/router/popup-only.ts | 11 +++ extension/src/service-worker/session-timer.ts | 50 ++++++++++ extension/src/shared/messages.ts | 11 ++- 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 extension/src/service-worker/__tests__/session-timer.test.ts create mode 100644 extension/src/service-worker/session-timer.ts diff --git a/extension/src/service-worker/__tests__/session-timer.test.ts b/extension/src/service-worker/__tests__/session-timer.test.ts new file mode 100644 index 0000000..39a56fc --- /dev/null +++ b/extension/src/service-worker/__tests__/session-timer.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import * as timer from '../session-timer'; + +describe('session-timer', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Reset to default config for each test + timer.setConfig({ mode: 'inactivity', minutes: 15 }); + timer.stopTimer(); + }); + + afterEach(() => { + timer.stopTimer(); + vi.useRealTimers(); + }); + + it('fires callback after inactivity timeout', () => { + const cb = vi.fn(); + timer.onExpired(cb); + timer.resetTimer(); + + // Default is 15 minutes = 900_000 ms + vi.advanceTimersByTime(899_999); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('resets timer on each resetTimer() call', () => { + const cb = vi.fn(); + timer.onExpired(cb); + timer.resetTimer(); + + // Advance 10 minutes + vi.advanceTimersByTime(600_000); + expect(cb).not.toHaveBeenCalled(); + + // Reset — clock should restart + timer.resetTimer(); + + // Advance another 10 minutes (20 total, but only 10 since last reset) + vi.advanceTimersByTime(600_000); + expect(cb).not.toHaveBeenCalled(); + + // Advance remaining 5 minutes to hit the 15-minute mark from last reset + vi.advanceTimersByTime(300_000); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('does not fire when mode is every_time', () => { + const cb = vi.fn(); + timer.onExpired(cb); + timer.setConfig({ mode: 'every_time' }); + timer.resetTimer(); + + // Advance well past the default 15 minutes + vi.advanceTimersByTime(60 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + }); + + it('respects updated minutes', () => { + const cb = vi.fn(); + timer.onExpired(cb); + timer.setConfig({ mode: 'inactivity', minutes: 5 }); + timer.resetTimer(); + + vi.advanceTimersByTime(4 * 60 * 1000); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(60 * 1000); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('getConfig returns current config', () => { + // Default + expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 15 }); + + // After set + timer.setConfig({ mode: 'every_time' }); + expect(timer.getConfig()).toEqual({ mode: 'every_time' }); + + timer.setConfig({ mode: 'inactivity', minutes: 30 }); + expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 30 }); + }); + + it('stopTimer prevents firing', () => { + const cb = vi.fn(); + timer.onExpired(cb); + timer.resetTimer(); + + vi.advanceTimersByTime(600_000); // 10 minutes in + timer.stopTimer(); + + // Advance past what would have been the 15-minute mark + vi.advanceTimersByTime(600_000); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 64ec2e8..8adbb56 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -2,9 +2,12 @@ /// forwards every message into router/index.route(). import type { Request, Response } from '../shared/messages'; +import type { SessionTimeoutConfig } from '../shared/messages'; import type { RouterState } from './router/index'; import { route } from './router/index'; import * as vault from './vault'; +import { clearCurrent } from './session'; +import * as sessionTimer from './session-timer'; // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; @@ -43,9 +46,28 @@ const state: RouterState = { wasm: null, }; +// --- Session timer wiring --- + +sessionTimer.onExpired(() => { + // eslint-disable-next-line no-console + console.log('[relicario sw] session expired — locking vault'); + clearCurrent(); + state.manifest = null; + // Best-effort broadcast — receiver may not exist yet. + chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {}); +}); + +// Restore saved session config from chrome.storage.local on SW startup. +chrome.storage.local.get('sessionTimeoutConfig').then((r) => { + if (r.sessionTimeoutConfig) { + sessionTimer.setConfig(r.sessionTimeoutConfig as SessionTimeoutConfig); + } +}).catch(() => {}); + chrome.runtime.onMessage.addListener( (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { (async () => { + sessionTimer.resetTimer(); if (!state.wasm) { // eslint-disable-next-line no-console console.log('[relicario sw] initializing WASM on first message'); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 275067e..20fa71c 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -11,6 +11,7 @@ import { createGitHost, base64ToUint8Array } from '../git-host'; import * as vault from '../vault'; import * as session from '../session'; import * as devices from '../devices'; +import * as sessionTimer from '../session-timer'; // --- Shared ambient state owned by the SW module --- // @@ -353,6 +354,16 @@ export async function handle( const history = state.wasm.get_field_history(JSON.stringify(item)); return { ok: true, data: { history } }; } + + case 'get_session_config': + return { ok: true, data: { config: sessionTimer.getConfig() } }; + + case 'update_session_config': { + sessionTimer.setConfig(msg.config); + sessionTimer.resetTimer(); + await chrome.storage.local.set({ sessionTimeoutConfig: msg.config }); + return { ok: true }; + } } } diff --git a/extension/src/service-worker/session-timer.ts b/extension/src/service-worker/session-timer.ts new file mode 100644 index 0000000..9948586 --- /dev/null +++ b/extension/src/service-worker/session-timer.ts @@ -0,0 +1,50 @@ +/// Session inactivity timer. +/// +/// Two modes: +/// - `inactivity`: fires the expiry callback after N minutes of no +/// resetTimer() calls (i.e. no popup/vault-tab messages). +/// - `every_time`: no timer — the session is cleared on every popup close +/// (handled elsewhere). resetTimer() is a no-op. + +import type { SessionTimeoutConfig } from '../shared/messages'; + +let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 }; +let timerId: ReturnType | null = null; +let expiredCallback: (() => void) | null = null; + +/** Register the callback invoked when the inactivity timer fires. */ +export function onExpired(cb: () => void): void { + expiredCallback = cb; +} + +/** Return the current session timeout config. */ +export function getConfig(): SessionTimeoutConfig { + return config; +} + +/** Update the config. Also stops any running timer (caller should + * resetTimer() afterwards if the session is still active). */ +export function setConfig(c: SessionTimeoutConfig): void { + config = c; + stopTimer(); +} + +/** Clear and restart the inactivity timer. No-op when mode is `every_time`. */ +export function resetTimer(): void { + stopTimer(); + if (config.mode !== 'inactivity') return; + + const ms = config.minutes * 60 * 1000; + timerId = setTimeout(() => { + timerId = null; + if (expiredCallback) expiredCallback(); + }, ms); +} + +/** Cancel any pending timer without changing config. */ +export function stopTimer(): void { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } +} diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 128affd..934775a 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -4,6 +4,12 @@ import type { FieldHistoryView, } from './types'; +// --- Session timeout config --- + +export type SessionTimeoutConfig = + | { mode: 'inactivity'; minutes: number } + | { mode: 'every_time' }; + // --- Messages a popup (or setup page) may send --- export type PopupMessage = @@ -39,7 +45,9 @@ export type PopupMessage = | { type: 'restore_item'; id: ItemId } | { type: 'purge_item'; id: ItemId } | { type: 'purge_all_trash' } - | { type: 'get_field_history'; id: ItemId }; + | { type: 'get_field_history'; id: ItemId } + | { type: 'get_session_config' } + | { type: 'update_session_config'; config: SessionTimeoutConfig }; // --- Messages a content script may send --- @@ -143,6 +151,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'list_devices', 'add_device', 'revoke_device', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'get_field_history', + 'get_session_config', 'update_session_config', ] as PopupMessage['type'][]); export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([