diff --git a/src/gui/src/UI/UIWindowLoginInProgress.js b/src/gui/src/UI/UIWindowLoginInProgress.js
index 0ece76689..7b3b98307 100644
--- a/src/gui/src/UI/UIWindowLoginInProgress.js
+++ b/src/gui/src/UI/UIWindowLoginInProgress.js
@@ -18,6 +18,7 @@
*/
import UIWindow from './UIWindow.js';
+import { loginProgressBody } from './loginProgressBody.js';
async function UIWindowLoginInProgress (options) {
return new Promise(async (resolve) => {
@@ -34,17 +35,7 @@ async function UIWindowLoginInProgress (options) {
profile_pic = window.icons['profile.svg'];
}
- let h = '';
- h += '
';
- h += ``;
- h += `
Logging in as ${options.user_info.email === null ? options.user_info.username : options.user_info.email}
`;
- // spinner
- h += '';
-
- h += '
';
+ const h = loginProgressBody(options.user_info, profile_pic);
const el_window = await UIWindow({
title: i18n('window_title_authenticating'),
diff --git a/src/gui/src/UI/loginProgressBody.js b/src/gui/src/UI/loginProgressBody.js
new file mode 100644
index 000000000..ca900639c
--- /dev/null
+++ b/src/gui/src/UI/loginProgressBody.js
@@ -0,0 +1,49 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const SPINNER_SVG = '';
+
+/**
+ * Body markup for the "logging in as …" window.
+ *
+ * Every interpolated value is encoded: the user object is whatever the API
+ * answered `whoami` with, and the picture lands inside a quoted `style`
+ * attribute where an unencoded quote would end the attribute.
+ *
+ * @param {{ username?: string, email?: string|null }} [userInfo]
+ * @param {string} [profilePic] - Image URL or data URI.
+ * @returns {string}
+ */
+export const loginProgressBody = (userInfo, profilePic) => {
+ const identity = userInfo?.email || userInfo?.username || '';
+
+ let h = '';
+ h += '
';
+
+ return h;
+};
+
+export default loginProgressBody;
diff --git a/src/gui/src/UI/loginProgressBody.test.js b/src/gui/src/UI/loginProgressBody.test.js
new file mode 100644
index 000000000..aa76d70e7
--- /dev/null
+++ b/src/gui/src/UI/loginProgressBody.test.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import { encode } from 'html-entities';
+import { loginProgressBody } from './loginProgressBody.js';
+
+beforeAll(() => {
+ // Same encoder the GUI installs globally (see lib/html-entities.js).
+ globalThis.html_encode = str => encode(str);
+ // Placeholder substitution only — the caller is responsible for encoding,
+ // which is exactly what these tests check.
+ globalThis.i18n = (key, vars = {}, encode_html = true) => {
+ expect(encode_html).toBe(false);
+ return `Logging in as ${vars.identity}`;
+ };
+});
+
+describe('loginProgressBody', () => {
+ it('shows the email, falling back to the username', () => {
+ expect(loginProgressBody({ username: 'alice', email: 'alice@example.com' }, ''))
+ .toContain('alice@example.com');
+ expect(loginProgressBody({ username: 'alice', email: null }, ''))
+ .toContain('alice');
+ });
+
+ it('encodes an identity carrying markup', () => {
+ const h = loginProgressBody({ email: '' }, '');
+ expect(h).not.toContain(' {
+ const h = loginProgressBody(
+ { username: 'alice' },
+ "data:image/svg+xml,'); background: red; x: ('",
+ );
+ expect(h).not.toContain("'); background: red");
+ expect(h).toContain(''); background: red');
+ });
+
+ it('renders without a user or a picture', () => {
+ const h = loginProgressBody(undefined, undefined);
+ expect(h).toContain('');
+ expect(h).toContain("url('')");
+ });
+});
diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js
index 9fda10e21..f5694ca4b 100644
--- a/src/gui/src/helpers.js
+++ b/src/gui/src/helpers.js
@@ -578,7 +578,7 @@ window.refresh_user_data = async (auth_token) => {
}
};
-window.update_auth_data = async (auth_token, user, api_origin) => {
+window.update_auth_data = async (auth_token, user) => {
window.auth_token = auth_token;
// Write the v2 key going forward and clear any lingering v1 key so
// a single localStorage source-of-truth is used.
@@ -606,11 +606,6 @@ window.update_auth_data = async (auth_token, user, api_origin) => {
}
}
- if ( api_origin ) {
- window.api_origin = api_origin;
- localStorage.setItem('api_origin', api_origin);
- }
-
// Has username changed?
if ( window.user?.username !== user.username )
{
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 088cf2196..8192c863c 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -181,6 +181,7 @@ const en = {
log_in: 'Log In',
log_into_another_account_anyway: 'Log into another account anyway',
log_out: 'Log Out',
+ logging_in_as: 'Logging in as {{identity}}',
looks_good: 'Looks good!',
manage_sessions: 'Manage Sessions',
modified: 'Modified',
diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js
index 23257c45e..eeafcccb9 100644
--- a/src/gui/src/initgui.js
+++ b/src/gui/src/initgui.js
@@ -58,6 +58,7 @@ import { ThemeService } from './services/ThemeService.js';
// factory name so a bare `privacy_aware_path(path)` call in this module can't
// silently resolve to the factory — use `window.privacy_aware_path` instead.
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
+import { resolveAPIOrigin } from './util/apiOrigin.js';
import {
deliversTokenToOpener,
trustsOpenerOriginParam,
@@ -896,6 +897,9 @@ window.initgui = async function (options) {
.filter((element) => element);
window.url_paths = url_paths;
+ // GET query params provided
+ window.url_query_params = new URLSearchParams(window.location.search);
+
// Install device signal helpers; collection is lazy. The fingerprint is
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude
// dispatch id needs gui_params.preludeSdkKey.
@@ -907,11 +911,25 @@ window.initgui = async function (options) {
if (window.auth_token && puter.authToken !== window.auth_token) {
puter.setAuthToken(window.auth_token);
}
- // update SDK if api_origin is different from the one in the SDK
- if (window.api_origin && puter.APIOrigin !== window.api_origin) {
- puter.setAPIOrigin(
- localStorage.getItem('api_origin') || window.api_origin,
- );
+ // Point the SDK at this deployment's own API. Anywhere but a Puter served
+ // off the developer's own machine, that is the only origin we'll use — see
+ // resolveAPIOrigin for why, and for the dev flow that is the exception.
+ if (window.api_origin) {
+ const api_origin = resolveAPIOrigin({
+ configuredOrigin: window.api_origin,
+ guiOrigin: window.location.origin,
+ urlOrigin: window.url_query_params.get('api_origin'),
+ storedOrigin: localStorage.getItem('api_origin'),
+ });
+ if (api_origin === window.api_origin) {
+ localStorage.removeItem('api_origin');
+ } else {
+ // Sticks for the rest of the session: only the boot that carried
+ // the parameter has it in the URL.
+ localStorage.setItem('api_origin', api_origin);
+ window.api_origin = api_origin;
+ }
+ if (puter.APIOrigin !== api_origin) puter.setAPIOrigin(api_origin);
}
// Print the version to the console
@@ -960,9 +978,6 @@ window.initgui = async function (options) {
'',
);
- // GET query params provided
- window.url_query_params = new URLSearchParams(window.location.search);
-
// will hold the result of the whoami API call
let whoami;
@@ -1333,17 +1348,12 @@ window.initgui = async function (options) {
// -------------------------------------------------------------------------------------
else if (window.url_query_params.has('auth_token')) {
let query_param_auth_token = window.url_query_params.get('auth_token');
- let api_origin;
-
- // check if we have api_origin in the URL query params
- if (window.url_query_params.has('api_origin')) {
- api_origin = window.url_query_params.get('api_origin');
- puter.setAPIOrigin(api_origin);
- }
const previous_auth_token = window.auth_token;
- const previous_api_origin = window.api_origin;
+ // The token is resolved against our own API origin — deliberately not
+ // one named by the same URL that carried the token, which would leave
+ // the identity we're about to render up to whoever wrote the link.
puter.setAuthToken(query_param_auth_token);
try {
@@ -1358,7 +1368,7 @@ window.initgui = async function (options) {
// Confirm the identity before adopting a token that came from the
// URL. Every other path into `update_auth_data` is an account the
// user picked in the UI, so those don't ask.
- if (whoami && window.user?.uuid !== whoami.uuid) {
+ if (whoami && (!window.user || window.user.uuid !== whoami.uuid)) {
const proceed = await UIAlert({
type: 'confirm',
// `false` — UIAlert encodes the message itself.
@@ -1376,7 +1386,6 @@ window.initgui = async function (options) {
// Back to whatever session was already here; normal boot
// picks up below.
puter.setAuthToken(previous_auth_token);
- if (api_origin) puter.setAPIOrigin(previous_api_origin);
whoami = null;
}
}
@@ -1432,11 +1441,7 @@ window.initgui = async function (options) {
// show login progress window
UIWindowLoginInProgress({ user_info: whoami });
// update auth data
- await window.update_auth_data(
- query_param_auth_token,
- whoami,
- api_origin,
- );
+ await window.update_auth_data(query_param_auth_token, whoami);
}
// remove auth_token from URL, keeping the current path (e.g. `/` or `/desktop`)
// and hash (dashboard tab links like /#usage)
diff --git a/src/gui/src/util/apiOrigin.js b/src/gui/src/util/apiOrigin.js
new file mode 100644
index 000000000..5e1fe52fe
--- /dev/null
+++ b/src/gui/src/util/apiOrigin.js
@@ -0,0 +1,71 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const LOOPBACK_HOSTNAMES = new Set(['localhost', '[::1]', '::1']);
+const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
+
+/**
+ * Is this origin a Puter served off the developer's own machine?
+ *
+ * @param {string} [origin] - An absolute origin, e.g. `https://puter.com`.
+ * @returns {boolean}
+ */
+export const isLoopbackOrigin = (origin) => {
+ let hostname;
+ try {
+ hostname = new URL(origin).hostname;
+ } catch (e) {
+ return false;
+ }
+
+ return LOOPBACK_HOSTNAMES.has(hostname) ||
+ hostname.endsWith('.localhost') ||
+ LOOPBACK_IPV4.test(hostname);
+};
+
+/**
+ * The API origin the GUI talks to.
+ *
+ * Normally this deployment's own configured origin, and nothing else: an
+ * origin named by the URL would let a link resolve the signed-in desktop --
+ * `whoami` included -- against a server we don't control. Apps may repoint
+ * their own SDK instance; the GUI may not.
+ *
+ * The one exception is a Puter served from the developer's own machine, where
+ * `npm start -- --server` drives a local GUI against a remote backend. That
+ * choice sticks for the rest of the session, so the caller persists whatever
+ * comes back when it differs from the configured origin, and passes it here as
+ * `storedOrigin` on later boots.
+ *
+ * @param {object} params
+ * @param {string} params.configuredOrigin - Server-templated `api_origin`.
+ * @param {string} params.guiOrigin - Origin this page is served from.
+ * @param {string|null} [params.urlOrigin] - `?api_origin=` from the URL.
+ * @param {string|null} [params.storedOrigin] - Origin kept from a past boot.
+ * @returns {string}
+ */
+export const resolveAPIOrigin = ({
+ configuredOrigin,
+ guiOrigin,
+ urlOrigin,
+ storedOrigin,
+}) => {
+ if (!isLoopbackOrigin(guiOrigin)) return configuredOrigin;
+ return urlOrigin || storedOrigin || configuredOrigin;
+};
diff --git a/src/gui/src/util/apiOrigin.test.js b/src/gui/src/util/apiOrigin.test.js
new file mode 100644
index 000000000..950d40290
--- /dev/null
+++ b/src/gui/src/util/apiOrigin.test.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { describe, it, expect } from 'vitest';
+import { isLoopbackOrigin, resolveAPIOrigin } from './apiOrigin.js';
+
+const CONFIGURED = 'https://api.puter.com';
+const EVIL = 'https://attacker.example';
+
+describe('isLoopbackOrigin', () => {
+ it('accepts the hosts a local Puter is served from', () => {
+ for ( const origin of [
+ 'http://localhost:4100',
+ 'http://puter.localhost:4100',
+ 'http://api.puter.localhost:4100',
+ 'http://127.0.0.1:4100',
+ 'http://127.10.0.2',
+ 'http://[::1]:4100',
+ ] ) {
+ expect(isLoopbackOrigin(origin), origin).toBe(true);
+ }
+ });
+
+ it('rejects everything else, including lookalikes', () => {
+ for ( const origin of [
+ 'https://puter.com',
+ 'https://myputer.example',
+ 'https://localhost.attacker.example',
+ 'https://notlocalhost',
+ 'https://127.0.0.1.attacker.example',
+ 'not a url',
+ undefined,
+ ] ) {
+ expect(isLoopbackOrigin(origin), String(origin)).toBe(false);
+ }
+ });
+});
+
+describe('resolveAPIOrigin', () => {
+ it('ignores a URL-supplied origin on a hosted deployment', () => {
+ expect(resolveAPIOrigin({
+ configuredOrigin: CONFIGURED,
+ guiOrigin: 'https://puter.com',
+ urlOrigin: EVIL,
+ })).toBe(CONFIGURED);
+ });
+
+ it('ignores a stored origin on a hosted deployment', () => {
+ expect(resolveAPIOrigin({
+ configuredOrigin: CONFIGURED,
+ guiOrigin: 'https://puter.com',
+ storedOrigin: EVIL,
+ })).toBe(CONFIGURED);
+ });
+
+ it('ignores both on a self-hosted deployment', () => {
+ expect(resolveAPIOrigin({
+ configuredOrigin: 'https://api.myputer.example',
+ guiOrigin: 'https://myputer.example',
+ urlOrigin: EVIL,
+ storedOrigin: EVIL,
+ })).toBe('https://api.myputer.example');
+ });
+
+ it('honors a URL-supplied origin on a local Puter', () => {
+ expect(resolveAPIOrigin({
+ configuredOrigin: 'http://api.puter.localhost:4100',
+ guiOrigin: 'http://puter.localhost:4100',
+ urlOrigin: CONFIGURED,
+ })).toBe(CONFIGURED);
+ });
+
+ it('falls back to a stored origin on a local Puter, URL winning', () => {
+ const local = {
+ configuredOrigin: 'http://api.puter.localhost:4100',
+ guiOrigin: 'http://puter.localhost:4100',
+ };
+ expect(resolveAPIOrigin({ ...local, storedOrigin: CONFIGURED }))
+ .toBe(CONFIGURED);
+ expect(resolveAPIOrigin({ ...local, urlOrigin: CONFIGURED, storedOrigin: EVIL }))
+ .toBe(CONFIGURED);
+ });
+
+ it('falls back to the configured origin when nothing is supplied', () => {
+ expect(resolveAPIOrigin({
+ configuredOrigin: CONFIGURED,
+ guiOrigin: 'http://puter.localhost:4100',
+ urlOrigin: null,
+ storedOrigin: null,
+ })).toBe(CONFIGURED);
+ });
+});
diff --git a/src/puter-js/TESTING.md b/src/puter-js/TESTING.md
index cb30b152f..d2406ddef 100644
--- a/src/puter-js/TESTING.md
+++ b/src/puter-js/TESTING.md
@@ -120,8 +120,8 @@ By default, video and trace are saved **only on failure**. `test:e2e:record` ena
`globalSetup` runs at the start of the test run:
1. Direct `POST /login` from Node with the admin credentials → JWT token.
-2. Probes `window.api_origin` from Puter (server-templated) so we can pass it through.
-3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=&api_origin=`, then accepts the "Continue as \?" confirmation the auth_token handler shows before adopting a token from a URL. Puter's `initgui` auth_token handler then runs the full auth setup: `puter.setAuthToken`, `puter.setAPIOrigin`, `/session/sync-cookie`, `update_auth_data`.
+2. Probes `window.api_origin` from Puter (server-templated) so the SDK's adopted origin can be asserted — the GUI always uses its own API origin and will not take one from the URL.
+3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=`, then accepts the "Continue as \?" confirmation the auth_token handler shows before adopting a token from a URL. Puter's `initgui` auth_token handler then runs the full auth setup: `puter.setAuthToken`, `/session/sync-cookie`, `update_auth_data`.
4. Saves cookies + localStorage to `tests/e2e/.auth/state.json` (gitignored, cached for 24h).
Every test context loads with that `storageState` → already signed in as admin → no per-test `/login`, no `/signup`, no rate limits.
diff --git a/src/puter-js/tests/e2e/globalSetup.js b/src/puter-js/tests/e2e/globalSetup.js
index 549d24234..3dbd9e204 100644
--- a/src/puter-js/tests/e2e/globalSetup.js
+++ b/src/puter-js/tests/e2e/globalSetup.js
@@ -90,17 +90,15 @@ export default async function globalSetup () {
// 1) Direct API login → token (no browser, no /signup, no rate-limit risk).
const token = await loginAsAdmin({ origin: PUTER_ORIGIN, username: ADMIN_USERNAME, password: ADMIN_PASSWORD });
- // 2) Open Puter with ?auth_token=&api_origin= so initgui's
- // auth_token flow runs the full setup (puter.setAuthToken,
- // puter.setAPIOrigin, /session/sync-cookie, update_auth_data) and persists
- // the API origin to localStorage.api_origin — otherwise the bundled SDK
- // keeps its production default (https://api.puter.com) and apps.create
- // hits the wrong server.
+ // 2) Open Puter with ?auth_token= so initgui's auth_token flow runs
+ // the full setup (puter.setAuthToken, /session/sync-cookie,
+ // update_auth_data). The GUI points the SDK at its own templated
+ // api_origin — that is not overridable from the URL.
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
- // Probe the GUI's templated api_origin first so we can pass it through.
+ // Probe the GUI's templated api_origin so we can assert the SDK adopts it.
await page.goto(PUTER_ORIGIN);
const apiOrigin = await page.evaluate(() => window.api_origin || null);
if ( ! apiOrigin ) {
@@ -108,10 +106,7 @@ export default async function globalSetup () {
throw new Error(`Could not determine api_origin from ${PUTER_ORIGIN} (window.api_origin was empty).`);
}
- await page.goto(
- `${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}` +
- `&api_origin=${encodeURIComponent(apiOrigin)}`,
- );
+ await page.goto(`${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}`);
await page.waitForFunction(() => !!window.puter, null, { timeout: 60_000 });
@@ -122,13 +117,12 @@ export default async function globalSetup () {
.click({ timeout: 60_000 });
await page.waitForFunction(
- () => {
+ expectedApiOrigin => {
// v2 key since the v1→v2 cutover; legacy key tolerated.
const ls = (typeof localStorage !== 'undefined') ? (localStorage.getItem('auth_token_v2') || localStorage.getItem('auth_token')) : null;
- const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
- return !!(ls && lsApi && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === lsApi);
+ return !!(ls && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === expectedApiOrigin);
},
- null,
+ apiOrigin,
{ timeout: 60_000 },
);
diff --git a/src/puter-js/tests/e2e/helpers/testApp.js b/src/puter-js/tests/e2e/helpers/testApp.js
index 0678950bd..d5ac0e6cc 100644
--- a/src/puter-js/tests/e2e/helpers/testApp.js
+++ b/src/puter-js/tests/e2e/helpers/testApp.js
@@ -15,8 +15,8 @@ const PUTER_READY_TIMEOUT = 60_000;
export async function waitForPuterReady (page) {
await page.waitForFunction(() => !!window.puter, null, { timeout: PUTER_READY_TIMEOUT });
- // With storageState, sign-in should already be done. Wait for both auth
- // and the API origin to be picked up by the SDK from localStorage.
+ // With storageState, sign-in should already be done. Wait for the token to
+ // be picked up and the SDK pointed at the GUI's own API origin.
try {
await page.waitForFunction(
() => {
@@ -25,11 +25,10 @@ export async function waitForPuterReady (page) {
const ls = (typeof localStorage !== 'undefined')
? (localStorage.getItem('auth_token_v2') || localStorage.getItem('auth_token'))
: null;
- const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
return !!(
- ls && lsApi &&
+ ls &&
window.auth_token && window.puter?.authToken &&
- window.puter?.APIOrigin === lsApi
+ window.puter?.APIOrigin === window.api_origin
);
},
null,