fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.
This commit is contained in:
Juan Castro committed 2026-11-02 10:14:08 -05:00
1 parent e069ac68c3
commit 6d3b49d1e4
11 files changed
+421 -74

No files matched your search

+3 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+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. Only the subscription Puter made is removed. A subscription another push service made on your site, under any service worker, stays as it is.
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
+6 -4
View File
@@ -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<void>}
*/
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;
};
/**
+36 -1
View File
@@ -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();
}
+81 -21
View File
@@ -21,6 +21,7 @@ import {
currentWebPush,
removeWebPush,
syncWebPush,
unsubscribeWebPush,
webPushState,
webPushSupported,
workerFor,
@@ -239,6 +240,7 @@ export class NotificationsModule extends EventListener {
]) {
methods[name] = methods[name].bind(this);
}
if (puter.env === 'web') this.#watchUser();
}
/**
@@ -340,7 +342,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
@@ -464,8 +466,9 @@ 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
@@ -473,17 +476,58 @@ export class NotificationsModule extends EventListener {
async _forgetPush(authToken) {
if (this.puter.env !== 'web') return;
this.#stopSyncs();
try {
if (authToken && webPushSupported()) {
await removeWebPush(this.puter, { 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<string | null> } | null} */
let last = null;
const note = () => {
const token = this.puter.authToken;
if (!token || token === last?.token) return;
const previous = last;
last = { token, user: this.#tokenUser(token) };
if (previous) void this.#forgetIfAnotherUser(previous, last);
};
note();
this.puter.onAuthStateChanged?.(note);
}
async #forgetIfAnotherUser(previous, current) {
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) {
@@ -555,20 +599,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;
}
@@ -596,12 +642,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,
@@ -609,6 +657,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 {
@@ -4,29 +4,46 @@ 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.
*/
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 response = keepalive
? await sendKeepalive(puter, method, route, body, authToken)
: 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 isJson = response.headers
.get('content-type')
@@ -488,14 +488,8 @@ export async function webPushState(puter, endpoint) {
}
}
/**
* 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(
/** Ends Puter's subscriptions in the browser only; resolves their endpoints. */
export async function unsubscribeWebPush(
puter,
{ worker = null, authToken, nav = globalThis.navigator } = {},
) {
@@ -506,18 +500,41 @@ export async function removeWebPush(
});
for (const subscription of subscriptions) {
try {
await request(
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 = null,
authToken,
keepalive = false,
nav = globalThis.navigator,
} = {},
) {
const endpoints = await unsubscribeWebPush(puter, {
worker,
authToken,
nav,
});
await Promise.all(
endpoints.map((endpoint) =>
request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken, interactiveReauth: false },
);
} finally {
await subscription.unsubscribe();
}
}
return subscriptions.length > 0;
{ endpoint },
{ authToken, interactiveReauth: false, keepalive },
),
),
);
return endpoints.length > 0;
}
/** This browser's subscription for `worker`, or null. */
@@ -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) => ({
@@ -1378,3 +1379,61 @@ 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('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();
}
});
});
@@ -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);
}
@@ -711,20 +711,31 @@ describe('removeWebPush', () => {
};
const theirKey = new Uint8Array([4, ...Array(64).fill(9)]);
it('tells the server, then ends the subscription', async () => {
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 () => {
@@ -1039,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', () => {});
@@ -1047,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() }));
@@ -2049,14 +2103,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');
@@ -2066,10 +2127,13 @@ 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);
});
@@ -2106,6 +2170,33 @@ describe('sign-out', () => {
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' });
@@ -2113,3 +2204,73 @@ describe('sign-out', () => {
expect(mockRequest).not.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);
});
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));
});
});