fix: only replace a browser subscription on the relay's own answer, and move the other logins along

This commit is contained in:
Linus Rath
2026-11-04 03:13:58 +01:00
parent 226b9383ce
commit bc8653de8b
3 changed files with 84 additions and 6 deletions
+49 -1
View File
@@ -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<string, boolean | 'unknown'>) {
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<typeof vi.fn>).mockResolvedValue(moved);
const stored = new Map<string, string>();
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();
+14 -3
View File
@@ -45,11 +45,22 @@ interface RelayRegistrations {
entries: Record<string, RelayRegistration>;
}
async function readRelayRegistrations(cache: Cache, key: string): Promise<RelayRegistrations> {
async function readStored(cache: Cache, key: string): Promise<RelayRegistrations> {
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<Record<string, RelayRegistration>> {
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<void>
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' } }));
+21 -2
View File
@@ -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<void> {
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({