Settle requestPermission on the launch paths that could still throw

Three gaps left by the permission-request rework, each verified against a
live stack before and after the fix.

The IPC handler normalises a non-object `options` so it can always reply,
but `typeof null === 'object'` let null through the guard; reading
`.permission` off it threw out of the message listener before any reply,
so `puter.ui.requestPermission(null)` hung forever in env=app while
env=web answered false for the same input.

In the env=web branch only the consent-dialog path was wrapped, even
though its own catch reasons that this resolves to a boolean for every
other caller. A `window.open` refused by throwing rather than by
returning null escaped the launch branch and rejected instead.

The perms docs were flipped to platforms: [websites, apps], but every
entry point except `request()` reads the signed-in user's identity
first, so on a signed-out site they reject with Unauthorized and never
prompt — the permission popup deliberately does not sign the site in.
Document the sign-in precondition on those pages.
This commit is contained in:
jelveh
2026-07-26 12:21:18 -07:00
parent 1b2482dcb8
commit 2da282070e
16 changed files with 159 additions and 28 deletions
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request to see a user's email. If the user has already granted this permission the user will not be prompted and their email address will be returned. If the user grants permission their email address will be returned. If the user does not allow access `undefined` will be returned. If the user does not have an email address, the value of their email address will be `null`.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write (manage) access to the user's apps. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write (manage) access to the user's subdomains. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's apps. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's Desktop folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's Documents folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's Pictures folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's subdomains. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request read access to the user's Videos folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write access to the user's Desktop folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write access to the user's Documents folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write access to the user's Pictures folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+6
View File
@@ -6,6 +6,12 @@ platforms: [websites, apps]
Request write access to the user's Videos folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+4 -2
View File
@@ -1331,8 +1331,10 @@ const ipc_listener = async (event, handled) => {
return;
}
// options must be an object
if ( event.data.options === undefined || typeof event.data.options !== 'object' )
// options must be an object. `typeof null === 'object'`, so null has to
// be caught here too — reading `.permission` off it throws, and the
// throw escapes before any reply, hanging the caller's promise forever.
if ( ! event.data.options || typeof event.data.options !== 'object' )
{
event.data.options = {};
}
+28 -26
View File
@@ -1291,35 +1291,37 @@ class UI extends EventListener {
settle(false);
};
if ( hasUserActivation() ) {
// A user gesture is active — open the popup immediately.
// Unique window name per request: window.open() reuses a
// window with the same name, which would hijack a popup an
// earlier, still-pending request is waiting on.
watchPopup(openAuthPopup(url, `puter-permission-${msg_id}`));
} else {
// No user gesture: a popup opened now would be blocked by the
// browser. Show a consent dialog first; the popup is then
// opened from the user's click on that dialog, which provides
// the gesture the browser requires.
const dialog = new PuterDialog(() => {}, () => {}, {
popupURL: url,
// Same unique-name reasoning as the direct path above.
popupName: `puter-permission-${msg_id}`,
onLaunch: (popup) => watchPopup(popup),
onCancel: () => settle(false),
});
consentDialog = dialog;
try {
// Every path out of here resolves a boolean, so anything that
// throws while launching — `window.open` refused outright by a
// policy or an override rather than returning null, a dialog that
// won't construct, no `document.body` yet because this was called
// from a <head> script — has to deny rather than reject.
try {
if ( hasUserActivation() ) {
// A user gesture is active — open the popup immediately.
// Unique window name per request: window.open() reuses a
// window with the same name, which would hijack a popup an
// earlier, still-pending request is waiting on.
watchPopup(openAuthPopup(url, `puter-permission-${msg_id}`));
} else {
// No user gesture: a popup opened now would be blocked by
// the browser. Show a consent dialog first; the popup is
// then opened from the user's click on that dialog, which
// provides the gesture the browser requires.
const dialog = new PuterDialog(() => {}, () => {}, {
popupURL: url,
// Same unique-name reasoning as the direct path above.
popupName: `puter-permission-${msg_id}`,
onLaunch: (popup) => watchPopup(popup),
onCancel: () => settle(false),
});
consentDialog = dialog;
document.body.appendChild(dialog);
dialog.open();
} catch (e) {
// Nothing to show the user (e.g. called from a <head>
// script, so there is no body yet). This resolves to a
// boolean for every other caller, so deny rather than
// reject — and let cleanup drop the message listener.
settle(false);
}
} catch (e) {
// `settle` runs cleanup, so the message listener is dropped too.
settle(false);
}
});
};
@@ -90,6 +90,31 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
}
});
test('a malformed options argument is denied rather than left hanging', async ({ page }) => {
// `typeof null === 'object'`, so a null `options` slipped past the
// "must be an object" guard and faulted on the `.permission` read —
// before any reply had been posted, leaving the caller's promise
// pending forever. env=web already answered false for the same input.
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
const outcome = await appFrame.locator('body').evaluate(async () => {
const settled = puter.ui.requestPermission(null).then(
v => `resolved:${v}`,
e => `rejected:${e?.message ?? e}`,
);
return Promise.race([
settled,
new Promise(r => setTimeout(() => r('never settled'), 10_000)),
]);
});
expect(outcome).toBe('resolved:false');
await expect(page.locator('dialog.perm-dialog')).toHaveCount(0);
} finally {
await deleteTestApp(page, appName);
}
});
test('Escape dismisses the dialog as a denial', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
try {
@@ -480,6 +505,30 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
expect(await page.evaluate(() => window.__permPromise)).toBe(false);
});
test('a refused window.open denies instead of rejecting', async ({ page }) => {
// requestPermission is documented `Promise<boolean>` and every handled
// path resolves false. `window.open` refused outright — by a policy or
// an override that throws rather than returning null — used to escape
// the launch branch and reject, since only the consent-dialog path was
// wrapped.
await page.goto('/');
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
await page.goto(PERMISSION_FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const outcome = await page.evaluate(async () => {
window.open = () => { throw new Error('blocked by policy'); };
const settled = puter.ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
}).then(v => `resolved:${v}`, e => `rejected:${e?.message ?? e}`);
return Promise.race([
settled,
new Promise(r => setTimeout(() => r('never settled'), 10_000)),
]);
});
expect(outcome).toBe('resolved:false');
});
test('closing the popup without answering resolves false', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });