fix: settle a permission request from what is already granted

`puter.perms.request()` already pooled a permission read and prompted only
for what was missing. The raw `puter.ui.requestPermission()` did not, so
every caller still on it re-asked the user on each launch — including
`perms.requestAppData()`, whose own docs promise the opposite, and the
driver-denial retry.

- puter.js: `ui.requestPermission()` reads what is held before prompting and
  resolves true when the whole request is covered. Only in env=app and
  env=web, the environments that raise a prompt; elsewhere the method still
  answers false without asking anyone. A check that cannot be made — no
  token, an unreadable request shape, a failed read, or one that outlasts its
  timeout — falls through to the prompt rather than standing in for an
  answer. Public signature unchanged.

- GUI: the request-permission popup asks the same question as the app, using
  the user-app token its own exchange already mints, and skips the dialog
  when the access is held. This is the one case the SDK cannot settle for
  itself: a signed-out site holds no token to check with. An origin the
  browser does not vouch for never reaches the check, since the exchange
  fails first.

Both checks are time-boxed, because each one stands in front of something
that is waiting: the popup's gates the dialog, so a stalled read would leave
the prompt unshown and the opener pending, and the SDK's spends the browser's
transient activation, which a slow read would cost the popup.

Note that driver, service and feature scopes are implicitly granted to every
app (backend/data/hardcoded-permissions.js), so requests for those now
settle silently — the dialog was asking about access the app already had.
Consent scopes (email, fs, apps, subdomains, app-data, app-root-dir) are
unaffected and still prompt until granted.

Fixes a bug this method already had on the way past: `pollDecision` read an
undeclared `permission`, so every attempt threw a ReferenceError into its
network-failure catch and the COOP-severed-opener recovery burned its full
five-minute timeout before answering false. It polls `requested` now, and
requires the whole list.

Tests: the e2e suite drove its dialogs with an implicitly-held driver
permission, so the fixture now asks for a driver nothing implies, fresh per
page load, which also removes the cross-test grant carry-over the old
revokes worked around. The reconciliation tests ask for the held scope plus
an unheld one, since a fully-held request no longer reaches a dialog. Adds a
backend contract test for check-permissions under an app-under-user actor,
which is what the two new client paths rest on.
This commit is contained in:
Juan Castro
2026-08-27 16:35:06 -04:00
parent 909949c68d
commit c4be7fabac
10 changed files with 639 additions and 106 deletions
@@ -5024,6 +5024,64 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
]);
});
// What lets a permission request settle without a prompt. The second string
// is a consent scope nothing implies, so the first's `true` is the grant.
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cp-${uuidv4()}`,
title: 'TestCheckPermsApp',
index_url: 'https://check-perms.example.test/index.html',
},
{ ownerUserId: user.id },
);
const granted = `user:${user.uuid}:email:read`;
const ungranted = `apps-of-user:${user.uuid}:read`;
const appActor = {
user: actor.user,
app: { id: app.id, uid: app.uid },
} as unknown as Actor;
const before = makeRes();
await inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [granted, ungranted] },
{ actor: appActor },
),
before,
),
);
expect(before.body).toEqual({
permissions: { [granted]: false, [ungranted]: false },
});
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission: granted, extra: {} },
{ actor },
),
makeRes(),
),
);
const after = makeRes();
await inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [granted, ungranted] },
{ actor: appActor },
),
after,
),
);
expect(after.body).toEqual({
permissions: { [granted]: true, [ungranted]: false },
});
});
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
+78
View File
@@ -0,0 +1,78 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
// The dialog waits behind this check, so a stalled read must not hold it up.
const CHECK_TIMEOUT_MS = 5000;
/**
* Whether every one of these permissions is already held by whoever `token`
* identifies — an app-under-user token, so the answer is about that app's
* access and not the user's own.
*
* Used to skip a permission prompt that has nothing to ask about. A check that
* couldn't be made is not an answer: no token, an empty list, a failed read, or
* one that times out all report `false`, leaving the prompt to run.
*
* @param {string[]} permissions
* @param {string} token - App-under-user token to check as.
* @param {object} [deps] Injectable seams for tests.
* @param {typeof fetch} [deps.fetchImpl]
* @param {string} [deps.apiOrigin]
* @param {number} [deps.timeoutMs]
* @returns {Promise<boolean>}
*/
export const holdsPermissions = async (
permissions,
token,
{
fetchImpl = globalThis.fetch?.bind(globalThis),
apiOrigin = window.api_origin,
timeoutMs = CHECK_TIMEOUT_MS,
} = {},
) => {
if ( ! token || ! Array.isArray(permissions) || permissions.length === 0 ) {
return false;
}
const controller = typeof AbortController !== 'undefined'
? new AbortController()
: null;
const expiry = setTimeout(() => controller?.abort(), timeoutMs);
try {
const resp = await fetchImpl(`${apiOrigin}/auth/check-permissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ permissions: [...new Set(permissions)] }),
...(controller ? { signal: controller.signal } : {}),
});
if ( ! resp.ok ) return false;
const held = (await resp.json())?.permissions ?? {};
// Every scope: one prompt is one decision, so partly-held is unheld.
return permissions.every((p) => held[p] === true);
} catch (e) {
console.error('Failed to check held permissions', e);
return false;
} finally {
clearTimeout(expiry);
}
};
export default holdsPermissions;
@@ -0,0 +1,104 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import { holdsPermissions } from './holdsPermissions.js';
const EMAIL = 'user:u-1:email:read';
const APPS = 'apps-of-user:u-1:read';
// Minimal fetch double: records calls and answers with what is held.
const makeFetch = ({ held = {}, ok = true, throws = false } = {}) => {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
calls.push({ url, method: opts.method, headers: opts.headers, body: JSON.parse(opts.body) });
if ( throws ) throw new Error('network down');
return {
ok,
status: ok ? 200 : 500,
json: async () => ({ permissions: held }),
};
};
return { fetchImpl, calls };
};
const deps = (fetchImpl) => ({ fetchImpl, apiOrigin: 'https://api.test' });
describe('holdsPermissions', () => {
it('reports held only when every permission is held', async () => {
const both = makeFetch({ held: { [EMAIL]: true, [APPS]: true } });
await expect(holdsPermissions([EMAIL, APPS], 'app-token', deps(both.fetchImpl)))
.resolves.toBe(true);
const partial = makeFetch({ held: { [EMAIL]: true, [APPS]: false } });
await expect(holdsPermissions([EMAIL, APPS], 'app-token', deps(partial.fetchImpl)))
.resolves.toBe(false);
});
it('asks as the app whose access is in question, deduped', async () => {
const { fetchImpl, calls } = makeFetch({ held: { [EMAIL]: true } });
await holdsPermissions([EMAIL, EMAIL], 'app-token', deps(fetchImpl));
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://api.test/auth/check-permissions');
expect(calls[0].headers.Authorization).toBe('Bearer app-token');
expect(calls[0].body).toEqual({ permissions: [EMAIL] });
});
// A check that couldn't be made is not an answer: the caller prompts.
it('reports not held when the read fails', async () => {
const failed = makeFetch({ ok: false });
await expect(holdsPermissions([EMAIL], 'app-token', deps(failed.fetchImpl)))
.resolves.toBe(false);
const broken = makeFetch({ throws: true });
await expect(holdsPermissions([EMAIL], 'app-token', deps(broken.fetchImpl)))
.resolves.toBe(false);
});
// The prompt waits behind this check.
it('gives up on a read that outlasts its timeout', async () => {
const calls = [];
const fetchImpl = (url, opts = {}) => {
calls.push(opts.signal);
return new Promise((_resolve, reject) => {
opts.signal?.addEventListener('abort', () => reject(new Error('aborted')));
});
};
await expect(holdsPermissions([EMAIL], 'app-token', {
fetchImpl,
apiOrigin: 'https://api.test',
timeoutMs: 5,
})).resolves.toBe(false);
// Aborted rather than left running behind the answer.
expect(calls[0]?.aborted).toBe(true);
});
it('does not ask at all without a token or a permission', async () => {
const { fetchImpl, calls } = makeFetch({ held: { [EMAIL]: true } });
await expect(holdsPermissions([EMAIL], '', deps(fetchImpl))).resolves.toBe(false);
await expect(holdsPermissions([], 'app-token', deps(fetchImpl))).resolves.toBe(false);
await expect(holdsPermissions(null, 'app-token', deps(fetchImpl))).resolves.toBe(false);
expect(calls).toHaveLength(0);
});
});
+9 -1
View File
@@ -51,6 +51,7 @@ import {
wantsFullToken,
} from './util/authmeGrant.js';
import init_device_signals from './helpers/deviceSignals.js';
import { holdsPermissions } from './helpers/holdsPermissions.js';
import item_icon from './helpers/itemIcon.js';
import launch_app from './helpers/launchApp.js';
import { parse_url_paths } from './helpers/urlPaths.js';
@@ -81,6 +82,9 @@ const postAuthActions = async (action) => {
// bootstraps the app row a permission grant is written against, so an
// action that depends on it has to report failure rather than prompt.
let token_exchange_failed = false;
// The token the exchange minted for the opener's app, kept only to ask what
// that app holds — a question this window's own token cannot answer.
let user_app_token = null;
// -------------------------------------------------------------------------------------
// Action: AuthMe — redirect to a third-party URL with the user's auth token
// -------------------------------------------------------------------------------------
@@ -385,6 +389,7 @@ const postAuthActions = async (action) => {
// This is an implicit app and the app_uid is sent back from the server
// we cache it here so that we can use it later
window.host_app_uid = data.app_uid;
user_app_token = data.token;
// send token to parent. The opener is unreachable when it is
// cross-origin isolated (COOP severs the relationship); those
// flows learn the outcome server-side instead.
@@ -688,6 +693,9 @@ const postAuthActions = async (action) => {
if ( token_exchange_failed ) {
throw new Error('token exchange failed; not prompting');
}
// A signed-out opener holds no token to settle this for itself, so
// ask here, as the app, with the token the exchange just minted.
const already_held = await holdsPermissions(permissions, user_app_token);
// The requesting app is identified by its origin, and only the
// server turns that origin into a grant target. No uid is sent
// from here: a uid from the query string is chosen by whoever
@@ -700,7 +708,7 @@ const postAuthActions = async (action) => {
// name. Passing the origin instead makes the server resolve the
// same origin the dialog displayed, and reject it outright
// unless it names an app that really exists.
granted = await UIPermissionDialog({
granted = already_held || await UIPermissionDialog({
// See IPC.js: both forms, so a single scope still works with a
// dialog that only understands the scalar.
permissions,
+57 -2
View File
@@ -2,6 +2,7 @@ import EventListener from '../lib/EventListener.js';
import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js';
import FSItem from './FSItem.js';
import PuterDialog from './PuterDialog.js';
import { checkPermissions } from './perms/lib/holds.js';
/**
@@ -278,6 +279,9 @@ const pipError = (name, message) => {
const MAX_REQUESTED_PERMISSIONS = 16;
// Short on purpose: a request usually spends a user gesture while it waits.
const PERMISSION_CHECK_TIMEOUT_MS = 2000;
/**
* An interface for interacting with another app. Returned by the UI methods
* that launch or connect to one; it cannot be constructed directly.
@@ -1707,6 +1711,47 @@ export class UIModule extends EventListener {
document.body.appendChild(el);
};
/**
* Whether every permission in a request is already held, read without
* prompting and without changing anything.
*
* A check that couldn't be made is not an answer: no token, an unreadable
* request, a failed read, or one that outlasts its timeout all fall through
* to the prompt.
*
* @param {{ permission?: string, permissions?: string[] }} options
* @returns {Promise<boolean>}
*/
async #alreadyHeld (options) {
if ( ! this.authToken ) return false;
const requested = Array.isArray(options?.permissions)
? options.permissions
: [options?.permission];
// The prompt paths answer false for a shape they can't read themselves.
if ( requested.length === 0
|| requested.length > MAX_REQUESTED_PERMISSIONS
|| requested.some(p => typeof p !== 'string' || p === '') ) {
return false;
}
let expiry;
try {
const held = await Promise.race([
checkPermissions(this.puter, [...new Set(requested)]),
// Waiting out a stalled read would cost the popup its gesture.
new Promise((resolve) => {
expiry = setTimeout(() => resolve(null), PERMISSION_CHECK_TIMEOUT_MS);
}),
]);
if ( held === null ) return false;
// Every scope: one prompt is one decision, so partly-held is unheld.
return requested.every(p => held[p] === true);
} catch (e) {
return false;
} finally {
clearTimeout(expiry);
}
}
/**
* Asks the user to grant a permission to this app. Inside the Puter GUI
* the request is relayed to the desktop; on the web the permission
@@ -1715,10 +1760,19 @@ export class UIModule extends EventListener {
* One prompt may cover several scopes: pass `permissions` instead of
* `permission` and the user answers for the whole list at once.
*
* Access already granted resolves `true` without prompting.
*
* @param {{ permission?: string, permissions?: string[] }} options
* @returns {Promise<boolean>} `true` only if the permission was granted.
*/
async requestPermission (options) {
// Only where a prompt would be raised. Elsewhere this answers false
// without asking anyone, and a check must not turn that into a grant.
if ( ( this.env === 'app' || this.env === 'web' )
&& await this.#alreadyHeld(options) ) {
return true;
}
if ( this.env === 'app' ) {
const result = await this.#postMessageAsync('requestPermission', { options });
return result.granted === true;
@@ -1954,12 +2008,13 @@ export class UIModule extends EventListener {
'Content-Type': 'application/json',
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({ permissions: [permission] }),
body: JSON.stringify({ permissions: requested }),
...(controller ? { signal: controller.signal } : {}),
});
if ( ! resp.ok ) continue;
const data = await resp.json();
if ( data?.permissions?.[permission] === true ) {
// The whole list, since one prompt is one decision.
if ( requested.every(p => data?.permissions?.[p] === true) ) {
settle(true);
}
} catch (e) {
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The permission check goes through the shared request helper, so mocking it
// needs no server.
const mockReq = vi.fn();
vi.mock('./perms/lib/req.js', () => ({ req: (...args) => mockReq(...args) }));
const { UIModule } = await import('./UI.js');
const CHECK_ROUTE = '/auth/check-permissions';
/** Answer the mocked helper with what is held, and nothing else. */
const held = (permissions = {}) => {
mockReq.mockImplementation(async (_puter, route) => {
if ( route === CHECK_ROUTE ) return { permissions };
throw new Error(`unexpected route: ${route}`);
});
};
const postMessage = vi.fn();
/** A UI module in one environment, with the GUI it posts to stubbed. */
const makeUI = ({ env = 'app', authToken = 'token-1' } = {}) =>
new UIModule({
env,
authToken,
APIOrigin: 'https://api.test',
appID: 'app-1',
util: {},
}, { appInstanceID: 'instance-1' });
/** The messages sent to the GUI, ignoring the constructor's READY. */
const promptCalls = () =>
postMessage.mock.calls.filter(([msg]) => msg?.msg === 'requestPermission');
beforeEach(() => {
globalThis.window = { parent: { postMessage } };
mockReq.mockReset();
postMessage.mockReset();
held();
});
afterEach(() => {
delete globalThis.window;
});
describe('ui.requestPermission on access that is already held', () => {
it('resolves true without prompting the user', async () => {
held({ 'fs:/alice/Documents:read': true });
const ui = makeUI();
await expect(ui.requestPermission({
permission: 'fs:/alice/Documents:read',
})).resolves.toBe(true);
expect(promptCalls()).toHaveLength(0);
expect(mockReq).toHaveBeenCalledWith(ui.puter, CHECK_ROUTE, {
permissions: ['fs:/alice/Documents:read'],
});
});
it('resolves true for a whole multi-scope request', async () => {
held({ 'apps-of-user:u-1:read': true, 'user:u-1:email:read': true });
const ui = makeUI();
await expect(ui.requestPermission({
permissions: ['apps-of-user:u-1:read', 'user:u-1:email:read'],
})).resolves.toBe(true);
expect(promptCalls()).toHaveLength(0);
});
it('answers a third-party site the same way, with no popup', async () => {
held({ 'driver:puter-image-generation:generate': true });
const ui = makeUI({ env: 'web' });
await expect(ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
})).resolves.toBe(true);
});
});
describe('ui.requestPermission when the check does not settle it', () => {
// One prompt is one decision, so partly-held still has something to ask.
it('prompts for a partly-held set', async () => {
held({ 'apps-of-user:u-1:read': true });
const ui = makeUI();
ui.requestPermission({
permissions: ['apps-of-user:u-1:read', 'user:u-1:email:read'],
});
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
});
it('prompts when nothing is held', async () => {
const ui = makeUI();
ui.requestPermission({ permission: 'user:u-1:email:read' });
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
});
// Not a grant, and not a refusal either: the prompt is where this went before.
it('prompts when the check fails', async () => {
mockReq.mockImplementation(async () => ({
error: true,
code: 'internal_error',
}));
const ui = makeUI();
ui.requestPermission({ permission: 'user:u-1:email:read' });
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
});
// The request is spending a user gesture while it waits.
it('prompts rather than waiting out a stalled check', async () => {
mockReq.mockImplementation(() => new Promise(() => {}));
const ui = makeUI();
ui.requestPermission({ permission: 'user:u-1:email:read' });
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1), { timeout: 10_000 });
}, 15_000);
it('does not ask the server when there is no token to ask with', async () => {
const ui = makeUI({ authToken: null });
ui.requestPermission({ permission: 'user:u-1:email:read' });
await vi.waitFor(() => expect(promptCalls()).toHaveLength(1));
expect(mockReq).not.toHaveBeenCalled();
});
});
describe('ui.requestPermission where no prompt can be raised', () => {
// This environment has always answered false, and a check run as the user —
// who holds far more than the app would — must not turn that into a grant.
it('keeps answering false in the GUI, without a check', async () => {
held({ 'user:u-1:email:read': true });
const ui = makeUI({ env: 'gui' });
await expect(ui.requestPermission({
permission: 'user:u-1:email:read',
})).resolves.toBe(false);
expect(mockReq).not.toHaveBeenCalled();
});
});
+7 -6
View File
@@ -289,12 +289,13 @@ export default suite('perms', {
}
},
// The one-method-per-task names still ship for apps written against them,
// and still prompt without consulting what is already held — which is why
// the folder and apps assertions here are the opposite of what `request`
// now answers for the same access. `requestPermission` is the exception:
// it forwards to `request`, so it picks up the new behaviour (asserted
// separately below).
// The one-method-per-task names still ship, and go straight to the prompt
// without pooling a read of their own — which is why the folder and apps
// assertions here are the opposite of what `request` answers. Node and
// workerd raise no prompt, so nothing is consulted; in an app or on a
// website `ui.requestPermission` checks first and these would answer for
// access already held. `requestPermission` forwards to `request`, so it
// settles from the pooled read everywhere (asserted separately below).
'the deprecated request aliases keep delegating': {
platforms: ['node', 'workerd'],
fn: async (t) => {
@@ -29,6 +29,14 @@
</script>
<script src="/dist/puter.dev.js"></script>
<script>
// A driver nothing implies: every app implicitly holds the built-in
// driver scopes (backend hardcoded-permissions.js), and access already
// held resolves without a prompt. Fresh per page load unless `?perm=`
// pins it, so one test's Allow can't settle another test's request.
window.__driverPermission =
new URLSearchParams(location.search).get('perm')
|| `driver:e2e-${Math.random().toString(36).slice(2, 10)}:generate`;
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
@@ -75,7 +83,7 @@
document.getElementById('req-driver-perm').addEventListener('click', async () => {
try {
const granted = await puter.ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
permission: window.__driverPermission,
});
log(`perm:driver:${granted}`);
} catch (e) {
@@ -105,15 +105,11 @@ test.describe('puter.perms.requestAppData (env=app)', () => {
expect(listed.res).toContain('birthday');
expect(listed.res).not.toContain('oauthToken');
// -- a repeat request prompts again --
// `requestAppData` does not consult existing grants before
// prompting, unlike `requestEmail` (checks whoami) and the folder
// helpers (stat first). Pinned as current behaviour: an app calling
// this on every launch re-asks the user.
// -- a repeat request settles from the grant, with no prompt --
// What an app calling this on every launch relies on.
await ask(appFrame, target.uid, { kv: ['get'] });
await expect(dialog).toBeVisible();
await dialog.locator('.perm-dialog-allow').click();
expect(await settle(appFrame)).toEqual({ ok: true, value: true });
await expect(dialog).toHaveCount(0);
} finally {
await deleteTestApp(page, appName);
await deleteTestApp(page, target.name);
@@ -6,6 +6,12 @@ const PERMISSION_FIXTURE_URL = FIXTURE_URL.replace(
'request-permission.html',
);
// A driver nothing implies, for the tests that name a permission themselves:
// the built-in driver scopes are already held, so asking for one of those would
// settle before any dialog. Only used by requests that end in a denial, so no
// row is left behind for the next test.
const UNHELD_DRIVER_PERMISSION = 'driver:e2e-unheld:generate';
// Playwright's Chromium reports `navigator.userActivation.isActive` as true
// even with zero interactions, which routes the SDK to the direct-popup path.
// Stubbing it as inactive forces the consent-dialog (no-gesture) path.
@@ -47,6 +53,28 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
}
});
test('access the app already holds resolves true with no dialog', async ({ page }) => {
// Asking again on every launch is what an app has to work around.
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
const dialog = page.locator('dialog.perm-dialog');
const granted = appFrame.locator('#log [data-entry="perm:email:true"]');
await appFrame.locator('#req-email-perm').click();
await expect(dialog).toBeVisible();
await dialog.locator('.perm-dialog-allow').click();
await expect(granted).toHaveCount(1);
// Asked a second time, the request settles from what is held.
await appFrame.locator('#req-email-perm').click();
await expect(granted).toHaveCount(2);
await expect(dialog).toHaveCount(0);
} finally {
await deleteTestApp(page, appName);
}
});
test('duplicate concurrent requests share one dialog and one decision', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
try {
@@ -136,18 +164,22 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
// desktop inert — not just the requesting app's window. Several at once
// would wall the user in behind a pile of prompts.
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
// Two scopes the app lacks, named so the prompts can be told apart.
const alpha = 'driver:e2e-alpha:generate';
const beta = 'driver:e2e-beta:generate';
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('body').evaluate(() => {
window.__serialResults = Promise.all([
puter.ui.requestPermission({ permission: 'driver:puter-image-generation:generate' }),
puter.ui.requestPermission({ permission: 'driver:puter-chat-completion:complete' }),
]);
});
await appFrame.locator('body').evaluate((_el, perms) => {
window.__serialResults = Promise.all(
perms.map(permission => puter.ui.requestPermission({ permission })),
);
}, [alpha, beta]);
const dialogs = page.locator('dialog.perm-dialog');
await expect(dialogs).toHaveCount(1);
// Each request reads what is held first, so the order is a race.
const deniedAlpha = (await dialogs.first().innerText()).includes('e2e-alpha');
await dialogs.first().locator('.perm-dialog-deny').click();
// The queued request gets its own prompt once the first is answered.
await expect(dialogs).toHaveCount(1);
@@ -155,7 +187,7 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
// Each caller still receives its own decision.
const results = await appFrame.locator('body').evaluate(() => window.__serialResults);
expect(results).toEqual([false, true]);
expect(results).toEqual(deniedAlpha ? [false, true] : [true, false]);
} finally {
await deleteTestApp(page, appName);
}
@@ -263,9 +295,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
// has to undo it — otherwise the app is told "denied" while the
// permission is live in the user's account.
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
const permission = 'driver:puter-image-generation:generate';
// Scoped to this app's uid: other tests grant the same permission to
// the fixture origin's app, whose row outlives them.
// The fixture picks its own per page load, and this is the row to withdraw.
let permission;
const isGrantedTo = (appUid) => page.evaluate(async ({ perm, uid }) => {
const res = await fetch(`${puter.APIOrigin}/auth/list-permissions`, {
headers: { 'Authorization': `Bearer ${puter.authToken}` },
@@ -278,6 +309,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
try {
const appFrame = await gotoTestApp(page, appName);
permission = await appFrame.locator('body')
.evaluate(() => window.__driverPermission);
const appUid = await page.evaluate(
async (name) => (await puter.apps.get(name)).uid,
appName,
@@ -302,15 +335,22 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
await route.continue();
});
await appFrame.locator('#req-driver-perm').click();
// Alongside a scope the app lacks: a fully-held request never prompts.
await appFrame.locator('body').evaluate((_el, perms) => {
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
}, [permission, 'driver:e2e-lost-response:generate']);
await expect(dialog).toBeVisible();
await dialog.locator('.perm-dialog-allow').click();
// The dialog hands itself back with a retryable error.
await expect(dialog.locator('.perm-dialog-error')).toBeVisible({ timeout: 30_000 });
await dialog.locator('.perm-dialog-deny').click();
expect(await appFrame.locator('body').evaluate(() => window.__reGrant))
.toBe(false);
await expect.poll(() => revoked, { timeout: 15_000 }).toBe(true);
// "Don't Allow" has to mean the permission is not granted.
// "Don't Allow" has to mean the permission is not granted. The
// withdrawal covers every scope the prompt listed, earlier grants
// included — the dialog's behaviour, not the check's.
await expect.poll(() => isGrantedTo(appUid), { timeout: 15_000 }).toBe(false);
} finally {
await deleteTestApp(page, appName);
@@ -327,7 +367,7 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
// permission the request may have committed is left behind, granted,
// with the app told it was refused.
const appName = await registerTestApp(page, { fixtureURL: PERMISSION_FIXTURE_URL });
const permission = 'driver:puter-image-generation:generate';
let permission;
const isGrantedTo = (appUid) => page.evaluate(async ({ perm, uid }) => {
const res = await fetch(`${puter.APIOrigin}/auth/list-permissions`, {
headers: { 'Authorization': `Bearer ${puter.authToken}` },
@@ -340,6 +380,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
try {
const appFrame = await gotoTestApp(page, appName);
permission = await appFrame.locator('body')
.evaluate(() => window.__driverPermission);
const appUid = await page.evaluate(
async (name) => (await puter.apps.get(name)).uid,
appName,
@@ -364,7 +406,10 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
await route.continue();
});
await appFrame.locator('#req-driver-perm').click();
// Alongside a scope the app lacks, for the same reason as above.
await appFrame.locator('body').evaluate((_el, perms) => {
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
}, [permission, 'driver:e2e-hanging-grant:generate']);
await expect(dialog).toBeVisible();
await dialog.locator('.perm-dialog-allow').click();
await expect(dialog.locator('.perm-dialog-allow.perm-dialog-busy')).toBeVisible();
@@ -377,8 +422,8 @@ test.describe('puter.ui.requestPermission (env=app)', () => {
document.querySelector('dialog.perm-dialog')?.close();
});
await expect(appFrame.locator('#log [data-entry="perm:driver:false"]'))
.toBeVisible({ timeout: 30_000 });
expect(await appFrame.locator('body').evaluate(() => window.__reGrant))
.toBe(false);
await expect.poll(() => revoked, { timeout: 20_000 }).toBe(true);
await expect.poll(() => isGrantedTo(appUid), { timeout: 20_000 }).toBe(false);
} finally {
@@ -408,9 +453,9 @@ test.describe('puter.ui.requestPermission (env=gui)', () => {
let popupOpened = false;
page.on('popup', () => { popupOpened = true; });
const granted = await page.evaluate(() =>
puter.ui.requestPermission({ permission: 'driver:puter-image-generation:generate' }),
);
const granted = await page.evaluate((permission) =>
puter.ui.requestPermission({ permission }),
UNHELD_DRIVER_PERMISSION);
expect(granted).toBe(false);
expect(popupOpened).toBe(false);
// Neither the consent dialog nor the permission dialog may appear.
@@ -499,11 +544,9 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
// Kick off the request from evaluate (no user activation): the SDK
// must show the PuterDialog consent step instead of a blocked popup.
await page.evaluate(() => {
window.__permPromise = puter.ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
});
});
await page.evaluate((permission) => {
window.__permPromise = puter.ui.requestPermission({ permission });
}, UNHELD_DRIVER_PERMISSION);
const continueButton = page.locator('puter-dialog #launch-auth-popup');
await expect(continueButton).toBeVisible();
@@ -527,11 +570,9 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
await page.goto(PERMISSION_FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
await page.evaluate(() => {
window.__permPromise = puter.ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
});
});
await page.evaluate((permission) => {
window.__permPromise = puter.ui.requestPermission({ permission });
}, UNHELD_DRIVER_PERMISSION);
const cancelButton = page.locator('puter-dialog #launch-auth-popup-cancel');
await expect(cancelButton).toBeVisible();
await cancelButton.click();
@@ -550,16 +591,15 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
await page.goto(PERMISSION_FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const outcome = await page.evaluate(async () => {
const outcome = await page.evaluate(async (permission) => {
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}`);
const settled = puter.ui.requestPermission({ permission })
.then(v => `resolved:${v}`, e => `rejected:${e?.message ?? e}`);
return Promise.race([
settled,
new Promise(r => setTimeout(() => r('never settled'), 10_000)),
]);
});
}, UNHELD_DRIVER_PERMISSION);
expect(outcome).toBe('resolved:false');
});
@@ -594,17 +634,6 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
async (origin) => (await window.getUserAppToken(origin))?.token,
fixtureOrigin,
);
await page.evaluate(async ({ origin, perm }) => {
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({ origin, permission: perm }),
});
}, { origin: fixtureOrigin, perm: 'driver:puter-image-generation:generate' });
await page.goto(PERMISSION_FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
await page.evaluate((t) => puter.setAuthToken(t), appToken);
@@ -633,12 +662,11 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => {
await page.goto(PERMISSION_FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
await page.evaluate(() => {
await page.evaluate((permission) => {
window.__permSettled = 'pending';
window.__permPromise = puter.ui.requestPermission({
permission: 'driver:puter-image-generation:generate',
}).then((v) => { window.__permSettled = `resolved:${v}`; return v; });
});
window.__permPromise = puter.ui.requestPermission({ permission })
.then((v) => { window.__permSettled = `resolved:${v}`; return v; });
}, UNHELD_DRIVER_PERMISSION);
await expect(page.locator('puter-dialog #launch-auth-popup')).toBeVisible();
await page.keyboard.press('Escape');
@@ -715,8 +743,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
// denial about a second after the click — before the user had even seen
// the dialog — and then the Allow they went on to click committed a
// grant the site had been told it did not get.
const permission = 'driver:puter-image-generation:generate';
// Hand the site a token for its *own* app, the way a signed-in
// third-party site holds one. Without it the poll fallback has nothing
// to authenticate with and answers false immediately (covered above),
@@ -735,20 +761,7 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
);
expect(typeof appToken).toBe('string');
// Earlier tests grant this same permission to the fixture origin's app,
// and the row outlives them — clear it so the poll starting out true
// can't pass this test on its own.
await page.evaluate(async ({ origin, perm }) => {
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({ origin, permission: perm }),
});
}, { origin: fixtureOrigin, perm: permission });
// The fixture's permission is fresh per load, so no poll can start true.
await context.route(PERMISSION_FIXTURE_URL, async (route) => {
const resp = await route.fetch();
await route.fulfill({
@@ -809,8 +822,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
// user had decided anything. What separates the two is whether the popup
// ever announced itself, which only reaches an opener that is still
// attached.
const permission = 'driver:puter-image-generation:generate';
await page.goto('/');
await page.waitForFunction(() => !!window.getUserAppToken && !!window.auth_token,
null, { timeout: 60_000 });
@@ -819,19 +830,6 @@ test.describe('puter.ui.requestPermission (env=web, COOP-only opener)', () => {
async (origin) => (await window.getUserAppToken(origin))?.token,
fixtureOrigin,
);
// Earlier tests leave this permission granted to the fixture origin's
// app; clear it so a poll that starts out true can't mask a premature
// denial.
await page.evaluate(async ({ origin, perm }) => {
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({ origin, permission: perm }),
});
}, { origin: fixtureOrigin, perm: permission });
await context.route(PERMISSION_FIXTURE_URL, async (route) => {
const resp = await route.fetch();
@@ -910,7 +908,9 @@ test.describe('request-permission popup reconciliation', () => {
// withdrawal request is fired from the closing document, so unless it
// is sent `keepalive` the browser cancels it with the popup — leaving
// the user told "denied" while the grant is live in their account.
const permission = 'driver:puter-image-generation:generate';
// Pinned, not left to the fixture's per-load default: the row must be known.
const permission = 'driver:e2e-popup-reconcile:generate';
const fixtureURL = `${PERMISSION_FIXTURE_URL}?perm=${encodeURIComponent(permission)}`;
const fixtureOrigin = new URL(PERMISSION_FIXTURE_URL).origin;
// A GUI-origin page for server-side state checks: the fixture origin
@@ -937,7 +937,20 @@ test.describe('request-permission popup reconciliation', () => {
);
}, { perm: permission, uid: appUid });
await page.goto(PERMISSION_FIXTURE_URL);
// A pinned row outlives a run that dies before the withdrawal below,
// and would settle the first request without the dialog this test needs.
await checker.evaluate(async ({ origin, perm }) => {
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({ origin, permission: perm }),
});
}, { origin: fixtureOrigin, perm: permission });
await page.goto(fixtureURL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
// Grant for real first, so there is a live row the withdrawal must
@@ -956,9 +969,12 @@ test.describe('request-permission popup reconciliation', () => {
await context.route('**/auth/grant-user-app', route =>
route.fulfill({ status: 502, body: '{}' }));
// Alongside a scope the site lacks: a fully-held request opens nothing.
[popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#req-driver-perm').click(),
page.evaluate((perms) => {
window.__reGrant = puter.ui.requestPermission({ permissions: perms });
}, [permission, 'driver:e2e-popup-reconcile-2:generate']),
]);
const dialog = popup.locator('dialog.perm-dialog');
await expect(dialog).toBeVisible({ timeout: 60_000 });
@@ -967,7 +983,7 @@ test.describe('request-permission popup reconciliation', () => {
await dialog.locator('.perm-dialog-deny').click();
// The popup answers and closes itself; the withdrawal must survive it.
await expect(page.locator('#log [data-entry="perm:driver:false"]')).toBeVisible();
expect(await page.evaluate(() => window.__reGrant)).toBe(false);
await expect.poll(() => popup.isClosed(), { timeout: 15_000 }).toBe(true);
await expect.poll(isGranted, { timeout: 20_000 }).toBe(false);
});
@@ -1040,7 +1056,7 @@ test.describe('request-permission action hardening', () => {
// would otherwise prompt for an unnamed requester and grant to an app
// the dialog never showed the user.
await page.goto(
'/action/request-permission?permission=driver%3Aputer-image-generation%3Agenerate' +
`/action/request-permission?permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}` +
'&app_uid=app-00000000-0000-4000-8000-000000000000',
);
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
@@ -1123,7 +1139,7 @@ test.describe('request-permission action hardening', () => {
window.open(
`${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true`
+ `&cross_origin_isolated=true&signin_session=${s}`
+ '&permission=driver%3Aputer-image-generation%3Agenerate&msg_id=77',
+ `&permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}&msg_id=77`,
'perm-isolated-probe',
'width=600,height=700',
);
@@ -1155,7 +1171,7 @@ test.describe('request-permission action hardening', () => {
window.open(
`${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true`
+ `&opener_origin=${encodeURIComponent(o)}`
+ '&permission=driver%3Aputer-image-generation%3Agenerate&msg_id=88',
+ `&permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}&msg_id=88`,
'perm-opener-origin-probe',
'width=600,height=700',
);
@@ -1177,7 +1193,7 @@ test.describe('request-permission action hardening', () => {
// handshake). Believing the query string would let a bare link raise a
// consent prompt in any app's name and commit the user's grant to it.
await page.goto(
'/action/request-permission?permission=driver%3Aputer-image-generation%3Agenerate' +
`/action/request-permission?permission=${encodeURIComponent(UNHELD_DRIVER_PERMISSION)}` +
`&origin=${encodeURIComponent('https://not-the-requester.example/')}`,
);
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
@@ -1257,3 +1273,62 @@ test.describe('request-permission action hardening', () => {
expect(info.readsInOrder).toBe(true);
});
});
test.describe('request-permission popup on access already granted', () => {
test('a signed-out site is answered without a prompt', async ({ page }) => {
// A signed-out site holds no token to settle this itself, so the popup
// does it once the exchange has run. Answering `true` with nothing
// clicked is the assertion: only Allow could produce that otherwise.
// Pinned through `?perm=`, since the grant has to name the same scope.
const permission = 'driver:e2e-already-granted:generate';
const fixtureURL = `${PERMISSION_FIXTURE_URL}?perm=${encodeURIComponent(permission)}`;
const fixtureOrigin = new URL(PERMISSION_FIXTURE_URL).origin;
await page.goto('/');
await page.waitForFunction(() => !!window.getUserAppToken && !!window.auth_token,
null, { timeout: 60_000 });
// Bootstraps the app row the grant is written against.
const appToken = await page.evaluate(
async (origin) => (await window.getUserAppToken(origin))?.token,
fixtureOrigin,
);
expect(typeof appToken).toBe('string');
// Runs on the GUI page, which is where the user's own token lives.
const post = (route) => page.evaluate(async ({ route: r, origin, perm }) => {
const res = await fetch(`${window.api_origin}${r}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({ origin, permission: perm }),
});
return res.status;
}, { route, origin: fixtureOrigin, perm: permission });
expect(await post('/auth/grant-user-app')).toBe(200);
try {
await page.goto(fixtureURL);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.locator('body.ready').waitFor({ timeout: 60_000 });
expect(await page.evaluate(() => !!puter.authToken)).toBe(false);
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#req-driver-perm').click(),
]);
await expect(page.locator('#log [data-entry="perm:driver:true"]'))
.toBeVisible({ timeout: 60_000 });
await expect.poll(() => popup.isClosed(), { timeout: 30_000 }).toBe(true);
} finally {
// The row outlives the test, and a stray grant is one more thing a
// later failure could be blamed on.
await page.goto('/');
await page.waitForFunction(() => !!window.auth_token, null, { timeout: 60_000 });
await post('/auth/revoke-user-app');
}
});
});