diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index e9ad7ee95..ea36d69ce 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -6,6 +6,7 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import { buildEmailPushConfig, disableWebPush, + dropAllWebPushRegistrations, enableWebPush, endWebPushForSignOut, isWebPushEnabled, @@ -1992,6 +1993,33 @@ describe('endWebPushForSignOut', () => { }); }); +describe('dropAllWebPushRegistrations', () => { + it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => { + const { browserSub } = installPushBrowser(); + localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x'); + localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a'); + localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY); + localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y'); + localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b'); + const calls = installFetch({}); + + await dropAllWebPushRegistrations(); + + expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull(); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull(); + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does nothing with nothing registered', async () => { + const { browserSub } = installPushBrowser(); + const calls = installFetch({}); + await dropAllWebPushRegistrations(); + expect(calls).toEqual([]); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('listPushDevices', () => { it('flags this device and reports the relay status of each registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); diff --git a/lib/web-push.ts b/lib/web-push.ts index 7daf97d71..b399c6edf 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -1119,6 +1119,34 @@ export async function endWebPushForSignOut(client: IJMAPClient): Promise { await disableWebPush({ client, relayBaseUrl }).catch(() => undefined); } +/** + * Nobody stays signed in here. A login without a connection can't reach its mail + * server, but the relay needs no login: without the record its pings go nowhere. + * Whatever is left of the keys goes, so nothing keeps the browser subscription. + */ +export async function dropAllWebPushRegistrations(): Promise { + if (typeof localStorage === 'undefined') return; + const accounts: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(SUBSCRIPTION_ID_PREFIX)) accounts.push(key.slice(SUBSCRIPTION_ID_PREFIX.length)); + } + if (accounts.length === 0) return; + for (const accountId of accounts) { + const relay = localStorage.getItem(relayBaseKey(accountId)); + const device = localStorage.getItem(deviceClientIdKey(accountId)); + if (relay && device) await dropRelayRecord(relay, device); + localStorage.removeItem(subscriptionIdKey(accountId)); + localStorage.removeItem(relayBaseKey(accountId)); + localStorage.removeItem(endpointKey(accountId)); + await forgetRelayRegistration(accountId); + } + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null); + const sub = await registration?.pushManager.getSubscription().catch(() => null); + await sub?.unsubscribe().catch(() => undefined); +} + export interface PushDevice { // The JMAP PushSubscription id - what you destroy to revoke it. id: string; diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts index 2c71e9486..eafa12111 100644 --- a/stores/__tests__/auth-store-push-signout.test.ts +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {})); -vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut })); +const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut, dropAllWebPushRegistrations })); import { setClientForTests, useAuthStore } from '../auth-store'; import { useAccountStore } from '../account-store'; @@ -78,6 +79,13 @@ describe('signing out ends the login\'s push registration', () => { expect(useAuthStore.getState().client).toBeNull(); }); + it('signing out of everything also covers logins with no connection', async () => { + dropAllWebPushRegistrations.mockClear(); + useAuthStore.setState({ isDemoMode: false }); + await useAuthStore.getState().logoutAll(); + expect(dropAllWebPushRegistrations).toHaveBeenCalledTimes(1); + }); + it('not in demo mode', async () => { const client = fakeClient(); useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e14a2171e..cd4a7420c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -2738,7 +2738,15 @@ export const useAuthStore = create()( for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); } await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); - if (!get().isDemoMode && !keepLocalData) await endPushForSignOut(clients.values()); + if (!get().isDemoMode && !keepLocalData) { + // Each connected login first, on a budget that one unreachable server can + // use up. What's left, and whoever had no connection, only needs the relay. + await endPushForSignOut(clients.values()); + await Promise.race([ + import('@/lib/web-push').then((push) => push.dropAllWebPushRegistrations()).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); + } const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts];