fix: recover web push subscriptions after PWA reinstall (#24720)

This commit is contained in:
Kyle Carberry
2026-04-26 14:49:10 -07:00
committed by GitHub
parent 99a83a2702
commit 069223ae26
12 changed files with 493 additions and 18 deletions
+140 -1
View File
@@ -5,7 +5,11 @@ import type { WebpushMessage } from "#/api/typesGenerated";
// top level during module evaluation.
const mockShowNotification = vi.fn(() => Promise.resolve());
const mockRegistration = { showNotification: mockShowNotification };
const mockSubscribe = vi.fn();
const mockRegistration = {
showNotification: mockShowNotification,
pushManager: { subscribe: mockSubscribe },
};
const mockMatchAll =
vi.fn<
() => Promise<
@@ -326,3 +330,138 @@ describe("serviceWorker notificationclick handler", () => {
expect(mockClients.openWindow).toHaveBeenCalledWith("/agents");
});
});
// Helper to build a minimal PushSubscriptionChangeEvent-like object.
// Mirrors the spec shape consumed by the service worker handler.
function makePushSubscriptionChangeEvent(
oldSubscription: {
endpoint: string;
options?: { applicationServerKey?: ArrayBuffer | Uint8Array | string };
} | null,
newSubscription: {
endpoint: string;
options?: { applicationServerKey?: ArrayBuffer | Uint8Array | string };
toJSON: () => unknown;
} | null,
) {
let waitUntilPromise: Promise<unknown> = Promise.resolve();
return {
oldSubscription,
newSubscription,
waitUntil: (p: Promise<unknown>) => {
waitUntilPromise = p;
},
get _waitUntilPromise() {
return waitUntilPromise;
},
};
}
describe("serviceWorker pushsubscriptionchange handler", () => {
const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn(() =>
Promise.resolve({ ok: true, status: 204 } as Response),
);
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("re-subscribes and POSTs the new subscription when the browser supplies one", async () => {
const applicationServerKey = new Uint8Array([1, 2, 3]).buffer;
const newSubscription = {
endpoint: "https://push.example.com/new",
options: { applicationServerKey },
toJSON: () => ({
endpoint: "https://push.example.com/new",
keys: { auth: "new-auth", p256dh: "new-p256dh" },
}),
};
const oldSubscription = {
endpoint: "https://push.example.com/old",
options: { applicationServerKey },
};
const event = makePushSubscriptionChangeEvent(
oldSubscription,
newSubscription,
);
handlers.pushsubscriptionchange(event);
await event._waitUntilPromise;
// The browser already supplied newSubscription, so we must
// not call subscribe again.
expect(mockSubscribe).not.toHaveBeenCalled();
// Should have POSTed the new subscription and remove
// the old endpoint via DELETE.
const calls = mockFetch.mock.calls;
expect(calls).toHaveLength(2);
const [postUrl, postInit] = calls[0];
expect(postUrl).toBe("/api/v2/users/me/webpush/subscription");
expect(postInit?.method).toBe("POST");
const postBody = JSON.parse(postInit?.body as string);
expect(postBody).toEqual({
endpoint: "https://push.example.com/new",
auth_key: "new-auth",
p256dh_key: "new-p256dh",
});
const [deleteUrl, deleteInit] = calls[1];
expect(deleteUrl).toBe("/api/v2/users/me/webpush/subscription");
expect(deleteInit?.method).toBe("DELETE");
const deleteBody = JSON.parse(deleteInit?.body as string);
expect(deleteBody).toEqual({
endpoint: "https://push.example.com/old",
});
});
it("re-subscribes via pushManager when no newSubscription is provided", async () => {
const applicationServerKey = new Uint8Array([4, 5, 6]).buffer;
const oldSubscription = {
endpoint: "https://push.example.com/old",
options: { applicationServerKey },
};
const freshSubscription = {
endpoint: "https://push.example.com/fresh",
toJSON: () => ({
endpoint: "https://push.example.com/fresh",
keys: { auth: "fresh-auth", p256dh: "fresh-p256dh" },
}),
};
mockSubscribe.mockResolvedValue(freshSubscription);
const event = makePushSubscriptionChangeEvent(oldSubscription, null);
handlers.pushsubscriptionchange(event);
await event._waitUntilPromise;
expect(mockSubscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey,
});
const calls = mockFetch.mock.calls;
expect(calls).toHaveLength(2);
const postBody = JSON.parse(calls[0][1]?.body as string);
expect(postBody).toEqual({
endpoint: "https://push.example.com/fresh",
auth_key: "fresh-auth",
p256dh_key: "fresh-p256dh",
});
});
it("does nothing when there is no application server key to recover", async () => {
const event = makePushSubscriptionChangeEvent(null, null);
handlers.pushsubscriptionchange(event);
await event._waitUntilPromise;
expect(mockSubscribe).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
});
+58
View File
@@ -54,6 +54,64 @@ self.addEventListener("push", (event) => {
);
});
// Handle key rotation. The Push API spec requires the user agent to fire
// pushsubscriptionchange when the push service rotates the keys. Without
// this handler the device's local subscription updates but the server keeps
// the old keys, which is the failure mode after a PWA reinstall on iOS:
// the bell stays green client-side because pushManager.getSubscription()
// returns the new subscription, but coderd encrypts to the old keys and
// the device silently drops the message.
self.addEventListener("pushsubscriptionchange", (event) => {
event.waitUntil(handlePushSubscriptionChange(event));
});
async function handlePushSubscriptionChange(
event: PushSubscriptionChangeEvent,
): Promise<void> {
// Prefer the application server key the browser already negotiated, so
// we don't depend on the React side delivering a fresh VAPID key into
// the worker. Fall back to the old subscription's options when the new
// one is null (the browser revoked without auto-renewing).
const applicationServerKey =
event.newSubscription?.options.applicationServerKey ??
event.oldSubscription?.options.applicationServerKey;
if (!applicationServerKey) {
return;
}
const subscription =
event.newSubscription ??
(await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
}));
const json = subscription.toJSON();
if (!json.endpoint || !json.keys) {
return;
}
await fetch("/api/v2/users/me/webpush/subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
endpoint: json.endpoint,
auth_key: json.keys.auth,
p256dh_key: json.keys.p256dh,
}),
});
if (event.oldSubscription) {
await fetch("/api/v2/users/me/webpush/subscription", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ endpoint: event.oldSubscription.endpoint }),
});
}
}
// Handle notification click — navigate to the specific chat or agents page.
self.addEventListener("notificationclick", (event) => {
event.notification.close();