Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/HIVE_ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions web/apps/hive/src/lib/onboarding.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ const createOnboarding = () => {
);
await finalizePendingStoredKeyPair(deviceId);
await deviceKeysRepository.storeDeviceCredentials(deviceId, deviceToken);
notificationsStore.activateDevice(deviceId);

await paired.check();
await notificationsStore.loadFromIndexedDB();
Expand Down
187 changes: 187 additions & 0 deletions web/apps/hive/src/lib/services/app-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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'
]);
});
});
62 changes: 62 additions & 0 deletions web/apps/hive/src/lib/services/app-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export interface AppBootstrapDeps<TRegistration> {
registerServiceWorker: () => Promise<TRegistration>;
checkPaired: () => Promise<boolean>;
getDeviceId: () => Promise<string | null>;
activateNotifications: (deviceId: string) => void;
attachServiceWorkerListeners: () => void;
migrateLegacyNotifications: (deviceId: string) => Promise<void>;
loadPersistedNotifications: (phase: 'initial' | 'final') => Promise<void>;
runPostPairingChecks: (registration: TRegistration) => Promise<void>;
}

export interface AppBootstrapResult<TRegistration> {
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<TRegistration>(
deps: AppBootstrapDeps<TRegistration>
): Promise<AppBootstrapResult<TRegistration>> {
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 };
}
22 changes: 18 additions & 4 deletions web/apps/hive/src/lib/services/hive-db.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,12 +10,25 @@ export const ENCRYPTED_KEY_STORE = 'encrypted_private_keys';
export function openHiveDB(): Promise<IDBDatabase> {
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' });
}
Expand Down
Loading