fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.
This commit is contained in:
Juan Castro committed 2026-11-02 10:13:53 -05:00
1 parent 7b594d662f
commit e069ac68c3
6 files changed
+183 -41

No files matched your search

+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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 removes this browser's registration for your app. 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
@@ -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),
);
}
@@ -414,9 +414,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<boolean>}
* @internal
*/
@@ -475,10 +475,7 @@ export class NotificationsModule extends EventListener {
this.#stopSyncs();
try {
if (authToken && webPushSupported()) {
await removeWebPush(this.puter, {
worker: this.#worker ?? workerFor('web'),
authToken,
});
await removeWebPush(this.puter, { authToken });
}
} catch (error) {
console.warn(
@@ -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);
@@ -446,30 +489,35 @@ 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 this browser's subscriptions made with Puter's key, at `worker` or
* anywhere on the origin without one, and tells the server, with `authToken` in
* place of the live token when given.
*
* @returns {Promise<boolean>} Whether there was a subscription to remove.
*/
export async function removeWebPush(
puter,
{ worker, authToken, nav = globalThis.navigator } = {},
{ worker = null, authToken, nav = globalThis.navigator } = {},
) {
const registration = await nav.serviceWorker.getRegistration(
worker.scope ?? worker.url,
);
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();
const subscriptions = await puterSubscriptions(puter, {
worker,
authToken,
nav,
});
for (const subscription of subscriptions) {
try {
await request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken, interactiveReauth: false },
);
} finally {
await subscription.unsubscribe();
}
}
return true;
return subscriptions.length > 0;
}
/** This browser's subscription for `worker`, or null. */
@@ -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,7 +690,29 @@ describe('syncWebPush', () => {
});
describe('removeWebPush', () => {
/** 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('tells the server, then ends the subscription', async () => {
store.set(SERVER_KEY, KEY);
const subscription = makeSubscription();
const nav = makeNav({ existing: subscription });
mockRequest.mockResolvedValue({ removed: true });
@@ -702,6 +726,60 @@ describe('removeWebPush', () => {
);
expect(subscription.unsubscribe).toHaveBeenCalled();
});
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();
});
});
describe('workerFor', () => {
@@ -1849,6 +1927,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 +1943,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,
@@ -1971,6 +2051,7 @@ describe.each([
describe('sign-out', () => {
it('removes the subscription with the leaving user`s token and forgets their push state', async () => {
store.set(OPT_IN, true);
store.set(SERVER_KEY, KEY);
store.set('clicks:n1', { uid: 'n1' });
store.set('handlers:chat', '() => {}');
const subscription = makeSubscription();
@@ -1995,6 +2076,7 @@ describe('sign-out', () => {
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 +2094,18 @@ 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('does nothing inside the Puter desktop', async () => {
withBrowser(makeNav({ existing: makeSubscription() }));
const { module } = makeModule({ env: 'app' });