diff --git a/web/apps/hive/src/lib/services/app-bootstrap.test.ts b/web/apps/hive/src/lib/services/app-bootstrap.test.ts index f01e6c8..758fe11 100644 --- a/web/apps/hive/src/lib/services/app-bootstrap.test.ts +++ b/web/apps/hive/src/lib/services/app-bootstrap.test.ts @@ -184,4 +184,53 @@ describe('bootstrapAppShell', () => { 'postChecks' ]); }); + + it('propagates legacy notification migration failures before draining IndexedDB', async () => { + const calls: string[] = []; + const failure = new Error('migration failed'); + + await expect( + bootstrapAppShell({ + registerServiceWorker: () => { + calls.push('register'); + return Promise.resolve({ scope: '/' }); + }, + checkPaired: () => { + calls.push('checkPaired'); + return Promise.resolve(true); + }, + getDeviceId: () => { + calls.push('getDeviceId'); + return Promise.resolve('dev-a'); + }, + activateNotifications: (deviceId) => { + calls.push(`activate:${deviceId}`); + }, + attachServiceWorkerListeners: () => { + calls.push('attach'); + }, + migrateLegacyNotifications: (deviceId) => { + calls.push(`migrate:${deviceId}`); + return Promise.reject(failure); + }, + loadPersistedNotifications: (phase) => { + calls.push(`load:${phase}`); + return Promise.resolve(); + }, + runPostPairingChecks: () => { + calls.push('postChecks'); + return Promise.resolve(); + } + }) + ).rejects.toBe(failure); + + expect(calls).toEqual([ + 'register', + 'checkPaired', + 'getDeviceId', + 'activate:dev-a', + 'attach', + 'migrate:dev-a' + ]); + }); }); diff --git a/web/apps/hive/src/lib/services/device-keys-repository.ts b/web/apps/hive/src/lib/services/device-keys-repository.ts index e187763..5bfeba5 100644 --- a/web/apps/hive/src/lib/services/device-keys-repository.ts +++ b/web/apps/hive/src/lib/services/device-keys-repository.ts @@ -2,6 +2,7 @@ import { ENCRYPTED_KEY_STORE, ENCRYPTION_METADATA_STORE, WRAPPING_KEY_STORE, + openExistingHiveDB, openHiveDB } from './hive-db'; @@ -76,7 +77,7 @@ function putWrappedIdentity( export const deviceKeysRepository = { /** Returns the first usable key metadata record, skipping reserved entries. */ async getFirstMetadata(): Promise { - const db = await openHiveDB(); + const db = await openExistingHiveDB(); return new Promise((resolve, reject) => { const tx = db.transaction(ENCRYPTION_METADATA_STORE, 'readonly'); @@ -104,7 +105,7 @@ export const deviceKeysRepository = { wrappingKey: CryptoKey | null; wrappedPrivateKey: WrappedPrivateKeyRecord | null; }> { - const db = await openHiveDB(); + const db = await openExistingHiveDB(); const [wrappingKey, wrappedPrivateKey] = await Promise.all([ getStoreValue(db, WRAPPING_KEY_STORE, keyId, 'Wrapping key fetch failed'), getStoreValue( @@ -194,7 +195,7 @@ export const deviceKeysRepository = { /** Retrieves stored device credentials, if available. */ async getDeviceCredentials(): Promise { - const db = await openHiveDB(); + const db = await openExistingHiveDB(); return getStoreValue( db, ENCRYPTION_METADATA_STORE, diff --git a/web/apps/hive/src/lib/services/hive-db.test.ts b/web/apps/hive/src/lib/services/hive-db.test.ts new file mode 100644 index 0000000..401d2df --- /dev/null +++ b/web/apps/hive/src/lib/services/hive-db.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + ENCRYPTED_KEY_STORE, + ENCRYPTION_METADATA_STORE, + HIVE_DB_NAME, + NOTIFICATIONS_BY_DEVICE_INDEX, + NOTIFICATIONS_STORE, + WRAPPING_KEY_STORE, + openExistingHiveDB, + openHiveDB +} from './hive-db'; + +function deleteHiveDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(HIVE_DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(request.error?.message ?? 'Delete failed')); + request.onblocked = () => reject(new Error('Delete blocked')); + }); +} + +function createV1HiveDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(HIVE_DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' }); + db.createObjectStore(ENCRYPTION_METADATA_STORE, { keyPath: 'id' }); + db.createObjectStore(WRAPPING_KEY_STORE); + db.createObjectStore(ENCRYPTED_KEY_STORE, { keyPath: 'id' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new Error(request.error?.message ?? 'Open failed')); + }); +} + +describe('hive-db', () => { + beforeEach(async () => { + await deleteHiveDB(); + }); + + it('opens an existing v1 database without forcing the v2 upgrade', async () => { + const v1DB = await createV1HiveDB(); + v1DB.close(); + + const existing = await openExistingHiveDB(); + + expect(existing.version).toBe(1); + expect(existing.objectStoreNames.contains(ENCRYPTION_METADATA_STORE)).toBe(true); + existing.close(); + }); + + it('creates an empty v1 database when no Hive database exists yet', async () => { + const existing = await openExistingHiveDB(); + + expect(existing.version).toBe(1); + expect(existing.objectStoreNames.length).toBe(0); + existing.close(); + }); + + it('creates missing stores when upgrading a sparse existing database to v2', async () => { + const sparseDB = await openExistingHiveDB(); + expect(sparseDB.version).toBe(1); + sparseDB.close(); + + const upgraded = await openHiveDB(); + + expect(upgraded.version).toBe(2); + expect(upgraded.objectStoreNames.contains(NOTIFICATIONS_STORE)).toBe(true); + const tx = upgraded.transaction(NOTIFICATIONS_STORE, 'readonly'); + expect( + tx.objectStore(NOTIFICATIONS_STORE).indexNames.contains(NOTIFICATIONS_BY_DEVICE_INDEX) + ).toBe(true); + upgraded.close(); + }); +}); diff --git a/web/apps/hive/src/lib/services/hive-db.ts b/web/apps/hive/src/lib/services/hive-db.ts index a7462b2..0d61e0d 100644 --- a/web/apps/hive/src/lib/services/hive-db.ts +++ b/web/apps/hive/src/lib/services/hive-db.ts @@ -6,7 +6,16 @@ export const ENCRYPTION_METADATA_STORE = 'encryption_keys'; export const WRAPPING_KEY_STORE = 'wrapping_keys'; export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys'; -/** Opens the shared Hive IndexedDB database and creates required stores. */ +function attachVersionChangeClose(db: IDBDatabase): IDBDatabase { + // If another context (page or service worker) needs to upgrade, close + // this connection so the upgrade is not blocked. + db.onversionchange = () => { + db.close(); + }; + return db; +} + +/** Opens the shared Hive IndexedDB database and creates or upgrades required stores. */ export function openHiveDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(HIVE_DB_NAME, HIVE_DB_VERSION); @@ -23,7 +32,10 @@ export function openHiveDB(): Promise { // v1 -> v2: per-device scoping was introduced. Add the by-device index to // the existing store so legacy rows can be migrated lazily by the app shell. if (oldVersion >= 1 && oldVersion < 2) { - if (db.objectStoreNames.contains(NOTIFICATIONS_STORE)) { + if (!db.objectStoreNames.contains(NOTIFICATIONS_STORE)) { + const notifications = db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' }); + notifications.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId'); + } else { const notifications = request.transaction?.objectStore(NOTIFICATIONS_STORE); notifications?.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId'); } @@ -40,6 +52,33 @@ export function openHiveDB(): Promise { } }; request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed')); - request.onsuccess = () => resolve(request.result); + request.onblocked = () => + reject( + new Error( + 'IndexedDB upgrade blocked by another open BeeBuzz tab or worker — please close other tabs' + ) + ); + request.onsuccess = () => { + resolve(attachVersionChangeClose(request.result)); + }; + }); +} + +/** + * Opens the current on-disk Hive database without requesting a version upgrade. + * + * Service-worker push/click handling uses this for stores that already existed + * in v1 (device credentials, key material, and notification records). During a + * staged rollout, an old foreground page can keep a v1 connection open; asking + * the service worker for v2 in that moment can block decryption. Non-upgrading + * opens keep notification delivery independent from the by-device index upgrade. + */ +export function openExistingHiveDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(HIVE_DB_NAME); + request.onerror = () => reject(new Error(request.error?.message ?? 'IndexedDB open failed')); + request.onsuccess = () => { + resolve(attachVersionChangeClose(request.result)); + }; }); } diff --git a/web/apps/hive/src/lib/services/notifications-repository.ts b/web/apps/hive/src/lib/services/notifications-repository.ts index 25db661..d011855 100644 --- a/web/apps/hive/src/lib/services/notifications-repository.ts +++ b/web/apps/hive/src/lib/services/notifications-repository.ts @@ -1,4 +1,9 @@ -import { NOTIFICATIONS_BY_DEVICE_INDEX, NOTIFICATIONS_STORE, openHiveDB } from './hive-db'; +import { + NOTIFICATIONS_BY_DEVICE_INDEX, + NOTIFICATIONS_STORE, + openExistingHiveDB, + openHiveDB +} from './hive-db'; export interface StoredNotificationRecord { id: string; @@ -31,7 +36,7 @@ function normalizeLegacyRecord(record: Record): Record { - const db = await openHiveDB(); + const db = await openExistingHiveDB(); return new Promise((resolve, reject) => { const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); diff --git a/web/apps/hive/src/lib/stores/notifications.svelte.ts b/web/apps/hive/src/lib/stores/notifications.svelte.ts index 1189fa4..921e8ef 100644 --- a/web/apps/hive/src/lib/stores/notifications.svelte.ts +++ b/web/apps/hive/src/lib/stores/notifications.svelte.ts @@ -426,27 +426,39 @@ export function formatTime(date: Date): string { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', - second: '2-digit', hour12: false }); } export function formatRelativeTime(date: Date): string { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state + const now = new Date(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure utility, not reactive state + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.floor((today.getTime() - target.getTime()) / 86400000); + + // Not today: show absolute time for clarity across midnight + if (diffDays !== 0) { + return formatTime(date); + } + const diffMs = Date.now() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); - if (diffMs < 60000) { + if (diffSeconds < 10) { return 'now'; } + if (diffSeconds < 60) { + return `${diffSeconds}s`; + } + const diffMinutes = Math.floor(diffMs / 60000); if (diffMinutes < 60) { return `${diffMinutes}m`; } - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) { - return `${diffHours}h`; - } - return formatTime(date); } diff --git a/web/apps/hive/src/routes/(app)/+layout.svelte b/web/apps/hive/src/routes/(app)/+layout.svelte index 3cc6db1..358b74d 100644 --- a/web/apps/hive/src/routes/(app)/+layout.svelte +++ b/web/apps/hive/src/routes/(app)/+layout.svelte @@ -80,6 +80,13 @@ ); } else if (event.data?.type === 'NOTIFICATION_CLICKED') { const clickedNotification = event.data.notification; + // User tapped a system notification — always reload from IndexedDB + // in case the postMessage for the original push didn't reach us + // (common on iOS, and required when the click happens before the + // app shell has activated a device id). Run this BEFORE any + // device-id gating so the durable record is still recovered when + // `deviceId` on the click message is missing or stale. + void notificationsStore.loadFromIndexedDB(); if (clickedNotification?.deviceId !== notificationsStore.activeDeviceId) return; if ( clickedNotification?.id && @@ -98,9 +105,6 @@ clickedNotification.id ); } - // User tapped a system notification — reload from IndexedDB in case - // the postMessage for the original push didn't reach us (common on iOS). - void notificationsStore.loadFromIndexedDB(); } else if (event.data?.type === 'SUBSCRIPTION_CHANGED') { paired.clear(); toast.info('Push subscription expired. Please reconnect.'); diff --git a/web/apps/hive/src/sw-runtime.test.ts b/web/apps/hive/src/sw-runtime.test.ts index 2850fca..6ad3dd8 100644 --- a/web/apps/hive/src/sw-runtime.test.ts +++ b/web/apps/hive/src/sw-runtime.test.ts @@ -59,6 +59,20 @@ function createDeps(overrides: Partial = {}): ServiceW }; } +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('service worker runtime', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -327,6 +341,45 @@ describe('service worker runtime', () => { expect(client.postMessage).not.toHaveBeenCalled(); }); + it('still shows the notification when reading device credentials fails', async () => { + const client = { url: 'https://hive.beebuzz.test/inbox', postMessage: vi.fn() }; + const deps = createDeps({ + getDeviceCredentials: vi.fn(() => Promise.reject(new Error('IDB open blocked'))), + matchWindowClients: vi.fn(() => Promise.resolve([client])) + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await handlePushEvent( + deps, + createPushEvent({ + id: 'n-credentials-fail', + title: 'Sensor', + body: 'Temperature alert', + sent_at: '2026-04-20T13:20:00.000Z' + }) + ); + + expect(deps.showNotification).toHaveBeenCalledWith( + 'Sensor', + expect.objectContaining({ + body: 'Temperature alert', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- vitest matcher + data: expect.objectContaining({ + id: 'n-credentials-fail', + deviceId: undefined + }) + }) + ); + expect(deps.saveNotification).not.toHaveBeenCalled(); + expect(deps.matchWindowClients).not.toHaveBeenCalled(); + expect(client.postMessage).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + '[PUSH] Failed to read device credentials', + expect.objectContaining({ error: 'IDB open blocked' }) + ); + consoleSpy.mockRestore(); + }); + it('shows a decryption-specific fallback for encrypted payload failures', async () => { const ageHeader = new TextEncoder().encode('age-encryption.org/v1\ncorrupted-data'); const buffer = ageHeader.buffer.slice( @@ -456,6 +509,245 @@ describe('service worker runtime', () => { consoleSpy.mockRestore(); }); + it('classifies E2E envelope decryption failures as encrypted (not parse errors)', async () => { + // Server-side E2E delivery sends a plain JSON envelope pointing to the + // ciphertext attachment. If decryption of that attachment fails, the + // user must see the encryption-specific message — never the misleading + // "could not be parsed" text. + const envelopeBytes = new TextEncoder().encode( + JSON.stringify({ + beebuzz: { + id: 'n-e2e-fail', + token: 'attachment-token', + sent_at: '2026-04-20T13:30:00.000Z' + } + }) + ); + const buffer = envelopeBytes.buffer.slice( + envelopeBytes.byteOffset, + envelopeBytes.byteOffset + envelopeBytes.byteLength + ); + const deps = createDeps({ + fetch: vi.fn(() => Promise.resolve(new Response('ciphertext', { status: 200 }))), + decryptPayload: vi.fn(() => Promise.reject(new MissingDeviceIdentityError())) + }); + const event: PushEventLike = { + data: { arrayBuffer: () => buffer }, + waitUntil: () => {} + }; + + await handlePushEvent(deps, event); + + expect(deps.showNotification).toHaveBeenCalledWith('BeeBuzz Notification', { + body: 'Device key missing or invalid. Open BeeBuzz to re-pair.', + icon: '/assets/manifest-icon-192.maskable.png' + }); + expect(deps.saveNotification).not.toHaveBeenCalled(); + }); + + it('persists the clicked notification so the app can recover it after a missed postMessage', async () => { + // Closed-app click on iOS often loses the SW->client postMessage. The + // notification must still be in IndexedDB so the app shell finds it on + // the first drain. + const openedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn() + }; + const deps = createDeps({ + matchWindowClients: vi.fn(() => Promise.resolve([])), + openWindow: vi.fn(() => Promise.resolve(openedClient)) + }); + + await handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-persist', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + topic: 'alerts', + topicId: 'topic-1', + sentAt: '2026-04-20T11:00:00.000Z', + priority: 'high' + }) + ); + + expect(deps.saveNotification).toHaveBeenCalledWith({ + id: 'n-click-persist', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + topic: 'alerts', + topicId: 'topic-1', + sentAt: '2026-04-20T11:00:00.000Z', + priority: 'high', + attachment: undefined + }); + expect(openedClient.postMessage).toHaveBeenCalledTimes(1); + }); + + it('opens Hive before waiting for clicked-notification persistence', async () => { + const persist = createDeferred(); + const openedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn() + }; + const deps = createDeps({ + saveNotification: vi.fn(() => persist.promise), + matchWindowClients: vi.fn(() => Promise.resolve([])), + openWindow: vi.fn(() => Promise.resolve(openedClient)) + }); + + const clickPromise = handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-open-first', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + sentAt: '2026-04-20T11:00:00.000Z' + }) + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(deps.openWindow).toHaveBeenCalledWith('https://hive.beebuzz.test'); + expect(openedClient.postMessage).not.toHaveBeenCalled(); + + persist.resolve(); + await clickPromise; + + expect(openedClient.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'NOTIFICATION_CLICKED' }) + ); + }); + + it('falls back to current credentials when click data has no deviceId', async () => { + const openedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn() + }; + const deps = createDeps({ + matchWindowClients: vi.fn(() => Promise.resolve([])), + openWindow: vi.fn(() => Promise.resolve(openedClient)), + getDeviceCredentials: vi.fn(() => Promise.resolve({ deviceId: 'dev-current' })) + }); + + await handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-fallback', + title: 'Door', + body: 'Front door opened', + sentAt: '2026-04-20T11:00:00.000Z' + }) + ); + + expect(deps.saveNotification).toHaveBeenCalledWith( + expect.objectContaining({ id: 'n-click-fallback', deviceId: 'dev-current' }) + ); + }); + + it('opens a new Hive window when focusing an existing client fails on click', async () => { + const failingClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn(), + focus: vi.fn(() => Promise.reject(new Error('focus failed'))) + }; + const openedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn() + }; + const deps = createDeps({ + matchWindowClients: vi.fn(() => Promise.resolve([failingClient])), + openWindow: vi.fn(() => Promise.resolve(openedClient)) + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect( + handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-focus-fail', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + sentAt: '2026-04-20T11:00:00.000Z' + }) + ) + ).resolves.toBeUndefined(); + + expect(deps.openWindow).toHaveBeenCalledWith('https://hive.beebuzz.test'); + expect(openedClient.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'NOTIFICATION_CLICKED' }) + ); + consoleSpy.mockRestore(); + }); + + it('ignores malformed client URLs while handling notification clicks', async () => { + const malformedClient = { + url: 'not a url', + postMessage: vi.fn() + }; + const openedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn() + }; + const deps = createDeps({ + matchWindowClients: vi.fn(() => Promise.resolve([malformedClient])), + openWindow: vi.fn(() => Promise.resolve(openedClient)) + }); + + await handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-bad-url', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + sentAt: '2026-04-20T11:00:00.000Z' + }) + ); + + expect(deps.openWindow).toHaveBeenCalledWith('https://hive.beebuzz.test'); + expect(malformedClient.postMessage).not.toHaveBeenCalled(); + expect(openedClient.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'NOTIFICATION_CLICKED' }) + ); + }); + + it('does not reject when posting the click message fails', async () => { + const focusedClient = { + url: 'https://hive.beebuzz.test/', + postMessage: vi.fn(() => { + throw new Error('postMessage failed'); + }), + focus: vi.fn(function (this: typeof focusedClient) { + return Promise.resolve(this); + }) + }; + const deps = createDeps({ + matchWindowClients: vi.fn(() => Promise.resolve([focusedClient])) + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect( + handleNotificationClickEvent( + deps, + createNotificationClickEvent({ + id: 'n-click-post-fail', + deviceId: 'dev-a', + title: 'Door', + body: 'Front door opened', + sentAt: '2026-04-20T11:00:00.000Z' + }) + ) + ).resolves.toBeUndefined(); + + expect(focusedClient.postMessage).toHaveBeenCalledTimes(1); + consoleSpy.mockRestore(); + }); + it('broadcasts SUBSCRIPTION_CHANGED to all open clients', async () => { const client1 = { url: 'https://hive.beebuzz.test/', postMessage: vi.fn() }; const client2 = { url: 'https://hive.beebuzz.test/device', postMessage: vi.fn() }; diff --git a/web/apps/hive/src/sw-runtime.ts b/web/apps/hive/src/sw-runtime.ts index 2dab2b9..9bf4180 100644 --- a/web/apps/hive/src/sw-runtime.ts +++ b/web/apps/hive/src/sw-runtime.ts @@ -290,22 +290,14 @@ async function resolvePushPayload( console.log(`[PUSH] Payload size: ${payloadArray.byteLength} bytes`); } + let parsed: unknown; if (startsWith(payloadBytes, AGE_HEADER)) { if (deps.debug) { console.log('[PUSH] Detected age-encrypted payload, decrypting...'); } try { const decrypted = await deps.decryptPayload(payloadArray); - const parsed = JSON.parse(decrypted) as unknown; - if (isE2EEnvelope(parsed)) { - const envelope = parsed.beebuzz; - if (!envelope) { - throw new Error('missing encrypted notification envelope'); - } - return loadE2EPayload(deps, envelope.id, envelope.token, envelope.sent_at); - } - - return validateNotificationPayload(parsed); + parsed = JSON.parse(decrypted) as unknown; } catch (error) { throw new PushPayloadError( 'encrypted', @@ -313,26 +305,48 @@ async function resolvePushPayload( error ); } + } else { + if (deps.debug) { + console.log('[PUSH] Plain JSON payload detected'); + } + try { + parsed = parseJsonBytes(payloadBytes); + } catch (error) { + throw new PushPayloadError( + 'plain', + error instanceof Error ? error.message : 'unknown payload error', + error + ); + } } - if (deps.debug) { - console.log('[PUSH] Plain JSON payload detected'); - } - try { - const parsed = parseJsonBytes(payloadBytes); - if (isE2EEnvelope(parsed)) { - const envelope = parsed.beebuzz; - if (!envelope) { - throw new Error('missing encrypted notification envelope'); - } - return loadE2EPayload(deps, envelope.id, envelope.token, envelope.sent_at); + // E2E envelopes (BeeBuzz default for E2E delivery) require fetching the + // opaque ciphertext attachment and decrypting it. Any failure here is an + // encryption-side problem (missing key, fetch failure, decrypt failure), + // not a parse error: classify it as 'encrypted' so the user sees a useful + // notification body instead of "could not be parsed". + if (isE2EEnvelope(parsed)) { + const { beebuzz: envelope } = parsed; + if (!envelope) { + throw new PushPayloadError('plain', 'missing encrypted notification envelope'); } + try { + return await loadE2EPayload(deps, envelope.id, envelope.token, envelope.sent_at); + } catch (error) { + throw new PushPayloadError( + 'encrypted', + error instanceof Error ? error.message : 'unknown encrypted error', + error + ); + } + } + try { return validateNotificationPayload(parsed); } catch (error) { throw new PushPayloadError( 'plain', - error instanceof Error ? error.message : 'unknown payload error', + error instanceof Error ? error.message : 'invalid notification payload', error ); } @@ -462,6 +476,103 @@ export async function handlePushEvent( } } +/** + * Best-effort: persist the clicked notification to IndexedDB so the app shell + * can recover it after launch even when the original push-time persistence was + * skipped (e.g. credentials weren't readable yet) or the postMessage to the + * newly opened window was dropped (common on iOS / WebKit). + */ +async function persistClickedNotificationBestEffort( + deps: ServiceWorkerRuntimeDeps, + notificationData?: Record +): Promise { + if (!notificationData) return; + + const id = typeof notificationData.id === 'string' ? notificationData.id : undefined; + const title = typeof notificationData.title === 'string' ? notificationData.title : undefined; + const body = typeof notificationData.body === 'string' ? notificationData.body : ''; + const sentAt = typeof notificationData.sentAt === 'string' ? notificationData.sentAt : undefined; + if (!id || !title || !sentAt) return; + + let deviceId = + typeof notificationData.deviceId === 'string' ? notificationData.deviceId : undefined; + if (!deviceId) { + try { + deviceId = (await deps.getDeviceCredentials())?.deviceId; + } catch { + // Ignore — without a deviceId we cannot scope the record. + } + } + if (!deviceId) return; + + const attachment = + notificationData.attachment && + typeof notificationData.attachment === 'object' && + !Array.isArray(notificationData.attachment) + ? (notificationData.attachment as NotificationAttachmentEnvelope) + : undefined; + + try { + await deps.saveNotification({ + id, + deviceId, + title, + body, + topic: typeof notificationData.topic === 'string' ? notificationData.topic : '', + sentAt, + topicId: typeof notificationData.topicId === 'string' ? notificationData.topicId : undefined, + attachment, + priority: + typeof notificationData.priority === 'string' ? notificationData.priority : undefined + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown storage error'; + console.error('[CLICK] Failed to persist clicked notification', { error: message }); + } +} + +function isSameOriginClient(deps: ServiceWorkerRuntimeDeps, client: WorkerClient): boolean { + try { + return new URL(client.url).origin === deps.locationOrigin; + } catch { + return false; + } +} + +async function focusClientBestEffort(client: WorkerClient): Promise { + try { + return client.focus ? await client.focus() : client; + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown focus error'; + console.error('[CLICK] Failed to focus window client', { error: message }); + return undefined; + } +} + +async function openHiveWindowBestEffort( + deps: ServiceWorkerRuntimeDeps +): Promise { + try { + return (await deps.openWindow(deps.locationOrigin || '/')) ?? undefined; + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown openWindow error'; + console.error('[CLICK] Failed to open Hive window', { error: message }); + return undefined; + } +} + +function postClickMessageBestEffort( + client: WorkerClient, + notificationData?: Record +): void { + try { + client.postMessage(buildNotificationClickedMessage(notificationData)); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown postMessage error'; + console.error('[CLICK] Failed to post notification click message', { error: message }); + } +} + /** Handles notification clicks by focusing or opening Hive and sending a fallback payload. */ export async function handleNotificationClickEvent( deps: ServiceWorkerRuntimeDeps, @@ -469,22 +580,36 @@ export async function handleNotificationClickEvent( ): Promise { event.notification.close(); - const windows = await deps.matchWindowClients(false); let focused: WorkerClient | undefined; - for (const windowClient of windows) { - const clientOrigin = new URL(windowClient.url).origin; - if (clientOrigin === deps.locationOrigin) { - focused = windowClient.focus ? await windowClient.focus() : windowClient; - break; + try { + const windows = await deps.matchWindowClients(false); + for (const windowClient of windows) { + if (!isSameOriginClient(deps, windowClient)) continue; + focused = await focusClientBestEffort(windowClient); + if (focused) break; } + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown matchAll error'; + console.error('[CLICK] Failed to match window clients', { error: message }); } if (!focused) { - focused = (await deps.openWindow(deps.locationOrigin || '/')) ?? undefined; + focused = await openHiveWindowBestEffort(deps); + } + + // Keep Android's activation path as close as possible to the pre-fix + // behavior: do not touch IndexedDB until after focus/openWindow finished. + // Once a client exists, persist before the fallback postMessage so iOS can + // still recover from a dropped click message during cold launch. + try { + await persistClickedNotificationBestEffort(deps, event.notification.data); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown persistence error'; + console.error('[CLICK] Failed after opening Hive window', { error: message }); } if (focused) { - focused.postMessage(buildNotificationClickedMessage(event.notification.data)); + postClickMessageBestEffort(focused, event.notification.data); } } diff --git a/web/apps/hive/src/sw.ts b/web/apps/hive/src/sw.ts index 2af8a17..ced4d68 100644 --- a/web/apps/hive/src/sw.ts +++ b/web/apps/hive/src/sw.ts @@ -1,5 +1,5 @@ import { Decrypter } from 'age-encryption'; -import { getDeviceIdentity } from './lib/services/encryption'; +import { getDeviceIdentity, MissingDeviceIdentityError } from './lib/services/encryption'; import { deviceKeysRepository } from './lib/services/device-keys-repository'; import { notificationsRepository } from './lib/services/notifications-repository'; import { @@ -23,7 +23,9 @@ async function decryptPayload(data: ArrayBuffer): Promise { } const identity = await getDeviceIdentity(); if (!identity) { - throw new Error('❌ No encryption key found in IndexedDB - device may not be paired correctly'); + throw new MissingDeviceIdentityError( + 'No encryption key found in IndexedDB - device may not be paired correctly' + ); } if (DEBUG) { console.log(`[AGE] ✅ Identity loaded, starting decryption...`);