From bf0f5d71e961bb25fda74e26625753f810830cb2 Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 6 May 2026 00:16:54 +0200 Subject: [PATCH 1/3] fix(hive): resolve push handling regressions and improve notification UX - Make Service Worker resilient to IndexedDB upgrade blocks during push by creating the DB on-demand when it does not exist yet. - Harden notification click flow: best-effort focus/openWindow, catch malformed client URLs, and persist after opening to avoid Android activation stalls. - Remove redundant E2E envelope null check after type guard. - Improve relative time formatting: show seconds for recent messages, absolute HH:MM for same-day >1h and previous days. - Expand test coverage for SW push, click, credentials, and bootstrap. --- .../src/lib/services/app-bootstrap.test.ts | 49 +++ .../lib/services/device-keys-repository.ts | 7 +- .../hive/src/lib/services/hive-db.test.ts | 76 +++++ web/apps/hive/src/lib/services/hive-db.ts | 45 ++- .../lib/services/notifications-repository.ts | 9 +- .../src/lib/stores/notifications.svelte.ts | 23 +- web/apps/hive/src/routes/(app)/+layout.svelte | 10 +- web/apps/hive/src/sw-runtime.test.ts | 291 ++++++++++++++++++ web/apps/hive/src/sw-runtime.ts | 182 +++++++++-- web/apps/hive/src/sw.ts | 6 +- 10 files changed, 648 insertions(+), 50 deletions(-) create mode 100644 web/apps/hive/src/lib/services/hive-db.test.ts 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..cd5b027 100644 --- a/web/apps/hive/src/lib/stores/notifications.svelte.ts +++ b/web/apps/hive/src/lib/stores/notifications.svelte.ts @@ -426,27 +426,36 @@ 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 { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + 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..9062a95 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,44 @@ 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', + 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 +508,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..13bbca0 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,45 @@ 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; + 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 +473,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 +577,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...`); From 7ef53a3a70ac83c32c205e881cb15e2c084d7ddb Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 6 May 2026 00:23:03 +0200 Subject: [PATCH 2/3] fix(hive): guard against undefined E2E envelope after type guard TypeScript does not narrow the destructured 'envelope' binding even after isE2EEnvelope() returns true because 'beebuzz' is optional in the E2EEnvelope type. Add an explicit null check to satisfy the compiler and match the pre-squash behavior. --- web/apps/hive/src/sw-runtime.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/apps/hive/src/sw-runtime.ts b/web/apps/hive/src/sw-runtime.ts index 13bbca0..9bf4180 100644 --- a/web/apps/hive/src/sw-runtime.ts +++ b/web/apps/hive/src/sw-runtime.ts @@ -327,6 +327,9 @@ async function resolvePushPayload( // 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) { From d86e7faef36e470f5ce778f302edf19af096d1cc Mon Sep 17 00:00:00 2001 From: lucor Date: Wed, 6 May 2026 00:31:00 +0200 Subject: [PATCH 3/3] fix(hive): resolve svelte-check and eslint errors in CI - Disable svelte/prefer-svelte-reactivity for new Date() instances in formatRelativeTime, which is a pure utility function, not Svelte state. - Disable @typescript-eslint/no-unsafe-assignment for vitest's expect.objectContaining matcher in the credentials-failure test. --- web/apps/hive/src/lib/stores/notifications.svelte.ts | 3 +++ web/apps/hive/src/sw-runtime.test.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/web/apps/hive/src/lib/stores/notifications.svelte.ts b/web/apps/hive/src/lib/stores/notifications.svelte.ts index cd5b027..921e8ef 100644 --- a/web/apps/hive/src/lib/stores/notifications.svelte.ts +++ b/web/apps/hive/src/lib/stores/notifications.svelte.ts @@ -431,8 +431,11 @@ export function formatTime(date: Date): string { } 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); diff --git a/web/apps/hive/src/sw-runtime.test.ts b/web/apps/hive/src/sw-runtime.test.ts index 9062a95..6ad3dd8 100644 --- a/web/apps/hive/src/sw-runtime.test.ts +++ b/web/apps/hive/src/sw-runtime.test.ts @@ -363,6 +363,7 @@ describe('service worker runtime', () => { '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