diff --git a/src/docs/src/Auth/signOut.md b/src/docs/src/Auth/signOut.md index eba6ba9f3..029e1e5c0 100755 --- a/src/docs/src/Auth/signOut.md +++ b/src/docs/src/Auth/signOut.md @@ -6,6 +6,8 @@ platforms: [websites, apps] Signs the user out of the application. +On a website, signing out also turns push notifications off in this browser. It ends the push subscription Puter made for your app here and removes it from Puter, and [`onPush()`](/Notifications/onPush/) doesn't subscribe again until the next user calls [`enable()`](/Notifications/enable/). The user is signed out as soon as you call it. The returned promise resolves once the subscription is ended, so wait for it before you reload the page. + ## Syntax ```js @@ -18,7 +20,7 @@ None ## Return value -None +A `Promise` that resolves with nothing once the user is signed out and, on a website, this browser's push subscription for your app is ended. It doesn't reject. ## Example diff --git a/src/docs/src/Notifications/disable.md b/src/docs/src/Notifications/disable.md index eacd26fc3..a78f603be 100644 --- a/src/docs/src/Notifications/disable.md +++ b/src/docs/src/Notifications/disable.md @@ -6,7 +6,7 @@ platforms: [websites, apps] Turns push notifications off for your app. Notifications still reach your app live while it's open. -On a website, it ends this browser's push subscription for your app and removes it from Puter. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt. +On a website, it ends this browser's push subscription for your app and removes it from Puter. Only the subscription Puter made is removed, so a subscription another push service made on your site stays as it is. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt. The choice sticks. Page loads don't turn pushes back on, because [`onPush()`](/Notifications/onPush/) only keeps a subscription current after [`enable()`](/Notifications/enable/). Call [`enable()`](/Notifications/enable/) from a click to turn them on again. @@ -21,7 +21,7 @@ puter.notifications.disable(options) ## Parameters #### `options` (Object) (optional) -- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). Defaults to `/puter-sw.js`. Only used on a website. +- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). When it's set, only that worker's subscription is removed. Without it, Puter's subscription is removed under whichever of your site's service workers holds it. Only used on a website. ## Return value A `Promise` that resolves to `true` when a subscription was removed, and `false` when there was none. Inside the Puter desktop, it resolves `true` when your app held the permission. It resolves `false` in a browser that can't receive pushes. diff --git a/src/docs/src/Notifications/enable.md b/src/docs/src/Notifications/enable.md index d3788b69a..4f4d49369 100644 --- a/src/docs/src/Notifications/enable.md +++ b/src/docs/src/Notifications/enable.md @@ -10,7 +10,7 @@ On a website, the browser asks on your own origin, and pushes arrive through you The call resolves as soon as the browser has answered and Puter has its subscription. It doesn't wait for the confirmation. Pushes start once your page hands the confirmation back, usually within seconds. If the confirmation doesn't arrive within 30 seconds, the `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout`, or the console does without one, and the next page load tries again. -The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click. +The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, after [`puter.auth.signOut()`](/Auth/signOut/), or after a different user signs in, page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click. If the user turned your app off in Puter's settings, the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again. diff --git a/src/docs/src/Notifications/push.md b/src/docs/src/Notifications/push.md index 86cd32286..8fe06c255 100644 --- a/src/docs/src/Notifications/push.md +++ b/src/docs/src/Notifications/push.md @@ -112,7 +112,7 @@ document.getElementById('enable').addEventListener('click', async () => { [`enable()`](/Notifications/enable/#return-value) lists every error it rejects with. When nobody is signed in, it opens the Puter sign-in after the browser's prompt. Sign the user in first, from its own button, so the sign-in window isn't blocked as a popup. -To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also removes this browser's registration for your app. +To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also ends this browser's push subscription for your app and removes it from Puter. Signing in as a different user does the same, so pushes stay off in this browser until the new user calls [`enable()`](/Notifications/enable/). Only the subscription Puter made is removed. A subscription another push service made on your site, under any service worker, stays as it is. ## Sending a notification diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index cd37ab822..5f689a972 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -530,14 +530,16 @@ export class AuthModule extends PuterModule { /** * Signs the user out of this app by discarding its auth token. On a - * website, this browser's push subscription for the app is removed too. + * website, this browser's push subscription for the app is removed too. The + * token is gone at once; the promise resolves once the subscription is. * - * @type {() => void} + * @type {() => Promise} */ - signOut = () => { + signOut = async () => { // Started first, so it still holds the token of the user leaving. - void puter.notifications?._forgetPush?.(puter.authToken); + const forgetting = puter.notifications?._forgetPush?.(puter.authToken); puter.resetAuthToken(); + await forgetting; }; /** diff --git a/src/puter-js/src/modules/Auth.test.js b/src/puter-js/src/modules/Auth.test.js index 5c4227bf2..6b564d4f5 100644 --- a/src/puter-js/src/modules/Auth.test.js +++ b/src/puter-js/src/modules/Auth.test.js @@ -260,11 +260,46 @@ describe('signOut', () => { ]); }); + it('resolves once push cleanup is done, with the token discarded at once', async () => { + let finish; + const puter = { + authToken: 't', + notifications: { + _forgetPush: vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }, + resetAuthToken: vi.fn(() => { + puter.authToken = null; + }), + }; + vi.stubGlobal('puter', puter); + try { + let done = false; + const signingOut = makeAuth() + .signOut() + .then(() => { + done = true; + }); + expect(puter.authToken).toBeNull(); + await Promise.resolve(); + expect(done).toBe(false); + finish(); + await signingOut; + expect(done).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + it('signs out where there is no notifications module', () => { const puter = { authToken: 't', resetAuthToken: vi.fn() }; vi.stubGlobal('puter', puter); try { - expect(() => makeAuth().signOut()).not.toThrow(); + expect(makeAuth().signOut()).toBeInstanceOf(Promise); } finally { vi.unstubAllGlobals(); } diff --git a/src/puter-js/src/modules/notifications/disable.js b/src/puter-js/src/modules/notifications/disable.js index d53ef3ae2..2c24009b8 100644 --- a/src/puter-js/src/modules/notifications/disable.js +++ b/src/puter-js/src/modules/notifications/disable.js @@ -4,12 +4,14 @@ import { webPushSupported, workerFor } from './lib/webPush.js'; * Turns push notifications off for this app. Notifications still arrive live * while the app is open. * - * On a website it ends this browser's subscription, removes it from Puter, and - * stops this site's pages here from showing notifications themselves. - * `onPush()` stops renewing the subscription until `enable()` is called again. - * Inside the Puter desktop it revokes the app's `notifications:push` permission - * for the user, so its notifications stop going out as pushes to any device, - * and a later `enable()` asks for the permission again. + * On a website it ends this browser's subscription made with Puter's key (the + * one under `serviceWorker` when given, else any on the site), removes it from + * Puter, and stops this site's pages here from showing notifications + * themselves. `onPush()` stops renewing the subscription until `enable()` is + * called again. Inside the Puter desktop it revokes the app's + * `notifications:push` permission for the user, so its notifications stop going + * out as pushes to any device, and a later `enable()` asks for the permission + * again. * * @this {import('./index.js').NotificationsModule} * @param {{ serviceWorker?: string }} [options] @@ -21,7 +23,8 @@ export async function disable(options = {}) { if (puter.env === 'app') return (await puter.ui?.disablePush_?.()) === true; if (puter.env !== 'web' && puter.env !== 'gui') return false; if (!webPushSupported()) return false; + const anywhere = puter.env === 'web' && !options.serviceWorker; return await this._disableWebPush( - workerFor(puter.env, options.serviceWorker), + anywhere ? null : workerFor(puter.env, options.serviceWorker), ); } diff --git a/src/puter-js/src/modules/notifications/index.js b/src/puter-js/src/modules/notifications/index.js index edfd2afcb..55d1767c9 100644 --- a/src/puter-js/src/modules/notifications/index.js +++ b/src/puter-js/src/modules/notifications/index.js @@ -22,6 +22,7 @@ import { currentWebPush, removeWebPush, syncWebPush, + unsubscribeWebPush, webPushState, webPushSupported, workerFor, @@ -78,12 +79,19 @@ 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(); +/** Runs `run` alone among the site's tabs, or as is where Web Locks refuse. */ +const withPushLock = async (run) => { + let started = false; + const start = () => { + started = true; + return run(); + }; + try { + return await globalThis.navigator.locks.request(PUSH_LOCK, start); + } catch (error) { + if (started) throw error; + return await start(); + } }; /** Hidden, or in a window without focus. */ @@ -217,6 +225,10 @@ export class NotificationsModule extends EventListener { * doesn't register this browser after them. */ #stopVerifying = new AbortController(); + /** The cleanup after a user change, which `enable()` waits for. */ + #userChange = Promise.resolve(); + /** The token whose push state sign-out already ended. */ + #forgottenToken = null; /** @param {import('../../index.js').Puter} puter */ constructor(puter) { @@ -243,6 +255,7 @@ export class NotificationsModule extends EventListener { ]) { methods[name] = methods[name].bind(this); } + if (puter.env === 'web') this.#watchUser(); } /** @@ -344,7 +357,7 @@ export class NotificationsModule extends EventListener { /** * `onPush`'s sync on load, once per worker: only for a user who turned push - * on here with `enable()`. + * on here with `enable()`, else Puter's subscription here ends. * * @param {{ url: string; scope?: string }} worker * @internal @@ -375,6 +388,7 @@ export class NotificationsModule extends EventListener { * @internal */ async _enableWebPush(worker) { + await this.#userChange; this.#useWorker(worker); this.#listenForOptInChanges(); /** @type {() => void} */ @@ -390,6 +404,7 @@ export class NotificationsModule extends EventListener { renewRemoved: true, interactive: true, withLock: withPushLock, + onAnnounced: () => this.#recordOptIn(true), }); try { // The GUI reads the registered device right after. @@ -408,7 +423,6 @@ export class NotificationsModule extends EventListener { ); } this.#workerRegistered = true; - await this.#recordOptIn(true); void this.#finishSync( syncing, worker, @@ -418,9 +432,9 @@ export class NotificationsModule extends EventListener { /** * `disable()`: ends this browser's subscription and forgets every opt-in - * here. + * here. A website without a `worker` ends Puter's wherever it is. * - * @param {{ url: string; scope?: string }} worker + * @param {{ url: string; scope?: string } | null} worker * @returns {Promise} * @internal */ @@ -468,29 +482,78 @@ export class NotificationsModule extends EventListener { } /** - * Sign-out on a website: removes this browser's subscription with the token - * of the user leaving, and forgets their opt-in and taps. Never throws. + * Sign-out on a website: forgets every opt-in and tap here, ends Puter's + * subscription in the browser, then removes it with the token of the user + * leaving. Never throws. * * @param {string | null | undefined} authToken * @internal */ async _forgetPush(authToken) { if (this.puter.env !== 'web') return; + this.#forgottenToken = authToken ?? null; this.#stopSyncs(); - try { - if (authToken && webPushSupported()) { - await removeWebPush(this.puter, { - worker: this.#worker ?? workerFor('web'), - authToken, - }); + this.#optInChanged('off'); + await withPushLock(async () => { + await this.#forgetOptIns(); + if (!webPushSupported()) return; + try { + await (authToken + ? removeWebPush(this.puter, { authToken, keepalive: true }) + : unsubscribeWebPush(this.puter)); + } catch (error) { + console.warn( + '[puter.notifications] could not remove the push subscription at sign-out', + error, + ); } - } catch (error) { - console.warn( - '[puter.notifications] could not remove the push subscription at sign-out', - error, - ); - } - await this.#forgetOptIns(); + }); + } + + /** Another user signing in here ends push for the last one, as sign-out. */ + #watchUser() { + /** @type {{ token: string; user: Promise } | null} */ + let last = null; + const note = () => { + const token = this.puter.authToken; + // Signed in again with it, so a later change cleans up again. + if (token && token === this.#forgottenToken) + this.#forgottenToken = null; + if (!token || token === last?.token) return; + const previous = last; + last = { token, user: this.#tokenUser(token) }; + if (previous) { + this.#userChange = Promise.all([ + this.#userChange, + this.#forgetIfAnotherUser(previous, last), + ]); + } + }; + note(); + this.puter.onAuthStateChanged?.(note); + } + + async #forgetIfAnotherUser(previous, current) { + if (previous.token === this.#forgottenToken) return; + const [was, now] = await Promise.all([previous.user, current.user]); + if (was && now && was !== now) await this._forgetPush(previous.token); + } + + /** The user `token` names: its `uu` claim, else its cached `/whoami`. */ + async #tokenUser(token) { + const claim = this.puter.decodeJwtPayload?.(token)?.uu; + const named = + typeof claim === 'string' + ? this.puter + .decodeCompressedAppID?.(claim) + ?.replace(/^app-/, '') + : null; + if (named) return named; + // `setAuthToken` starts that lookup right after telling its listeners. + await null; + if (this.puter.authToken !== token) return null; + const user = await this.puter.whoamiCache_; + return typeof user?.uuid === 'string' ? user.uuid : null; } #useWorker(worker) { @@ -562,20 +625,22 @@ export class NotificationsModule extends EventListener { this.#optInVersion++; } - /** The signed-in user's opt-in, read from IndexedDB into `#optedIn`. */ + /** The user's opt-in, read into `#optedIn`; null when it can't be read. */ async #readOptIn() { this.#listenForOptInChanges(); const version = this.#optInVersion; + /** @type {boolean | null} */ let on = false; try { - const uuid = needsSignIn(this.puter) - ? null - : await this.#userUuid(); - on = !!uuid && (await hasOptIn(uuid)); + if (!needsSignIn(this.puter)) { + const uuid = await this.#userUuid(); + on = uuid ? await hasOptIn(uuid) : null; + } } catch (error) { warnStorage(error); + on = null; } - if (version === this.#optInVersion) this.#optedIn = on; + if (version === this.#optInVersion) this.#optedIn = on === true; return on; } @@ -603,12 +668,14 @@ export class NotificationsModule extends EventListener { } async #syncIfOptedIn(worker) { - if (!(await this.#readOptIn())) return; + const optedIn = await this.#readOptIn(); + if (optedIn === false) await this.#endUnwanted(); + if (!optedIn) return; await this.#finishSync( syncWebPush(this.puter, { worker, signal: this.#stopVerifying.signal, - stillWanted: () => this.#readOptIn(), + stillWanted: async () => (await this.#readOptIn()) === true, withLock: withPushLock, }), worker, @@ -616,6 +683,18 @@ export class NotificationsModule extends EventListener { ); } + /** Ends a subscription left here without an opt-in, in the browser only. */ + async #endUnwanted() { + await withPushLock(async () => { + if ((await this.#readOptIn()) !== false) return; + try { + await unsubscribeWebPush(this.puter); + } catch { + // The next load tries again. + } + }); + } + /** Settles a sync nobody waits on; quiet about the expected failures. */ async #finishSync(syncing, worker, what) { try { diff --git a/src/puter-js/src/modules/notifications/lib/api.js b/src/puter-js/src/modules/notifications/lib/api.js index b4a90fcd5..3e5863f83 100644 --- a/src/puter-js/src/modules/notifications/lib/api.js +++ b/src/puter-js/src/modules/notifications/lib/api.js @@ -4,29 +4,50 @@ import { PuterJSError } from '../../../lib/PuterJSError.js'; const UNAVAILABLE_MESSAGE = "Push notifications aren't available on this deployment"; +/** A request the browser still sends after the page unloads; no sign-in. */ +const sendKeepalive = (puter, method, route, body, authToken) => { + const token = authToken ?? puter.authToken; + return fetch(`${puter.APIOrigin}${route}`, { + method, + keepalive: true, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +}; + /** * One `/push` call; the server's `{ message, code }` passes through. A 404 the * device routes didn't answer themselves becomes `push_unavailable`. With * `authToken`, that token is sent instead of the live one, and a rejected one * opens no sign-in. `interactiveReauth: false` keeps a rejected live token from - * opening one too. + * opening one too. `keepalive` sends it even if the page unloads meanwhile, + * where the browser allows it. */ export async function request( puter, method, route, body, - { authToken, interactiveReauth = true } = {}, + { authToken, interactiveReauth = true, keepalive = false } = {}, ) { - const response = await fetchUrl(`${puter.APIOrigin}${route}`, { - method, - ...(authToken - ? { authToken, interactiveReauth: false } - : { includePuterAuth: true }), - ...(interactiveReauth ? {} : { interactiveReauth: false }), - headers: { 'Content-Type': 'application/json' }, - ...(body ? { body: JSON.stringify(body) } : {}), - }); + const send = () => + fetchUrl(`${puter.APIOrigin}${route}`, { + method, + ...(authToken + ? { authToken, interactiveReauth: false } + : { includePuterAuth: true }), + ...(interactiveReauth ? {} : { interactiveReauth: false }), + headers: { 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + // Some browsers refuse a keepalive request that needs a CORS preflight. + const response = keepalive + ? await sendKeepalive(puter, method, route, body, authToken).catch(send) + : await send(); const isJson = response.headers .get('content-type') diff --git a/src/puter-js/src/modules/notifications/lib/webPush.js b/src/puter-js/src/modules/notifications/lib/webPush.js index 1f606fd56..bc428a695 100644 --- a/src/puter-js/src/modules/notifications/lib/webPush.js +++ b/src/puter-js/src/modules/notifications/lib/webPush.js @@ -238,18 +238,61 @@ const turnedOff = () => 'push_verification_canceled', ); -/** Whether `buffer` is the key Puter last subscribed this origin with. */ -const earlierPuterKey = async (buffer) => { +/** The key Puter last subscribed this origin with, or null. */ +const storedPuterKey = async () => { try { const stored = await idbGet(HANDLERS_STORE, SERVER_KEY_ENTRY); - return ( - typeof stored === 'string' && sameKey(buffer, fromBase64Url(stored)) - ); + return typeof stored === 'string' ? fromBase64Url(stored) : null; } catch { - return false; + return null; } }; +/** Whether `buffer` is the key Puter last subscribed this origin with. */ +const earlierPuterKey = async (buffer) => { + const stored = await storedPuterKey(); + return !!stored && sameKey(buffer, stored); +}; + +/** 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; + try { + const { publicKey } = await request( + puter, + 'GET', + '/push/web/key', + undefined, + { authToken, interactiveReauth: false }, + ); + return typeof publicKey === 'string' && publicKey !== '' + ? fromBase64Url(publicKey) + : null; + } catch { + return null; + } +}; + +/** Subscriptions with Puter's key, at `worker` or anywhere on the origin. */ +const puterSubscriptions = async (puter, { worker, authToken, nav }) => { + const registrations = worker + ? [await nav.serviceWorker.getRegistration(worker.scope ?? worker.url)] + : await nav.serviceWorker.getRegistrations(); + const subscriptions = ( + await Promise.all( + registrations.map((r) => r?.pushManager.getSubscription()), + ) + ).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 []; + return subscriptions.filter((subscription) => + sameKey(subscription.options?.applicationServerKey, key), + ); +}; + const rememberPuterKey = async (publicKey) => { try { await idbPut(HANDLERS_STORE, SERVER_KEY_ENTRY, publicKey); @@ -282,7 +325,7 @@ const register = async ( puter, nav, subscribe, - { label, onPending, signal, interactive, whileWanted }, + { label, onPending, signal, interactive, whileWanted, onAnnounced }, ) => { /** @type {ReturnType | null} */ let challenge = null; @@ -291,10 +334,15 @@ const register = async ( const subscription = await subscribe(); // Before the POST: the confirmation can arrive before it returns. challenge = nextChallenge(nav, signal); - return { + const result = await announce( + puter, subscription, - result: await announce(puter, subscription, label, interactive), - }; + label, + interactive, + ); + if (signal?.aborted) throw turnedOff(); + await onAnnounced?.(); + return { subscription, result }; }); if (result.status === 'active') return result.device; onPending?.(); @@ -334,6 +382,7 @@ const register = async ( * interactive?: boolean; * stillWanted?: () => boolean | Promise; * withLock?: (run: () => Promise) => Promise; + * onAnnounced?: () => Promise; * }} options * `onPending` runs once the server has the subscription and only its * confirmation is left. Aborting `signal` stops waiting for that @@ -342,7 +391,8 @@ const register = async ( * `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`. + * holds, else reject with `push_verification_canceled`. `onAnnounced` runs in + * the same lock once the server has the subscription. * @returns {Promise>} The server's device record. */ export async function syncWebPush( @@ -357,6 +407,7 @@ export async function syncWebPush( interactive = false, stillWanted = () => true, withLock = (run) => run(), + onAnnounced, }, ) { const { publicKey } = await request( @@ -384,6 +435,7 @@ export async function syncWebPush( onPending, signal, interactive, + onAnnounced, whileWanted: (run) => withLock(async () => { if (signal?.aborted || !(await stillWanted())) @@ -445,31 +497,53 @@ export async function webPushState(puter, endpoint) { } } -/** - * Ends this browser's subscription for `worker` and tells the server, with - * `authToken` in place of the live token when given. - */ +/** Ends Puter's subscriptions in the browser only; resolves their endpoints. */ +export async function unsubscribeWebPush( + puter, + { worker = null, authToken, nav = globalThis.navigator } = {}, +) { + const subscriptions = await puterSubscriptions(puter, { + worker, + authToken, + nav, + }); + for (const subscription of subscriptions) { + try { + await subscription.unsubscribe(); + } catch { + // Already gone, or the browser can't tell. + } + } + return subscriptions.map((subscription) => subscription.endpoint); +} + +/** `unsubscribeWebPush`, then the server; resolves whether there was one. */ export async function removeWebPush( puter, - { worker, authToken, nav = globalThis.navigator } = {}, + { + worker = null, + authToken, + keepalive = false, + nav = globalThis.navigator, + } = {}, ) { - const registration = await nav.serviceWorker.getRegistration( - worker.scope ?? worker.url, + const endpoints = await unsubscribeWebPush(puter, { + worker, + authToken, + nav, + }); + await Promise.all( + endpoints.map((endpoint) => + request( + puter, + 'DELETE', + '/push/web/subscriptions', + { endpoint }, + { authToken, interactiveReauth: false, keepalive }, + ), + ), ); - const subscription = await registration?.pushManager.getSubscription(); - if (!subscription) return false; - try { - await request( - puter, - 'DELETE', - '/push/web/subscriptions', - { endpoint: subscription.endpoint }, - { authToken, interactiveReauth: false }, - ); - } finally { - await subscription.unsubscribe(); - } - return true; + return endpoints.length > 0; } /** This browser's subscription for `worker`, or null. */ diff --git a/src/puter-js/src/modules/notifications/notifications.test.js b/src/puter-js/src/modules/notifications/notifications.test.js index 22f21471d..341f035e0 100644 --- a/src/puter-js/src/modules/notifications/notifications.test.js +++ b/src/puter-js/src/modules/notifications/notifications.test.js @@ -9,6 +9,7 @@ vi.mock('../../lib/networkUtils.js', () => ({ const { NotificationsModule } = await import('./index.js'); const { webPushState } = await import('./lib/webPush.js'); +const { request } = await import('./lib/api.js'); const { PuterJSError } = await import('../../lib/PuterJSError.js'); const jsonResponse = (body, status = 200) => ({ @@ -1453,3 +1454,94 @@ describe('webPushState', () => { ).resolves.toBe(null); }); }); + +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 })); + vi.stubGlobal('fetch', fetch); + try { + const puter = { APIOrigin: 'https://api.test', authToken: 'live' }; + await expect( + request( + puter, + 'DELETE', + '/push/web/subscriptions', + { endpoint: 'e' }, + { authToken: 'leaving', keepalive: true }, + ), + ).resolves.toEqual({ removed: true }); + expect(fetch).toHaveBeenCalledWith( + 'https://api.test/push/web/subscriptions', + { + method: 'DELETE', + keepalive: true, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer leaving', + }, + body: JSON.stringify({ endpoint: 'e' }), + }, + ); + expect(mockFetchUrl).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('sends it once more through fetchUrl when the browser refuses keepalive', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('Failed to fetch'); + }), + ); + mockFetchUrl.mockResolvedValue(jsonResponse({ removed: true })); + try { + await expect( + request( + { APIOrigin: 'https://api.test', authToken: 'live' }, + 'DELETE', + '/push/web/subscriptions', + { endpoint: 'e' }, + { + authToken: 'leaving', + interactiveReauth: false, + keepalive: true, + }, + ), + ).resolves.toEqual({ removed: true }); + expect(mockFetchUrl).toHaveBeenCalledTimes(1); + expect(mockFetchUrl.mock.calls[0][1]).toMatchObject({ + method: 'DELETE', + authToken: 'leaving', + interactiveReauth: false, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('passes the server error through', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonResponse({ message: 'no', code: 'token_auth_failed' }, 401), + ), + ); + try { + const error = await rejects(() => + request( + { APIOrigin: 'https://api.test', authToken: 't' }, + 'DELETE', + '/push/web/subscriptions', + { endpoint: 'e' }, + { keepalive: true }, + ), + ); + expect(error.code).toBe('token_auth_failed'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/puter-js/src/modules/notifications/onPush.js b/src/puter-js/src/modules/notifications/onPush.js index 0f63f6849..771724e8a 100644 --- a/src/puter-js/src/modules/notifications/onPush.js +++ b/src/puter-js/src/modules/notifications/onPush.js @@ -1,5 +1,4 @@ import { PuterJSError } from '../../lib/PuterJSError.js'; -import { needsSignIn } from '../events/lib/signIn.js'; import { webPushSupported, workerFor } from './lib/webPush.js'; /** Same rule the server applies to a notification's `channel`. */ @@ -120,12 +119,7 @@ export async function onPush(key, handler, options = {}) { worker, }); - if ( - worker && - !needsSignIn(puter) && - webPushSupported() && - Notification.permission === 'granted' - ) { + if (worker && webPushSupported() && Notification.permission === 'granted') { // Not awaited: a new subscription waits on a confirmation push. this._syncPushOnLoad(worker); } diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index 47cdb780d..0f627b37a 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -48,6 +48,7 @@ const SCRIPT = `${ORIGIN}/puter-sw.js`; const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url'); const OLD_KEY = Buffer.from([4, ...Array(64).fill(3)]).toString('base64url'); const OPT_IN = 'handlers:puter:opt-in:u-1'; +const SERVER_KEY = 'handlers:puter:server-key'; const makeSubscription = ( key = fromBase64Url(KEY), @@ -105,6 +106,7 @@ const makeNav = ({ serviceWorker: { register: vi.fn(async () => registration), getRegistration: vi.fn(async () => registration), + getRegistrations: vi.fn(async () => [registration]), addEventListener: (type, fn) => listeners.add(fn), removeEventListener: (type, fn) => listeners.delete(fn), startMessages: vi.fn(), @@ -688,19 +690,106 @@ describe('syncWebPush', () => { }); describe('removeWebPush', () => { - it('tells the server, then ends the subscription', async () => { + /** A browser with a registration per scope, each holding `subscription`. */ + const navWith = (byScope) => { + const registrations = Object.entries(byScope).map( + ([scope, subscription]) => ({ + scope: `${ORIGIN}${scope}`, + pushManager: { getSubscription: async () => subscription }, + }), + ); + return { + serviceWorker: { + getRegistrations: vi.fn(async () => registrations), + getRegistration: vi.fn(async (path) => + registrations.find((r) => + `${ORIGIN}${path}`.startsWith(r.scope), + ), + ), + }, + }; + }; + const theirKey = new Uint8Array([4, ...Array(64).fill(9)]); + + it('ends the subscription in the browser, then tells the server', async () => { + store.set(SERVER_KEY, KEY); + const order = []; const subscription = makeSubscription(); + subscription.unsubscribe.mockImplementation(async () => { + order.push('unsubscribe'); + }); + mockRequest.mockImplementation(async (_p, method) => { + order.push(method); + return { removed: true }; + }); const nav = makeNav({ existing: subscription }); - mockRequest.mockResolvedValue({ removed: true }); await expect(removeWebPush(puter, { worker, nav })).resolves.toBe(true); expect(mockRequest).toHaveBeenCalledWith( puter, 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: undefined, interactiveReauth: false }, + { + authToken: undefined, + interactiveReauth: false, + keepalive: false, + }, ); - expect(subscription.unsubscribe).toHaveBeenCalled(); + expect(order).toEqual(['unsubscribe', 'DELETE']); + }); + + it('leaves a subscription made with another key alone, and tells the server nothing', async () => { + store.set(SERVER_KEY, KEY); + const theirs = makeSubscription(theirKey); + const nav = makeNav({ existing: theirs }); + await expect(removeWebPush(puter, { worker, nav })).resolves.toBe( + false, + ); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('without a worker, finds Puter`s subscription in a folder and keeps the site`s own at the root', async () => { + store.set(SERVER_KEY, KEY); + const theirs = makeSubscription(theirKey, 'https://push.example/site'); + const ours = makeSubscription(); + const nav = navWith({ '/': theirs, '/push/': ours }); + mockRequest.mockResolvedValue({ removed: true }); + await expect(removeWebPush(puter, { nav })).resolves.toBe(true); + expect(ours.unsubscribe).toHaveBeenCalled(); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + expect(mockRequest.mock.calls.map(([, , , body]) => body)).toEqual([ + { endpoint: ours.endpoint }, + ]); + }); + + it('asks the server for Puter`s key, without a sign-in, when none is stored', async () => { + const theirs = makeSubscription(theirKey, 'https://push.example/site'); + const ours = makeSubscription(); + const nav = navWith({ '/': theirs, '/push/': ours }); + server(() => ({ removed: true })); + await removeWebPush(puter, { nav, authToken: 'leaving' }); + expect(mockRequest.mock.calls[0]).toEqual([ + puter, + 'GET', + '/push/web/key', + undefined, + { authToken: 'leaving', interactiveReauth: false }, + ]); + expect(ours.unsubscribe).toHaveBeenCalled(); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + }); + + it('touches nothing when Puter`s key can`t be told', async () => { + const subscription = makeSubscription(); + const nav = makeNav({ existing: subscription }); + mockRequest.mockRejectedValue( + new PuterJSError('down', 'internal_error'), + ); + await expect(removeWebPush(puter, { worker, nav })).resolves.toBe( + false, + ); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); }); }); @@ -961,7 +1050,7 @@ describe('onPush', () => { describe('on load', () => { it('syncs nothing for a user who never turned push on here', async () => { - withBrowser(makeNav({ existing: makeSubscription() })); + withBrowser(makeNav()); server(); const { module } = makeModule(); await module.onPush('chat', () => {}); @@ -969,6 +1058,49 @@ describe('onPush', () => { expect(mockRequest).not.toHaveBeenCalled(); }); + it.each([ + ['a user who never turned push on here', {}], + ['a visitor who is not signed in', { authToken: null }], + ])( + 'ends Puter`s subscription in the browser only, for %s', + async (_label, over) => { + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + const { module } = makeModule(over); + await module.onPush('chat', () => {}, { onError: () => {} }); + await vi.waitFor(() => + expect(subscription.unsubscribe).toHaveBeenCalled(), + ); + expect(mockRequest).not.toHaveBeenCalled(); + }, + ); + + it('leaves the subscription alone when the opt-ins can`t be read', async () => { + store.set(SERVER_KEY, KEY); + storage.broken = true; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + const { module } = makeModule(); + await module.onPush('chat', () => {}, { onError: () => {} }); + await flush(); + await flush(); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('leaves another service`s subscription alone', async () => { + store.set(SERVER_KEY, KEY); + const theirs = makeSubscription(new Uint8Array([4, 1, 2])); + withBrowser(makeNav({ existing: theirs })); + const { module } = makeModule(); + await module.onPush('chat', () => {}); + await flush(); + await flush(); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + }); + it('syncs once for every onPush on the page when the user turned push on', async () => { store.set(OPT_IN, true); withBrowser(makeNav({ existing: makeSubscription() })); @@ -1631,6 +1763,51 @@ describe('enable', () => { }); }); +describe('enable() while another tab loads', () => { + it('records the opt-in before the other tab checks it, so the new subscription stays', async () => { + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + 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: 'active', device: { uid: 'd1' } }); + }); + } + return {}; + }); + const { module: enabling } = makeModule(); + const turningOn = enabling.enable(); + await vi.waitFor(() => expect(announce).toBeDefined()); + + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError: () => {} }); + await flush(); + announce(); + await expect(turningOn).resolves.toBe(true); + await flush(); + await flush(); + + expect(store.get(OPT_IN)).toBe(true); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); + }); + + it('records no opt-in when the announce fails', async () => { + withBrowser(makeNav({ existing: makeSubscription() })); + server(() => { + throw new PuterJSError('down', 'internal_error'); + }); + const { module } = makeModule(); + await expect(module.enable()).rejects.toMatchObject({ + code: 'internal_error', + }); + expect(store.has(OPT_IN)).toBe(false); + }); +}); + describe('enable after the user removed this browser in Puter', () => { it('replaces the removed subscription and turns pushes back on', async () => { const removed = makeSubscription(undefined, 'https://push.example/1'); @@ -1849,6 +2026,7 @@ describe('disable', () => { it('removes the subscription and forgets the opt-in', async () => { store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); const subscription = makeSubscription(); withBrowser(makeNav({ existing: subscription })); mockRequest.mockResolvedValue({ removed: true }); @@ -1864,6 +2042,7 @@ describe.each([ ['without Web Locks', true], ])('another tab turning push off, %s', (_label, noLocks) => { beforeEach(() => { + store.set(SERVER_KEY, KEY); if (noLocks) Object.defineProperty(globalThis.navigator, 'locks', { value: undefined, @@ -1969,13 +2148,21 @@ describe.each([ }); describe('sign-out', () => { - it('removes the subscription with the leaving user`s token and forgets their push state', async () => { + it('forgets the push state, ends the subscription in the browser, then removes it with the leaving user`s token', async () => { store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); store.set('clicks:n1', { uid: 'n1' }); store.set('handlers:chat', '() => {}'); + const order = []; const subscription = makeSubscription(); + subscription.unsubscribe.mockImplementation(async () => { + order.push(['unsubscribe', store.has(OPT_IN)]); + }); withBrowser(makeNav({ existing: subscription })); - mockRequest.mockResolvedValue({ removed: true }); + mockRequest.mockImplementation(async (_p, method) => { + order.push([method]); + return { removed: true }; + }); const { module } = makeModule(); await module._forgetPush('leaving-token'); @@ -1985,16 +2172,20 @@ describe('sign-out', () => { 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: 'leaving-token', interactiveReauth: false }, + { + authToken: 'leaving-token', + interactiveReauth: false, + keepalive: true, + }, ); - expect(subscription.unsubscribe).toHaveBeenCalled(); - expect(store.has(OPT_IN)).toBe(false); + expect(order).toEqual([['unsubscribe', false], ['DELETE']]); expect(store.has('clicks:n1')).toBe(false); expect(store.has('handlers:chat')).toBe(true); }); it('never rejects, and still forgets the push state when the server refuses', async () => { store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); const subscription = makeSubscription(); withBrowser(makeNav({ existing: subscription })); mockRequest.mockRejectedValue( @@ -2012,6 +2203,45 @@ describe('sign-out', () => { warn.mockRestore(); }); + it('leaves another service`s subscription on the site alone', async () => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + const theirs = makeSubscription(new Uint8Array([4, 1, 2])); + withBrowser(makeNav({ existing: theirs })); + const { module } = makeModule(); + await module._forgetPush('leaving-token'); + expect(theirs.unsubscribe).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + }); + + it('stops another tab`s sync waiting for its confirmation', async () => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { removed: true }, + ); + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError: () => {} }); + await vi.waitFor(() => + expect(mockRequest.mock.calls.map(([, , r]) => r)).toContain( + '/push/web/subscriptions', + ), + ); + const { module: other } = makeModule(); + await other._forgetPush('leaving-token'); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await flush(); + await flush(); + expect(mockRequest.mock.calls.map(([, , r]) => r)).not.toContain( + '/push/web/subscriptions/verify', + ); + }); + it('does nothing inside the Puter desktop', async () => { withBrowser(makeNav({ existing: makeSubscription() })); const { module } = makeModule({ env: 'app' }); @@ -2019,3 +2249,213 @@ describe('sign-out', () => { expect(mockRequest).not.toHaveBeenCalled(); }); }); + +describe('where the browser refuses Web Locks, as in a sandboxed frame', () => { + beforeEach(() => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: vi.fn(async () => { + throw new Error('The request was denied'); + }), + }, + configurable: true, + }); + }); + afterEach(() => { + delete globalThis.navigator.locks; + }); + + it('signs out without rejecting and still ends the subscription', async () => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + mockRequest.mockResolvedValue({ removed: true }); + const { module } = makeModule(); + await expect( + module._forgetPush('leaving-token'), + ).resolves.toBeUndefined(); + expect(subscription.unsubscribe).toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + }); + + it('still ends a subscription left here without an opt-in on load', async () => { + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + const { module } = makeModule(); + await module.onPush('chat', () => {}, { onError: () => {} }); + await vi.waitFor(() => + expect(subscription.unsubscribe).toHaveBeenCalled(), + ); + }); +}); + +describe('a different user signing in', () => { + const USERS = { 'token-a': 'u-1', 'token-a2': 'u-1', 'token-b': 'u-2' }; + /** A page signed in as u-1, whose tokens name their user in `uu`. */ + const signedIn = (over = {}) => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + mockRequest.mockResolvedValue({ removed: true }); + const { module, host } = makeModule({ + authToken: 'token-a', + decodeJwtPayload: (token) => ({ uu: USERS[token] }), + decodeCompressedAppID: (value) => value, + ...over, + }); + return { module, host, subscription }; + }; + const deletes = () => + mockRequest.mock.calls.filter(([, method]) => method === 'DELETE'); + + it.each([ + ['over the session', ['token-b']], + ['after the last token was dropped', [null, 'token-b']], + ])( + 'ends the last user`s pushes with their token, %s', + async (_label, tokens) => { + const { host, subscription } = signedIn(); + for (const token of tokens) host.setAuthToken(token); + await vi.waitFor(() => expect(deletes()).toHaveLength(1)); + expect(deletes()[0][4]).toMatchObject({ + authToken: 'token-a', + keepalive: true, + }); + expect(subscription.unsubscribe).toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + }, + ); + + it.each([ + ['a new token for the same user', ['token-a2']], + ['the token being dropped', [null]], + ])('keeps everything on %s', async (_label, tokens) => { + const { host, subscription } = signedIn(); + for (const token of tokens) host.setAuthToken(token); + await flush(); + await flush(); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(store.get(OPT_IN)).toBe(true); + }); + + /** A page of u-1 where `enable()` signs in u-2; `/whoami` takes a moment. */ + const enablingPage = () => { + store.set(OPT_IN, true); + store.set(SERVER_KEY, KEY); + const subscription = makeSubscription(); + withBrowser(makeNav({ existing: subscription })); + const listeners = new Set(); + const lookUp = (token) => + new Promise((resolve) => + setTimeout(() => resolve({ uuid: USERS[token] }), 20), + ); + const { module, host } = makeModule({ + authToken: 'token-a', + whoami: undefined, + whoamiCache_: lookUp('token-a'), + decodeJwtPayload: () => null, + onAuthStateChanged: (listener) => listeners.add(listener), + setAuthToken(token) { + this.authToken = token; + for (const listener of listeners) listener(); + this.whoamiCache_ = token ? lookUp(token) : null; + }, + ui: { + authenticateWithPuter: async () => host.setAuthToken('token-b'), + }, + }); + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + if (method === 'POST' && route === '/push/web/subscriptions') + return { status: 'active', device: { uid: 'd1' } }; + return { removed: true }; + }); + return { module, host, subscription }; + }; + const OPT_IN_B = 'handlers:puter:opt-in:u-2'; + const routes = () => mockRequest.mock.calls.map(([, m, r]) => `${m} ${r}`); + + it('lets the next user turn push on after a sign-out on the same page', async () => { + const { module, host, subscription } = enablingPage(); + await module._forgetPush('token-a'); + host.setAuthToken(null); + subscription.unsubscribe.mockClear(); + mockRequest.mockClear(); + + await expect(module.enable()).resolves.toBe(true); + await flush(); + await flush(); + + expect(store.get(OPT_IN_B)).toBe(true); + expect(subscription.unsubscribe).not.toHaveBeenCalled(); + expect(routes()).not.toContain('DELETE /push/web/subscriptions'); + }); + + it('ends the last user`s pushes before the next one, signing in from enable(), turns them on', async () => { + const { module, host } = enablingPage(); + await flush(); + host.setAuthToken(null); + + await expect(module.enable()).resolves.toBe(true); + await flush(); + await flush(); + + expect(store.has(OPT_IN)).toBe(false); + expect(store.get(OPT_IN_B)).toBe(true); + const order = routes(); + expect(order.indexOf('DELETE /push/web/subscriptions')).toBeLessThan( + order.indexOf('POST /push/web/subscriptions'), + ); + expect(order.lastIndexOf('DELETE /push/web/subscriptions')).toBe( + order.indexOf('DELETE /push/web/subscriptions'), + ); + }); + + it('ends push for a user who signed out and back in with the same token, once another user signs in', async () => { + const { module, host, subscription } = signedIn({ whoami: undefined }); + server((route) => { + if (route === '/whoami') return { uuid: USERS[host.authToken] }; + if (route === '/push/web/subscriptions') + return { status: 'active', device: { uid: 'd1' } }; + return { removed: true }; + }); + await module._forgetPush('token-a'); + host.setAuthToken(null); + host.setAuthToken('token-a'); + await expect(module.enable()).resolves.toBe(true); + expect(store.get(OPT_IN)).toBe(true); + mockRequest.mockClear(); + subscription.unsubscribe.mockClear(); + + host.setAuthToken('token-b'); + await vi.waitFor(() => expect(deletes()).toHaveLength(1)); + expect(deletes()[0][4]).toMatchObject({ authToken: 'token-a' }); + expect(subscription.unsubscribe).toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + + await expect(module.enable()).resolves.toBe(true); + expect(store.get('handlers:puter:opt-in:u-2')).toBe(true); + }); + + it('tells users apart by the cached /whoami when the token names none', async () => { + const listeners = new Set(); + const { host } = signedIn({ + decodeJwtPayload: () => null, + whoamiCache_: Promise.resolve({ uuid: 'u-1' }), + onAuthStateChanged: (listener) => listeners.add(listener), + // As `puter.setAuthToken`: listeners first, then the lookup. + setAuthToken(token) { + this.authToken = token; + for (const listener of listeners) listener(); + this.whoamiCache_ = Promise.resolve({ uuid: USERS[token] }); + }, + }); + await flush(); + host.setAuthToken('token-b'); + await vi.waitFor(() => expect(deletes()).toHaveLength(1)); + }); +});