diff --git a/src/hooks/useNotificationSW.ts b/src/hooks/useNotificationSW.ts index b1b2c9f..1c957fc 100644 --- a/src/hooks/useNotificationSW.ts +++ b/src/hooks/useNotificationSW.ts @@ -6,23 +6,9 @@ import { testRelayConnectivity, DEFAULT_RELAY_URL, } from '@/lib/pushRelay'; +import { validateWorkerMessage, mainThreadRequestTracker } from '@/lib/workerProtocol'; import { getAppServiceWorkerRegistration } from '@/lib/swRegistration'; -interface SWMessage { - type: string; - channel: string; - payload: { - id: string; - title: string; - body: string; - timestamp: number; - amount?: string; - asset?: string; - sender?: string; - data?: Record; - }; -} - export interface WebPushState { supported: boolean; permission: NotificationPermission; @@ -132,17 +118,60 @@ export function useNotificationSW(): UseNotificationSWReturn { }; }, [state.supported]); - // Listen for WRAITH_NOTIFICATION messages posted by the SW + // Listen for messages posted by the SW useEffect(() => { - const handler = (event: MessageEvent) => { - if ( - event.data?.type !== 'WRAITH_NOTIFICATION' || - event.data?.channel !== 'wraith-notifications' - ) { + const handler = (event: MessageEvent) => { + const validation = validateWorkerMessage(event.data); + if (!validation.valid) { + console.warn('[useNotificationSW] Ignoring invalid message from SW:', validation.reason); return; } - const { id, title, body, timestamp, amount, asset, sender, data } = event.data.payload; - addNotification({ id, title, body, timestamp, amount, asset, sender, data }); + + const msg = validation.data; + + switch (msg.type) { + case 'WRAITH_NOTIFICATION': + if (msg.channel === 'wraith-notifications') { + const { id, title, body, amount, asset, sender, url } = msg.payload; + addNotification({ + id, + title, + body, + timestamp: Date.now(), + amount, + asset, + sender, + data: { url }, + }); + } + break; + + case 'VIEWING_KEY_REGISTERED': + case 'VIEWING_KEY_UNREGISTERED': + case 'PUSH_SUBSCRIPTION_REGISTERED': + case 'PUSH_SUBSCRIPTION_UNREGISTERED': + case 'STELLAR_SCAN_RECOVERY_COMPLETE': + mainThreadRequestTracker.unregister(msg.requestId); + break; + + case 'VIEWING_KEY_ERROR': + case 'PUSH_SUBSCRIPTION_ERROR': + console.error('[useNotificationSW] SW error:', msg.error); + mainThreadRequestTracker.unregister(msg.requestId); + break; + + case 'STELLAR_SCAN_RETENTION_GAP': + console.warn('[useNotificationSW] Retention gap detected:', msg); + break; + + case 'OFFLINE_QUEUE_FLUSH': + console.log('[useNotificationSW] Offline queue flush requested'); + break; + + case 'NAVIGATE_TO_MATCH': + console.log('[useNotificationSW] Navigate to match:', msg.stealthAddress); + break; + } }; navigator.serviceWorker.addEventListener('message', handler); @@ -260,6 +289,7 @@ export function useNotificationSW(): UseNotificationSWReturn { } localStorage.removeItem(STORAGE_KEY_SUBSCRIBED); + mainThreadRequestTracker.clear(); setState((prev) => ({ ...prev, subscribed: false, diff --git a/src/lib/workerProtocol.test.ts b/src/lib/workerProtocol.test.ts new file mode 100644 index 0000000..6ae3600 --- /dev/null +++ b/src/lib/workerProtocol.test.ts @@ -0,0 +1,682 @@ +/** + * workerProtocol.test.ts + * + * Tests for the validated worker message protocol. + * + * Covers: + * - Message validation for all message types + * - Malformed message handling + * - Unknown message type handling + * - Request ID generation and tracking + * - Stale response filtering + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + generateRequestId, + requestIdSequence, + validateMainThreadMessage, + validateWorkerMessage, + RequestTracker, + mainThreadRequestTracker, + type MainThreadMessage, + type WorkerMessage, +} from './workerProtocol'; + +describe('workerProtocol', () => { + beforeEach(() => { + mainThreadRequestTracker.clear(); + }); + + describe('requestIdSequence', () => { + it('extracts sequence from valid request ID', () => { + expect(requestIdSequence('req-123')).toBe(123); + expect(requestIdSequence('req-0')).toBe(0); + expect(requestIdSequence('req-999999')).toBe(999999); + }); + + it('returns -1 for invalid request IDs', () => { + expect(requestIdSequence('invalid')).toBe(-1); + expect(requestIdSequence('req-')).toBe(-1); + expect(requestIdSequence('req-abc')).toBe(-1); + expect(requestIdSequence('')).toBe(-1); + }); + }); + + describe('generateRequestId', () => { + it('generates unique, monotonic request IDs', () => { + const id1 = generateRequestId(); + const id2 = generateRequestId(); + const id3 = generateRequestId(); + + expect(id1).toMatch(/^req-\d+$/); + expect(id2).toMatch(/^req-\d+$/); + expect(id3).toMatch(/^req-\d+$/); + expect(id1).not.toBe(id2); + expect(id2).not.toBe(id3); + expect(requestIdSequence(id1)).toBeLessThan(requestIdSequence(id2)); + expect(requestIdSequence(id2)).toBeLessThan(requestIdSequence(id3)); + }); + }); + + describe('RequestTracker', () => { + it('tracks pending requests', () => { + const tracker = new RequestTracker(); + const id = 'req-1'; + + expect(tracker.isPending(id)).toBe(false); + tracker.register(id); + expect(tracker.isPending(id)).toBe(true); + tracker.unregister(id); + expect(tracker.isPending(id)).toBe(false); + }); + + it('clears all pending requests', () => { + const tracker = new RequestTracker(); + tracker.register('req-1'); + tracker.register('req-2'); + tracker.register('req-3'); + + expect(tracker.size()).toBe(3); + tracker.clear(); + expect(tracker.size()).toBe(0); + expect(tracker.isPending('req-1')).toBe(false); + expect(tracker.isPending('req-2')).toBe(false); + expect(tracker.isPending('req-3')).toBe(false); + }); + }); + + describe('validateMainThreadMessage', () => { + describe('SKIP_WAITING', () => { + it('validates correct SKIP_WAITING message', () => { + const msg = { type: 'SKIP_WAITING' }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects SKIP_WAITING with extra fields', () => { + const msg = { type: 'SKIP_WAITING', extra: 'field' }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('RECOVER_SCAN_CURSOR', () => { + it('validates correct RECOVER_SCAN_CURSOR message', () => { + const msg: MainThreadMessage = { + type: 'RECOVER_SCAN_CURSOR', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + oldestAvailableLedger: 1000, + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects RECOVER_SCAN_CURSOR with invalid requestId', () => { + const msg = { + type: 'RECOVER_SCAN_CURSOR', + requestId: 'invalid', + publicKey: 'GABC123...XYZ', + oldestAvailableLedger: 1000, + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('requestId'); + } + }); + + it('rejects RECOVER_SCAN_CURSOR with missing publicKey', () => { + const msg = { + type: 'RECOVER_SCAN_CURSOR', + requestId: 'req-1', + oldestAvailableLedger: 1000, + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('publicKey'); + } + }); + + it('rejects RECOVER_SCAN_CURSOR with invalid ledger', () => { + const msg = { + type: 'RECOVER_SCAN_CURSOR', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + oldestAvailableLedger: -1, + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('oldestAvailableLedger'); + } + }); + }); + + describe('REGISTER_VIEWING_KEY', () => { + it('validates correct REGISTER_VIEWING_KEY message', () => { + const msg: MainThreadMessage = { + type: 'REGISTER_VIEWING_KEY', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + encryptedViewingKey: 'encrypted-key-1', + encryptedSpendingPubKey: 'encrypted-key-2', + encryptedSpendingScalar: 'encrypted-key-3', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects REGISTER_VIEWING_KEY with missing encrypted fields', () => { + const msg = { + type: 'REGISTER_VIEWING_KEY', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('encryptedViewingKey'); + } + }); + + it('rejects REGISTER_VIEWING_KEY with extra fields', () => { + const msg = { + type: 'REGISTER_VIEWING_KEY', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + encryptedViewingKey: 'encrypted-key-1', + encryptedSpendingPubKey: 'encrypted-key-2', + encryptedSpendingScalar: 'encrypted-key-3', + extra: 'field', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('UNREGISTER_VIEWING_KEY', () => { + it('validates correct UNREGISTER_VIEWING_KEY message', () => { + const msg: MainThreadMessage = { + type: 'UNREGISTER_VIEWING_KEY', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects UNREGISTER_VIEWING_KEY with extra fields', () => { + const msg = { + type: 'UNREGISTER_VIEWING_KEY', + requestId: 'req-1', + publicKey: 'GABC123...XYZ', + extra: 'field', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('TRIGGER_SCAN', () => { + it('validates correct TRIGGER_SCAN message', () => { + const msg: MainThreadMessage = { + type: 'TRIGGER_SCAN', + requestId: 'req-1', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects TRIGGER_SCAN with extra fields', () => { + const msg = { + type: 'TRIGGER_SCAN', + requestId: 'req-1', + extra: 'field', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('REGISTER_PUSH_SUBSCRIPTION', () => { + it('validates correct REGISTER_PUSH_SUBSCRIPTION message', () => { + const msg: MainThreadMessage = { + type: 'REGISTER_PUSH_SUBSCRIPTION', + requestId: 'req-1', + subscription: { + endpoint: 'https://push.example.com', + keys: { + p256dh: 'p256dh-key', + auth: 'auth-key', + }, + }, + metaAddressHash: 'hash123', + relayUrl: 'https://relay.example.com', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects REGISTER_PUSH_SUBSCRIPTION with invalid subscription', () => { + const msg = { + type: 'REGISTER_PUSH_SUBSCRIPTION', + requestId: 'req-1', + subscription: 'invalid', + metaAddressHash: 'hash123', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('subscription'); + } + }); + + it('rejects REGISTER_PUSH_SUBSCRIPTION with extra fields', () => { + const msg = { + type: 'REGISTER_PUSH_SUBSCRIPTION', + requestId: 'req-1', + subscription: { + endpoint: 'https://push.example.com', + keys: { + p256dh: 'p256dh-key', + auth: 'auth-key', + }, + }, + metaAddressHash: 'hash123', + extra: 'field', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('UNREGISTER_PUSH_SUBSCRIPTION', () => { + it('validates correct UNREGISTER_PUSH_SUBSCRIPTION message', () => { + const msg: MainThreadMessage = { + type: 'UNREGISTER_PUSH_SUBSCRIPTION', + requestId: 'req-1', + subscription: { + endpoint: 'https://push.example.com', + keys: { + p256dh: 'p256dh-key', + auth: 'auth-key', + }, + }, + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects UNREGISTER_PUSH_SUBSCRIPTION with extra fields', () => { + const msg = { + type: 'UNREGISTER_PUSH_SUBSCRIPTION', + requestId: 'req-1', + subscription: { + endpoint: 'https://push.example.com', + keys: { + p256dh: 'p256dh-key', + auth: 'auth-key', + }, + }, + extra: 'field', + }; + const result = validateMainThreadMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('malformed messages', () => { + it('rejects null', () => { + const result = validateMainThreadMessage(null); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects undefined', () => { + const result = validateMainThreadMessage(undefined); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects array', () => { + const result = validateMainThreadMessage([]); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects string', () => { + const result = validateMainThreadMessage('invalid'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects number', () => { + const result = validateMainThreadMessage(123); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects message without type', () => { + const result = validateMainThreadMessage({ data: 'test' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('type'); + } + }); + + it('rejects message with unknown type', () => { + const result = validateMainThreadMessage({ type: 'UNKNOWN_TYPE' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('Unknown message type'); + } + }); + }); + }); + + describe('validateWorkerMessage', () => { + describe('VIEWING_KEY_REGISTERED', () => { + it('validates correct VIEWING_KEY_REGISTERED message', () => { + const msg: WorkerMessage = { + type: 'VIEWING_KEY_REGISTERED', + requestId: 'req-1', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects VIEWING_KEY_REGISTERED with extra fields', () => { + const msg = { + type: 'VIEWING_KEY_REGISTERED', + requestId: 'req-1', + extra: 'field', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('VIEWING_KEY_ERROR', () => { + it('validates correct VIEWING_KEY_ERROR message', () => { + const msg: WorkerMessage = { + type: 'VIEWING_KEY_ERROR', + requestId: 'req-1', + error: 'Something went wrong', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects VIEWING_KEY_ERROR without error', () => { + const msg = { + type: 'VIEWING_KEY_ERROR', + requestId: 'req-1', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('error'); + } + }); + }); + + describe('STELLAR_SCAN_RETENTION_GAP', () => { + it('validates correct STELLAR_SCAN_RETENTION_GAP message', () => { + const msg: WorkerMessage = { + type: 'STELLAR_SCAN_RETENTION_GAP', + publicKey: 'GABC123...XYZ', + oldestAvailableLedger: 1000, + lastScannedLedger: 500, + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('validates STELLAR_SCAN_RETENTION_GAP without lastScannedLedger', () => { + const msg: WorkerMessage = { + type: 'STELLAR_SCAN_RETENTION_GAP', + publicKey: 'GABC123...XYZ', + oldestAvailableLedger: 1000, + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + }); + + describe('WRAITH_NOTIFICATION', () => { + it('validates correct WRAITH_NOTIFICATION message', () => { + const msg: WorkerMessage = { + type: 'WRAITH_NOTIFICATION', + channel: 'wraith-notifications', + payload: { + version: 1, + id: 'notif-123', + title: 'Payment Received', + body: 'You received 10 XLM', + amount: '10', + asset: 'XLM', + url: '/notifications', + }, + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects WRAITH_NOTIFICATION with wrong channel', () => { + const msg = { + type: 'WRAITH_NOTIFICATION', + channel: 'wrong-channel', + payload: { + version: 1, + id: 'notif-123', + title: 'Payment Received', + body: 'You received 10 XLM', + url: '/notifications', + }, + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('channel'); + } + }); + + it('rejects WRAITH_NOTIFICATION with invalid payload', () => { + const msg = { + type: 'WRAITH_NOTIFICATION', + channel: 'wraith-notifications', + payload: 'invalid', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('payload'); + } + }); + + it('rejects WRAITH_NOTIFICATION with extra fields', () => { + const msg = { + type: 'WRAITH_NOTIFICATION', + channel: 'wraith-notifications', + payload: { + version: 1, + id: 'notif-123', + title: 'Payment Received', + body: 'You received 10 XLM', + url: '/notifications', + }, + extra: 'field', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('OFFLINE_QUEUE_FLUSH', () => { + it('validates correct OFFLINE_QUEUE_FLUSH message', () => { + const msg: WorkerMessage = { + type: 'OFFLINE_QUEUE_FLUSH', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects OFFLINE_QUEUE_FLUSH with extra fields', () => { + const msg = { + type: 'OFFLINE_QUEUE_FLUSH', + extra: 'field', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('NAVIGATE_TO_MATCH', () => { + it('validates correct NAVIGATE_TO_MATCH message', () => { + const msg: WorkerMessage = { + type: 'NAVIGATE_TO_MATCH', + stealthAddress: 'GABC123...XYZ', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data).toEqual(msg); + } + }); + + it('rejects NAVIGATE_TO_MATCH with extra fields', () => { + const msg = { + type: 'NAVIGATE_TO_MATCH', + stealthAddress: 'GABC123...XYZ', + extra: 'field', + }; + const result = validateWorkerMessage(msg); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('extra fields'); + } + }); + }); + + describe('malformed messages', () => { + it('rejects null', () => { + const result = validateWorkerMessage(null); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects undefined', () => { + const result = validateWorkerMessage(undefined); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects array', () => { + const result = validateWorkerMessage([]); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('object'); + } + }); + + it('rejects message without type', () => { + const result = validateWorkerMessage({ data: 'test' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('type'); + } + }); + + it('rejects message with unknown type', () => { + const result = validateWorkerMessage({ type: 'UNKNOWN_TYPE' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain('Unknown message type'); + } + }); + }); + }); +}); diff --git a/src/lib/workerProtocol.ts b/src/lib/workerProtocol.ts new file mode 100644 index 0000000..3e9e0e9 --- /dev/null +++ b/src/lib/workerProtocol.ts @@ -0,0 +1,643 @@ +/** + * workerProtocol.ts + * + * Validated message protocol for worker ↔ main thread communication. + * + * This module defines a strict, type-safe protocol for all messages exchanged + * between the service worker(s) and the main thread. It includes: + * + * - Discriminated unions for all message types (request, success, error, retention-gap, progress) + * - Runtime validation functions for inbound messages + * - Request ID generation and tracking + * - Stale response filtering + * - Malformed message handling + * + * All messages must be validated before processing. Unknown or malformed messages + * are logged and ignored, preventing crashes and security issues. + */ + +// ─── Request ID Generation ───────────────────────────────────────────────────── + +let requestIdCounter = 0; + +/** + * Generate a unique request ID for tracking async worker operations. + * IDs are monotonic within the session and encoded as strings for JSON serialization. + */ +export function generateRequestId(): string { + return `req-${++requestIdCounter}`; +} + +/** + * Extract the numeric sequence from a request ID for comparison. + * Returns -1 for invalid IDs. + */ +export function requestIdSequence(id: string): number { + const match = id.match(/^req-(\d+)$/); + return match ? parseInt(match[1], 10) : -1; +} + +// ─── Message Type Discriminators ─────────────────────────────────────────────── + +/** + * Direction: main thread → worker (requests) + */ +export type WorkerRequest = + | { type: 'SKIP_WAITING' } + | { + type: 'RECOVER_SCAN_CURSOR'; + requestId: string; + publicKey: string; + oldestAvailableLedger: number; + } + | { + type: 'REGISTER_VIEWING_KEY'; + requestId: string; + publicKey: string; + encryptedViewingKey: string; + encryptedSpendingPubKey: string; + encryptedSpendingScalar: string; + } + | { type: 'UNREGISTER_VIEWING_KEY'; requestId: string; publicKey: string } + | { type: 'TRIGGER_SCAN'; requestId: string } + | { + type: 'REGISTER_PUSH_SUBSCRIPTION'; + requestId: string; + subscription: PushSubscriptionJSON; + metaAddressHash: string; + relayUrl?: string; + } + | { + type: 'UNREGISTER_PUSH_SUBSCRIPTION'; + requestId: string; + subscription: PushSubscriptionJSON; + metaAddressHash?: string; + }; + +/** + * Direction: worker → main thread (success responses) + */ +export type WorkerSuccess = + | { type: 'VIEWING_KEY_REGISTERED'; requestId: string } + | { type: 'VIEWING_KEY_UNREGISTERED'; requestId: string } + | { type: 'PUSH_SUBSCRIPTION_REGISTERED'; requestId: string } + | { type: 'PUSH_SUBSCRIPTION_UNREGISTERED'; requestId: string } + | { + type: 'STELLAR_SCAN_RECOVERY_COMPLETE'; + requestId: string; + publicKey: string; + } + | { type: 'OFFLINE_QUEUE_FLUSH' }; + +/** + * Direction: worker → main thread (error responses) + */ +export type WorkerError = + | { type: 'VIEWING_KEY_ERROR'; requestId: string; error: string } + | { type: 'PUSH_SUBSCRIPTION_ERROR'; requestId: string; error: string }; + +/** + * Direction: worker → main thread (retention gap notifications) + */ +export type WorkerRetentionGap = { + type: 'STELLAR_SCAN_RETENTION_GAP'; + publicKey: string; + oldestAvailableLedger: number; + lastScannedLedger?: number; +}; + +/** + * Direction: worker → main thread (progress updates) + */ +export type WorkerProgress = never; // No progress updates currently defined + +/** + * Direction: worker → main thread (broadcast notifications) + */ +export type WorkerBroadcast = + | { + type: 'WRAITH_NOTIFICATION'; + channel: 'wraith-notifications'; + payload: ValidatedPushPayload; + } + | { type: 'NAVIGATE_TO_MATCH'; stealthAddress: string }; + +/** + * Union of all valid worker → main thread messages + */ +export type WorkerMessage = WorkerSuccess | WorkerError | WorkerRetentionGap | WorkerBroadcast; + +/** + * Union of all valid main thread → worker messages + */ +export type MainThreadMessage = WorkerRequest; + +// ─── Supporting Types ─────────────────────────────────────────────────────────── + +export interface PushSubscriptionJSON { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface ValidatedPushPayload { + version: 1; + id: string; + title: string; + body: string; + amount?: string; + asset?: string; + sender?: string; + url: string; +} + +// ─── Validation Functions ─────────────────────────────────────────────────────── + +/** + * Result of message validation + */ +export type ValidationResult = { valid: true; data: T } | { valid: false; reason: string }; + +/** + * Validate that a value is a non-empty string of at most maxLength characters + */ +function assertString(value: unknown, field: string, maxLength: number, required = false): string { + if (value === undefined && !required) { + throw new Error(`${field} is missing but optional`); + } + if (typeof value !== 'string') { + throw new Error(`${field} must be a string`); + } + if (required && value.length === 0) { + throw new Error(`${field} must be non-empty`); + } + if (value.length > maxLength) { + throw new Error(`${field} exceeds maximum length of ${maxLength}`); + } + return value; +} + +/** + * Validate that a value is a safe integer within the specified range + */ +function assertInteger(value: unknown, field: string, min: number, max: number): number { + if (typeof value !== 'number') { + throw new Error(`${field} must be a number`); + } + if (!Number.isSafeInteger(value)) { + throw new Error(`${field} must be a safe integer`); + } + if (value < min || value > max) { + throw new Error(`${field} must be between ${min} and ${max}`); + } + return value; +} + +/** + * Validate a request ID format + */ +function assertRequestId(value: unknown): string { + const id = assertString(value, 'requestId', 64, true); + if (!/^req-\d+$/.test(id)) { + throw new Error('requestId must match format req-{number}'); + } + return id; +} + +/** + * Validate a PushSubscriptionJSON structure + */ +function assertPushSubscription(value: unknown): PushSubscriptionJSON { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('subscription must be an object'); + } + const sub = value as Record; + const endpoint = assertString(sub.endpoint, 'subscription.endpoint', 512, true); + const keys = sub.keys; + if (!keys || typeof keys !== 'object' || Array.isArray(keys)) { + throw new Error('subscription.keys must be an object'); + } + const keysObj = keys as Record; + const p256dh = assertString(keysObj.p256dh, 'subscription.keys.p256dh', 256, true); + const auth = assertString(keysObj.auth, 'subscription.keys.auth', 256, true); + return { endpoint, keys: { p256dh, auth } }; +} + +/** + * Validate a ValidatedPushPayload structure + */ +function assertPushPayload(value: unknown): ValidatedPushPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('payload must be an object'); + } + const payload = value as Record; + if (payload.version !== 1) { + throw new Error('payload.version must be 1'); + } + const id = assertString(payload.id, 'payload.id', 128, true); + const title = assertString(payload.title, 'payload.title', 100, true); + const body = assertString(payload.body, 'payload.body', 500, true); + const amount = + payload.amount !== undefined ? assertString(payload.amount, 'payload.amount', 64) : undefined; + const asset = + payload.asset !== undefined ? assertString(payload.asset, 'payload.asset', 16) : undefined; + const sender = + payload.sender !== undefined ? assertString(payload.sender, 'payload.sender', 128) : undefined; + const url = assertString(payload.url, 'payload.url', 256, true); + return { version: 1, id, title, body, amount, asset, sender, url }; +} + +// ─── Main Thread → Worker Validation ─────────────────────────────────────────── + +/** + * Validate a message from the main thread to the worker + */ +export function validateMainThreadMessage(value: unknown): ValidationResult { + try { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Message must be an object'); + } + const msg = value as Record; + const type = assertString(msg.type, 'type', 64, true); + + switch (type) { + case 'SKIP_WAITING': + if (Object.keys(msg).length !== 1) { + throw new Error('SKIP_WAITING message must not have extra fields'); + } + return { valid: true, data: { type: 'SKIP_WAITING' } }; + + case 'RECOVER_SCAN_CURSOR': { + const allowedFields = ['type', 'requestId', 'publicKey', 'oldestAvailableLedger']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `RECOVER_SCAN_CURSOR message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const publicKey = assertString(msg.publicKey, 'publicKey', 128, true); + const oldestAvailableLedger = assertInteger( + msg.oldestAvailableLedger, + 'oldestAvailableLedger', + 1, + Number.MAX_SAFE_INTEGER, + ); + return { + valid: true, + data: { type: 'RECOVER_SCAN_CURSOR', requestId, publicKey, oldestAvailableLedger }, + }; + } + + case 'REGISTER_VIEWING_KEY': { + const allowedFields = [ + 'type', + 'requestId', + 'publicKey', + 'encryptedViewingKey', + 'encryptedSpendingPubKey', + 'encryptedSpendingScalar', + ]; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `REGISTER_VIEWING_KEY message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const publicKey = assertString(msg.publicKey, 'publicKey', 128, true); + const encryptedViewingKey = assertString( + msg.encryptedViewingKey, + 'encryptedViewingKey', + 512, + true, + ); + const encryptedSpendingPubKey = assertString( + msg.encryptedSpendingPubKey, + 'encryptedSpendingPubKey', + 512, + true, + ); + const encryptedSpendingScalar = assertString( + msg.encryptedSpendingScalar, + 'encryptedSpendingScalar', + 512, + true, + ); + return { + valid: true, + data: { + type: 'REGISTER_VIEWING_KEY', + requestId, + publicKey, + encryptedViewingKey, + encryptedSpendingPubKey, + encryptedSpendingScalar, + }, + }; + } + + case 'UNREGISTER_VIEWING_KEY': { + const allowedFields = ['type', 'requestId', 'publicKey']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `UNREGISTER_VIEWING_KEY message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const publicKey = assertString(msg.publicKey, 'publicKey', 128, true); + return { valid: true, data: { type: 'UNREGISTER_VIEWING_KEY', requestId, publicKey } }; + } + + case 'TRIGGER_SCAN': { + const allowedFields = ['type', 'requestId']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error(`TRIGGER_SCAN message has extra fields: ${extraFields.join(', ')}`); + } + const requestId = assertRequestId(msg.requestId); + return { valid: true, data: { type: 'TRIGGER_SCAN', requestId } }; + } + + case 'REGISTER_PUSH_SUBSCRIPTION': { + const allowedFields = ['type', 'requestId', 'subscription', 'metaAddressHash', 'relayUrl']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `REGISTER_PUSH_SUBSCRIPTION message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const subscription = assertPushSubscription(msg.subscription); + const metaAddressHash = assertString(msg.metaAddressHash, 'metaAddressHash', 128, true); + const relayUrl = + msg.relayUrl !== undefined ? assertString(msg.relayUrl, 'relayUrl', 512) : undefined; + return { + valid: true, + data: { + type: 'REGISTER_PUSH_SUBSCRIPTION', + requestId, + subscription, + metaAddressHash, + relayUrl, + }, + }; + } + + case 'UNREGISTER_PUSH_SUBSCRIPTION': { + const allowedFields = ['type', 'requestId', 'subscription', 'metaAddressHash']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `UNREGISTER_PUSH_SUBSCRIPTION message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const subscription = assertPushSubscription(msg.subscription); + const metaAddressHash = + msg.metaAddressHash !== undefined + ? assertString(msg.metaAddressHash, 'metaAddressHash', 128) + : undefined; + return { + valid: true, + data: { type: 'UNREGISTER_PUSH_SUBSCRIPTION', requestId, subscription, metaAddressHash }, + }; + } + + default: + throw new Error(`Unknown message type: ${type}`); + } + } catch (error) { + return { + valid: false, + reason: error instanceof Error ? error.message : 'Unknown validation error', + }; + } +} + +// ─── Worker → Main Thread Validation ────────────────────────────────────────── + +/** + * Validate a message from the worker to the main thread + */ +export function validateWorkerMessage(value: unknown): ValidationResult { + try { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Message must be an object'); + } + const msg = value as Record; + const type = assertString(msg.type, 'type', 64, true); + + switch (type) { + case 'VIEWING_KEY_REGISTERED': { + const allowedFields = ['type', 'requestId']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `VIEWING_KEY_REGISTERED message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + return { valid: true, data: { type: 'VIEWING_KEY_REGISTERED', requestId } }; + } + + case 'VIEWING_KEY_UNREGISTERED': { + const allowedFields = ['type', 'requestId']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `VIEWING_KEY_UNREGISTERED message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + return { valid: true, data: { type: 'VIEWING_KEY_UNREGISTERED', requestId } }; + } + + case 'PUSH_SUBSCRIPTION_REGISTERED': { + const allowedFields = ['type', 'requestId']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `PUSH_SUBSCRIPTION_REGISTERED message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + return { valid: true, data: { type: 'PUSH_SUBSCRIPTION_REGISTERED', requestId } }; + } + + case 'PUSH_SUBSCRIPTION_UNREGISTERED': { + const allowedFields = ['type', 'requestId']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `PUSH_SUBSCRIPTION_UNREGISTERED message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + return { valid: true, data: { type: 'PUSH_SUBSCRIPTION_UNREGISTERED', requestId } }; + } + + case 'VIEWING_KEY_ERROR': { + const allowedFields = ['type', 'requestId', 'error']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error(`VIEWING_KEY_ERROR message has extra fields: ${extraFields.join(', ')}`); + } + const requestId = assertRequestId(msg.requestId); + const error = assertString(msg.error, 'error', 512, true); + return { valid: true, data: { type: 'VIEWING_KEY_ERROR', requestId, error } }; + } + + case 'PUSH_SUBSCRIPTION_ERROR': { + const allowedFields = ['type', 'requestId', 'error']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `PUSH_SUBSCRIPTION_ERROR message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const error = assertString(msg.error, 'error', 512, true); + return { valid: true, data: { type: 'PUSH_SUBSCRIPTION_ERROR', requestId, error } }; + } + + case 'STELLAR_SCAN_RECOVERY_COMPLETE': { + const allowedFields = ['type', 'requestId', 'publicKey']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `STELLAR_SCAN_RECOVERY_COMPLETE message has extra fields: ${extraFields.join(', ')}`, + ); + } + const requestId = assertRequestId(msg.requestId); + const publicKey = assertString(msg.publicKey, 'publicKey', 128, true); + return { + valid: true, + data: { type: 'STELLAR_SCAN_RECOVERY_COMPLETE', requestId, publicKey }, + }; + } + + case 'STELLAR_SCAN_RETENTION_GAP': { + const allowedFields = ['type', 'publicKey', 'oldestAvailableLedger', 'lastScannedLedger']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `STELLAR_SCAN_RETENTION_GAP message has extra fields: ${extraFields.join(', ')}`, + ); + } + const publicKey = assertString(msg.publicKey, 'publicKey', 128, true); + const oldestAvailableLedger = assertInteger( + msg.oldestAvailableLedger, + 'oldestAvailableLedger', + 1, + Number.MAX_SAFE_INTEGER, + ); + const lastScannedLedger = + msg.lastScannedLedger !== undefined + ? assertInteger(msg.lastScannedLedger, 'lastScannedLedger', 1, Number.MAX_SAFE_INTEGER) + : undefined; + return { + valid: true, + data: { + type: 'STELLAR_SCAN_RETENTION_GAP', + publicKey, + oldestAvailableLedger, + lastScannedLedger, + }, + }; + } + + case 'OFFLINE_QUEUE_FLUSH': + if (Object.keys(msg).length !== 1) { + throw new Error('OFFLINE_QUEUE_FLUSH message must not have extra fields'); + } + return { valid: true, data: { type: 'OFFLINE_QUEUE_FLUSH' } }; + + case 'WRAITH_NOTIFICATION': { + const allowedFields = ['type', 'channel', 'payload']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error( + `WRAITH_NOTIFICATION message has extra fields: ${extraFields.join(', ')}`, + ); + } + const channel = assertString(msg.channel, 'channel', 64, true); + if (channel !== 'wraith-notifications') { + throw new Error('channel must be wraith-notifications'); + } + const payload = assertPushPayload(msg.payload); + return { valid: true, data: { type: 'WRAITH_NOTIFICATION', channel, payload } }; + } + + case 'NAVIGATE_TO_MATCH': { + const allowedFields = ['type', 'stealthAddress']; + const extraFields = Object.keys(msg).filter((k) => !allowedFields.includes(k)); + if (extraFields.length > 0) { + throw new Error(`NAVIGATE_TO_MATCH message has extra fields: ${extraFields.join(', ')}`); + } + const stealthAddress = assertString(msg.stealthAddress, 'stealthAddress', 128, true); + return { valid: true, data: { type: 'NAVIGATE_TO_MATCH', stealthAddress } }; + } + + default: + throw new Error(`Unknown message type: ${type}`); + } + } catch (error) { + return { + valid: false, + reason: error instanceof Error ? error.message : 'Unknown validation error', + }; + } +} + +// ─── Request ID Tracking ─────────────────────────────────────────────────────── + +/** + * Track pending requests and filter stale responses + */ +export class RequestTracker { + private pending = new Map(); + + /** + * Register a request ID as pending + */ + register(requestId: string): void { + this.pending.set(requestId, Date.now()); + } + + /** + * Unregister a request ID after receiving a response + */ + unregister(requestId: string): void { + this.pending.delete(requestId); + } + + /** + * Check if a request ID is still pending (not yet responded to) + */ + isPending(requestId: string): boolean { + return this.pending.has(requestId); + } + + /** + * Clear all pending requests (e.g., on page navigation or SW restart) + */ + clear(): void { + this.pending.clear(); + } + + /** + * Get the count of pending requests + */ + size(): number { + return this.pending.size; + } +} + +/** + * Global request tracker instance for the main thread + */ +export const mainThreadRequestTracker = new RequestTracker(); diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 30df302..543261d 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -20,6 +20,14 @@ import { pruneNotificationIds, releaseNotificationId, } from './notificationStore'; +import { + validateMainThreadMessage, + validateWorkerMessage, + type MainThreadMessage, + type WorkerMessage, + type WorkerSuccess, + type WorkerError, +} from '../lib/workerProtocol'; declare const self: ServiceWorkerGlobalScope; @@ -186,8 +194,14 @@ async function updateLastScannedLedger( async function notifyRetentionGap(publicKey: string, gap: RetentionGap): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const response: WorkerMessage = { + type: 'STELLAR_SCAN_RETENTION_GAP', + publicKey, + oldestAvailableLedger: gap.oldestAvailableLedger, + lastScannedLedger: gap.lastScannedLedger, + }; for (const client of clients) { - client.postMessage({ type: 'STELLAR_SCAN_RETENTION_GAP', publicKey, ...gap }); + client.postMessage(response); } } @@ -330,224 +344,234 @@ self.addEventListener('notificationclick', (event) => { // ── Message handler ──────────────────────────────────────────────────────────── self.addEventListener('message', (event) => { - const { type, publicKey, encryptedViewingKey, encryptedSpendingPubKey, encryptedSpendingScalar } = - event.data; - - if (type === 'SKIP_WAITING') { - self.skipWaiting(); + const validation = validateMainThreadMessage(event.data); + if (!validation.valid) { + console.warn('[app-sw] Ignoring invalid message from main thread:', validation.reason); return; } - if (type === 'RECOVER_SCAN_CURSOR') { - event.waitUntil( - (async () => { - const db = await openDB(); - try { - const recoveryLedger = Number(event.data.oldestAvailableLedger); - if ( - typeof publicKey !== 'string' || - !Number.isSafeInteger(recoveryLedger) || - recoveryLedger <= 0 - ) { - return; - } - const storedKey = await new Promise((resolve, reject) => { - const request = db - .transaction(STORE_NAME, 'readonly') - .objectStore(STORE_NAME) - .get(publicKey); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - if (!storedKey) return; - await scanStoredKey(db, storedKey, recoveryLedger); - const clients = await self.clients.matchAll({ - type: 'window', - includeUncontrolled: true, - }); - clients.forEach((client) => - client.postMessage({ type: 'STELLAR_SCAN_RECOVERY_COMPLETE', publicKey }), - ); - } finally { - db.close(); - } - })(), - ); - return; - } + const msg = validation.data; - if (type === 'REGISTER_VIEWING_KEY') { - event.waitUntil( - (async () => { - try { - const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const data: StoredViewingKey = { - publicKey, - encryptedViewingKey, - encryptedSpendingPubKey, - encryptedSpendingScalar, - timestamp: Date.now(), - }; - await new Promise((resolve, reject) => { - const request = store.put(data); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - db.close(); - (event.source as Client)?.postMessage({ type: 'VIEWING_KEY_REGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to register viewing key:', error); - (event.source as Client)?.postMessage({ - type: 'VIEWING_KEY_ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - })(), - ); - } + switch (msg.type) { + case 'SKIP_WAITING': + self.skipWaiting(); + return; - if (type === 'UNREGISTER_VIEWING_KEY') { - event.waitUntil( - (async () => { - try { + case 'RECOVER_SCAN_CURSOR': + event.waitUntil( + (async () => { const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - await new Promise((resolve, reject) => { - const request = store.delete(publicKey); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - // Unregister periodic sync if no keys remain - const allKeys = await new Promise((resolve, reject) => { - const tx = db.transaction([STORE_NAME], 'readonly'); - const st = tx.objectStore(STORE_NAME); - const req = st.getAll(); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result || []); - }); - db.close(); - - if (allKeys.length === 0 && 'periodicSync' in self.registration) { - await ( - self.registration as ServiceWorkerRegistration & { - periodicSync: { unregister(tag: string): Promise }; - } - ).periodicSync.unregister(SYNC_TAG); + try { + const storedKey = await new Promise((resolve, reject) => { + const request = db + .transaction(STORE_NAME, 'readonly') + .objectStore(STORE_NAME) + .get(msg.publicKey); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + if (!storedKey) return; + await scanStoredKey(db, storedKey, msg.oldestAvailableLedger); + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + const response: WorkerMessage = { + type: 'STELLAR_SCAN_RECOVERY_COMPLETE', + requestId: msg.requestId, + publicKey: msg.publicKey, + }; + clients.forEach((client) => client.postMessage(response)); + } finally { + db.close(); } - - (event.source as Client)?.postMessage({ type: 'VIEWING_KEY_UNREGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to unregister viewing key:', error); - } - })(), - ); - } - - if (type === 'TRIGGER_SCAN') { - event.waitUntil(handleSync()); - } - - // Push subscription management - if (type === 'REGISTER_PUSH_SUBSCRIPTION') { - event.waitUntil( - (async () => { - try { - const { subscription, metaAddressHash, relayUrl } = event.data ?? {}; - if (!subscription || !metaAddressHash) { - throw new Error('Missing subscription or metaAddressHash'); + })(), + ); + return; + + case 'REGISTER_VIEWING_KEY': + event.waitUntil( + (async () => { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const data: StoredViewingKey = { + publicKey: msg.publicKey, + encryptedViewingKey: msg.encryptedViewingKey, + encryptedSpendingPubKey: msg.encryptedSpendingPubKey, + encryptedSpendingScalar: msg.encryptedSpendingScalar, + timestamp: Date.now(), + }; + await new Promise((resolve, reject) => { + const request = store.put(data); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + db.close(); + const response: WorkerMessage = { + type: 'VIEWING_KEY_REGISTERED', + requestId: msg.requestId, + }; + (event.source as Client)?.postMessage(response); + } catch (error) { + console.error('[app-sw] Failed to register viewing key:', error); + const response: WorkerMessage = { + type: 'VIEWING_KEY_ERROR', + requestId: msg.requestId, + error: error instanceof Error ? error.message : 'Unknown error', + }; + (event.source as Client)?.postMessage(response); } + })(), + ); + return; + + case 'UNREGISTER_VIEWING_KEY': + event.waitUntil( + (async () => { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + await new Promise((resolve, reject) => { + const request = store.delete(msg.publicKey); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); - // Store subscription info in IndexedDB - const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - - // Update existing entry or create new one - const existing = await new Promise((resolve, reject) => { - const request = store.get(subscription.keys?.p256dh || 'default'); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const entry: StoredViewingKey = existing || { - publicKey: subscription.keys?.p256dh || 'default', - encryptedViewingKey: '', - encryptedSpendingPubKey: '', - encryptedSpendingScalar: '', - timestamp: Date.now(), - }; - - // Store relay URL and meta-address hash - (entry as any).relayUrl = relayUrl; - (entry as any).metaAddressHash = metaAddressHash; - (entry as any).pushSubscription = subscription; - - await new Promise((resolve, reject) => { - const request = store.put(entry); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - db.close(); - (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_REGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to register push subscription:', error); - (event.source as Client)?.postMessage({ - type: 'PUSH_SUBSCRIPTION_ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - })(), - ); - } - - if (type === 'UNREGISTER_PUSH_SUBSCRIPTION') { - event.waitUntil( - (async () => { - try { - const { subscription, metaAddressHash } = event.data ?? {}; - if (!subscription) { - throw new Error('Missing subscription'); + // Unregister periodic sync if no keys remain + const allKeys = await new Promise((resolve, reject) => { + const tx = db.transaction([STORE_NAME], 'readonly'); + const st = tx.objectStore(STORE_NAME); + const req = st.getAll(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result || []); + }); + db.close(); + + if (allKeys.length === 0 && 'periodicSync' in self.registration) { + await ( + self.registration as ServiceWorkerRegistration & { + periodicSync: { unregister(tag: string): Promise }; + } + ).periodicSync.unregister(SYNC_TAG); + } + + const response: WorkerMessage = { + type: 'VIEWING_KEY_UNREGISTERED', + requestId: msg.requestId, + }; + (event.source as Client)?.postMessage(response); + } catch (error) { + console.error('[app-sw] Failed to unregister viewing key:', error); } + })(), + ); + return; + + case 'TRIGGER_SCAN': + event.waitUntil(handleSync()); + return; + + case 'REGISTER_PUSH_SUBSCRIPTION': + event.waitUntil( + (async () => { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Update existing entry or create new one + const existing = await new Promise((resolve, reject) => { + const request = store.get(msg.subscription.keys.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); - const db = await openDB(); - const transaction = db.transaction([STORE_NAME], 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - - // Remove push subscription from entry - const existing = await new Promise((resolve, reject) => { - const request = store.get(subscription.keys?.p256dh || 'default'); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); + const entry: StoredViewingKey = existing || { + publicKey: msg.subscription.keys.p256dh || 'default', + encryptedViewingKey: '', + encryptedSpendingPubKey: '', + encryptedSpendingScalar: '', + timestamp: Date.now(), + }; - if (existing) { - delete (existing as any).relayUrl; - delete (existing as any).metaAddressHash; - delete (existing as any).pushSubscription; + // Store relay URL and meta-address hash + (entry as any).relayUrl = msg.relayUrl; + (entry as any).metaAddressHash = msg.metaAddressHash; + (entry as any).pushSubscription = msg.subscription; await new Promise((resolve, reject) => { - const request = store.put(existing); + const request = store.put(entry); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); + + db.close(); + const response: WorkerMessage = { + type: 'PUSH_SUBSCRIPTION_REGISTERED', + requestId: msg.requestId, + }; + (event.source as Client)?.postMessage(response); + } catch (error) { + console.error('[app-sw] Failed to register push subscription:', error); + const response: WorkerMessage = { + type: 'PUSH_SUBSCRIPTION_ERROR', + requestId: msg.requestId, + error: error instanceof Error ? error.message : 'Unknown error', + }; + (event.source as Client)?.postMessage(response); } + })(), + ); + return; + + case 'UNREGISTER_PUSH_SUBSCRIPTION': + event.waitUntil( + (async () => { + try { + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Remove push subscription from entry + const existing = await new Promise((resolve, reject) => { + const request = store.get(msg.subscription.keys.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); - db.close(); - (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_UNREGISTERED' }); - } catch (error) { - console.error('[app-sw] Failed to unregister push subscription:', error); - (event.source as Client)?.postMessage({ - type: 'PUSH_SUBSCRIPTION_ERROR', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - })(), - ); + if (existing) { + delete (existing as any).relayUrl; + delete (existing as any).metaAddressHash; + delete (existing as any).pushSubscription; + + await new Promise((resolve, reject) => { + const request = store.put(existing); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + db.close(); + const response: WorkerMessage = { + type: 'PUSH_SUBSCRIPTION_UNREGISTERED', + requestId: msg.requestId, + }; + (event.source as Client)?.postMessage(response); + } catch (error) { + console.error('[app-sw] Failed to unregister push subscription:', error); + const response: WorkerMessage = { + type: 'PUSH_SUBSCRIPTION_ERROR', + requestId: msg.requestId, + error: error instanceof Error ? error.message : 'Unknown error', + }; + (event.source as Client)?.postMessage(response); + } + })(), + ); + return; } });