diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js
index 2ca338cc0..179c95acd 100644
--- a/src/gui/src/UI/UIWindowLogin.js
+++ b/src/gui/src/UI/UIWindowLogin.js
@@ -191,8 +191,11 @@ async function UIWindowLogin (options) {
// returns it as a plain sign-in popup, losing whatever the popup
// was opened to do — and, for a permission prompt, handing the
// opener a token instead of a decision. Don't offer it there.
+ // A cross-origin-isolated opener can only get the token through
+ // the session relay, which the provider's return doesn't keep.
if ( window.embedded_in_popup
- && ! offersFederatedSignInInPopup(window.gui_action) ) {
+ && ( ! offersFederatedSignInInPopup(window.gui_action)
+ || window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) {
return;
}
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
diff --git a/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js
new file mode 100644
index 000000000..a7c0d4b71
--- /dev/null
+++ b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
+
+const state = vi.hoisted(() => ({ el: null }));
+vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
+vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
+vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
+vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
+vi.mock('../helpers/authLogoHeader.js', () => ({
+ authLogoHeader: () => '',
+ wireAuthLogoHeader: vi.fn(),
+}));
+vi.mock('./UIWindow.js', () => ({
+ default: async (options) => {
+ state.el = document.createElement('div');
+ state.el.innerHTML = options.body_content;
+ document.body.append(state.el);
+ options.onAppend?.(state.el);
+ return state.el;
+ },
+}));
+globalThis.def = globalThis.def ?? ((value) => value);
+const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
+const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
+
+const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
+const RELAY_POPUP =
+ 'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s';
+const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1';
+
+const asPopup = (query) => {
+ window.embedded_in_popup = true;
+ window.gui_action = 'sign-in';
+ window.url_query_params = new URLSearchParams(query);
+};
+
+const askedForProviders = () =>
+ fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+ globalThis.$ = jQuery;
+ jQuery.fn.close = vi.fn();
+ globalThis.i18n = (key) => key;
+ globalThis.html_encode = (value) => jQuery('
').text(value).html();
+ globalThis.fetch = vi.fn(
+ async () => new Response(JSON.stringify({ providers: ['google'] })),
+ );
+ window.gui_origin = 'https://puter.test';
+ window.api_origin = 'https://api.puter.test';
+ window.gui_params = { turnstileSiteKey: 'site-key' };
+ window.turnstile = { render: vi.fn() };
+ window.icons = {};
+ window.uuidv4 = () => 'oidc-popup-test';
+ window.embedded_in_popup = false;
+ window.gui_action = undefined;
+ window.url_query_params = new URLSearchParams('');
+ state.el = null;
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe.each([
+ [
+ 'login',
+ () => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }),
+ '.login-btn',
+ ],
+ [
+ 'signup',
+ () => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }),
+ '.signup-btn',
+ ],
+])('OIDC buttons in the %s window', (_name, open, formButton) => {
+ const rendered = async () => {
+ void open();
+ await vi.waitFor(() =>
+ expect(jQuery(state.el).find(formButton)).toHaveLength(1),
+ );
+ };
+
+ // The provider's return drops the relay that hands the token to an
+ // isolated opener, so only password sign-in can finish there.
+ it('hides them in a cross-origin-isolated relay popup', async () => {
+ asPopup(RELAY_POPUP);
+ await rendered();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(askedForProviders()).toBe(false);
+ expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe(
+ 'none',
+ );
+ });
+
+ it('keeps them in a regular sign-in popup', async () => {
+ asPopup(PLAIN_POPUP);
+ await rendered();
+
+ await vi.waitFor(() =>
+ expect(
+ jQuery(state.el).find('.oidc-google-btn').css('display'),
+ ).toBe('flex'),
+ );
+ });
+
+ it('keeps them outside a popup', async () => {
+ await rendered();
+
+ await vi.waitFor(() =>
+ expect(
+ jQuery(state.el).find('.oidc-google-btn').css('display'),
+ ).toBe('flex'),
+ );
+ expect(askedForProviders()).toBe(true);
+ });
+});
diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js
index 671c9cea4..660393b4b 100644
--- a/src/gui/src/UI/UIWindowSignup.js
+++ b/src/gui/src/UI/UIWindowSignup.js
@@ -285,10 +285,16 @@ function UIWindowSignup(options) {
// provider returns it as a plain sign-in popup, losing
// whatever the popup was opened to do — and, for a
// permission prompt, handing the opener a token instead
- // of a decision. Don't offer it there.
+ // of a decision. Don't offer it there. A
+ // cross-origin-isolated opener can only get the token
+ // through the session relay, which the provider's
+ // return doesn't keep.
if (
window.embedded_in_popup &&
- !offersFederatedSignInInPopup(window.gui_action)
+ (!offersFederatedSignInInPopup(window.gui_action) ||
+ window.url_query_params?.get(
+ 'cross_origin_isolated',
+ ) === 'true')
) {
return;
}
diff --git a/src/puter-js/src/index.authFlow.test.js b/src/puter-js/src/index.authFlow.test.js
new file mode 100644
index 000000000..72ba77840
--- /dev/null
+++ b/src/puter-js/src/index.authFlow.test.js
@@ -0,0 +1,134 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('socket.io-client', () => ({
+ io: () => ({
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ disconnect: vi.fn(),
+ }),
+}));
+vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} }));
+vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} }));
+
+const origDefine = customElements.define.bind(customElements);
+customElements.define = (name, cls) =>
+ customElements.get(name) || origDefine(name, cls);
+
+const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent');
+const origXHR = globalThis.XMLHttpRequest;
+const urls = [];
+class CountingXHR {
+ _listeners = {};
+ status = 200;
+ readyState = 0;
+ responseType = '';
+ upload = { addEventListener() {} };
+ open(method, url) {
+ this.url = url;
+ urls.push(`${method} ${url}`);
+ }
+ setRequestHeader() {}
+ addEventListener(type, fn) {
+ (this._listeners[type] ??= []).push(fn);
+ }
+ getResponseHeader(n) {
+ return n.toLowerCase() === 'content-type' ? 'application/json' : null;
+ }
+ abort() {}
+ send() {
+ queueMicrotask(() => {
+ this.responseText = JSON.stringify({ username: 'u' });
+ this.response = this.responseText;
+ this.readyState = 4;
+ for (const fn of this._listeners.readystatechange ?? [])
+ fn.call(this);
+ for (const fn of this._listeners.load ?? [])
+ fn.call(this, { target: this });
+ });
+ }
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ urls.length = 0;
+ Object.defineProperty(globalThis, 'parent', {
+ value: globalThis,
+ configurable: true,
+ });
+ globalThis.XMLHttpRequest = CountingXHR;
+});
+afterEach(() => {
+ if (origParent) Object.defineProperty(globalThis, 'parent', origParent);
+ globalThis.XMLHttpRequest = origXHR;
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+const flush = async () => {
+ for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
+};
+
+describe('implicit sign-in on a web page', () => {
+ it('runs onAuth twice for one sign-in', async () => {
+ history.replaceState(null, '', '/site/index.html');
+ vi.resetModules();
+ // Hide `process` from the SDK's environment probe only, so the
+ // module's own instance boots as a top-level web page.
+ const realProcess = globalThis.process;
+ Object.defineProperty(globalThis, 'process', {
+ configurable: true,
+ get() {
+ const stack = new Error().stack ?? '';
+ return /src\/index\.js:\d+/.test(stack.split('\n')[2] ?? '')
+ ? undefined
+ : realProcess;
+ },
+ });
+ let puter;
+ try {
+ ({ puter } = await import('./index.js'));
+ } finally {
+ Object.defineProperty(globalThis, 'process', {
+ value: realProcess,
+ configurable: true,
+ writable: true,
+ });
+ }
+ expect(puter.env).toBe('web');
+ expect(puter.authToken).toBeFalsy();
+
+ const popup = { closed: false };
+ vi.spyOn(window, 'open').mockReturnValue(popup);
+ Object.defineProperty(navigator, 'userActivation', {
+ value: { hasBeenActive: true, isActive: true },
+ configurable: true,
+ });
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue('sess-1');
+
+ const onAuth = vi.fn();
+ puter.onAuth = onAuth;
+
+ const signedIn = puter.ui.authenticateWithPuter();
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: puter.defaultGUIOrigin,
+ source: popup,
+ data: {
+ msg: 'puter.token',
+ msg_id: 'sess-1',
+ success: true,
+ token: 'tok',
+ },
+ }),
+ );
+ await signedIn;
+ await flush();
+
+ expect(puter.authToken).toBe('tok');
+ expect(onAuth).toHaveBeenCalledTimes(1);
+ const whoami = urls.filter((u) => u.includes('/whoami'));
+ expect(whoami.length).toBe(1);
+ });
+});
diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js
index 197f643a4..e9b8932fb 100644
--- a/src/puter-js/src/index.js
+++ b/src/puter-js/src/index.js
@@ -722,8 +722,6 @@ export class Puter {
// When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because
// the SDK needs to show a dialog to the user to ask for permission to access their Puter account.
else if (this.env === 'web') {
- // initialize submodules
- this.initSubmodules();
try {
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
@@ -754,6 +752,8 @@ export class Puter {
console.error('Error accessing localStorage:', error);
}
+ this.initSubmodules();
+
// Print a CTA for developers to publish their app on the Puter App Store
this.printDevCTA();
@@ -2015,9 +2015,14 @@ globalThis.addEventListener &&
'*',
);
} else if (event.data.msg === 'puter.token') {
+ if (puter.auth?.pendingSignInMessageIds_?.has(event.data.msg_id))
+ return;
if (!puter.tokenSourceAllowed_(event.source)) {
return;
}
+ // Could be a late reply from an old popup; the flow that owns the
+ // prompt settles its own failures.
+ if (event.data.success === false) return;
// Set the authToken property
puter.setAuthToken(event.data.token);
// update appID only when token does not include app identity
@@ -2038,7 +2043,8 @@ globalThis.addEventListener &&
// Not awaited: the waiting sign-ins below settle first.
(async () => {
try {
- puter.onAuth(await puter.getUser());
+ const user = await puter.whoamiCache_;
+ puter.onAuth(user ?? (await puter.getUser()));
} catch (e) {
console.error(e);
}
diff --git a/src/puter-js/src/index.test.js b/src/puter-js/src/index.test.js
index aa1ac1572..678504e91 100644
--- a/src/puter-js/src/index.test.js
+++ b/src/puter-js/src/index.test.js
@@ -328,6 +328,29 @@ describe('GUI message listener', () => {
).resolves.toBeUndefined();
});
+ it('ignores a late failure message without touching a pending sign-in', async () => {
+ const { puter, handler } = await bootWithListener();
+ const previous = puter.authToken;
+ const waiter = { resolve: vi.fn(), reject: vi.fn() };
+ puter.puterAuthState.isPromptOpen = true;
+ puter.puterAuthState.resolvers.push(waiter);
+ await handler({
+ origin: puter.defaultGUIOrigin,
+ source: globalThis.parent,
+ data: {
+ msg: 'puter.token',
+ success: false,
+ token: 'invalid',
+ error: 'auth_canceled',
+ },
+ });
+ expect(puter.authToken).toBe(previous);
+ expect(waiter.reject).not.toHaveBeenCalled();
+ expect(waiter.resolve).not.toHaveBeenCalled();
+ expect(puter.puterAuthState.resolvers).toEqual([waiter]);
+ expect(puter.puterAuthState.isPromptOpen).toBe(true);
+ });
+
it('reports a failed user lookup after sign-in', async () => {
const { puter, handler } = await bootWithListener();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
diff --git a/src/puter-js/src/index.webBoot.test.js b/src/puter-js/src/index.webBoot.test.js
new file mode 100644
index 000000000..89c3805e0
--- /dev/null
+++ b/src/puter-js/src/index.webBoot.test.js
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const ioCalls = [];
+vi.mock('socket.io-client', () => ({
+ io: (origin, opts) => {
+ ioCalls.push({ origin, token: opts?.auth?.auth_token ?? null });
+ return {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ disconnect: vi.fn(),
+ };
+ },
+}));
+vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} }));
+vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} }));
+
+const STORAGE_KEY = 'puter.auth.token.v2';
+const ORIGIN_KEY = 'puter.auth.token.origin.v2';
+const b64url = (value) => Buffer.from(value).toString('base64url');
+const APP_TOKEN = `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify({ t: 'au', v: '2', au: Buffer.alloc(16, 1).toString('base64') }))}.sig`;
+
+const origDefine = customElements.define.bind(customElements);
+customElements.define = (name, cls) =>
+ customElements.get(name) || origDefine(name, cls);
+
+const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent');
+const origXHR = globalThis.XMLHttpRequest;
+class StubXHR {
+ open() {}
+ setRequestHeader() {}
+ addEventListener() {}
+ send() {}
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ ioCalls.length = 0;
+ Object.defineProperty(globalThis, 'parent', {
+ value: globalThis,
+ configurable: true,
+ });
+ globalThis.XMLHttpRequest = StubXHR;
+});
+afterEach(() => {
+ if (origParent) Object.defineProperty(globalThis, 'parent', origParent);
+ globalThis.XMLHttpRequest = origXHR;
+});
+
+describe('web-env boot with a stored token', () => {
+ it('opens only one authenticated socket', async () => {
+ localStorage.setItem(STORAGE_KEY, APP_TOKEN);
+ localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com');
+ history.replaceState(null, '', '/site/index.html');
+ vi.resetModules();
+ const { Puter } = await import('./index.js');
+ ioCalls.length = 0;
+
+ const realProcess = globalThis.process;
+ Object.defineProperty(globalThis, 'process', {
+ configurable: true,
+ get() {
+ const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
+ return /src\/index\.js:\d+/.test(caller)
+ ? undefined
+ : realProcess;
+ },
+ });
+ let puter;
+ try {
+ puter = new Puter();
+ } finally {
+ Object.defineProperty(globalThis, 'process', {
+ value: realProcess,
+ configurable: true,
+ writable: true,
+ });
+ }
+
+ expect(puter.env).toBe('web');
+ expect(puter.authToken).toBe(APP_TOKEN);
+ expect(ioCalls).toEqual([
+ { origin: 'https://api.puter.com', token: APP_TOKEN },
+ ]);
+ });
+});
diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js
index 1b93e330d..faa0431c3 100644
--- a/src/puter-js/src/modules/Auth.js
+++ b/src/puter-js/src/modules/Auth.js
@@ -116,6 +116,9 @@ import {
* are for apps that drive the sign-in flow themselves.
*/
export class AuthModule extends PuterModule {
+ /** @internal */
+ pendingSignInMessageIds_ = new Set();
+
/**
* Signs the user in, opening a popup with the appropriate authentication
* method. Must be called from a user gesture (such as a click) — without
@@ -164,6 +167,7 @@ export class AuthModule extends PuterModule {
return new Promise((resolve, reject) => {
const signinsession = crypto.randomUUID();
const msg_id = signinsession;
+ this.pendingSignInMessageIds_.add(msg_id);
const popupName = `Puter-sign-in-${signinsession}`;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`;
@@ -177,6 +181,7 @@ export class AuthModule extends PuterModule {
let popupWindow = null;
const cleanup = () => {
+ this.pendingSignInMessageIds_.delete(msg_id);
if (checkClosed) {
clearInterval(checkClosed);
checkClosed = null;
@@ -184,6 +189,18 @@ export class AuthModule extends PuterModule {
window.removeEventListener('message', messageHandler);
};
+ const notifyAuth = () => {
+ if (typeof puter.onAuth !== 'function') return;
+ void (async () => {
+ try {
+ const user = await puter.whoamiCache_;
+ puter.onAuth(user ?? (await puter.getUser()));
+ } catch (error) {
+ console.error(error);
+ }
+ })();
+ };
+
if (window.crossOriginIsolated) {
(async () => {
// Bounded too: isolated with a gesture, nothing watches.
@@ -223,6 +240,7 @@ export class AuthModule extends PuterModule {
settled = true;
cleanup();
puter.setAuthToken(auth_token);
+ notifyAuth();
resolve({ success: true, token: auth_token });
return '';
}
@@ -262,6 +280,7 @@ export class AuthModule extends PuterModule {
if (e.data.success) {
// set the auth token
puter.setAuthToken(e.data.token);
+ notifyAuth();
resolve(e.data);
} else {
reject(e.data);
diff --git a/src/puter-js/src/modules/Auth.signIn.test.js b/src/puter-js/src/modules/Auth.signIn.test.js
index cf04a3c0e..89cbcb1f0 100644
--- a/src/puter-js/src/modules/Auth.signIn.test.js
+++ b/src/puter-js/src/modules/Auth.signIn.test.js
@@ -131,6 +131,18 @@ describe('signIn popup binding', () => {
},
);
+ it('notifies explicit sign-in once using the cached user lookup', async () => {
+ const user = { username: 'alice' };
+ puter.whoamiCache_ = Promise.resolve(user);
+ puter.onAuth = vi.fn();
+ puter.getUser = vi.fn();
+ const signedIn = auth.signIn();
+ respond(popups[0], 'token');
+ await signedIn;
+ expect(puter.onAuth).toHaveBeenCalledExactlyOnceWith(user);
+ expect(puter.getUser).not.toHaveBeenCalled();
+ });
+
it('keeps the isolated-page relay independent of popup messages', async () => {
vi.stubGlobal('crossOriginIsolated', true);
let answer;
diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js
index d0cd541b5..4e13e4202 100644
--- a/src/puter-js/src/modules/FileSystem/index.js
+++ b/src/puter-js/src/modules/FileSystem/index.js
@@ -103,6 +103,10 @@ export class PuterJSFileSystemModule extends PuterModule {
this.socket.disconnect();
}
+ this.socket = null;
+ if (!this.authToken) return;
+ this.socketToken_ = this.authToken;
+ this.socketOrigin_ = this.APIOrigin;
this.socket = io(this.APIOrigin, {
auth: {
auth_token: this.authToken,
@@ -212,6 +216,9 @@ export class PuterJSFileSystemModule extends PuterModule {
}
if ( this.puter.socketEnabled ) {
+ if (this.socket?.active &&
+ this.socketToken_ === this.authToken &&
+ this.socketOrigin_ === this.APIOrigin) return;
this.initializeSocket();
}
}
diff --git a/src/puter-js/src/modules/FileSystem/index.test.js b/src/puter-js/src/modules/FileSystem/index.test.js
index dc6021c00..8a06844ab 100644
--- a/src/puter-js/src/modules/FileSystem/index.test.js
+++ b/src/puter-js/src/modules/FileSystem/index.test.js
@@ -137,6 +137,43 @@ describe('auth state', () => {
expect(fs.socket).not.toBe(previousSocket);
});
+ it('keeps an active socket when credentials and origin have not changed', () => {
+ const fs = makeModule('web');
+ const previous = fs.socket;
+ previous.active = true;
+ fs.onAuthStateChanged();
+ expect(fs.socket).toBe(previous);
+ expect(previous.disconnect).not.toHaveBeenCalled();
+ });
+
+ it('replaces an inactive socket even when credentials are unchanged', () => {
+ const fs = makeModule('web');
+ const previous = fs.socket;
+ previous.active = false;
+ fs.onAuthStateChanged();
+ expect(fs.socket).not.toBe(previous);
+ expect(previous.disconnect).toHaveBeenCalledOnce();
+ });
+
+ it('closes the socket on sign-out without opening an anonymous replacement', () => {
+ const fs = makeModule('web');
+ const previous = fs.socket;
+ fs.puter.authToken = null;
+ fs.onAuthStateChanged();
+ expect(previous.disconnect).toHaveBeenCalledOnce();
+ expect(fs.socket).toBeNull();
+ });
+
+ it('replaces an active socket when the API origin changes', () => {
+ const fs = makeModule('web');
+ const previous = fs.socket;
+ previous.active = true;
+ fs.puter.APIOrigin = 'https://new-api.test';
+ fs.onAuthStateChanged();
+ expect(fs.socket).not.toBe(previous);
+ expect(previous.disconnect).toHaveBeenCalledOnce();
+ });
+
it('never opens a socket when the client opted out', () => {
const fs = makeModule('web', false);
diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js
index 52f1fdc0a..44c2239a8 100644
--- a/src/puter-js/src/modules/UI.js
+++ b/src/puter-js/src/modules/UI.js
@@ -2615,11 +2615,6 @@ export class UIModule extends EventListener {
return puter.auth.signIn({ request_auth: true }).then(
() => {
settle(true);
- if ( puter.onAuth && typeof puter.onAuth === 'function' ) {
- puter.getUser().then((user) => {
- puter.onAuth(user);
- });
- }
},
(err) => {
settle(false);