diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 7a44d1daa..969504d09 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -20,6 +20,7 @@ import { import { webPushErrorKey } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; const RELAY = 'https://relay.example'; const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`; @@ -277,7 +278,7 @@ function installFetch(activeByDevice: Record) { if (url.includes('/api/push/active/')) { const device = url.split('/api/push/active/')[1]; const state = activeByDevice[device]; - if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404); + if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404); return json({ active: state }); } return json({}); @@ -763,6 +764,53 @@ describe('an endpoint the push service gave up on', () => { expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); }); + it('does not take a relay that lacks the route for one that lost the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404); + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('moves the other logins to the new endpoint, connected or not', async () => { + const { registration } = installPushBrowser(); + const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) }; + (registration.pushManager.subscribe as ReturnType).mockResolvedValue(moved); + const stored = new Map(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined), + put: async (key: string, res: Response) => { stored.set(key, await res.text()); }, + }), + }); + await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD'); + const client = registered(); + const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404); + if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) }); + return json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + const other = bodies.find((b) => b.body.subscriptionId === 'device-other'); + expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web'); + expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved'); + expect(other?.body.accountLabel).toBe('o@example.org'); + expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1); + }); + it('keeps the subscription while the relay knows the record', async () => { const { browserSub } = installPushBrowser(); const client = registered(); diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 283e7b6dd..034d4efa0 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -45,11 +45,22 @@ interface RelayRegistrations { entries: Record; } -async function readRelayRegistrations(cache: Cache, key: string): Promise { +async function readStored(cache: Cache, key: string): Promise { const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined; return stored?.entries ? stored : { entries: {} }; } +/** Every login's relay registration on this browser, by JMAP account id. */ +export async function readRelayRegistrations(): Promise> { + try { + if (typeof caches === 'undefined') return {}; + const cache = await caches.open(PUSH_STATE_CACHE); + return (await readStored(cache, relayRegistrationsKey(window.location.origin))).entries; + } catch { + return {}; + } +} + /** * Lets the worker re-register a new browser endpoint with the relay when no tab * is open (pushsubscriptionchange). None of this is secret to the browser that @@ -60,7 +71,7 @@ export async function keepRelayRegistration(accountId: string, entry: RelayRegis if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); const key = relayRegistrationsKey(window.location.origin); - const stored = await readRelayRegistrations(cache, key); + const stored = await readStored(cache, key); stored.entries[accountId] = entry; stored.applicationServerKey = applicationServerKey; await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); @@ -74,7 +85,7 @@ export async function forgetRelayRegistration(accountId: string): Promise if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); const key = relayRegistrationsKey(window.location.origin); - const stored = await readRelayRegistrations(cache, key); + const stored = await readStored(cache, key); if (!(accountId in stored.entries)) return; delete stored.entries[accountId]; await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); diff --git a/lib/web-push.ts b/lib/web-push.ts index 20c969e90..818cbc5e6 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -14,7 +14,7 @@ import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; import { IS_LITE } from '@/lib/lite'; import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update'; -import { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; +import { forgetRelayRegistration, keepRelayRegistration, readRelayRegistrations } from '@/lib/pwa-notify/worker-state'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, @@ -664,12 +664,28 @@ async function relayDroppedOurEndpoint( if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false; try { const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`)); - return res.status === 404; + if (res.status !== 404) return false; + // An old relay without this route answers 404 for everything. + const body = (await res.json().catch(() => null)) as { error?: unknown } | null; + return body?.error === 'Unknown subscription'; } catch { return false; } } +/** The browser moved to a new endpoint: every other login's relay record has to follow, connected in this tab or not. */ +async function moveOtherLogins(accountId: string, subscription: PushSubscription): Promise { + const others = Object.entries(await readRelayRegistrations()).filter(([id]) => id !== accountId); + for (const [, entry] of others) { + await registerWithRelay({ + relayBaseUrl: entry.relay, + subscriptionId: entry.subscriptionId, + subscription: { endpoint: subscription.endpoint, keys: { p256dh: readPushKey(subscription, 'p256dh'), auth: readPushKey(subscription, 'auth') } }, + accountLabel: entry.accountLabel, + }).catch(() => undefined); + } +} + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, @@ -815,9 +831,11 @@ async function enableWebPushForThisTab( } const accountId = params.client.getAccountId(); const deviceClientId = getOrCreateDeviceClientId(accountId); + let endpointGivenUp = false; if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) { await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; + endpointGivenUp = true; // The other accounts' records point at the old endpoint: their turn comes too. lastResync.clear(); } @@ -826,6 +844,7 @@ async function enableWebPushForThisTab( userVisibleOnly: true, applicationServerKey, }); + if (endpointGivenUp) await moveOtherLogins(accountId, pushSubscription); } await registerWithRelay({