diff --git a/src/docs/src/Notifications/enable.md b/src/docs/src/Notifications/enable.md index 4f4d49369..208a540c0 100644 --- a/src/docs/src/Notifications/enable.md +++ b/src/docs/src/Notifications/enable.md @@ -44,7 +44,7 @@ It rejects with an error carrying `message` and `code`: | `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. | | `push_worker_unavailable` | The service worker couldn't be registered or didn't start, for example because nothing is served at its path. | | `push_worker_conflict` | Another service worker controls the scope. Add the `importScripts()` line to that worker and pass its path as `serviceWorker`. | -| `push_subscription_conflict` | The worker already has a push subscription from another service, such as Firebase or OneSignal. Give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. | +| `push_subscription_conflict` | The worker already has a push subscription from another service, such as Firebase or OneSignal. Give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead. | | `push_subscribe_failed` | The browser couldn't create a push subscription, usually because it couldn't reach its push service. | | `push_subscription_invalid` | The browser's push service refused the subscription, even after a fresh one. | | `push_endpoint_revoked` | Another Puter account turned your app off on this browser's subscription, and a fresh subscription couldn't replace it. | diff --git a/src/docs/src/Notifications/push.md b/src/docs/src/Notifications/push.md index 1d12e4aea..16d3f63f7 100644 --- a/src/docs/src/Notifications/push.md +++ b/src/docs/src/Notifications/push.md @@ -56,7 +56,7 @@ importScripts('https://js.puter.com/push-sw.js'); The worker follows the browser's service worker rules: - **One worker per scope.** If your site already has a service worker for that scope, add the `importScripts()` line to it and pass its path as `serviceWorker` to [`onPush()`](/Notifications/onPush/), [`enable()`](/Notifications/enable/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Otherwise [`enable()`](/Notifications/enable/) rejects with `push_worker_conflict` and leaves your worker in place. Puter's script only adds event listeners and only handles pushes sent by Puter, so your worker's own install, fetch and push handling keep working. -- **One push subscription per worker.** If that worker already gets pushes from another service, such as Firebase or OneSignal, give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. Otherwise [`enable()`](/Notifications/enable/) rejects with `push_subscription_conflict`. +- **One push subscription per worker.** If that worker already gets pushes from another service, such as Firebase or OneSignal, give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. Otherwise [`enable()`](/Notifications/enable/) rejects with `push_subscription_conflict`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead. - **Content-Security-Policy.** If the worker is served with a Content-Security-Policy, its `script-src` has to allow `https://js.puter.com` and `'unsafe-eval'`. The worker re-creates your handler from its source with `new Function`. - **Updates.** A changed worker only runs once it activates. Call `self.skipWaiting()` in it, or close every tab of your site. - **Errors.** An error your handler throws in the worker shows only in the service worker's console, not the page's. In Chrome, open it from DevTools under **Application** > **Service workers**. @@ -302,6 +302,7 @@ The browser can be allowed to show notifications while the operating system hide | It rejects with `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click first. | | The `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout` | The worker doesn't import `push-sw.js`, or the confirmation push didn't arrive within 30 seconds. The next page load tries again. | | A push shows the plain title and text, not your handler's result | This browser hasn't run [`onPush()`](/Notifications/onPush/) for that `channel` yet, or the handler threw, took over 5 seconds, or used an outside variable. Check the worker's console. | +| Pushes stop until the user opens your site again | The browser replaced its push subscription for your site, as browsers sometimes do. Your service worker keeps the new one, and the next page load that calls [`onPush()`](/Notifications/onPush/) registers it with Puter. Pushes sent before that don't arrive. | | No push while your site is open in a tab | The open page received the notification live. It shows one only when it's hidden or its window isn't focused. | | No push at all | The browser isn't running, the operating system hides its notifications ([above](#operating-system-settings)), the user reached the [push limit](/rate-limits-and-quotas/#notifications) for your app, or the user has no usage credit left. | diff --git a/src/puter-js/src/modules/notifications/index.js b/src/puter-js/src/modules/notifications/index.js index ec14388a9..2a46ea6fe 100644 --- a/src/puter-js/src/modules/notifications/index.js +++ b/src/puter-js/src/modules/notifications/index.js @@ -9,6 +9,8 @@ import { request } from './lib/api.js'; import { CLICK_KEEP_MS, HANDLERS_STORE, + SUBSCRIPTION_CHANGED_PREFIX, + SYNCED_PREFIX, TAP_BASE_PREFIX, forgetOldClicks, forgetPushState, @@ -26,6 +28,8 @@ import { removeWebPush, syncWebPush, unsubscribeWebPush, + usesPuterKey, + webPushCheck, webPushState, webPushSupported, workerFor, @@ -63,6 +67,8 @@ const HANDLER_TIMEOUT_MS = 5_000; const WORKER_SHOW_TIMEOUT_MS = 2_000; /** How long after load a page a tap opened waits for a `click` handler. */ const UNREAD_TAP_MS = 10_000; +/** How long a load only asks the server about a subscription it confirmed. */ +const SYNCED_FOR_MS = 24 * 60 * 60 * 1000; /** Sync failures that are the server's or the user's choice, not a fault. */ const QUIET_SYNC_ERRORS = new Set([ 'push_device_removed', @@ -408,7 +414,7 @@ export class NotificationsModule extends EventListener { (event) => { if (event.data?.type !== SUBSCRIPTION_CHANGED_MESSAGE) return; if (this.#pushHandlers.size > 0 && this.#worker) - void this.#syncIfOptedIn(this.#worker); + void this.#syncIfOptedIn(this.#worker, { full: true }); }, ); } @@ -440,6 +446,7 @@ export class NotificationsModule extends EventListener { interactive: true, withLock: withPushLock, onAnnounced: () => this.#recordOptIn(true), + onSynced: (endpoint) => this.#noteSynced(worker, endpoint), }); try { // The GUI reads the registered device right after. @@ -715,22 +722,121 @@ export class NotificationsModule extends EventListener { }; } - async #syncIfOptedIn(worker) { + /** With `full`, registers again even when a status call would do. */ + async #syncIfOptedIn(worker, { full = false } = {}) { const optedIn = await this.#readOptIn(); if (optedIn === false) await this.#endUnwanted(); if (!optedIn) return; + if (!full && (await this.#settledByStatus(worker))) return; await this.#finishSync( syncWebPush(this.puter, { worker, signal: this.#stopVerifying.signal, stillWanted: async () => (await this.#readOptIn()) === true, withLock: withPushLock, + onSynced: (endpoint) => this.#noteSynced(worker, endpoint), }), worker, 'could not keep the push subscription current', ); } + /** Settles the load with a status call when that's enough; false if not. */ + async #settledByStatus(worker) { + const signal = this.#stopVerifying.signal; + let subscription = null; + let uuid = null; + let synced = null; + try { + uuid = await this.#userUuid(); + if (!uuid) return false; + let changed; + let current; + [synced, changed, current] = await Promise.all([ + idbGet(HANDLERS_STORE, SYNCED_PREFIX + uuid), + idbGet( + HANDLERS_STORE, + SUBSCRIPTION_CHANGED_PREFIX + workerScope(worker), + ), + currentWebPush({ worker }), + ]); + const age = Date.now() - synced?.at; + if ( + changed != null || + !current || + synced?.endpoint !== current.endpoint || + !(age >= 0 && age < SYNCED_FOR_MS) || + !(await usesPuterKey(current)) + ) + return false; + subscription = current; + } catch { + return false; + } + this.#checkWorkerUpdate(worker); + const answer = await webPushCheck(this.puter, subscription.endpoint); + if (signal.aborted) return true; + if (answer?.state === 'removed' || answer?.state === 'revoked') { + await withPushLock(async () => { + // Another tab's enable() since the check wins. + const [now, record] = await Promise.all([ + currentWebPush({ worker }), + idbGet(HANDLERS_STORE, SYNCED_PREFIX + uuid), + ]).catch(() => []); + if ( + !now || + now.endpoint !== subscription.endpoint || + record?.at !== synced.at + ) + return; + if (answer.state === 'removed') + await this.#dropRemoved(worker, now); + else await this.#recordOptIn(false); + }); + return true; + } + if (answer?.state !== 'active') return false; + if ( + answer.publicKey !== null && + !(await usesPuterKey(subscription, answer.publicKey)) + ) + return false; + this.#workerRegistered = true; + return true; + } + + /** Has the browser look for a newer worker script, as registering does. */ + #checkWorkerUpdate(worker) { + try { + void globalThis.navigator.serviceWorker + .getRegistration(worker.scope ?? worker.url) + .then((registration) => registration?.update()) + .catch(() => {}); + } catch { + // No service workers here. + } + } + + /** Remembers the subscription the server confirmed, for the next loads. */ + async #noteSynced(worker, endpoint) { + if (this.puter.env !== 'web') return; + try { + const uuid = await this.#userUuid(); + if (uuid) { + await idbPut(HANDLERS_STORE, SYNCED_PREFIX + uuid, { + endpoint, + at: Date.now(), + }); + } + await idbDelete( + HANDLERS_STORE, + SUBSCRIPTION_CHANGED_PREFIX + workerScope(worker), + ); + } catch { + // The next load registers again. + } + } + /** Ends a subscription left here without an opt-in, in the browser only. */ async #endUnwanted() { await withPushLock(async () => { diff --git a/src/puter-js/src/modules/notifications/lib/pushStore.js b/src/puter-js/src/modules/notifications/lib/pushStore.js index 43db68ca1..67e9bb9ed 100644 --- a/src/puter-js/src/modules/notifications/lib/pushStore.js +++ b/src/puter-js/src/modules/notifications/lib/pushStore.js @@ -7,6 +7,12 @@ export const CLICKS_STORE = 'clicks'; // stays at the version workers already installed open. const OPT_IN_PREFIX = 'puter:opt-in:'; export const SERVER_KEY_ENTRY = 'puter:server-key'; +/** Every key the server named as Puter's, the current one first. */ +export const SERVER_KEYS_ENTRY = 'puter:server-keys'; +/** Per user: `{ endpoint, at }` of the subscription the server last confirmed. */ +export const SYNCED_PREFIX = 'puter:synced:'; +/** Per worker scope: set by `push-sw.js` when a subscription is replaced. */ +export const SUBSCRIPTION_CHANGED_PREFIX = 'puter:subscription-changed:'; /** Where a tap without a URL goes, per worker scope; read by `push-sw.js`. */ export const TAP_BASE_PREFIX = 'puter:tap-base:'; /** How long a kept tap waits for its page, as in `push-sw.js`. */ @@ -55,13 +61,17 @@ export const setOptIn = (userUuid, on, idb = globalThis.indexedDB) => ? idbPut(HANDLERS_STORE, OPT_IN_PREFIX + userUuid, true, idb) : idbDelete(HANDLERS_STORE, OPT_IN_PREFIX + userUuid, idb); -/** Forgets every user's opt-in and every kept tap. */ +/** Forgets every user's opt-in and sync, and every kept tap. */ export const forgetPushState = async (idb = globalThis.indexedDB) => { - await run(idb, HANDLERS_STORE, 'readwrite', (s) => - s.delete( - globalThis.IDBKeyRange.bound(OPT_IN_PREFIX, `${OPT_IN_PREFIX}￿`), - ), - ); + for (const prefix of [ + OPT_IN_PREFIX, + SYNCED_PREFIX, + SUBSCRIPTION_CHANGED_PREFIX, + ]) { + await run(idb, HANDLERS_STORE, 'readwrite', (s) => + s.delete(globalThis.IDBKeyRange.bound(prefix, `${prefix}￿`)), + ); + } await run(idb, CLICKS_STORE, 'readwrite', (s) => s.clear()); }; diff --git a/src/puter-js/src/modules/notifications/lib/webPush.js b/src/puter-js/src/modules/notifications/lib/webPush.js index 61851f351..3bb5a4711 100644 --- a/src/puter-js/src/modules/notifications/lib/webPush.js +++ b/src/puter-js/src/modules/notifications/lib/webPush.js @@ -2,6 +2,7 @@ import { PuterJSError } from '../../../lib/PuterJSError.js'; import { request } from './api.js'; import { HANDLERS_STORE, + SERVER_KEYS_ENTRY, SERVER_KEY_ENTRY, idbGet, idbPut, @@ -242,6 +243,17 @@ const turnedOff = () => 'push_verification_canceled', ); +/** The keys in `values` that decode, skipping anything else. */ +const decodeKeys = (values) => + values.flatMap((value) => { + if (typeof value !== 'string' || value === '') return []; + try { + return [fromBase64Url(value)]; + } catch { + return []; + } + }); + /** The key Puter last subscribed this origin with, or null. */ const storedPuterKey = async () => { try { @@ -252,29 +264,43 @@ const storedPuterKey = async () => { } }; -/** Whether `buffer` is the key Puter last subscribed this origin with. */ -const earlierPuterKey = async (buffer) => { - const stored = await storedPuterKey(); - return !!stored && sameKey(buffer, stored); +/** That key and every key the server listed as Puter's. */ +const storedPuterKeys = async () => { + let listed = []; + try { + const stored = await idbGet(HANDLERS_STORE, SERVER_KEYS_ENTRY); + if (Array.isArray(stored)) listed = decodeKeys(stored); + } catch { + // Only the last key is known then. + } + const last = await storedPuterKey(); + return last ? [last, ...listed] : listed; }; -/** Puter's key as stored here, else as the server gives it, else null. */ -const puterKey = async (puter, authToken) => { - const stored = await storedPuterKey(); - if (stored) return stored; +/** Whether `buffer` is a key Puter subscribed with: stored here, or in `listed`. */ +const earlierPuterKey = async (buffer, listed = []) => + [...listed, ...(await storedPuterKeys())].some((key) => + sameKey(buffer, key), + ); + +/** Puter's keys as stored here, else as the server gives them, else none. */ +const puterKeys = async (puter, authToken) => { + const stored = await storedPuterKeys(); + if (stored.length > 0) return stored; try { - const { publicKey } = await request( + const { publicKey, previousKeys } = await request( puter, 'GET', '/push/web/key', undefined, { authToken, interactiveReauth: false }, ); - return typeof publicKey === 'string' && publicKey !== '' - ? fromBase64Url(publicKey) - : null; + return decodeKeys([ + publicKey, + ...(Array.isArray(previousKeys) ? previousKeys : []), + ]); } catch { - return null; + return []; } }; @@ -290,16 +316,23 @@ const puterSubscriptions = async (puter, { worker, authToken, nav }) => { ).filter(Boolean); // The GUI's own worker carries Puter's subscription only. if (subscriptions.length === 0 || puter.env === 'gui') return subscriptions; - const key = await puterKey(puter, authToken); - if (!key) return []; + const keys = await puterKeys(puter, authToken); return subscriptions.filter((subscription) => - sameKey(subscription.options?.applicationServerKey, key), + keys.some((key) => + sameKey(subscription.options?.applicationServerKey, key), + ), ); }; -const rememberPuterKey = async (publicKey) => { +const rememberPuterKey = async (publicKey, previousKeys) => { try { await idbPut(HANDLERS_STORE, SERVER_KEY_ENTRY, publicKey); + if (Array.isArray(previousKeys)) { + await idbPut(HANDLERS_STORE, SERVER_KEYS_ENTRY, [ + publicKey, + ...previousKeys.filter((k) => typeof k === 'string'), + ]); + } } catch { // Only a later key rotation needs it. } @@ -310,11 +343,15 @@ const rememberPuterKey = async (publicKey) => { * with Puter's earlier key is ended; on a website, one made with any other key * belongs to something else on the site and is left alone. */ -const currentSubscription = async (puter, registration, key) => { +const currentSubscription = async (puter, registration, key, earlier) => { const subscription = await registration.pushManager.getSubscription(); const existing = subscription?.options?.applicationServerKey; if (!subscription || sameKey(existing, key)) return subscription; - if (puter.env === 'web' && existing && !(await earlierPuterKey(existing))) { + if ( + puter.env === 'web' && + existing && + !(await earlierPuterKey(existing, earlier)) + ) { throw new PuterJSError( 'This browser already has a push subscription from another service on this site', 'push_subscription_conflict', @@ -329,7 +366,15 @@ const register = async ( puter, nav, subscribe, - { label, onPending, signal, interactive, whileWanted, onAnnounced }, + { + label, + onPending, + signal, + interactive, + whileWanted, + onAnnounced, + onSynced, + }, ) => { /** @type {ReturnType | null} */ let challenge = null; @@ -346,14 +391,16 @@ const register = async ( ); if (signal?.aborted) throw turnedOff(); await onAnnounced?.(); + if (result.status === 'active') + await onSynced?.(subscription.endpoint); return { subscription, result }; }); if (result.status === 'active') return result.device; onPending?.(); const answer = await challenge.promise; if (answer === null) throw turnedOff(); - const { device } = await whileWanted(() => - request( + const { device } = await whileWanted(async () => { + const verified = await request( puter, 'POST', '/push/web/subscriptions/verify', @@ -362,8 +409,10 @@ const register = async ( challenge: answer, }, { interactiveReauth: false }, - ), - ); + ); + await onSynced?.(subscription.endpoint); + return verified; + }); return device; } finally { challenge?.cancel(); @@ -387,6 +436,7 @@ const register = async ( * stillWanted?: () => boolean | Promise; * withLock?: (run: () => Promise) => Promise; * onAnnounced?: () => Promise; + * onSynced?: (endpoint: string) => Promise; * }} options * `onPending` runs once the server has the subscription and only its * confirmation is left. Aborting `signal` stops waiting for that @@ -412,9 +462,10 @@ export async function syncWebPush( stillWanted = () => true, withLock = (run) => run(), onAnnounced, + onSynced, }, ) { - const { publicKey } = await request( + const { publicKey, previousKeys } = await request( puter, 'GET', '/push/web/key', @@ -428,6 +479,7 @@ export async function syncWebPush( ); } const key = fromBase64Url(publicKey); + const earlier = decodeKeys(Array.isArray(previousKeys) ? previousKeys : []); const registration = await registerWorker(nav, worker, { keepSiteWorker: puter.env !== 'gui', }); @@ -440,6 +492,7 @@ export async function syncWebPush( signal, interactive, onAnnounced, + onSynced, whileWanted: (run) => withLock(async () => { if (signal?.aborted || !(await stillWanted())) @@ -453,9 +506,13 @@ export async function syncWebPush( nav, async () => { subscription = - (await currentSubscription(puter, registration, key)) ?? - (await subscribeWith(registration, key)); - await rememberPuterKey(publicKey); + (await currentSubscription( + puter, + registration, + key, + earlier, + )) ?? (await subscribeWith(registration, key)); + await rememberPuterKey(publicKey, previousKeys); return subscription; }, options, @@ -487,20 +544,45 @@ export async function syncWebPush( * @returns {Promise<'active' | 'removed' | 'revoked' | 'unknown' | null>} */ export async function webPushState(puter, endpoint) { + return (await webPushCheck(puter, endpoint))?.state ?? null; +} + +/** `webPushState`'s answer with the key the server names as Puter's, if any. */ +export async function webPushCheck(puter, endpoint) { try { - const { state } = await request( + const { state, publicKey } = await request( puter, 'POST', '/push/web/subscriptions/status', { endpoint }, { interactiveReauth: false }, ); - return SUBSCRIPTION_STATES.has(state) ? state : null; + if (!SUBSCRIPTION_STATES.has(state)) return null; + return { + state, + publicKey: + typeof publicKey === 'string' && publicKey !== '' + ? publicKey + : null, + }; } catch { return null; } } +/** Whether `subscription` uses Puter's stored key, and `publicKey` if given. */ +export async function usesPuterKey(subscription, publicKey = null) { + const existing = subscription?.options?.applicationServerKey; + const stored = await storedPuterKey(); + if (!stored || !sameKey(existing, stored)) return false; + if (publicKey === null) return true; + try { + return sameKey(existing, fromBase64Url(publicKey)); + } catch { + return false; + } +} + /** Ends Puter's subscriptions in the browser only; resolves their endpoints. */ export async function unsubscribeWebPush( puter, diff --git a/src/puter-js/src/modules/notifications/notifications.test.js b/src/puter-js/src/modules/notifications/notifications.test.js index d02e889fd..456b057e6 100644 --- a/src/puter-js/src/modules/notifications/notifications.test.js +++ b/src/puter-js/src/modules/notifications/notifications.test.js @@ -8,7 +8,8 @@ vi.mock('../../lib/networkUtils.js', () => ({ })); const { NotificationsModule } = await import('./index.js'); -const { webPushState } = await import('./lib/webPush.js'); +const { webPushCheck, webPushState } = await import('./lib/webPush.js'); +const { forgetPushState } = await import('./lib/pushStore.js'); const { request } = await import('./lib/api.js'); const { PuterJSError } = await import('../../lib/PuterJSError.js'); @@ -1514,6 +1515,67 @@ describe('webPushState', () => { }); }); +describe('webPushCheck', () => { + const host = { APIOrigin: 'https://api.test' }; + + it('gives the key the server names along with the state', async () => { + mockFetchUrl.mockResolvedValue( + jsonResponse({ state: 'active', publicKey: 'BKey' }), + ); + await expect( + webPushCheck(host, 'https://push.example/1'), + ).resolves.toEqual({ state: 'active', publicKey: 'BKey' }); + }); + + it('gives no key for a server that names none', async () => { + mockFetchUrl.mockResolvedValue(jsonResponse({ state: 'active' })); + await expect( + webPushCheck(host, 'https://push.example/1'), + ).resolves.toEqual({ state: 'active', publicKey: null }); + }); +}); + +describe('forgetPushState', () => { + it('forgets opt-ins, confirmed syncs and replaced-subscription marks, and every kept tap', async () => { + const calls = []; + const request = (call) => { + calls.push(call); + const req = {}; + queueMicrotask(() => req.onsuccess?.()); + return req; + }; + const db = { + transaction: (store) => ({ + objectStore: () => ({ + delete: (range) => request(['delete', store, range]), + clear: () => request(['clear', store]), + }), + }), + close: () => {}, + }; + const idb = { + open: () => { + const req = { result: db }; + queueMicrotask(() => req.onsuccess()); + return req; + }, + }; + const previous = globalThis.IDBKeyRange; + globalThis.IDBKeyRange = { bound: (lower) => lower }; + try { + await forgetPushState(idb); + } finally { + globalThis.IDBKeyRange = previous; + } + expect(calls).toEqual([ + ['delete', 'handlers', 'puter:opt-in:'], + ['delete', 'handlers', 'puter:synced:'], + ['delete', 'handlers', 'puter:subscription-changed:'], + ['clear', 'clicks'], + ]); + }); +}); + describe('request with keepalive', () => { it('sends through fetch with keepalive and the given token, never through fetchUrl', async () => { const fetch = vi.fn(async () => jsonResponse({ removed: true })); diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index 3632a3969..57ee04271 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -18,6 +18,9 @@ vi.mock('./lib/pushStore.js', () => { HANDLERS_STORE: 'handlers', CLICKS_STORE: 'clicks', SERVER_KEY_ENTRY: 'puter:server-key', + SERVER_KEYS_ENTRY: 'puter:server-keys', + SYNCED_PREFIX: 'puter:synced:', + SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:', TAP_BASE_PREFIX: 'puter:tap-base:', CLICK_KEEP_MS: 5 * 60 * 1000, takeClick: stored(async (uid, id) => { @@ -43,8 +46,14 @@ vi.mock('./lib/pushStore.js', () => { : void store.delete(optIn(uuid)), ), forgetPushState: stored(async () => { + const forgotten = [ + optIn(''), + 'handlers:puter:synced:', + 'handlers:puter:subscription-changed:', + 'clicks:', + ]; for (const key of [...store.keys()]) { - if (key.startsWith(optIn('')) || key.startsWith('clicks:')) + if (forgotten.some((prefix) => key.startsWith(prefix))) store.delete(key); } }), @@ -283,6 +292,46 @@ describe('syncWebPush', () => { expect(nav.pushManager.subscribe).not.toHaveBeenCalled(); }); + describe('with the earlier keys the server lists', () => { + const listing = () => + mockRequest.mockImplementation(async (_p, method, route) => + route === '/push/web/key' + ? { publicKey: KEY, previousKeys: [OLD_KEY] } + : { status: 'active', device: { uid: 'd1' } }, + ); + + it('replaces a subscription made with one, though none is stored here', async () => { + const stale = makeSubscription(fromBase64Url(OLD_KEY)); + const nav = makeNav({ existing: stale }); + listing(); + await syncWebPush(puter, { worker, nav }); + expect(stale.unsubscribe).toHaveBeenCalled(); + expect(nav.pushManager.subscribe).toHaveBeenCalled(); + expect(store.get(SERVER_KEY)).toBe(KEY); + expect(store.get('handlers:puter:server-keys')).toEqual([ + KEY, + OLD_KEY, + ]); + }); + + it('still leaves a subscription made with a key it doesn`t list', async () => { + const theirs = makeSubscription(new Uint8Array([4, 1, 2])); + const nav = makeNav({ existing: theirs }); + listing(); + await expect( + syncWebPush(puter, { worker, nav }), + ).rejects.toMatchObject({ code: 'push_subscription_conflict' }); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + }); + + it('stores none from a server that lists none', async () => { + const nav = makeNav({ existing: makeSubscription() }); + server(); + await syncWebPush(puter, { worker, nav }); + expect(store.has('handlers:puter:server-keys')).toBe(false); + }); + }); + it('replaces any other subscription on the GUI`s own worker', async () => { const stale = makeSubscription(new Uint8Array([4, 1, 2])); const nav = makeNav({ @@ -795,6 +844,30 @@ describe('removeWebPush', () => { expect(theirs.unsubscribe).not.toHaveBeenCalled(); }); + it('without a worker, also ends one made with a key the server listed as Puter`s', async () => { + store.set(SERVER_KEY, KEY); + store.set('handlers:puter:server-keys', [KEY, OLD_KEY]); + const theirs = makeSubscription(theirKey, 'https://push.example/site'); + const older = makeSubscription(fromBase64Url(OLD_KEY)); + const nav = navWith({ '/': theirs, '/push/': older }); + mockRequest.mockResolvedValue({ removed: true }); + await expect(removeWebPush(puter, { nav })).resolves.toBe(true); + expect(older.unsubscribe).toHaveBeenCalled(); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + }); + + it('takes the earlier keys from the server when none is stored', async () => { + const older = makeSubscription(fromBase64Url(OLD_KEY)); + const nav = navWith({ '/push/': older }); + mockRequest.mockImplementation(async (_p, method, route) => + route === '/push/web/key' + ? { publicKey: KEY, previousKeys: [OLD_KEY] } + : { removed: true }, + ); + await expect(removeWebPush(puter, { nav })).resolves.toBe(true); + expect(older.unsubscribe).toHaveBeenCalled(); + }); + it('touches nothing when Puter`s key can`t be told', async () => { const subscription = makeSubscription(); const nav = makeNav({ existing: subscription }); @@ -1361,6 +1434,255 @@ describe('onPush', () => { }); }); + describe('on load after the server confirmed this browser', () => { + const ENDPOINT = 'https://push.example/send/abc'; + const SYNCED = 'handlers:puter:synced:u-1'; + const CHANGED = `handlers:puter:subscription-changed:${ORIGIN}/`; + const NEW_KEY = Buffer.from([4, ...Array(64).fill(5)]).toString( + 'base64url', + ); + const routes = () => mockRequest.mock.calls.map(([, , r]) => r); + const confirmed = (over = {}) => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + store.set(SYNCED, { endpoint: ENDPOINT, at: Date.now(), ...over }); + }; + const statusSays = (answer) => + mockRequest.mockImplementation(async (_p, _m, route) => { + if (route === '/push/web/subscriptions/status') { + if (answer instanceof Error) throw answer; + return answer; + } + if (route === '/push/web/key') return { publicKey: KEY }; + return { status: 'active', device: { uid: 'd1' } }; + }); + const load = async () => { + const { module } = makeModule(); + await module.onPush('chat', () => {}, { onError: () => {} }); + for (let i = 0; i < 5; i++) await flush(); + return module; + }; + + it.each([ + ['a server that names no key', { state: 'active' }], + [ + 'a server that names the same key', + { state: 'active', publicKey: KEY }, + ], + ])( + 'only asks the server about the subscription, on %s', + async (_label, answer) => { + confirmed(); + withBrowser(makeNav({ existing: makeSubscription() })); + statusSays(answer); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + }, + ); + + it('has the browser look for a newer worker script, without waiting for it', async () => { + confirmed(); + const nav = makeNav({ existing: makeSubscription() }); + nav.registration.update = vi.fn(() => new Promise(() => {})); + withBrowser(nav); + statusSays({ state: 'active' }); + await load(); + expect(nav.registration.update).toHaveBeenCalledTimes(1); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + }); + + it('ends a subscription the user removed in Puter, after only asking', async () => { + confirmed(); + const subscription = makeSubscription(); + const nav = makeNav({ existing: subscription }); + withBrowser(nav); + statusSays({ state: 'removed' }); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); + expect(store.has(OPT_IN)).toBe(false); + expect(nav.pushManager.subscribe).not.toHaveBeenCalled(); + }); + + it('keeps pushes off once the user turned the app off in Puter, after only asking', async () => { + confirmed(); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + statusSays({ state: 'revoked' }); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + expect(store.has(OPT_IN)).toBe(false); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); + }); + + it.each([ + ['removed', 'a new subscription'], + ['revoked', 'a new subscription'], + ['removed', 'a new confirmation'], + ])( + 'leaves things alone when the server says %s but another tab`s enable() made %s meanwhile', + async (state, change) => { + confirmed(); + const stale = makeSubscription(); + const fresh = makeSubscription( + fromBase64Url(KEY), + 'https://push.example/send/new', + ); + const nav = makeNav({ existing: stale }); + withBrowser(nav); + let answer; + mockRequest.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + if (change === 'a new subscription') { + nav.pushManager.getSubscription.mockResolvedValue(fresh); + store.set(SYNCED, { + endpoint: fresh.endpoint, + at: Date.now() + 1, + }); + } else { + store.set(SYNCED, { + endpoint: ENDPOINT, + at: Date.now() + 1, + }); + } + answer({ state }); + for (let i = 0; i < 5; i++) await flush(); + expect(stale.unsubscribe).not.toHaveBeenCalled(); + expect(fresh.unsubscribe).not.toHaveBeenCalled(); + expect(store.get(OPT_IN)).toBe(true); + }, + ); + + it.each([ + [ + 'a sync over a day old', + { at: Date.now() - 25 * 60 * 60 * 1000 }, + { state: 'active' }, + ], + [ + 'another subscription', + { endpoint: 'https://push.example/send/other' }, + { state: 'active' }, + ], + ['an unknown subscription', {}, { state: 'unknown' }], + [ + 'a server that can`t tell', + {}, + new PuterJSError('Not Found', 'not_found'), + ], + ['a new key', {}, { state: 'active', publicKey: NEW_KEY }], + ])('registers again after %s', async (_label, over, answer) => { + confirmed(over); + withBrowser(makeNav({ existing: makeSubscription() })); + statusSays(answer); + await load(); + expect(routes()).toEqual( + expect.arrayContaining([ + '/push/web/key', + '/push/web/subscriptions', + ]), + ); + expect(store.get(SYNCED).at).toBeGreaterThan( + Date.now() - 60 * 1000, + ); + }); + + it('remembers a confirmed subscription, so the next load only asks', async () => { + store.set(OPT_IN, true); + withBrowser(makeNav({ existing: makeSubscription() })); + statusSays({ state: 'active' }); + await load(); + expect(routes()).toEqual([ + '/push/web/key', + '/push/web/subscriptions', + ]); + expect(store.get(SYNCED)).toEqual({ + endpoint: ENDPOINT, + at: expect.any(Number), + }); + + mockRequest.mockClear(); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + }); + + it('remembers a subscription once its confirmation is verified', async () => { + store.set(OPT_IN, true); + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd2' } }, + ); + await load(); + expect(store.has(SYNCED)).toBe(false); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await vi.waitFor(() => + expect(store.get(SYNCED)?.endpoint).toBe(ENDPOINT), + ); + }); + + it('registers again, and forgets the mark, after the worker saw the browser replace the subscription', async () => { + confirmed(); + store.set(CHANGED, 1); + withBrowser(makeNav({ existing: makeSubscription() })); + statusSays({ state: 'active' }); + await load(); + expect(routes()).toEqual([ + '/push/web/key', + '/push/web/subscriptions', + ]); + expect(store.has(CHANGED)).toBe(false); + }); + + it('registers in full when the worker says the browser replaced the subscription', async () => { + confirmed(); + const nav = makeNav({ existing: makeSubscription() }); + withBrowser(nav); + statusSays({ state: 'active' }); + await load(); + expect(routes()).toEqual(['/push/web/subscriptions/status']); + nav.post({ type: 'puter-push-subscription-changed' }); + await vi.waitFor(() => + expect(routes()).toEqual([ + '/push/web/subscriptions/status', + '/push/web/key', + '/push/web/subscriptions', + ]), + ); + }); + + it('lets enable() register in full', async () => { + confirmed(); + withBrowser(makeNav({ existing: makeSubscription() })); + statusSays({ state: 'active' }); + const { module } = makeModule(); + await expect(module.enable()).resolves.toBe(true); + expect(routes()).toEqual([ + '/push/web/key', + '/push/web/subscriptions', + ]); + }); + + it('is forgotten by disable()', async () => { + confirmed(); + store.set(CHANGED, 1); + withBrowser(makeNav({ existing: makeSubscription() })); + mockRequest.mockResolvedValue({ removed: true }); + const { module } = makeModule(); + await module.disable(); + expect(store.has(SYNCED)).toBe(false); + expect(store.has(CHANGED)).toBe(false); + }); + }); + describe('live delivery while the user is elsewhere', () => { let nav; const page = ({ visibilityState = 'visible', focused = true } = {}) => { diff --git a/src/puter-js/src/push-sw.js b/src/puter-js/src/push-sw.js index 9c10309a9..df528fac3 100644 --- a/src/puter-js/src/push-sw.js +++ b/src/puter-js/src/push-sw.js @@ -373,9 +373,57 @@ event.waitUntil(Promise.all([routeClick(puter, startedAt), forgetOldClicksQuietly()])); }); + // Entries pushStore.js writes. + const OPT_IN_PREFIX = 'puter:opt-in:'; + const SERVER_KEY = 'puter:server-key'; + const SERVER_KEYS = 'puter:server-keys'; + const CHANGED_PREFIX = 'puter:subscription-changed:'; + + const fromBase64Url = (value) => { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + }; + + function sameKey (buffer, value) { + if ( ! buffer ) return false; + try { + const bytes = new Uint8Array(buffer); + const key = fromBase64Url(value); + return bytes.length === key.length && bytes.every((b, i) => b === key[i]); + } catch { + return false; + } + } + + async function puterKeys () { + const last = await idb(HANDLERS, 'readonly', (s) => s.get(SERVER_KEY)); + const listed = await idb(HANDLERS, 'readonly', (s) => s.get(SERVER_KEYS)); + return [last, ...(Array.isArray(listed) ? listed : [])].filter((key) => str(key)); + } + + // No Puter session here: keep Puter's subscription and mark it for the next page load to register. + async function keepSubscription (event) { + const optIns = await idb(HANDLERS, 'readonly', (s) => s.count(IDBKeyRange.bound(OPT_IN_PREFIX, `${OPT_IN_PREFIX}\uffff`))); + if ( ! optIns ) return; + const keys = await puterKeys(); + const old = event.oldSubscription?.options?.applicationServerKey; + if ( old && ! keys.some((key) => sameKey(old, key)) ) return; + await idb(HANDLERS, 'readwrite', (s) => s.put(Date.now(), CHANGED_PREFIX + self.registration.scope)); + if ( event.newSubscription || ! keys.length ) return; + if ( await self.registration.pushManager.getSubscription() ) return; + // Without the old subscription, only a worker a Puter page used (it saved a tap base) subscribes. + if ( ! old && ! await idb(HANDLERS, 'readonly', (s) => s.get(TAP_BASE_PREFIX + self.registration.scope)) ) return; + await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: fromBase64Url(keys[0]) }); + } + self.addEventListener('pushsubscriptionchange', (event) => { - // The worker has no Puter session; an open page registers the new one. event.waitUntil((async () => { + try { + await keepSubscription(event); + } catch ( err ) { + console.warn('[puter] could not keep the push subscription', err); + } for ( const win of await windows() ) win.postMessage({ type: CHANGED_MESSAGE }); })()); }); diff --git a/src/puter-js/src/push-sw.test.js b/src/puter-js/src/push-sw.test.js index 3f5b5e1ae..33f69e814 100644 --- a/src/puter-js/src/push-sw.test.js +++ b/src/puter-js/src/push-sw.test.js @@ -34,6 +34,13 @@ const fakeIndexedDB = (stores) => { get: (key) => request(() => map.get(key)), put: (value, key) => request(() => void map.set(key, value)), delete: (key) => request(() => void map.delete(key)), + count: (range) => + request( + () => + [...map.keys()].filter( + (key) => key >= range.lower && key <= range.upper, + ).length, + ), openCursor: () => { const keys = [...map.keys()]; const cursorAt = (req, i) => @@ -84,6 +91,7 @@ const loadWorker = ({ windows = [], matchAllDelayMs = 0, scope: registrationScope = `${ORIGIN}/`, + subscription = null, } = {}) => { const listeners = {}; const shown = []; @@ -103,6 +111,10 @@ const loadWorker = ({ showNotification: vi.fn(async (title, options) => { shown.push({ title, options }); }), + pushManager: { + getSubscription: vi.fn(async () => subscription), + subscribe: vi.fn(async () => ({ endpoint: 'https://push.example/new' })), + }, }, clients: { matchAll: vi.fn(async () => { @@ -130,6 +142,8 @@ const loadWorker = ({ Number, Function, indexedDB: fakeIndexedDB(stores), + IDBKeyRange: { bound: (lower, upper) => ({ lower, upper }) }, + atob, }); context.self = context; vm.runInContext(SOURCE, context); @@ -155,6 +169,10 @@ const loadWorker = ({ const pushData = (payload) => ({ json: () => payload }); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const TAP_BASE = 'puter:tap-base:'; +// 65 bytes, 0x04 first, as base64url. +const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url'); +const OLD_KEY = Buffer.from([4, ...Array(64).fill(3)]).toString('base64url'); +const CHANGED = `puter:subscription-changed:${ORIGIN}/`; describe('push-sw push', () => { it('leaves a push without a Puter message to the app', async () => { @@ -597,3 +615,79 @@ describe('push-sw notifications a page shows', () => { expect(worker.shown).toEqual([]); }); }); + +describe('push-sw pushsubscriptionchange', () => { + const keyOf = (value) => ({ options: { applicationServerKey: new Uint8Array(Buffer.from(value, 'base64url')).buffer } }); + const optedIn = { 'puter:opt-in:u-1': true, 'puter:server-key': KEY }; + + it('marks the change and subscribes again with Puter`s key when the browser gave no new subscription', async () => { + const win = windowClient(`${ORIGIN}/`); + const worker = loadWorker({ handlers: optedIn, windows: [win] }); + await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY), newSubscription: null }); + expect(worker.stores.handlers.get(CHANGED)).toEqual(expect.any(Number)); + const { subscribe } = worker.scope.registration.pushManager; + expect(subscribe).toHaveBeenCalledTimes(1); + expect([...subscribe.mock.calls[0][0].applicationServerKey]).toEqual([...Buffer.from(KEY, 'base64url')]); + expect(subscribe.mock.calls[0][0].userVisibleOnly).toBe(true); + expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' }); + }); + + it('keeps the new subscription the browser gave', async () => { + const worker = loadWorker({ handlers: optedIn }); + await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY), newSubscription: { endpoint: 'https://push.example/2' } }); + expect(worker.stores.handlers.has(CHANGED)).toBe(true); + expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled(); + }); + + it('subscribes with the current key after one the server listed as earlier', async () => { + const worker = loadWorker({ handlers: { ...optedIn, 'puter:server-keys': [KEY, OLD_KEY] } }); + await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(OLD_KEY) }); + expect(worker.stores.handlers.has(CHANGED)).toBe(true); + const [[options]] = worker.scope.registration.pushManager.subscribe.mock.calls; + expect([...options.applicationServerKey]).toEqual([...Buffer.from(KEY, 'base64url')]); + }); + + it('only tells the open windows when nobody turned push on here', async () => { + const win = windowClient(`${ORIGIN}/`); + const worker = loadWorker({ handlers: { 'puter:server-key': KEY }, windows: [win] }); + await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY) }); + expect(worker.stores.handlers.has(CHANGED)).toBe(false); + expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled(); + expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' }); + }); + + it('leaves another service`s subscription to it', async () => { + const worker = loadWorker({ handlers: optedIn }); + await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(Buffer.from([4, 1, 2]).toString('base64url')) }); + expect(worker.stores.handlers.has(CHANGED)).toBe(false); + expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled(); + }); + + it('without the old subscription, subscribes only a worker a Puter page used', async () => { + const unused = loadWorker({ handlers: optedIn }); + await unused.fire('pushsubscriptionchange', {}); + expect(unused.stores.handlers.has(CHANGED)).toBe(true); + expect(unused.scope.registration.pushManager.subscribe).not.toHaveBeenCalled(); + + const used = loadWorker({ handlers: { ...optedIn, [`${TAP_BASE}${ORIGIN}/`]: `${ORIGIN}/` } }); + await used.fire('pushsubscriptionchange', {}); + expect(used.scope.registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); + + it('subscribes nothing when the browser already has a subscription again', async () => { + const worker = loadWorker({ handlers: optedIn, subscription: { endpoint: 'https://push.example/3' } }); + await worker.fire('pushsubscriptionchange', {}); + expect(worker.stores.handlers.has(CHANGED)).toBe(true); + expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled(); + }); + + it('still tells the open windows when IndexedDB fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const win = windowClient(`${ORIGIN}/`); + const worker = loadWorker({ handlers: optedIn, windows: [win] }); + worker.stores.broken = true; + await worker.fire('pushsubscriptionchange', {}); + expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' }); + warn.mockRestore(); + }); +});