From 6d9207288254ceab765a47ebcb5ac49ba7645996 Mon Sep 17 00:00:00 2001 From: lucor Date: Tue, 5 May 2026 20:18:49 +0200 Subject: [PATCH] feat(hive): scope notification history to the current device id Hive notification history is now bound to a single backend device id, so unpairing or repairing with a different device never leaks old messages. IndexedDB is bumped to v2 with a by-device index. On first boot after the upgrade a lazy migration stamps legacy records with the current device id, normalises any snake_case fields, and removes records that belong to another device. The localStorage cache uses the same scoping, with garbage collection of stale keys and a one-shot cleanup of the old unscoped legacy keys. To enforce the correct order the app shell now bootstraps through a single orchestration function: register the worker, check paired state, read credentials, activate the store for the current device, attach listeners, run the migration, and only then drain IndexedDB. The store guards every mutating operation with the active device id, and the service worker includes the device id in every push message so the app shell can silently ignore messages meant for a previous device. All new tests run against a real in-memory IndexedDB via fake-indexeddb instead of mocks, covering the migration, key cleanup, and bootstrap ordering. --- docs/HIVE_ONBOARDING.md | 7 + web/apps/hive/src/lib/onboarding.svelte.ts | 1 + .../src/lib/services/app-bootstrap.test.ts | 187 +++++++++++ .../hive/src/lib/services/app-bootstrap.ts | 62 ++++ web/apps/hive/src/lib/services/hive-db.ts | 22 +- .../services/notifications-repository.test.ts | 157 +++++++++ .../lib/services/notifications-repository.ts | 62 +++- .../lib/stores/notifications.svelte.test.ts | 299 ++++++++++++++++++ .../src/lib/stores/notifications.svelte.ts | 128 +++++--- web/apps/hive/src/routes/(app)/+layout.svelte | 86 +++-- web/apps/hive/src/sw-runtime.test.ts | 38 +++ web/apps/hive/src/sw-runtime.ts | 50 ++- web/apps/hive/src/sw.ts | 3 + web/package.json | 1 + web/packages/shared/src/testing/setup.ts | 1 + .../src/testing/sveltekit-environment.ts | 9 + web/packages/shared/src/types.ts | 2 + web/pnpm-lock.yaml | 9 + web/vitest.config.ts | 9 + 19 files changed, 1043 insertions(+), 90 deletions(-) create mode 100644 web/apps/hive/src/lib/services/app-bootstrap.test.ts create mode 100644 web/apps/hive/src/lib/services/app-bootstrap.ts create mode 100644 web/apps/hive/src/lib/services/notifications-repository.test.ts create mode 100644 web/apps/hive/src/lib/stores/notifications.svelte.test.ts create mode 100644 web/packages/shared/src/testing/setup.ts create mode 100644 web/packages/shared/src/testing/sveltekit-environment.ts diff --git a/docs/HIVE_ONBOARDING.md b/docs/HIVE_ONBOARDING.md index d9179f3..48533a3 100644 --- a/docs/HIVE_ONBOARDING.md +++ b/docs/HIVE_ONBOARDING.md @@ -126,6 +126,12 @@ On startup, Hive checks whether an apparently paired device is still healthy: The health check lives in `reconcilePushState()` inside `web/apps/hive/src/lib/onboarding.svelte.ts`. It is used both during onboarding init and again when the paired app shell boots from `web/apps/hive/src/routes/(app)/+layout.svelte`. +## Notification History Scope + +Hive notification history is scoped to the current backend `device_id`. The paired app shell registers the service worker first, then confirms paired state and loads stored device credentials before activating the notification store, attaching service worker message listeners, or draining IndexedDB. Local notification history uses per-device browser storage keys, and the service worker includes the current `device_id` when it persists IndexedDB records or posts notification messages to open clients. + +If stored device credentials are missing, Hive keeps the app shell bootable so `reconcilePushState()` can surface reconnect-required recovery, but it does not activate notification history or import cached notification records. Legacy unscoped notification records in localStorage or IndexedDB are not imported into the active history. + ## Testing & Diagnostics The debug route `/debug` exists to verify browser behavior around: @@ -180,4 +186,5 @@ If you change any of the following, update the relevant section of this document - change capability checks in `capability.ts` - change install policy or `skipInstall` behavior in `install.ts` or `onboarding.svelte.ts` - change the pairing flow in `push.ts` or `encryption.ts` +- change notification history scoping, startup notification drains, or service worker notification message handling - add or remove files under `web/apps/hive/src/lib/services/` that affect onboarding, pairing, or diagnostics diff --git a/web/apps/hive/src/lib/onboarding.svelte.ts b/web/apps/hive/src/lib/onboarding.svelte.ts index 64ebdd6..62bc2f8 100644 --- a/web/apps/hive/src/lib/onboarding.svelte.ts +++ b/web/apps/hive/src/lib/onboarding.svelte.ts @@ -338,6 +338,7 @@ const createOnboarding = () => { ); await finalizePendingStoredKeyPair(deviceId); await deviceKeysRepository.storeDeviceCredentials(deviceId, deviceToken); + notificationsStore.activateDevice(deviceId); await paired.check(); await notificationsStore.loadFromIndexedDB(); diff --git a/web/apps/hive/src/lib/services/app-bootstrap.test.ts b/web/apps/hive/src/lib/services/app-bootstrap.test.ts new file mode 100644 index 0000000..f01e6c8 --- /dev/null +++ b/web/apps/hive/src/lib/services/app-bootstrap.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import { bootstrapAppShell } from './app-bootstrap'; + +describe('bootstrapAppShell', () => { + it('activates the current device before attaching the bridge and draining IndexedDB', async () => { + const calls: string[] = []; + const registration = { scope: '/' }; + + const result = await bootstrapAppShell({ + registerServiceWorker: () => { + calls.push('register'); + return Promise.resolve(registration); + }, + 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.resolve(); + }, + loadPersistedNotifications: (phase) => { + calls.push(`load:${phase}`); + return Promise.resolve(); + }, + runPostPairingChecks: () => { + calls.push('postChecks'); + return Promise.resolve(); + } + }); + + expect(result).toEqual({ registration, isPaired: true, deviceId: 'dev-a' }); + expect(calls).toEqual([ + 'register', + 'checkPaired', + 'getDeviceId', + 'activate:dev-a', + 'attach', + 'migrate:dev-a', + 'load:initial', + 'postChecks', + 'load:final' + ]); + }); + + it('skips notification activation and drains when the device is not paired', async () => { + const calls: string[] = []; + + const result = await bootstrapAppShell({ + registerServiceWorker: () => { + calls.push('register'); + return Promise.resolve({ scope: '/' }); + }, + checkPaired: () => { + calls.push('checkPaired'); + return Promise.resolve(false); + }, + 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.resolve(); + }, + loadPersistedNotifications: (phase) => { + calls.push(`load:${phase}`); + return Promise.resolve(); + }, + runPostPairingChecks: () => { + calls.push('postChecks'); + return Promise.resolve(); + } + }); + + expect(result.isPaired).toBe(false); + expect(result.deviceId).toBeNull(); + expect(calls).toEqual(['register', 'checkPaired']); + }); + + it('leaves notification history inactive when paired credentials are missing', async () => { + const calls: string[] = []; + + const result = await bootstrapAppShell({ + registerServiceWorker: () => { + calls.push('register'); + return Promise.resolve({ scope: '/' }); + }, + checkPaired: () => { + calls.push('checkPaired'); + return Promise.resolve(true); + }, + getDeviceId: () => { + calls.push('getDeviceId'); + return Promise.resolve(null); + }, + activateNotifications: (deviceId) => { + calls.push(`activate:${deviceId}`); + }, + attachServiceWorkerListeners: () => { + calls.push('attach'); + }, + migrateLegacyNotifications: (deviceId) => { + calls.push(`migrate:${deviceId}`); + return Promise.resolve(); + }, + loadPersistedNotifications: (phase) => { + calls.push(`load:${phase}`); + return Promise.resolve(); + }, + runPostPairingChecks: () => { + calls.push('postChecks'); + return Promise.resolve(); + } + }); + + expect(result).toEqual({ registration: { scope: '/' }, isPaired: true, deviceId: null }); + expect(calls).toEqual(['register', 'checkPaired', 'getDeviceId']); + }); + + it('skips the final drain when post-pairing checks fail but keeps the listener attached', async () => { + const calls: string[] = []; + const failure = new Error('post-checks 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.resolve(); + }, + loadPersistedNotifications: (phase) => { + calls.push(`load:${phase}`); + return Promise.resolve(); + }, + runPostPairingChecks: () => { + calls.push('postChecks'); + return Promise.reject(failure); + } + }) + ).rejects.toBe(failure); + + expect(calls).toEqual([ + 'register', + 'checkPaired', + 'getDeviceId', + 'activate:dev-a', + 'attach', + 'migrate:dev-a', + 'load:initial', + 'postChecks' + ]); + }); +}); diff --git a/web/apps/hive/src/lib/services/app-bootstrap.ts b/web/apps/hive/src/lib/services/app-bootstrap.ts new file mode 100644 index 0000000..b8ca641 --- /dev/null +++ b/web/apps/hive/src/lib/services/app-bootstrap.ts @@ -0,0 +1,62 @@ +export interface AppBootstrapDeps { + registerServiceWorker: () => Promise; + checkPaired: () => Promise; + getDeviceId: () => Promise; + activateNotifications: (deviceId: string) => void; + attachServiceWorkerListeners: () => void; + migrateLegacyNotifications: (deviceId: string) => Promise; + loadPersistedNotifications: (phase: 'initial' | 'final') => Promise; + runPostPairingChecks: (registration: TRegistration) => Promise; +} + +export interface AppBootstrapResult { + registration: TRegistration; + isPaired: boolean; + deviceId: string | null; +} + +/** + * Boots the Hive app shell after resolving the current backend device identity. + * + * Invariants enforced by this function: + * 1. Paired state and stored device credentials are checked before any local + * notification history is loaded. + * 2. Notification history is activated for the current backend device ID before + * the service worker bridge is attached or IndexedDB is drained. + * 3. Legacy IndexedDB records (created before per-device scoping) are stamped + * with the current deviceId or removed if they belong to a different device. + * 4. A final drain runs after slower paired-device checks, closing the residual + * window before polling starts. If those checks reject, the final drain is + * skipped and the caller owns listener teardown. + * + * Early-return contracts: + * - `checkPaired` resolves to `false`: no further work runs and the caller is + * expected to redirect to the pairing flow. + * - `getDeviceId` resolves to `null` while paired: notification activation, + * listener attach, and IndexedDB drains are all skipped. The caller owns + * recovery (typically through `reconcilePushState`). + */ +export async function bootstrapAppShell( + deps: AppBootstrapDeps +): Promise> { + const registration = await deps.registerServiceWorker(); + + const isPaired = await deps.checkPaired(); + if (!isPaired) { + return { registration, isPaired, deviceId: null }; + } + + const deviceId = await deps.getDeviceId(); + if (!deviceId) { + return { registration, isPaired, deviceId: null }; + } + + deps.activateNotifications(deviceId); + deps.attachServiceWorkerListeners(); + await deps.migrateLegacyNotifications(deviceId); + await deps.loadPersistedNotifications('initial'); + await deps.runPostPairingChecks(registration); + await deps.loadPersistedNotifications('final'); + + return { registration, isPaired, deviceId }; +} diff --git a/web/apps/hive/src/lib/services/hive-db.ts b/web/apps/hive/src/lib/services/hive-db.ts index 740ac15..a7462b2 100644 --- a/web/apps/hive/src/lib/services/hive-db.ts +++ b/web/apps/hive/src/lib/services/hive-db.ts @@ -1,6 +1,7 @@ export const HIVE_DB_NAME = 'BeeBuzz'; -export const HIVE_DB_VERSION = 1; +export const HIVE_DB_VERSION = 2; export const NOTIFICATIONS_STORE = 'notifications'; +export const NOTIFICATIONS_BY_DEVICE_INDEX = 'by-device'; export const ENCRYPTION_METADATA_STORE = 'encryption_keys'; export const WRAPPING_KEY_STORE = 'wrapping_keys'; export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys'; @@ -9,12 +10,25 @@ export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys'; export function openHiveDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(HIVE_DB_NAME, HIVE_DB_VERSION); - request.onupgradeneeded = () => { + request.onupgradeneeded = (event) => { const db = request.result; + const oldVersion = event.oldVersion; - if (!db.objectStoreNames.contains(NOTIFICATIONS_STORE)) { - db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' }); + // First-time install: create the notifications store with the by-device index. + if (oldVersion < 1) { + const notifications = db.createObjectStore(NOTIFICATIONS_STORE, { keyPath: 'id' }); + notifications.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId'); } + + // 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)) { + const notifications = request.transaction?.objectStore(NOTIFICATIONS_STORE); + notifications?.createIndex(NOTIFICATIONS_BY_DEVICE_INDEX, 'deviceId'); + } + } + if (!db.objectStoreNames.contains(ENCRYPTION_METADATA_STORE)) { db.createObjectStore(ENCRYPTION_METADATA_STORE, { keyPath: 'id' }); } diff --git a/web/apps/hive/src/lib/services/notifications-repository.test.ts b/web/apps/hive/src/lib/services/notifications-repository.test.ts new file mode 100644 index 0000000..4a65572 --- /dev/null +++ b/web/apps/hive/src/lib/services/notifications-repository.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { NOTIFICATIONS_STORE, openHiveDB } from './hive-db'; +import { notificationsRepository, type StoredNotificationRecord } from './notifications-repository'; + +async function clearNotificationsStore(): Promise { + const db = await openHiveDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); + const request = tx.objectStore(NOTIFICATIONS_STORE).clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(request.error?.message ?? 'Clear failed')); + }); +} + +describe('notificationsRepository', () => { + beforeEach(async () => { + await clearNotificationsStore(); + }); + + it('saves records with a device ID', async () => { + const record: StoredNotificationRecord = { + id: 'n-a', + deviceId: 'dev-a', + title: 'Door', + body: 'Opened', + topic: 'alerts', + sentAt: '2026-04-20T09:00:00.000Z' + }; + + await notificationsRepository.save(record); + const list = await notificationsRepository.listByDevice('dev-a'); + + expect(list).toEqual([record]); + }); + + it('lists only records for the requested device', async () => { + await notificationsRepository.save({ + id: 'n-a', + deviceId: 'dev-a', + title: 'Door', + body: 'Opened', + topic: 'alerts', + sentAt: '2026-04-20T09:00:00.000Z' + }); + await notificationsRepository.save({ + id: 'n-b', + deviceId: 'dev-b', + title: 'Window', + body: 'Closed', + topic: 'alerts', + sentAt: '2026-04-20T10:00:00.000Z' + }); + + const list = await notificationsRepository.listByDevice('dev-a'); + + expect(list).toHaveLength(1); + expect(list[0].id).toBe('n-a'); + }); + + it('deletes only the imported IDs requested by the caller', async () => { + await notificationsRepository.save({ + id: 'n-a', + deviceId: 'dev-a', + title: 'Door', + body: 'Opened', + topic: 'alerts', + sentAt: '2026-04-20T09:00:00.000Z' + }); + await notificationsRepository.save({ + id: 'n-b', + deviceId: 'dev-b', + title: 'Window', + body: 'Closed', + topic: 'alerts', + sentAt: '2026-04-20T10:00:00.000Z' + }); + + await notificationsRepository.deleteMany(['n-a']); + + const devAList = await notificationsRepository.listByDevice('dev-a'); + const devBList = await notificationsRepository.listByDevice('dev-b'); + + expect(devAList).toHaveLength(0); + expect(devBList).toHaveLength(1); + }); + + it('migrates legacy records without deviceId and removes foreign ones', async () => { + // Seed a legacy record (no deviceId) directly via raw IndexedDB. + const db = await openHiveDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); + const store = tx.objectStore(NOTIFICATIONS_STORE); + store.put({ + id: 'n-legacy', + title: 'Legacy', + body: 'No deviceId', + topic: 'alerts', + sentAt: '2026-04-20T08:00:00.000Z' + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(tx.error?.message ?? 'Seed failed')); + }); + + await notificationsRepository.save({ + id: 'n-current', + deviceId: 'dev-a', + title: 'Current', + body: 'Already scoped', + topic: 'alerts', + sentAt: '2026-04-20T09:00:00.000Z' + }); + await notificationsRepository.save({ + id: 'n-foreign', + deviceId: 'dev-b', + title: 'Foreign', + body: 'Wrong device', + topic: 'alerts', + sentAt: '2026-04-20T10:00:00.000Z' + }); + + await notificationsRepository.migrateLegacyNotifications('dev-a'); + + const devAList = await notificationsRepository.listByDevice('dev-a'); + const devBList = await notificationsRepository.listByDevice('dev-b'); + + expect(devAList.map((r) => r.id).sort()).toEqual(['n-current', 'n-legacy']); + expect(devBList).toHaveLength(0); + }); + + it('normalizes snake_case fields during legacy migration', async () => { + const db = await openHiveDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); + const store = tx.objectStore(NOTIFICATIONS_STORE); + store.put({ + id: 'n-snake', + title: 'Snake', + body: 'Case', + topic: 'alerts', + sent_at: '2026-04-20T08:00:00.000Z', + topic_id: 't-1' + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(new Error(tx.error?.message ?? 'Seed failed')); + }); + + await notificationsRepository.migrateLegacyNotifications('dev-a'); + + const list = await notificationsRepository.listByDevice('dev-a'); + expect(list).toHaveLength(1); + const migrated = list[0] as unknown as Record; + expect(migrated.sentAt).toBe('2026-04-20T08:00:00.000Z'); + expect(migrated.sent_at).toBeUndefined(); + expect(migrated.topicId).toBe('t-1'); + expect(migrated.topic_id).toBeUndefined(); + }); +}); diff --git a/web/apps/hive/src/lib/services/notifications-repository.ts b/web/apps/hive/src/lib/services/notifications-repository.ts index 29cdb94..25db661 100644 --- a/web/apps/hive/src/lib/services/notifications-repository.ts +++ b/web/apps/hive/src/lib/services/notifications-repository.ts @@ -1,7 +1,8 @@ -import { NOTIFICATIONS_STORE, openHiveDB } from './hive-db'; +import { NOTIFICATIONS_BY_DEVICE_INDEX, NOTIFICATIONS_STORE, openHiveDB } from './hive-db'; export interface StoredNotificationRecord { id: string; + deviceId: string; title: string; body: string; topic: string; @@ -11,6 +12,22 @@ export interface StoredNotificationRecord { priority?: string; } +/** + * Normalizes a legacy record by converting snake_case fields to camelCase. + */ +function normalizeLegacyRecord(record: Record): Record { + const normalized: Record = { ...record }; + if ('sent_at' in record && !('sentAt' in record)) { + normalized.sentAt = record.sent_at; + delete normalized.sent_at; + } + if ('topic_id' in record && !('topicId' in record)) { + normalized.topicId = record.topic_id; + delete normalized.topic_id; + } + return normalized; +} + export const notificationsRepository = { /** Persists one notification record to IndexedDB. */ async save(input: StoredNotificationRecord): Promise { @@ -26,14 +43,15 @@ export const notificationsRepository = { }); }, - /** Loads all persisted notification records from IndexedDB. */ - async list(): Promise[]> { + /** Loads persisted notification records for one backend device ID. */ + async listByDevice(deviceId: string): Promise { const db = await openHiveDB(); return new Promise((resolve, reject) => { const tx = db.transaction(NOTIFICATIONS_STORE, 'readonly'); - const request = tx.objectStore(NOTIFICATIONS_STORE).getAll(); - request.onsuccess = () => resolve(request.result as Record[]); + const index = tx.objectStore(NOTIFICATIONS_STORE).index(NOTIFICATIONS_BY_DEVICE_INDEX); + const request = index.getAll(deviceId); + request.onsuccess = () => resolve(request.result as StoredNotificationRecord[]); request.onerror = () => reject(new Error(request.error?.message ?? 'Notifications fetch failed')); }); @@ -56,5 +74,39 @@ export const notificationsRepository = { tx.oncomplete = () => resolve(); tx.onerror = () => reject(new Error(tx.error?.message ?? 'Notifications delete failed')); }); + }, + + /** + * Migrates legacy notification records that lack a deviceId. + * + * - Records without a deviceId are stamped with the current deviceId and + * re-saved so they become queryable via the by-device index. + * - Records that already belong to a different deviceId are deleted. + */ + async migrateLegacyNotifications(deviceId: string): Promise { + const db = await openHiveDB(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); + const store = tx.objectStore(NOTIFICATIONS_STORE); + const request = store.getAll(); + + request.onsuccess = () => { + const records = request.result as Array>; + for (const record of records) { + const recordDeviceId = record.deviceId; + if (typeof recordDeviceId !== 'string') { + // Legacy record: normalize fields and stamp with current deviceId. + store.put({ ...normalizeLegacyRecord(record), deviceId }); + } else if (recordDeviceId !== deviceId) { + // Belongs to a different device: remove. + store.delete(record.id as string); + } + } + }; + tx.oncomplete = () => resolve(); + tx.onerror = () => + reject(new Error(tx.error?.message ?? 'Legacy migration transaction failed')); + }); } }; diff --git a/web/apps/hive/src/lib/stores/notifications.svelte.test.ts b/web/apps/hive/src/lib/stores/notifications.svelte.test.ts new file mode 100644 index 0000000..eef3529 --- /dev/null +++ b/web/apps/hive/src/lib/stores/notifications.svelte.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NOTIFICATIONS_STORE, openHiveDB } from '$lib/services/hive-db'; +import { notificationsRepository } from '$lib/services/notifications-repository'; + +async function loadStore() { + vi.resetModules(); + return import('./notifications.svelte'); +} + +async function clearNotificationsStore(): Promise { + const db = await openHiveDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(NOTIFICATIONS_STORE, 'readwrite'); + const request = tx.objectStore(NOTIFICATIONS_STORE).clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(request.error?.message ?? 'Clear failed')); + }); +} + +describe('notificationsStore', () => { + beforeEach(async () => { + localStorage.clear(); + await clearNotificationsStore(); + }); + + it('does not hydrate localStorage at module load', async () => { + localStorage.setItem( + 'notifications:dev-a', + JSON.stringify([ + { + id: 'n-a', + title: 'Door', + body: 'Opened', + sentAt: '2026-04-20T09:00:00.000Z' + } + ]) + ); + + const { notificationsStore } = await loadStore(); + + expect(notificationsStore.activeDeviceId).toBeNull(); + expect(notificationsStore.list).toEqual([]); + }); + + it('activates and loads only the selected device history', async () => { + localStorage.setItem( + 'notifications:dev-a', + JSON.stringify([ + { + id: 'n-a', + title: 'Door', + body: 'Opened', + sentAt: '2026-04-20T09:00:00.000Z' + } + ]) + ); + + const { notificationsStore } = await loadStore(); + + notificationsStore.activateDevice('dev-a'); + expect(notificationsStore.list.map((notification) => notification.id)).toEqual(['n-a']); + + // Re-seed dev-b because activating dev-a garbage-collected stale entries. + localStorage.setItem( + 'notifications:dev-b', + JSON.stringify([ + { + id: 'n-b', + title: 'Window', + body: 'Closed', + sentAt: '2026-04-20T10:00:00.000Z' + } + ]) + ); + notificationsStore.activateDevice('dev-b'); + expect(notificationsStore.list.map((notification) => notification.id)).toEqual(['n-b']); + }); + + it('persists new notifications only under the active device key', async () => { + const { notificationsStore } = await loadStore(); + + notificationsStore.add( + 'Ignored', + 'No device', + null, + null, + '2026-04-20T08:00:00.000Z', + undefined, + 'normal', + 'n-ignored' + ); + expect(localStorage.getItem('notifications')).toBeNull(); + + notificationsStore.activateDevice('dev-a'); + notificationsStore.add( + 'Door', + 'Opened', + null, + null, + '2026-04-20T09:00:00.000Z', + undefined, + 'high', + 'n-a' + ); + + expect(localStorage.getItem('notifications')).toBeNull(); + expect(localStorage.getItem('notifications:dev-b')).toBeNull(); + expect(JSON.parse(localStorage.getItem('notifications:dev-a') ?? '[]')).toEqual([ + expect.objectContaining({ id: 'n-a', title: 'Door' }) + ]); + }); + + it('clears in-memory state on deactivate without touching localStorage', async () => { + const { notificationsStore } = await loadStore(); + + notificationsStore.activateDevice('dev-a'); + notificationsStore.add( + 'Door', + 'Opened', + null, + null, + '2026-04-20T09:00:00.000Z', + undefined, + 'normal', + 'n-a' + ); + expect(notificationsStore.list).toHaveLength(1); + expect(notificationsStore.unreadCount).toBe(1); + + notificationsStore.deactivateDevice(); + + expect(notificationsStore.activeDeviceId).toBeNull(); + expect(notificationsStore.list).toEqual([]); + expect(notificationsStore.unreadCount).toBe(0); + // localStorage retains the per-device payload so a later activate restores history. + expect(JSON.parse(localStorage.getItem('notifications:dev-a') ?? '[]')).toHaveLength(1); + }); + + it('drops unread state when switching the active device', async () => { + const { notificationsStore } = await loadStore(); + + notificationsStore.activateDevice('dev-a'); + notificationsStore.add( + 'A', + '', + null, + null, + '2026-04-20T09:00:00.000Z', + undefined, + 'normal', + 'n-a' + ); + expect(notificationsStore.unreadCount).toBe(1); + + notificationsStore.activateDevice('dev-b'); + + expect(notificationsStore.list).toEqual([]); + expect(notificationsStore.unreadCount).toBe(0); + }); + + it('ignores mutating operations while no device is active', async () => { + const { notificationsStore } = await loadStore(); + + notificationsStore.add( + 'Ignored', + '', + null, + null, + '2026-04-20T09:00:00.000Z', + undefined, + 'normal', + 'n-orphan' + ); + notificationsStore.markAsRead('n-orphan'); + notificationsStore.markAsUnread('n-orphan'); + notificationsStore.remove('n-orphan'); + notificationsStore.removeMany(['n-orphan']); + notificationsStore.clearAll(); + + expect(notificationsStore.list).toEqual([]); + expect(notificationsStore.unreadCount).toBe(0); + expect(localStorage.length).toBe(0); + }); + + it('removes stale localStorage entries for other devices on activate', async () => { + localStorage.setItem( + 'notifications:dev-a', + JSON.stringify([ + { + id: 'n-a', + title: 'Door', + body: 'Opened', + sentAt: '2026-04-20T09:00:00.000Z' + } + ]) + ); + localStorage.setItem('notifications_read_ids:dev-a', JSON.stringify(['n-a'])); + localStorage.setItem( + 'notifications:dev-b', + JSON.stringify([ + { + id: 'n-b', + title: 'Window', + body: 'Closed', + sentAt: '2026-04-20T10:00:00.000Z' + } + ]) + ); + localStorage.setItem('notifications_read_ids:dev-b', JSON.stringify(['n-b'])); + + const { notificationsStore } = await loadStore(); + notificationsStore.activateDevice('dev-a'); + + expect(localStorage.getItem('notifications:dev-a')).not.toBeNull(); + expect(localStorage.getItem('notifications_read_ids:dev-a')).not.toBeNull(); + expect(localStorage.getItem('notifications:dev-b')).toBeNull(); + expect(localStorage.getItem('notifications_read_ids:dev-b')).toBeNull(); + }); + + it('removes legacy unscoped localStorage keys on activate', async () => { + localStorage.setItem( + 'notifications', + JSON.stringify([ + { + id: 'n-legacy', + title: 'Old', + body: 'Unscoped', + sentAt: '2026-04-20T07:00:00.000Z' + } + ]) + ); + localStorage.setItem('notifications_read_ids', JSON.stringify(['n-legacy'])); + + const { notificationsStore } = await loadStore(); + notificationsStore.activateDevice('dev-a'); + + expect(localStorage.getItem('notifications')).toBeNull(); + expect(localStorage.getItem('notifications_read_ids')).toBeNull(); + }); + + it('does not reset state when activating the already-active device', async () => { + const { notificationsStore } = await loadStore(); + + notificationsStore.activateDevice('dev-a'); + notificationsStore.add( + 'Door', + 'Opened', + null, + null, + '2026-04-20T09:00:00.000Z', + undefined, + 'normal', + 'n-a' + ); + expect(notificationsStore.list).toHaveLength(1); + expect(notificationsStore.unreadCount).toBe(1); + + notificationsStore.markAsRead('n-a'); + expect(notificationsStore.unreadCount).toBe(0); + + // Re-activating the same device must not reload from localStorage. + notificationsStore.activateDevice('dev-a'); + expect(notificationsStore.list).toHaveLength(1); + expect(notificationsStore.unreadCount).toBe(0); + }); + + it('imports and deletes only IndexedDB records for the active device', async () => { + await notificationsRepository.save({ + id: 'n-a', + deviceId: 'dev-a', + title: 'Door', + body: 'Opened', + topic: 'alerts', + sentAt: '2026-04-20T09:00:00.000Z' + }); + await notificationsRepository.save({ + id: 'n-b', + deviceId: 'dev-b', + title: 'Window', + body: 'Closed', + topic: 'alerts', + sentAt: '2026-04-20T10:00:00.000Z' + }); + + const { notificationsStore } = await loadStore(); + + await notificationsStore.loadFromIndexedDB(); + expect(notificationsStore.list).toEqual([]); + + notificationsStore.activateDevice('dev-a'); + await notificationsStore.loadFromIndexedDB(); + + expect(notificationsStore.list.map((notification) => notification.id)).toEqual(['n-a']); + + // Verify record was deleted from IndexedDB after import. + const remaining = await notificationsRepository.listByDevice('dev-a'); + expect(remaining).toHaveLength(0); + }); +}); diff --git a/web/apps/hive/src/lib/stores/notifications.svelte.ts b/web/apps/hive/src/lib/stores/notifications.svelte.ts index 12da519..1189fa4 100644 --- a/web/apps/hive/src/lib/stores/notifications.svelte.ts +++ b/web/apps/hive/src/lib/stores/notifications.svelte.ts @@ -3,8 +3,8 @@ import { notificationsRepository } from '$lib/services/notifications-repository' import { SvelteSet } from 'svelte/reactivity'; import type { Notification, NotificationPriority } from '@beebuzz/shared/types'; -const STORAGE_KEY = 'notifications'; -const READ_IDS_KEY = 'notifications_read_ids'; +const STORAGE_KEY_PREFIX = 'notifications:'; +const READ_IDS_KEY_PREFIX = 'notifications_read_ids:'; export type TopicSummary = { name: string; count: number; @@ -50,49 +50,48 @@ function computeTopicSummaries( function createNotificationsStore() { let notifications = $state([]); + let activeDeviceId = $state(null); const unreadIds = new SvelteSet(); - function parseStoredNotification( - record: Record, - sentAtField: 'sentAt' | 'sent_at' - ): Notification | null { - const rawSentAt = record[sentAtField]; + function parseStoredNotification(record: unknown): Notification | null { + if (!record || typeof record !== 'object') return null; + const r = record as Record; if ( - typeof record.id !== 'string' || - typeof record.title !== 'string' || - typeof record.body !== 'string' || - typeof rawSentAt !== 'string' + typeof r.id !== 'string' || + typeof r.title !== 'string' || + typeof r.body !== 'string' || + typeof r.sentAt !== 'string' ) { return null; } - const sentAt = new Date(rawSentAt); + const sentAt = new Date(r.sentAt); if (Number.isNaN(sentAt.getTime())) { return null; } return { - id: record.id, - title: record.title, - body: record.body, - topicId: (record.topicId as string | null) ?? (record.topic_id as string | null) ?? null, - topic: (record.topic as string | null) ?? null, + id: r.id, + title: r.title, + body: r.body, + topicId: (r.topicId as string | null) ?? null, + topic: (r.topic as string | null) ?? null, sentAt, - priority: (record.priority as NotificationPriority) ?? 'normal', - attachment: record.attachment as Notification['attachment'] + priority: (r.priority as NotificationPriority) ?? 'normal', + attachment: r.attachment as Notification['attachment'] }; } /** Persists read IDs to localStorage. */ function saveReadIds() { - if (!browser) return; + if (!browser || !activeDeviceId) return; const readArray = notifications.map((n) => n.id).filter((id) => !unreadIds.has(id)); - localStorage.setItem(READ_IDS_KEY, JSON.stringify(readArray)); + localStorage.setItem(`${READ_IDS_KEY_PREFIX}${activeDeviceId}`, JSON.stringify(readArray)); } function save() { - if (!browser) return; + if (!browser || !activeDeviceId) return; const toSave = notifications.map((n) => ({ id: n.id, title: n.title, @@ -103,13 +102,15 @@ function createNotificationsStore() { priority: n.priority, attachment: n.attachment })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${activeDeviceId}`, JSON.stringify(toSave)); saveReadIds(); } - function load() { - if (!browser) return; - const saved = localStorage.getItem(STORAGE_KEY); + function loadForActiveDevice() { + if (!browser || !activeDeviceId) return; + const saved = localStorage.getItem(`${STORAGE_KEY_PREFIX}${activeDeviceId}`); + notifications = []; + unreadIds.clear(); if (saved) { try { const parsed: unknown = JSON.parse(saved); @@ -118,14 +119,14 @@ function createNotificationsStore() { return; } notifications = parsed - .map((n: unknown) => parseStoredNotification(n as Record, 'sentAt')) + .map((n) => parseStoredNotification(n)) .filter((n): n is Notification => n !== null); // Restore read state: only mark as unread those not in the persisted read list unreadIds.clear(); // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temp set inside load, not reactive state const readSet = new Set(); - const savedReadIds = localStorage.getItem(READ_IDS_KEY); + const savedReadIds = localStorage.getItem(`${READ_IDS_KEY_PREFIX}${activeDeviceId}`); if (savedReadIds) { try { const readArray: unknown = JSON.parse(savedReadIds); @@ -143,27 +144,68 @@ function createNotificationsStore() { } } catch { notifications = []; + unreadIds.clear(); } } } + /** Removes localStorage entries belonging to devices other than the active one. */ + function removeStaleLocalStorage() { + if (!browser || !activeDeviceId) return; + const activeStorageKey = `${STORAGE_KEY_PREFIX}${activeDeviceId}`; + const activeReadKey = `${READ_IDS_KEY_PREFIX}${activeDeviceId}`; + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key) continue; + if ( + (key.startsWith(STORAGE_KEY_PREFIX) || key.startsWith(READ_IDS_KEY_PREFIX)) && + key !== activeStorageKey && + key !== activeReadKey + ) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + // One-shot cleanup of legacy unscoped keys (pre-device-scoping). + localStorage.removeItem('notifications'); + localStorage.removeItem('notifications_read_ids'); + } + + function activateDevice(deviceId: string) { + if (activeDeviceId === deviceId) return; + activeDeviceId = deviceId; + removeStaleLocalStorage(); + loadForActiveDevice(); + } + + function deactivateDevice() { + activeDeviceId = null; + notifications = []; + unreadIds.clear(); + } + async function loadFromIndexedDB(): Promise { - if (!browser) return; + if (!browser || !activeDeviceId) return; + const deviceId = activeDeviceId; return new Promise((resolve) => { try { void notificationsRepository - .list() + .listByDevice(deviceId) .then((records) => { + if (activeDeviceId !== deviceId) { + resolve(); + return; + } + const importedIds: string[] = []; const idbNotifications: Notification[] = []; for (const record of records) { - if (typeof record.id !== 'string') { - continue; - } - - const parsed = parseStoredNotification(record, 'sentAt'); + const parsed = parseStoredNotification(record); if (!parsed) { console.error( '[NotificationsStore] Skipped malformed IndexedDB notification record', @@ -211,6 +253,7 @@ function createNotificationsStore() { priority?: string, id?: string ) { + if (!activeDeviceId) return; if (!id) return; if (notifications.some((n) => n.id === id)) return; @@ -237,6 +280,7 @@ function createNotificationsStore() { } function remove(id: string) { + if (!activeDeviceId) return; notifications = notifications.filter((n) => n.id !== id); unreadIds.delete(id); save(); @@ -244,6 +288,7 @@ function createNotificationsStore() { /** Removes multiple notifications in one pass. */ function removeMany(ids: string[]) { + if (!activeDeviceId) return; if (ids.length === 0) return; const idSet = new Set(ids); @@ -255,23 +300,27 @@ function createNotificationsStore() { } function clearAll() { + if (!activeDeviceId) return; notifications = []; unreadIds.clear(); save(); } function markAsRead(id: string) { + if (!activeDeviceId) return; unreadIds.delete(id); saveReadIds(); } function markAsUnread(id: string) { + if (!activeDeviceId) return; unreadIds.add(id); saveReadIds(); } /** Marks the provided notifications as read in one pass. */ function markManyAsRead(ids: string[]) { + if (!activeDeviceId) return; for (const id of ids) { unreadIds.delete(id); } @@ -280,16 +329,17 @@ function createNotificationsStore() { /** Marks the provided notifications as unread in one pass. */ function markManyAsUnread(ids: string[]) { + if (!activeDeviceId) return; for (const id of ids) { unreadIds.add(id); } saveReadIds(); } - // Load initial data - load(); - return { + get activeDeviceId() { + return activeDeviceId; + }, get list() { return notifications; }, @@ -316,6 +366,8 @@ function createNotificationsStore() { markAsUnread, markManyAsRead, markManyAsUnread, + activateDevice, + deactivateDevice, loadFromIndexedDB }; } diff --git a/web/apps/hive/src/routes/(app)/+layout.svelte b/web/apps/hive/src/routes/(app)/+layout.svelte index 79ba85f..3cc6db1 100644 --- a/web/apps/hive/src/routes/(app)/+layout.svelte +++ b/web/apps/hive/src/routes/(app)/+layout.svelte @@ -10,6 +10,9 @@ import { paired } from '$lib/stores/paired.svelte'; import { notificationsStore } from '$lib/stores/notifications.svelte'; import { getVapidKey, registerServiceWorker } from '$lib/services/push'; + import { deviceKeysRepository } from '$lib/services/device-keys-repository'; + import { notificationsRepository } from '$lib/services/notifications-repository'; + import { bootstrapAppShell } from '$lib/services/app-bootstrap'; import { cleanupStalePairingState } from '$lib/services/startup-recovery'; import { formatStartupError } from '$lib/services/startup-error'; import { @@ -64,6 +67,7 @@ /** Handles messages from the service worker. */ const handleServiceWorkerMessage = (event: MessageEvent) => { if (event.data?.type === 'PUSH_RECEIVED') { + if (event.data.deviceId !== notificationsStore.activeDeviceId) return; notificationsStore.add( event.data.title, event.data.body, @@ -76,6 +80,7 @@ ); } else if (event.data?.type === 'NOTIFICATION_CLICKED') { const clickedNotification = event.data.notification; + if (clickedNotification?.deviceId !== notificationsStore.activeDeviceId) return; if ( clickedNotification?.id && typeof clickedNotification.title === 'string' && @@ -207,6 +212,7 @@ startupError = null; ready = false; stopPolling(); + notificationsStore.deactivateDevice(); if (swMessageListener) { navigator.serviceWorker.removeEventListener('message', swMessageListener); swMessageListener = null; @@ -217,14 +223,57 @@ ); try { - const registration = await withTimeout( - registerServiceWorker(), - STARTUP_TIMEOUT_MS, - 'Service worker registration' - ); - - // Check paired state (push subscription + encryption key) - const isPaired = await withTimeout(paired.check(), STARTUP_TIMEOUT_MS, 'Paired device check'); + // The bootstrap order matters: paired state and credentials determine the + // notification history scope, so no local notification cache is loaded until + // the current backend device ID is known. + const { isPaired } = await bootstrapAppShell({ + registerServiceWorker: () => + withTimeout(registerServiceWorker(), STARTUP_TIMEOUT_MS, 'Service worker registration'), + checkPaired: () => withTimeout(paired.check(), STARTUP_TIMEOUT_MS, 'Paired device check'), + getDeviceId: async () => { + const credentials = await withTimeout( + deviceKeysRepository.getDeviceCredentials(), + STARTUP_TIMEOUT_MS, + 'Device credentials load' + ); + return credentials?.deviceId ?? null; + }, + activateNotifications: (deviceId) => { + notificationsStore.activateDevice(deviceId); + }, + attachServiceWorkerListeners: () => { + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); + swMessageListener = handleServiceWorkerMessage; + hasSeenController = navigator.serviceWorker.controller !== null; + navigator.serviceWorker.addEventListener( + 'controllerchange', + handleServiceWorkerControllerChange + ); + }, + migrateLegacyNotifications: (deviceId) => + withTimeout( + notificationsRepository.migrateLegacyNotifications(deviceId), + STARTUP_TIMEOUT_MS, + 'Legacy notification migration' + ), + loadPersistedNotifications: (phase) => + withTimeout( + notificationsStore.loadFromIndexedDB(), + STARTUP_TIMEOUT_MS, + phase === 'initial' + ? 'Initial notification cache load' + : 'Final notification cache load' + ), + runPostPairingChecks: async (registration) => { + await withTimeout(getVapidKey(), STARTUP_TIMEOUT_MS, 'VAPID key fetch'); + if (health.status === 'unknown' && !health.loading) { + await withTimeout(health.check(), STARTUP_TIMEOUT_MS, 'Health check'); + } + watchServiceWorkerRegistration(registration); + await withTimeout(registration.update(), STARTUP_TIMEOUT_MS, 'Service worker update'); + syncWaitingWorker(registration); + } + }); if (!isPaired) { await cleanupStalePairingState(); @@ -256,27 +305,6 @@ return; } - navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); - swMessageListener = handleServiceWorkerMessage; - hasSeenController = navigator.serviceWorker.controller !== null; - navigator.serviceWorker.addEventListener( - 'controllerchange', - handleServiceWorkerControllerChange - ); - - await withTimeout(getVapidKey(), STARTUP_TIMEOUT_MS, 'VAPID key fetch'); - if (health.status === 'unknown' && !health.loading) { - await withTimeout(health.check(), STARTUP_TIMEOUT_MS, 'Health check'); - } - watchServiceWorkerRegistration(registration); - await withTimeout(registration.update(), STARTUP_TIMEOUT_MS, 'Service worker update'); - syncWaitingWorker(registration); - await withTimeout( - notificationsStore.loadFromIndexedDB(), - STARTUP_TIMEOUT_MS, - 'Notification cache load' - ); - ready = true; startPolling(); } catch (error: unknown) { diff --git a/web/apps/hive/src/sw-runtime.test.ts b/web/apps/hive/src/sw-runtime.test.ts index 03db904..2850fca 100644 --- a/web/apps/hive/src/sw-runtime.test.ts +++ b/web/apps/hive/src/sw-runtime.test.ts @@ -52,6 +52,7 @@ function createDeps(overrides: Partial = {}): ServiceW claimClients: vi.fn(() => Promise.resolve()), skipWaiting: vi.fn(() => Promise.resolve()), getPushSubscription: vi.fn(() => Promise.resolve(null)), + getDeviceCredentials: vi.fn(() => Promise.resolve({ deviceId: 'dev-a' })), decryptPayload: vi.fn(() => Promise.resolve('')), fetch: vi.fn(() => Promise.resolve(new Response('{}', { status: 200 }))), ...overrides @@ -92,6 +93,7 @@ describe('service worker runtime', () => { expect(order).toEqual(['save', 'show']); expect(deps.saveNotification).toHaveBeenCalledWith({ id: 'n-1', + deviceId: 'dev-a', title: 'Door', body: 'Front door opened', topic: 'alerts', @@ -142,6 +144,7 @@ describe('service worker runtime', () => { expect(healthyClient.postMessage).toHaveBeenCalledWith({ type: 'PUSH_RECEIVED', id: 'n-2', + deviceId: 'dev-a', title: 'Motion', body: 'Garage motion detected', topicId: undefined, @@ -166,6 +169,7 @@ describe('service worker runtime', () => { }); const event = createNotificationClickEvent({ id: 'n-3', + deviceId: 'dev-a', title: 'Bell', body: 'Someone rang the bell', topic: 'door', @@ -183,6 +187,7 @@ describe('service worker runtime', () => { type: 'NOTIFICATION_CLICKED', notification: { id: 'n-3', + deviceId: 'dev-a', title: 'Bell', body: 'Someone rang the bell', topic: 'door', @@ -208,6 +213,7 @@ describe('service worker runtime', () => { deps, createNotificationClickEvent({ id: 'n-4', + deviceId: 'dev-a', title: 'Alarm', body: 'Window opened', sentAt: '2026-04-20T12:00:00.000Z' @@ -219,6 +225,7 @@ describe('service worker runtime', () => { type: 'NOTIFICATION_CLICKED', notification: { id: 'n-4', + deviceId: 'dev-a', title: 'Alarm', body: 'Window opened', topic: null, @@ -290,6 +297,36 @@ describe('service worker runtime', () => { consoleSpy.mockRestore(); }); + it('shows the OS notification without importable UI history when credentials are missing', async () => { + const client = { url: 'https://hive.beebuzz.test/inbox', postMessage: vi.fn() }; + const deps = createDeps({ + getDeviceCredentials: vi.fn(() => Promise.resolve(null)), + matchWindowClients: vi.fn(() => Promise.resolve([client])) + }); + + await handlePushEvent( + deps, + createPushEvent({ + id: 'n-no-credentials', + title: 'Sensor', + body: 'Temperature alert', + sent_at: '2026-04-20T13:10:00.000Z' + }) + ); + + expect(deps.saveNotification).not.toHaveBeenCalled(); + const showNotificationCall = vi.mocked(deps.showNotification).mock.calls[0]; + const notificationOptions: NotificationOptionsWithData | undefined = showNotificationCall?.[1]; + expect(showNotificationCall?.[0]).toBe('Sensor'); + expect(notificationOptions?.body).toBe('Temperature alert'); + expect(notificationOptions?.data).toMatchObject({ + id: 'n-no-credentials', + deviceId: undefined + }); + expect(deps.matchWindowClients).not.toHaveBeenCalled(); + expect(client.postMessage).not.toHaveBeenCalled(); + }); + 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( @@ -358,6 +395,7 @@ describe('service worker runtime', () => { ); expect(deps.saveNotification).toHaveBeenCalledWith({ id: 'n-e2e-1', + deviceId: 'dev-a', title: 'Encrypted', body: 'Secret message', topic: 'alerts', diff --git a/web/apps/hive/src/sw-runtime.ts b/web/apps/hive/src/sw-runtime.ts index 0501685..2dab2b9 100644 --- a/web/apps/hive/src/sw-runtime.ts +++ b/web/apps/hive/src/sw-runtime.ts @@ -52,6 +52,7 @@ type WorkerClient = { type SavedNotification = { id: string; + deviceId: string; title: string; body: string; topic: string; @@ -81,6 +82,7 @@ export type ServiceWorkerRuntimeDeps = { }; }; } | null>; + getDeviceCredentials: () => Promise<{ deviceId: string } | null>; decryptPayload: (data: ArrayBuffer) => Promise; fetch: typeof fetch; }; @@ -224,7 +226,8 @@ async function loadE2EPayload( function buildNotificationOptions( deps: ServiceWorkerRuntimeDeps, - data: NotificationPayload + data: NotificationPayload, + deviceId?: string ): NotificationOptions { return { body: data.body, @@ -237,6 +240,7 @@ function buildNotificationOptions( body: data.body, topic: data.topic, topicId: data.topic_id, + deviceId, sentAt: data.sent_at, priority: data.priority, attachment: data.attachment @@ -266,6 +270,8 @@ function buildNotificationClickedMessage(notificationData?: Record { function saveNotificationToStorage(input: { id: string; + deviceId: string; title: string; body: string; topic: string; @@ -87,6 +89,7 @@ const runtimeDeps: ServiceWorkerRuntimeDeps = { claimClients: () => self.clients.claim(), skipWaiting: () => self.skipWaiting(), getPushSubscription: () => self.registration.pushManager.getSubscription(), + getDeviceCredentials: () => deviceKeysRepository.getDeviceCredentials(), decryptPayload, fetch: (input, init) => self.fetch(input, init) }; diff --git a/web/package.json b/web/package.json index 47a9900..2b45235 100644 --- a/web/package.json +++ b/web/package.json @@ -41,6 +41,7 @@ "autoprefixer": "^10.5.0", "daisyui": "^5.5.19", "eslint": "^10.2.1", + "fake-indexeddb": "^6.2.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.5.0", diff --git a/web/packages/shared/src/testing/setup.ts b/web/packages/shared/src/testing/setup.ts new file mode 100644 index 0000000..37b9389 --- /dev/null +++ b/web/packages/shared/src/testing/setup.ts @@ -0,0 +1 @@ +import 'fake-indexeddb/auto'; diff --git a/web/packages/shared/src/testing/sveltekit-environment.ts b/web/packages/shared/src/testing/sveltekit-environment.ts new file mode 100644 index 0000000..59bc31d --- /dev/null +++ b/web/packages/shared/src/testing/sveltekit-environment.ts @@ -0,0 +1,9 @@ +/** + * Static shim for $app/environment used by Vitest when running Hive unit + * tests in jsdom. Hive is a client-side PWA; every test assumes a browser + * context, so browser is unconditionally true. + */ +export const browser = true; +export const building = false; +export const dev = true; +export const version = 'test'; diff --git a/web/packages/shared/src/types.ts b/web/packages/shared/src/types.ts index 126a8c3..89deaf2 100644 --- a/web/packages/shared/src/types.ts +++ b/web/packages/shared/src/types.ts @@ -30,6 +30,7 @@ export type PushMessage = | { type: 'PUSH_RECEIVED'; id?: string; + deviceId?: string; title: string; body: string; topicId?: string | null; @@ -45,6 +46,7 @@ export type PushMessage = type: 'NOTIFICATION_CLICKED'; notification?: { id?: string; + deviceId?: string; title?: string; body?: string; topicId?: string | null; diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c1f780d..fcb3808 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: eslint-plugin-svelte: specifier: ^3.17.0 version: 3.17.0(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.4) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 globals: specifier: ^17.5.0 version: 17.5.0 @@ -1212,6 +1215,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2974,6 +2981,8 @@ snapshots: expect-type@1.3.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} diff --git a/web/vitest.config.ts b/web/vitest.config.ts index f159a5d..dac8d9a 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -6,12 +6,21 @@ export default defineConfig({ plugins: [svelte({ hot: false })], resolve: { alias: { + // Vitest cannot resolve SvelteKit runtime modules out of the box. + // Hive is a client-side PWA: all unit tests run in jsdom and every + // module under test expects a browser context, so we alias + // $app/environment to a static shim with browser=true unconditionally. + '$app/environment': path.resolve( + import.meta.dirname, + 'packages/shared/src/testing/sveltekit-environment.ts' + ), $lib: path.resolve(import.meta.dirname, 'apps/hive/src/lib'), '@beebuzz/shared': path.resolve(import.meta.dirname, 'packages/shared/src') } }, test: { include: ['{apps,packages}/*/src/**/*.{test,spec}.{js,ts}'], + setupFiles: ['packages/shared/src/testing/setup.ts'], environment: 'jsdom', globals: true }