Compare commits

...
Author SHA1 Message Date
Nariman JelvehandClaude Opus 5.5 c3275b1514 fix(puter-js): apps launched from desktop.puter.com take its messages
In an app, puter.js trusted only https://puter.com as the GUI origin, so
every desktop message from desktop.puter.com failed the origin check and
was dropped. The desktop waits for the app's answer to windowWillClose,
so app windows there never closed; itemsOpened, drag-and-drop, focus,
theme and locale changes were lost the same way, and a reauth request
was posted to the wrong origin.

An app now takes its GUI origin from the launch URL's `puter.origin`,
but only when that is one of Puter's own desktops (exactly
https://puter.com or https://desktop.puter.com); anything else keeps
https://puter.com, so a page that frames an app still can't drive it.
An explicit PUTER_ORIGIN still wins, and a top-level page never reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 20:43:52 -07:00
2 changed files with 114 additions and 0 deletions

No files matched your search

+32
View File
@@ -104,6 +104,25 @@ class Lock {
// (using defaultGUIOrigin breaks locally-hosted apps)
const PROD_ORIGIN = 'https://puter.com';
// The desktops Puter itself serves. An app learns which one launched it from
// the URL (`puter.origin`), and only one of these is believed: the GUI origin
// is what every message from the desktop is checked against, so taking an
// arbitrary one would let any page that frames an app drive it.
const FIRST_PARTY_GUI_ORIGINS = new Set([
'https://puter.com',
'https://desktop.puter.com',
]);
/** `value`'s origin when it is a first-party desktop, otherwise null. */
const firstPartyGUIOrigin = (value) => {
try {
const { origin } = new URL(value);
return FIRST_PARTY_GUI_ORIGINS.has(origin) ? origin : null;
} catch {
return null;
}
};
// localStorage key for the auth token. The retired key below is never read —
// only deleted, so a token from before the format change doesn't linger in a
// visitor's browser (see `discardRetiredAuthToken_`).
@@ -213,6 +232,9 @@ export class Puter {
#defaultAPIOrigin = 'https://api.puter.com';
#defaultGUIOrigin = 'https://puter.com';
// In app mode, the first-party desktop that launched this app, when it
// isn't the build's default one.
#launchGUIOrigin = null;
/** @returns {string} */
get defaultAPIOrigin() {
@@ -230,6 +252,7 @@ export class Puter {
get defaultGUIOrigin() {
return (
globalThis.PUTER_ORIGIN ||
this.#launchGUIOrigin ||
globalThis.PUTER_ORIGIN_ENV ||
this.#defaultGUIOrigin
);
@@ -634,6 +657,15 @@ export class Puter {
this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`;
}
// An app launched by a first-party desktop other than puter.com
// (e.g. desktop.puter.com) talks to that desktop: its messages come
// from there, and replies meant only for it go there.
if (this.env === 'app') {
this.#launchGUIOrigin = firstPartyGUIOrigin(
URLParams.get('puter.origin'),
);
}
// === START :: Logger ===
// Basic logger replacement (console-based)
+82
View File
@@ -240,6 +240,88 @@ describe('app-mode launch token', () => {
});
});
describe('GUI origin of an app', () => {
const DESKTOP = 'https://desktop.puter.com';
const launchedBy = (origin) =>
bootApp(
`${LAUNCH}${APP_TOKEN}&puter.origin=${encodeURIComponent(origin)}`,
);
/** Sends `data` to the app as its parent at `origin` would. */
const fromParent = async (origin, data) => {
window.dispatchEvent(
new MessageEvent('message', {
origin,
source: globalThis.parent,
data,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
};
const closeAnswers = () =>
globalThis.parent.postMessage.mock.calls
.map(([message]) => message)
.filter((message) => message?.original_msg_id === 'close-1');
it('is the first-party desktop that launched it', async () => {
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(DESKTOP);
expect((await launchedBy('https://puter.com')).defaultGUIOrigin).toBe(
'https://puter.com',
);
});
it('stays puter.com for any other launcher', async () => {
for (const origin of [
'https://evil.example',
'https://desktop.puter.com.evil.example',
'https://x.desktop.puter.com',
'http://desktop.puter.com',
'not a url',
]) {
expect((await launchedBy(origin)).defaultGUIOrigin).toBe(
'https://puter.com',
);
}
expect((await bootApp(`${LAUNCH}${APP_TOKEN}`)).defaultGUIOrigin).toBe(
'https://puter.com',
);
});
it('yields to an explicit PUTER_ORIGIN', async () => {
globalThis.PUTER_ORIGIN = 'https://self-hosted.test';
try {
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(
'https://self-hosted.test',
);
} finally {
delete globalThis.PUTER_ORIGIN;
}
});
it('is not taken from the URL of a top-level page', async () => {
const puter = await bootWeb(
`?puter.app_instance_id=i&puter.origin=${encodeURIComponent(DESKTOP)}`,
);
expect(puter.env).not.toBe('app');
expect(puter.defaultGUIOrigin).toBe('https://puter.com');
});
it('answers windowWillClose from that desktop and no other', async () => {
await launchedBy(DESKTOP);
const willClose = { msg: 'windowWillClose', msg_id: 'close-1' };
await fromParent('https://evil.example', willClose);
await fromParent('https://puter.com', willClose);
expect(closeAnswers()).toEqual([]);
await fromParent(DESKTOP, willClose);
expect(closeAnswers()).toEqual([
expect.objectContaining({ msg: true, original_msg_id: 'close-1' }),
]);
});
});
describe('token adoption on a top-level page', () => {
it('takes a token from a window it opened, and from nothing else', async () => {
const puter = await bootWeb('');