diff --git a/src/puter-js/src/modules/notifications/index.js b/src/puter-js/src/modules/notifications/index.js index b62c8cc51..edfd2afcb 100644 --- a/src/puter-js/src/modules/notifications/index.js +++ b/src/puter-js/src/modules/notifications/index.js @@ -5,6 +5,7 @@ import { cancel } from './cancel.js'; import { disable } from './disable.js'; import { enable } from './enable.js'; import { enablePush } from './enablePush.js'; +import { request } from './lib/api.js'; import { CLICKS_STORE, HANDLERS_STORE, @@ -48,6 +49,8 @@ export const CLICK_PARAM = 'puter.notification'; /** Where a site's tabs tell each other that a user's opt-in changed. */ const OPT_IN_CHANNEL = 'puter-push-opt-in'; +/** Held by a site's tabs while one changes this browser's subscription. */ +const PUSH_LOCK = 'puter-push'; /** As long as `push-sw.js` gives a handler. */ const HANDLER_TIMEOUT_MS = 5_000; @@ -60,6 +63,8 @@ const QUIET_SYNC_ERRORS = new Set([ 'push_revoked', 'push_unavailable', 'push_verification_canceled', + 'reauth_required', + 'token_auth_failed', 'too_many_requests', ]); @@ -73,6 +78,14 @@ const warnStorage = (error) => { ); }; +/** Runs `run` alone among the site's tabs, where the browser has Web Locks. */ +const withPushLock = (run) => { + const locks = globalThis.navigator?.locks; + return typeof locks?.request === 'function' + ? locks.request(PUSH_LOCK, () => run()) + : run(); +}; + /** Hidden, or in a window without focus. */ const pageAway = () => { const doc = globalThis.document; @@ -363,6 +376,7 @@ export class NotificationsModule extends EventListener { */ async _enableWebPush(worker) { this.#useWorker(worker); + this.#listenForOptInChanges(); /** @type {() => void} */ let pending = () => {}; const sent = new Promise((resolve) => { @@ -374,6 +388,8 @@ export class NotificationsModule extends EventListener { onPending: pending, signal, renewRemoved: true, + interactive: true, + withLock: withPushLock, }); try { // The GUI reads the registered device right after. @@ -409,12 +425,12 @@ export class NotificationsModule extends EventListener { * @internal */ async _disableWebPush(worker) { - this.#cancelVerification(); - try { - return await removeWebPush(this.puter, { worker }); - } finally { + this.#stopSyncs(); + if (this.puter.env === 'web') this.#optInChanged('off'); + return await withPushLock(async () => { if (this.puter.env === 'web') await this.#forgetOptIns(); - } + return await removeWebPush(this.puter, { worker }); + }); } /** @@ -460,8 +476,7 @@ export class NotificationsModule extends EventListener { */ async _forgetPush(authToken) { if (this.puter.env !== 'web') return; - this.#syncs.clear(); - this.#cancelVerification(); + this.#stopSyncs(); try { if (authToken && webPushSupported()) { await removeWebPush(this.puter, { @@ -510,8 +525,14 @@ export class NotificationsModule extends EventListener { const known = this.puter.whoami ?? (await this.puter.whoamiCache_) ?? null; if (typeof known?.uuid === 'string') return known.uuid; - const user = await this.puter.auth?.whoami?.(); - return typeof user?.uuid === 'string' ? user.uuid : null; + try { + const user = await request(this.puter, 'GET', '/whoami', null, { + interactiveReauth: false, + }); + return typeof user?.uuid === 'string' ? user.uuid : null; + } catch { + return null; + } } async #recordOptIn(on) { @@ -558,11 +579,11 @@ export class NotificationsModule extends EventListener { return on; } - /** Other tabs of the site re-read the opt-in this one just wrote. */ - #optInChanged() { + /** Other tabs re-read the opt-in; `off` also stops their syncs. */ + #optInChanged(kind = 'changed') { this.#listenForOptInChanges(); try { - this.#optInChannel?.postMessage('changed'); + this.#optInChannel?.postMessage(kind); } catch { // A closed channel; the other tabs read it on their next sync. } @@ -575,7 +596,10 @@ export class NotificationsModule extends EventListener { ) return; this.#optInChannel = new BroadcastChannel(OPT_IN_CHANNEL); - this.#optInChannel.onmessage = () => void this.#readOptIn(); + this.#optInChannel.onmessage = (event) => { + if (event.data === 'off') this.#stopSyncs(); + void this.#readOptIn(); + }; } async #syncIfOptedIn(worker) { @@ -584,6 +608,8 @@ export class NotificationsModule extends EventListener { syncWebPush(this.puter, { worker, signal: this.#stopVerifying.signal, + stillWanted: () => this.#readOptIn(), + withLock: withPushLock, }), worker, 'could not keep the push subscription current', @@ -625,9 +651,11 @@ export class NotificationsModule extends EventListener { await this.#recordOptIn(false); } - #cancelVerification() { + /** Ends this page's syncs in flight; the next `onPush` syncs again. */ + #stopSyncs() { this.#stopVerifying.abort(); this.#stopVerifying = new AbortController(); + this.#syncs.clear(); } /** diff --git a/src/puter-js/src/modules/notifications/lib/webPush.js b/src/puter-js/src/modules/notifications/lib/webPush.js index 11dddfff5..1f606fd56 100644 --- a/src/puter-js/src/modules/notifications/lib/webPush.js +++ b/src/puter-js/src/modules/notifications/lib/webPush.js @@ -220,11 +220,23 @@ const subscribeWith = async (registration, key) => { }; /** Tells the server about `subscription`; `active` or `pending`. */ -const announce = (puter, subscription, label) => - request(puter, 'POST', '/push/web/subscriptions', { - subscription: subscriptionBody(subscription), - ...(label ? { label } : {}), - }); +const announce = (puter, subscription, label, interactive) => + request( + puter, + 'POST', + '/push/web/subscriptions', + { + subscription: subscriptionBody(subscription), + ...(label ? { label } : {}), + }, + { interactiveReauth: interactive }, + ); + +const turnedOff = () => + new PuterJSError( + 'Push notifications were turned off before this browser was confirmed', + 'push_verification_canceled', + ); /** Whether `buffer` is the key Puter last subscribed this origin with. */ const earlierPuterKey = async (buffer) => { @@ -265,41 +277,44 @@ const currentSubscription = async (puter, registration, key) => { return null; }; -/** - * Announces `subscription` and, for one the server doesn't have yet, waits for - * the confirmation push and verifies it. - */ +/** Announces what `subscribe` gives, then confirms it if the server asks. */ const register = async ( puter, nav, - subscription, - { label, onPending, signal }, + subscribe, + { label, onPending, signal, interactive, whileWanted }, ) => { - // Listening first: the confirmation can arrive before the POST returns. - const challenge = nextChallenge(nav, signal); + /** @type {ReturnType | null} */ + let challenge = null; try { - const result = await announce(puter, subscription, label); + const { subscription, result } = await whileWanted(async () => { + const subscription = await subscribe(); + // Before the POST: the confirmation can arrive before it returns. + challenge = nextChallenge(nav, signal); + return { + subscription, + result: await announce(puter, subscription, label, interactive), + }; + }); if (result.status === 'active') return result.device; onPending?.(); const answer = await challenge.promise; - if (answer === null) { - throw new PuterJSError( - 'Push notifications were turned off before this browser was confirmed', - 'push_verification_canceled', - ); - } - const { device } = await request( - puter, - 'POST', - '/push/web/subscriptions/verify', - { - subscription: subscriptionBody(subscription), - challenge: answer, - }, + if (answer === null) throw turnedOff(); + const { device } = await whileWanted(() => + request( + puter, + 'POST', + '/push/web/subscriptions/verify', + { + subscription: subscriptionBody(subscription), + challenge: answer, + }, + { interactiveReauth: false }, + ), ); return device; } finally { - challenge.cancel(); + challenge?.cancel(); } }; @@ -316,12 +331,18 @@ const register = async ( * onPending?: () => void; * signal?: AbortSignal; * renewRemoved?: boolean; + * interactive?: boolean; + * stillWanted?: () => boolean | Promise; + * withLock?: (run: () => Promise) => Promise; * }} options * `onPending` runs once the server has the subscription and only its * confirmation is left. Aborting `signal` stops waiting for that * confirmation, with `push_verification_canceled`. `renewRemoved` replaces a * subscription the user removed in Puter instead of rejecting with - * `push_device_removed`. + * `push_device_removed`. `interactive` lets a rejected token open Puter's + * sign-in for the key and the subscription; the confirmation never does. + * Subscribing and confirming each run in `withLock` while `stillWanted` + * holds, else reject with `push_verification_canceled`. * @returns {Promise>} The server's device record. */ export async function syncWebPush( @@ -333,9 +354,18 @@ export async function syncWebPush( onPending, signal, renewRemoved = false, + interactive = false, + stillWanted = () => true, + withLock = (run) => run(), }, ) { - const { publicKey } = await request(puter, 'GET', '/push/web/key'); + const { publicKey } = await request( + puter, + 'GET', + '/push/web/key', + undefined, + { interactiveReauth: interactive }, + ); if (typeof publicKey !== 'string' || publicKey === '') { throw new PuterJSError( "Push notifications aren't available on this deployment", @@ -347,24 +377,50 @@ export async function syncWebPush( keepSiteWorker: puter.env !== 'gui', }); - let subscription = - (await currentSubscription(puter, registration, key)) ?? - (await subscribeWith(registration, key)); - await rememberPuterKey(publicKey); - - const options = { label, onPending, signal }; + /** @type {PushSubscription | null} */ + let subscription = null; + const options = { + label, + onPending, + signal, + interactive, + whileWanted: (run) => + withLock(async () => { + if (signal?.aborted || !(await stillWanted())) + throw turnedOff(); + return await run(); + }), + }; try { - return await register(puter, nav, subscription, options); + return await register( + puter, + nav, + async () => { + subscription = + (await currentSubscription(puter, registration, key)) ?? + (await subscribeWith(registration, key)); + await rememberPuterKey(publicKey); + return subscription; + }, + options, + ); } catch (error) { const renewable = RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) || (renewRemoved && error?.code === 'push_device_removed'); - if (!renewable || signal?.aborted) throw error; + if (!renewable || !subscription || signal?.aborted) throw error; // A brand-new subscription the push service doesn't know yet, an // endpoint another account turned off here, or one the user removed. - await subscription.unsubscribe(); - subscription = await subscribeWith(registration, key); - return await register(puter, nav, subscription, options); + const stale = subscription; + return await register( + puter, + nav, + async () => { + await stale.unsubscribe(); + return await subscribeWith(registration, key); + }, + options, + ); } } @@ -408,7 +464,7 @@ export async function removeWebPush( 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken }, + { authToken, interactiveReauth: false }, ); } finally { await subscription.unsubscribe(); diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index deb8fce3a..47cdb780d 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -178,6 +178,7 @@ describe('syncWebPush', () => { keys: { p256dh: 'p', auth: 'a' }, }, }, + { interactiveReauth: false }, ); expect(store.get('handlers:puter:server-key')).toBe(KEY); }); @@ -545,6 +546,145 @@ describe('syncWebPush', () => { ), ).toHaveLength(2); }); + + /** A server that answers pending; `confirm()` hands back the challenge. */ + const pendingServer = (nav) => { + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd8' } }, + ); + return () => + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + }; + const reauthByRoute = () => + Object.fromEntries( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options?.interactiveReauth, + ]), + ); + + it.each([ + [false, undefined], + [true, true], + ])( + 'with interactive: %s, lets only the key and the announce open a sign-in', + async (interactive, given) => { + const nav = makeNav(); + const confirm = pendingServer(nav); + const onPending = vi.fn(); + const syncing = syncWebPush(puter, { + worker, + nav, + onPending, + interactive: given, + }); + await vi.waitFor(() => expect(onPending).toHaveBeenCalled()); + confirm(); + await syncing; + expect(reauthByRoute()).toEqual({ + '/push/web/key': interactive, + '/push/web/subscriptions': interactive, + '/push/web/subscriptions/verify': false, + }); + }, + ); + + it('subscribes and announces inside withLock, and not at all once push is no longer wanted', async () => { + const nav = makeNav(); + const order = []; + server((route) => { + order.push(route); + return { status: 'active', device: { uid: 'd1' } }; + }); + const withLock = async (run) => { + order.push('lock'); + try { + return await run(); + } finally { + order.push('unlock'); + } + }; + nav.pushManager.subscribe.mockImplementation(async () => { + order.push('subscribe'); + return makeSubscription(); + }); + await syncWebPush(puter, { worker, nav, withLock }); + expect(order).toEqual([ + 'lock', + 'subscribe', + '/push/web/subscriptions', + 'unlock', + ]); + + mockRequest.mockClear(); + nav.pushManager.subscribe.mockClear(); + await expect( + syncWebPush(puter, { worker, nav, stillWanted: () => false }), + ).rejects.toMatchObject({ code: 'push_verification_canceled' }); + expect(nav.pushManager.subscribe).not.toHaveBeenCalled(); + expect(Object.keys(reauthByRoute())).toEqual(['/push/web/key']); + }); + + it.each(['active', 'pending'])( + 'waits for the lock as long as it takes when the server answers %s', + async (status) => { + vi.useFakeTimers(); + const unhandled = []; + const note = (reason) => unhandled.push(reason); + process.on('unhandledRejection', note); + try { + const nav = makeNav(); + server(() => ({ status, device: { uid: 'd9' } })); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const syncing = syncWebPush(puter, { + worker, + nav, + onPending: () => + nav.post({ + type: 'puter-push-challenge', + challenge: 'c.sig', + }), + withLock: async (run) => { + await held; + return await run(); + }, + }); + await vi.advanceTimersByTimeAsync(31_000); + release(); + await expect(syncing).resolves.toEqual({ uid: 'd9' }); + await vi.advanceTimersByTimeAsync(0); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', note); + } + }, + ); + + it('verifies only while push is still wanted when the confirmation arrives', async () => { + const nav = makeNav(); + const confirm = pendingServer(nav); + let wanted = true; + const syncing = syncWebPush(puter, { + worker, + nav, + onPending: () => { + wanted = false; + confirm(); + }, + stillWanted: () => wanted, + }); + await expect(syncing).rejects.toMatchObject({ + code: 'push_verification_canceled', + }); + expect(Object.keys(reauthByRoute())).not.toContain( + '/push/web/subscriptions/verify', + ); + }); }); describe('removeWebPush', () => { @@ -558,7 +698,7 @@ describe('removeWebPush', () => { 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: undefined }, + { authToken: undefined, interactiveReauth: false }, ); expect(subscription.unsubscribe).toHaveBeenCalled(); }); @@ -849,6 +989,8 @@ describe('onPush', () => { 'push_disabled', 'push_revoked', 'push_unavailable', + 'reauth_required', + 'token_auth_failed', 'too_many_requests', ])('keeps quiet about %s', async (code) => { store.set(OPT_IN, true); @@ -869,6 +1011,33 @@ describe('onPush', () => { error.mockRestore(); }); + it('never opens a sign-in, nor when the browser replaces the subscription', async () => { + store.set(OPT_IN, true); + const nav = makeNav({ existing: makeSubscription() }); + withBrowser(nav); + server(); + const { module } = makeModule(); + await module.onPush('chat', () => {}); + await vi.waitFor(() => + expect(mockRequest).toHaveBeenCalledTimes(2), + ); + nav.post({ type: 'puter-push-subscription-changed' }); + await vi.waitFor(() => + expect(mockRequest).toHaveBeenCalledTimes(4), + ); + expect( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options, + ]), + ).toEqual([ + ['/push/web/key', { interactiveReauth: false }], + ['/push/web/subscriptions', { interactiveReauth: false }], + ['/push/web/key', { interactiveReauth: false }], + ['/push/web/subscriptions', { interactiveReauth: false }], + ]); + }); + it('ends a subscription the user removed in Puter, quietly, and keeps pushes off until enable()', async () => { store.set(OPT_IN, true); const removed = makeSubscription(); @@ -1347,6 +1516,7 @@ describe('enable', () => { 'POST', '/push/web/subscriptions', expect.anything(), + { interactiveReauth: false }, ); }); @@ -1376,6 +1546,55 @@ describe('enable', () => { await expect(enabling).resolves.toBe(true); }); + it('lets the key and the announce open a sign-in, but not the confirmation', async () => { + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd2' } }, + ); + const { module } = makeModule(); + await module.enable(); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await vi.waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(3)); + expect( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options, + ]), + ).toEqual([ + ['/push/web/key', { interactiveReauth: true }], + ['/push/web/subscriptions', { interactiveReauth: true }], + ['/push/web/subscriptions/verify', { interactiveReauth: false }], + ]); + }); + + it('looks the user up without opening a sign-in when no user is cached', async () => { + withBrowser(makeNav({ existing: makeSubscription() })); + server((route) => + route === '/whoami' + ? { uuid: 'u-1' } + : { status: 'active', device: { uid: 'd1' } }, + ); + const auth = { whoami: vi.fn(async () => ({ uuid: 'u-1' })) }; + const { module } = makeModule({ + whoami: undefined, + whoamiCache_: Promise.resolve(null), + auth, + }); + await module.enable(); + expect(store.get(OPT_IN)).toBe(true); + expect(auth.whoami).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + 'GET', + '/whoami', + null, + { interactiveReauth: false }, + ); + }); + it('rejects user_gesture_required outside a click, without prompting', async () => { withBrowser(makeNav(), 'default'); Object.defineProperty(globalThis.navigator, 'userActivation', { @@ -1619,8 +1838,9 @@ describe('disable', () => { const { module } = makeModule(); const enabling = module.enable(); await vi.waitFor(() => expect(announce).toBeDefined()); - await module.disable(); + const disabling = module.disable(); announce(); + await expect(disabling).resolves.toBe(true); await expect(enabling).rejects.toMatchObject({ code: 'push_verification_canceled', }); @@ -1639,6 +1859,115 @@ describe('disable', () => { }); }); +describe.each([ + ['with Web Locks', false], + ['without Web Locks', true], +])('another tab turning push off, %s', (_label, noLocks) => { + beforeEach(() => { + if (noLocks) + Object.defineProperty(globalThis.navigator, 'locks', { + value: undefined, + configurable: true, + }); + }); + afterEach(() => { + if (noLocks) delete globalThis.navigator.locks; + }); + + /** Requests these tabs made; earlier tests' tabs still hear the channel. */ + const routes = (...tabs) => + mockRequest.mock.calls + .filter(([host]) => tabs.some((tab) => tab.puter === host)) + .map(([, method, route]) => `${method} ${route}`); + + it('stops a load sync before it announces, and the device stays removed', async () => { + store.set(OPT_IN, true); + withBrowser(makeNav({ existing: makeSubscription() })); + let releaseKey; + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') { + await new Promise((resolve) => { + releaseKey = resolve; + }); + return { publicKey: KEY }; + } + return method === 'DELETE' + ? { removed: true } + : { status: 'active', device: { uid: 'd1' } }; + }); + const onError = vi.fn(); + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError }); + await vi.waitFor(() => expect(releaseKey).toBeDefined()); + + const { module: other } = makeModule(); + await expect(other.disable()).resolves.toBe(true); + releaseKey(); + await flush(); + await flush(); + + expect(routes(loading, other)).toEqual([ + 'GET /push/web/key', + 'DELETE /push/web/subscriptions', + ]); + expect(onError).not.toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + }); + + it('stops a load sync waiting for its confirmation, without verifying', async () => { + store.set(OPT_IN, true); + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd1' } }, + ); + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError: () => {} }); + await vi.waitFor(() => + expect(routes(loading)).toContain('POST /push/web/subscriptions'), + ); + + const { module: other } = makeModule(); + await other.disable(); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await flush(); + await flush(); + + expect(routes(loading)).not.toContain( + 'POST /push/web/subscriptions/verify', + ); + }); + + it('makes an enable() in flight reject', async () => { + withBrowser(makeNav({ existing: makeSubscription() })); + let announce; + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + if (method === 'POST' && route === '/push/web/subscriptions') { + return await new Promise((resolve) => { + announce = () => resolve({ status: 'pending' }); + }); + } + return { removed: true }; + }); + const { module: enablingTab } = makeModule(); + const enabling = enablingTab.enable(); + await vi.waitFor(() => expect(announce).toBeDefined()); + + const { module: other } = makeModule(); + const disabling = other.disable(); + await flush(); + announce(); + await expect(enabling).rejects.toMatchObject({ + code: 'push_verification_canceled', + }); + await expect(disabling).resolves.toBe(true); + expect(store.has(OPT_IN)).toBe(false); + }); +}); + describe('sign-out', () => { it('removes the subscription with the leaving user`s token and forgets their push state', async () => { store.set(OPT_IN, true); @@ -1656,7 +1985,7 @@ describe('sign-out', () => { 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: 'leaving-token' }, + { authToken: 'leaving-token', interactiveReauth: false }, ); expect(subscription.unsubscribe).toHaveBeenCalled(); expect(store.has(OPT_IN)).toBe(false); diff --git a/src/puter-js/tests/e2e/fixtures/boot-reauth.html b/src/puter-js/tests/e2e/fixtures/boot-reauth.html index 91d752008..242d38a43 100644 --- a/src/puter-js/tests/e2e/fixtures/boot-reauth.html +++ b/src/puter-js/tests/e2e/fixtures/boot-reauth.html @@ -25,6 +25,13 @@ window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100'; +