fix(perms): put request() and check() on one path

`request` dispatched to the old per-task methods while `check` asked the
permission tables, so the two answered different questions about the same
access. Concretely, before this: `request('folder', { access: 'write' })`,
`'apps'`, `'subdomains'`, `'appData'` and `'permission'` prompted every time,
whether or not the access was held — which the docs said they wouldn't;
`check('folder')` reported false for a folder the app could read through an ACL
grant that no `fs:` string names, so a batch prompted for it needlessly; a
batch entry for `'appRootDir'` skipped the post-grant retry the single call
does, resolving `undefined` after a grant that had in fact succeeded; and an
N-entry batch made N permission reads plus 2N `whoami` calls.

Both now run the same pipeline — resolve the permission strings, read what is
held once, prompt for the remainder, resolve each entry — with per-resource
hooks for the parts only that resource can answer. So a batch costs one
permission read and one `whoami`, a check reports exactly what a request would
skip the prompt for, and `'folder'` uses the same stat-or-permission reading in
both.

Also:

- A resource is looked up as an own property, so `request('constructor')` is
  the permission string it always was rather than a TypeError.
- A permission read that fails no longer decides anything: `request` falls
  through to the prompt it would have raised anyway, `check` throws. Before,
  `check('appRootDir')` folded a failed check into "not granted", which is what
  the documentation says must not happen.
- Drops `requestFolder`, `requestApps`, `requestSubdomains` and
  `requestAppRootDir`. They were added in this branch and immediately deprecated
  — never shipped, and `request()` no longer needs to route through them. The 22
  methods that did ship keep their exact behaviour, prompting without consulting
  what is held, which the suite now asserts alongside the new behaviour.
- Documents `'appRootDir'`, which was a supported resource in every overload and
  in `PermsResource` but named in none of the docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Salazar
2026-08-21 01:30:41 -04:00
co-authored by Claude Opus 5
parent 18098dd71b
commit 38d7c7104f
12 changed files with 851 additions and 613 deletions
+1
View File
@@ -201,6 +201,7 @@ The resource decides which details the call takes and what a request resolves to
| `'apps'` | `{ access }` | `true` if granted |
| `'subdomains'` | `{ access }` | `true` if granted |
| `'appData'` | `{ app, scopes }` | `true` if granted |
| `'appRootDir'` | `{ app, access }` | The app's root directory |
| `'permission'` | `{ permission }` or `{ permissions }` | `true` if granted |
Anything denied resolves to a falsy value, so one `if` covers both outcomes. `access` is `'read'` (the default) or `'write'`.
+2 -2
View File
@@ -4,11 +4,11 @@ description: Check whether access has already been granted, without prompting th
platforms: [websites, apps]
---
Ask whether access is already granted. The user is never prompted — this only reports what they have already allowed.
Ask whether access is already granted. Nothing is prompted and nothing is changed; this only reports what the user has already allowed.
Use it to keep prompts out of the way until they are needed: show a feature as available when the access is in place, and offer an opt-in only where it is not.
It takes the same resources and details as [`puter.perms.request()`](/Perms/request/).
It takes the same resources and details as [`puter.perms.request()`](/Perms/request/). Both read the same state, so `check()` returning `true` means the matching `request()` won't prompt.
## Syntax
+4 -2
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps]
Request access to something belonging to the user. The first argument names the resource; the second carries the details that resource takes.
The user is prompted to allow or deny. If the access has already been granted, they are not prompted again.
The user is prompted to allow or deny. Anything already granted is skipped, so a call whose access is fully in place doesn't prompt at all. [`puter.perms.check()`](/Perms/check/) reads the same state, so the two always agree.
Inside the Puter desktop the prompt is shown as a dialog. On websites, it opens in a popup window on the Puter origin — call this from a user gesture (e.g. a click handler) so the browser doesn't block the popup; without a gesture, a consent dialog is shown first and the popup opens when the user clicks Continue.
@@ -31,6 +31,7 @@ What the request is about. The resource decides which details are accepted and w
| `'apps'` | `{ access }` | `true` if granted |
| `'subdomains'` | `{ access }` | `true` if granted |
| `'appData'` | `{ app, scopes }` | `true` if granted |
| `'appRootDir'` | `{ app, access }` | The app's root directory, or `undefined` if denied |
| `'permission'` | `{ permission }` or `{ permissions }` | `true` if granted |
#### `details` (object) (optional)
@@ -39,7 +40,7 @@ The fields the resource takes:
- **`name`** (string) — for `'folder'`: `'Desktop'`, `'Documents'`, `'Pictures'`, or `'Videos'`.
- **`access`** (string) — `'read'` (the default) or `'write'`. `write` implies read, and for `'apps'` and `'subdomains'` it covers managing them as well as reading them.
- **`app`** (string | object) — for `'appData'`: the target app, by uid or by registered name.
- **`app`** (string | object) — for `'appData'`: the target app, by uid or by registered name. For `'appRootDir'`: the app's uid, or an object with one.
- **`scopes`** (string | array | object) — for `'appData'`: what this app wants to do with that data. See [Using another app's data](/Perms/appData/) for the full scope forms.
- **`permission`** (string) / **`permissions`** (array of strings) — for `'permission'`: a raw permission string, or several to put behind one prompt. Pass one or the other, not both.
@@ -61,6 +62,7 @@ The array form resolves to an array of those values, in the order asked.
- File system: `fs:{path}:{read|write}`
- Apps: `apps-of-user:{uuid}:{read|write}`
- Subdomains: `subdomains-of-user:{uuid}:{read|write}`
- An app's root directory: `app-root-dir:{app_uid}:{read|write}`
Some permission strings are not supported and are denied silently.
+77 -49
View File
@@ -1,10 +1,18 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import { appRootDirPermission } from './lib/permissionStrings.js';
import { req } from './lib/req.js';
import { assertAccess, invalidArgument } from './lib/validate.js';
import { invalidArgument } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('../../index.js').Puter} Puter */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
const ROUTE = '/auth/request-app-root-dir';
/** How long to keep re-asking after a grant, and the first gap between tries. */
const MAX_TOTAL_WAIT_MS = 5000;
const FIRST_RETRY_DELAY_MS = 100;
/**
* The uid out of either accepted form of app identifier.
*
@@ -23,19 +31,73 @@ export function appUidOf (appUidOrObject) {
}
/**
* Ask the server for the app's root directory the directory when the access
* is held, an error object when it isn't, so it doubles as a check.
* Ask the server for the app's root directory, which it provisions on the first
* ask. Resolves the fs item, or a result with `error: true` when the caller may
* not claim it.
*
* @param {import('../../index.js').Puter} puter
* @param {Puter} puter
* @param {string} appUid
* @param {PermsAccess} access
* @returns {Promise<Record<string, unknown>>}
*/
export async function statAppRootDir (puter, appUid, access) {
return await req(puter, '/auth/request-app-root-dir', {
return await req(puter, ROUTE, { app_uid: appUid, access });
}
/**
* Whether the caller may claim that app's root directory. Uses the route's
* read-only mode, so unlike {@link statAppRootDir} it doesn't provision the
* directory just for being asked about it.
*
* A refusal is the answer, and comes back as `false`. Anything else means the
* question couldn't be asked, which is not the same as "no", so it throws.
*
* @param {Puter} puter
* @param {string} appUid
* @param {PermsAccess} access
* @returns {Promise<boolean>}
*/
export async function checkAppRootDir (puter, appUid, access) {
const result = await req(puter, ROUTE, {
app_uid: appUid,
access,
check: true,
});
if ( ! result.error ) return true;
if ( result.code === 'forbidden' ) return false;
throw new PuterJSError(
/** @type {string} */ (result.message) ?? 'app root dir check failed',
/** @type {string} */ (result.code) ?? 'unknown_error',
);
}
/**
* Claim the directory, re-asking with a bounded backoff while the server still
* refuses. A fresh grant may not have reached the permission cache yet, so the
* first refusal after one isn't final.
*
* @param {Puter} puter
* @param {string} appUid
* @param {PermsAccess} access
* @returns {Promise<Record<string, unknown> | undefined>} The fs item, or
* `undefined` if the server never allowed it.
*/
export async function pollAppRootDir (puter, appUid, access) {
let result = await statAppRootDir(puter, appUid, access);
let delay = FIRST_RETRY_DELAY_MS;
let totalWaited = 0;
while ( result.error && totalWaited < MAX_TOTAL_WAIT_MS ) {
await new Promise(r => setTimeout(r, delay));
totalWaited += delay;
result = await statAppRootDir(puter, appUid, access);
delay = Math.min(
delay * 2,
Math.max(FIRST_RETRY_DELAY_MS, MAX_TOTAL_WAIT_MS - totalWaited),
);
}
return result.error ? undefined : result;
}
/**
@@ -44,77 +106,43 @@ export async function statAppRootDir (puter, appUid, access) {
* permission and retries (with a short backoff to ride out server-side cache
* invalidation), returning the fs item on success or `undefined` if denied.
*
* @param {import('../../index.js').Puter} puter
* @param {Puter} puter
* @param {PermsAccess} access
* @param {string | { uid: string }} appUidOrObject
* @returns {Promise<Record<string, unknown> | undefined>}
*/
async function requestAppRootDirAccess (puter, access, appUidOrObject) {
export async function requestAppRootDirAccess (puter, access, appUidOrObject) {
const appUid = appUidOf(appUidOrObject);
let result;
const fetchIt = async () => {
result = await statAppRootDir(puter, appUid, access);
};
await fetchIt();
if ( ! result.error ) return result;
const first = await statAppRootDir(puter, appUid, access);
if ( ! first.error ) return first;
const granted = await puter.ui.requestPermission({
permission: appRootDirPermission(appUid, access),
});
if ( ! granted ) return undefined;
if ( granted ) {
await fetchIt();
// If the server has cache-invalidation lag, retry with backoff so this
// still works. A hack, but also a reasonable safeguard.
let delay = 100;
const maxTotalWait = 5000;
let totalWaited = 0;
while ( result.error && totalWaited < maxTotalWait ) {
await new Promise(r => setTimeout(r, delay));
totalWaited += delay;
await fetchIt();
if ( ! result.error ) break;
delay = Math.min(delay * 2, Math.max(100, maxTotalWait - totalWaited));
}
}
return result.error ? undefined : result;
}
/**
* Request access to the root directory of one of the user's apps.
*
* @this {PermsModule}
* @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`.
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
* @returns {Promise<Record<string, unknown> | undefined>} The directory fs item, or `undefined` if denied.
*/
export async function requestAppRootDir (appUid, accessLevel = 'read') {
const access = assertAccess(accessLevel);
return await requestAppRootDirAccess(this.puter, access, appUid);
return await pollAppRootDir(puter, appUid, access);
}
// -- Deprecated aliases --
/**
* @deprecated Use {@link requestAppRootDir} instead.
* @deprecated Use `request('appRootDir', { app })`.
* @this {PermsModule}
* @param {string | { uid: string }} appUid
* @returns {Promise<Record<string, unknown> | undefined>}
*/
export function requestReadAppRootDir (appUid) {
return this.requestAppRootDir(appUid, 'read');
return requestAppRootDirAccess(this.puter, 'read', appUid);
}
/**
* @deprecated Use {@link requestAppRootDir} instead.
* @deprecated Use `request('appRootDir', { app, access: 'write' })`.
* @this {PermsModule}
* @param {string | { uid: string }} appUid
* @returns {Promise<Record<string, unknown> | undefined>}
*/
export function requestWriteAppRootDir (appUid) {
return this.requestAppRootDir(appUid, 'write');
return requestAppRootDirAccess(this.puter, 'write', appUid);
}
@@ -5,60 +5,66 @@ const mockReq = vi.fn();
vi.mock('./lib/req.js', () => ({ req: (...args) => mockReq(...args) }));
const {
requestAppRootDir,
appUidOf,
checkAppRootDir,
pollAppRootDir,
requestReadAppRootDir,
requestWriteAppRootDir,
} = await import('./appRootDir.js');
const { PuterJSError } = await import('../../lib/PuterJSError.js');
const ROUTE = '/auth/request-app-root-dir';
const makeModule = (requestPermission) => ({
puter: {
APIOrigin: 'https://api.test',
ui: { requestPermission: vi.fn(requestPermission) },
},
requestAppRootDir,
});
describe('perms appRootDir', () => {
beforeEach(() => mockReq.mockReset());
it('requests write access with access:"write" in the body', async () => {
it('asks at the alias\'s access level, and does not prompt when allowed', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' }); // succeeds first try
const mod = makeModule();
const result = await requestAppRootDir.call(mod, 'app-123', 'write');
expect(result).toEqual({ path: '/root' });
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-123', access: 'write' },
);
expect(await requestWriteAppRootDir.call(mod, 'app-123')).toEqual({
path: '/root',
});
expect(mockReq).toHaveBeenCalledWith(mod.puter, ROUTE, {
app_uid: 'app-123',
access: 'write',
});
// Already had access, so no permission prompt.
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('defaults to read access', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' });
it('reads by default, and accepts an app object with a uid', async () => {
mockReq.mockResolvedValue({ path: '/root' });
const mod = makeModule();
await requestAppRootDir.call(mod, 'app-xyz');
await requestReadAppRootDir.call(mod, 'app-xyz');
expect(mockReq).toHaveBeenLastCalledWith(mod.puter, ROUTE, {
app_uid: 'app-xyz',
access: 'read',
});
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-xyz', access: 'read' },
);
await requestReadAppRootDir.call(mod, { uid: 'app-obj' });
expect(mockReq).toHaveBeenLastCalledWith(mod.puter, ROUTE, {
app_uid: 'app-obj',
access: 'read',
});
});
it('prompts for the write permission string and retries after a grant', async () => {
// First call is denied by the backend, second (post-grant) succeeds.
// First call is refused by the backend, second (post-grant) succeeds.
mockReq
.mockResolvedValueOnce({ error: true })
.mockResolvedValueOnce({ path: '/root' });
const mod = makeModule(() => true);
const result = await requestAppRootDir.call(mod, 'app-123', 'write');
const result = await requestWriteAppRootDir.call(mod, 'app-123');
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'app-root-dir:app-123:write',
@@ -67,60 +73,76 @@ describe('perms appRootDir', () => {
expect(mockReq).toHaveBeenCalledTimes(2);
});
it('accepts an app object with a uid', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' });
const mod = makeModule();
await requestAppRootDir.call(mod, { uid: 'app-obj' });
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-obj', access: 'read' },
);
});
it('returns undefined when the permission is denied', async () => {
it('returns undefined when the permission is denied, without re-asking', async () => {
mockReq.mockResolvedValue({ error: true });
const mod = makeModule(() => false);
const result = await requestAppRootDir.call(mod, 'app-123', 'write');
expect(result).toBeUndefined();
expect(await requestWriteAppRootDir.call(mod, 'app-123')).toBeUndefined();
expect(mockReq).toHaveBeenCalledTimes(1);
});
it('rejects a non-string, non-object app uid with a coded error', async () => {
expect(() => appUidOf(42)).toThrow(PuterJSError);
expect(() => appUidOf(42)).toThrow(/app_uid must be a string/);
expect(() => appUidOf({})).toThrow(PuterJSError);
expect(appUidOf({ uid: 'app-1' })).toBe('app-1');
expect(appUidOf('app-1')).toBe('app-1');
});
// A grant can lag the permission cache, so one refusal after it isn't final.
it('keeps re-asking after a grant until the server allows it', async () => {
mockReq
.mockResolvedValueOnce({ error: true })
.mockResolvedValueOnce({ error: true })
.mockResolvedValueOnce({ path: '/root' });
const mod = makeModule();
await expect(requestAppRootDir.call(mod, 42)).rejects.toBeInstanceOf(PuterJSError);
await expect(requestAppRootDir.call(mod, 42)).rejects.toMatchObject({
code: 'invalid_argument',
expect(await pollAppRootDir(mod.puter, 'app-1', 'read')).toEqual({
path: '/root',
});
expect(mockReq).toHaveBeenCalledTimes(3);
});
it('rejects an access level that is neither read nor write', async () => {
it('gives up polling and resolves undefined', async () => {
vi.useFakeTimers();
try {
mockReq.mockResolvedValue({ error: true });
const mod = makeModule();
const pending = pollAppRootDir(mod.puter, 'app-1', 'read');
await vi.runAllTimersAsync();
expect(await pending).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
// The check must not provision the directory as a side effect of asking.
it('checks access read-only, and reads a refusal as "not held"', async () => {
mockReq.mockResolvedValueOnce({ allowed: true });
const mod = makeModule();
expect(await checkAppRootDir(mod.puter, 'app-1', 'read')).toBe(true);
expect(mockReq).toHaveBeenCalledWith(mod.puter, ROUTE, {
app_uid: 'app-1',
access: 'read',
check: true,
});
mockReq.mockResolvedValueOnce({ error: true, code: 'forbidden' });
expect(await checkAppRootDir(mod.puter, 'app-1', 'write')).toBe(false);
});
// A check that couldn't be made is not a refusal: folding it into `false`
// would prompt someone who had already granted it.
it('surfaces a failed check rather than reporting "not held"', async () => {
mockReq.mockResolvedValueOnce({
error: true,
message: 'nope',
code: 'unauthorized',
});
await expect(
requestAppRootDir.call(mod, 'app-123', 'delete'),
).rejects.toMatchObject({ code: 'invalid_argument' });
expect(mockReq).not.toHaveBeenCalled();
});
it('keeps the deprecated read/write aliases delegating', async () => {
mockReq.mockResolvedValue({ path: '/root' });
const mod = makeModule();
await requestReadAppRootDir.call(mod, 'app-1');
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-1', access: 'read' },
);
await requestWriteAppRootDir.call(mod, 'app-1');
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-1', access: 'write' },
);
checkAppRootDir(makeModule().puter, 'app-1', 'read'),
).rejects.toMatchObject({ message: 'nope', code: 'unauthorized' });
});
});
+51 -53
View File
@@ -1,42 +1,56 @@
import { fsPermission } from './lib/permissionStrings.js';
import { assertAccess, assertFolderName } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('../../index.js').Puter} Puter */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/** @typedef {import('./types.js').PermsFolderName} PermsFolderName */
/**
* Where one of the user's special folders lives, shared with `check`.
* Where one of the user's special folders lives. The one place the path is
* spelled out, so a request and its matching `check` can't name it differently.
*
* @param {import('../../index.js').Puter} puter
* @param {string} username
* @param {string} folderName
* @returns {Promise<string>}
* @returns {string}
*/
export async function folderPathFor (puter, folderName) {
const whoami = await puter.auth.whoami();
return `/${whoami.username}/${folderName}`;
export function folderPathFor (username, folderName) {
return `/${username}/${folderName}`;
}
/**
* Whether the folder can be read already. Being able to stat it is the proof:
* read access can also come from an ACL grant that no `fs:` permission string
* names, so the permission tables alone would under-report it.
*
* @param {Puter} puter
* @param {string} folderPath
* @returns {Promise<boolean>}
*/
export async function folderReadable (puter, folderPath) {
try {
await puter.fs.stat({ path: folderPath });
return true;
} catch {
return false;
}
}
/**
* Resolve a folder request to its path, prompting only when the access isn't
* already held.
*
* @param {import('../../index.js').Puter} puter
* @param {Puter} puter
* @param {string} folderName
* @param {PermsAccess} accessLevel
* @returns {Promise<string | undefined>}
*/
async function requestFolderPath (puter, folderName, accessLevel) {
const folderPath = await folderPathFor(puter, folderName);
export async function requestFolderPath (puter, folderName, accessLevel) {
const whoami = await puter.auth.whoami();
const folderPath = folderPathFor(whoami.username, folderName);
// Being able to stat the folder means we already have at least read access.
try {
await puter.fs.stat({ path: folderPath });
if ( accessLevel !== 'write' ) {
return folderPath;
}
} catch (e) {
// No access yet, fall through to request permission.
// Read access is inferred from being able to stat the folder.
if ( accessLevel !== 'write' && await folderReadable(puter, folderPath) ) {
return folderPath;
}
const granted = await puter.ui.requestPermission({
@@ -45,30 +59,14 @@ async function requestFolderPath (puter, folderName, accessLevel) {
return granted ? folderPath : undefined;
}
/**
* Requests access to one of the user's special folders, returning its path if
* access is (or becomes) granted. Read access is inferred from being able to
* stat the folder; write access always prompts if not already held.
*
* @this {PermsModule}
* @param {PermsFolderName} folderName - Desktop, Documents, Pictures, or Videos.
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
* @returns {Promise<string | undefined>} The folder path, or `undefined` if denied.
*/
export async function requestFolder (folderName, accessLevel = 'read') {
const name = assertFolderName(folderName);
const access = assertAccess(accessLevel);
return await requestFolderPath(this.puter, name, access);
}
// -- Deprecated aliases --
//
// Kept so apps written against the old one-method-per-folder surface keep
// working. `requestFolder_` bypasses the name check the public method applies,
// preserving its any-folder behavior.
// working. `requestFolder_` takes any folder name, not just the four the
// supported surface covers, so it stays unvalidated.
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name, access })`.
* @this {PermsModule}
* @param {string} folderName
* @param {PermsAccess} accessLevel
@@ -79,73 +77,73 @@ export function requestFolder_ (folderName, accessLevel) {
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Desktop' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestReadDesktop () {
return this.requestFolder('Desktop', 'read');
return requestFolderPath(this.puter, 'Desktop', 'read');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Desktop', access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestWriteDesktop () {
return this.requestFolder('Desktop', 'write');
return requestFolderPath(this.puter, 'Desktop', 'write');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Documents' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestReadDocuments () {
return this.requestFolder('Documents', 'read');
return requestFolderPath(this.puter, 'Documents', 'read');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Documents', access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestWriteDocuments () {
return this.requestFolder('Documents', 'write');
return requestFolderPath(this.puter, 'Documents', 'write');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Pictures' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestReadPictures () {
return this.requestFolder('Pictures', 'read');
return requestFolderPath(this.puter, 'Pictures', 'read');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Pictures', access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestWritePictures () {
return this.requestFolder('Pictures', 'write');
return requestFolderPath(this.puter, 'Pictures', 'write');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Videos' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestReadVideos () {
return this.requestFolder('Videos', 'read');
return requestFolderPath(this.puter, 'Videos', 'read');
}
/**
* @deprecated Use {@link requestFolder} instead.
* @deprecated Use `request('folder', { name: 'Videos', access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<string | undefined>}
*/
export function requestWriteVideos () {
return this.requestFolder('Videos', 'write');
return requestFolderPath(this.puter, 'Videos', 'write');
}
+6 -20
View File
@@ -1,11 +1,8 @@
import { PuterModule } from '../../lib/PuterModule.js';
import { requestAppData } from './appData.js';
import { requestReadAppRootDir, requestWriteAppRootDir } from './appRootDir.js';
import {
requestAppRootDir,
requestReadAppRootDir, requestWriteAppRootDir,
} from './appRootDir.js';
import {
requestFolder, requestFolder_,
requestFolder_,
requestReadDesktop, requestWriteDesktop,
requestReadDocuments, requestWriteDocuments,
requestReadPictures, requestWritePictures,
@@ -16,26 +13,23 @@ import {
revokeApp, revokeAppAnyUser, revokeOrigin,
} from './grants.js';
import {
requestApps, requestEmail,
requestEmail,
requestManageApps, requestManageSubdomains,
requestPermission, requestReadApps, requestReadSubdomains,
requestSubdomains,
} from './permissions.js';
import { check, request } from './request.js';
/** @typedef {import('../../index.js').Puter} Puter */
// Every `this`-context method exposed on the module, rebound in the
// constructor so both `puter.perms.requestFolder(...)` and destructured
// `const { requestFolder } = puter.perms` calls keep the right `this`.
// constructor so both `puter.perms.request(...)` and destructured
// `const { request } = puter.perms` calls keep the right `this`.
const METHODS = [
'grantApp', 'grantAppAnyUser', 'grantOrigin',
'revokeApp', 'revokeAppAnyUser', 'revokeOrigin',
'request', 'check',
// Deprecated aliases; bound for the same reason as the rest.
'requestEmail',
'requestFolder', 'requestApps', 'requestSubdomains',
'requestAppRootDir', 'requestAppData',
'requestEmail', 'requestAppData',
'requestPermission', 'requestFolder_',
'requestReadDesktop', 'requestWriteDesktop',
'requestReadDocuments', 'requestWriteDocuments',
@@ -74,14 +68,6 @@ export class PermsModule extends PuterModule {
/** @deprecated Use `request('email')`. */
requestEmail = requestEmail;
/** @deprecated Use `request('folder', { name, access })`. */
requestFolder = requestFolder;
/** @deprecated Use `request('apps', { access })`. */
requestApps = requestApps;
/** @deprecated Use `request('subdomains', { access })`. */
requestSubdomains = requestSubdomains;
/** @deprecated Use `request('appRootDir', { app, access })`. */
requestAppRootDir = requestAppRootDir;
/** @deprecated Use `request('appData', { app, scopes })`. */
requestAppData = requestAppData;
+9 -12
View File
@@ -2,27 +2,24 @@ import { PuterJSError } from '../../../lib/PuterJSError.js';
import { req } from './req.js';
/**
* Whether the caller holds every one of these permissions, without prompting.
* All-or-nothing: a partly-granted set still needs its prompt.
* Which of these permissions the caller holds, without prompting. One round
* trip however many are asked about, so a whole batch pools into one call.
*
* Failures are thrown, not folded into "not held": "denied" and "never ran"
* differ, and a caller that can't tell them apart would prompt someone who has
* already granted it.
*
* @param {import('../../../index.js').Puter} puter
* @param {string[]} permissions
* @returns {Promise<boolean>}
* @returns {Promise<Record<string, boolean>>}
*/
export async function holdsPermissions (puter, permissions) {
if ( permissions.length === 0 ) return false;
export async function checkPermissions (puter, permissions) {
const result = await req(puter, '/auth/check-permissions', { permissions });
// Surfaced, not folded into `false`: "denied" and "never ran" differ.
if ( result.error ) {
throw new PuterJSError(
/** @type {string} */ (result.message) ?? 'permission check failed',
/** @type {string} */ (result.code) ?? 'unknown_error',
);
}
const held = /** @type {Record<string, boolean>} */ (
result.permissions ?? {}
);
return permissions.every((permission) => held[permission] === true);
return /** @type {Record<string, boolean>} */ (result.permissions ?? {});
}
+42 -49
View File
@@ -3,16 +3,16 @@ import {
emailPermission,
subdomainsPermission,
} from './lib/permissionStrings.js';
import { assertAccess } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('../../index.js').Puter} Puter */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/**
* Ask for a raw permission string, or several under one prompt. Unsupported
* strings are denied silently. Stays on `puter.ui`, which owns the IPC.
*
* @param {import('../../index.js').Puter} puter
* @param {Puter} puter
* @param {string[]} permissions
* @returns {Promise<boolean>}
*/
@@ -23,6 +23,32 @@ export async function requestPermissions (puter, permissions) {
: await puter.ui.requestPermission({ permissions });
}
/**
* @param {Puter} puter
* @param {PermsAccess} access
* @returns {Promise<boolean>}
*/
async function requestAppsAccess (puter, access) {
const whoami = await puter.auth.whoami();
return await requestPermissions(puter, [
appsPermission(whoami.uuid, access),
]);
}
/**
* @param {Puter} puter
* @param {PermsAccess} access
* @returns {Promise<boolean>}
*/
async function requestSubdomainsAccess (puter, access) {
const whoami = await puter.auth.whoami();
return await requestPermissions(puter, [
subdomainsPermission(whoami.uuid, access),
]);
}
// -- Deprecated aliases --
/**
* @deprecated Use {@link import('./request.js').request} instead.
* @this {PermsModule}
@@ -36,15 +62,16 @@ export function requestPermission (...args) {
}
/**
* Request to see the user's email. If already granted, the user is not
* prompted and their email is returned.
* Request access to the user's email address, returning it when granted.
*
* @deprecated Use `request('email')`.
* @this {PermsModule}
* @returns {Promise<string | null | undefined>} The email if granted, `null`
* if granted but no email is on file, or `undefined` if access is denied.
* @returns {Promise<string | null | undefined>}
*/
export async function requestEmail () {
let whoami = await this.puter.auth.whoami();
// The grant is what puts the field on `whoami`, so it being present at all
// means the access is already held — `null` included.
if ( whoami.email !== undefined ) return whoami.email;
const granted = await this.puter.ui.requestPermission({
@@ -52,76 +79,42 @@ export async function requestEmail () {
});
if ( granted ) {
whoami = await this.puter.auth.whoami();
return whoami.email;
}
return whoami.email;
}
/**
* Request access to the user's apps. `write` covers managing them (create,
* update, delete) as well as reading them.
*
* @this {PermsModule}
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
* @returns {Promise<boolean>} `true` if the permission was granted.
*/
export async function requestApps (accessLevel = 'read') {
const access = assertAccess(accessLevel);
const whoami = await this.puter.auth.whoami();
return await this.puter.ui.requestPermission({
permission: appsPermission(whoami.uuid, access),
});
}
/**
* Request access to the user's subdomains. `write` covers managing them as well
* as reading them.
*
* @this {PermsModule}
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
* @returns {Promise<boolean>} `true` if the permission was granted.
*/
export async function requestSubdomains (accessLevel = 'read') {
const access = assertAccess(accessLevel);
const whoami = await this.puter.auth.whoami();
return await this.puter.ui.requestPermission({
permission: subdomainsPermission(whoami.uuid, access),
});
}
// -- Deprecated aliases --
/**
* @deprecated Use {@link requestApps} instead.
* @deprecated Use `request('apps')`.
* @this {PermsModule}
* @returns {Promise<boolean>}
*/
export function requestReadApps () {
return this.requestApps('read');
return requestAppsAccess(this.puter, 'read');
}
/**
* @deprecated Use {@link requestApps} instead.
* @deprecated Use `request('apps', { access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<boolean>}
*/
export function requestManageApps () {
return this.requestApps('write');
return requestAppsAccess(this.puter, 'write');
}
/**
* @deprecated Use {@link requestSubdomains} instead.
* @deprecated Use `request('subdomains')`.
* @this {PermsModule}
* @returns {Promise<boolean>}
*/
export function requestReadSubdomains () {
return this.requestSubdomains('read');
return requestSubdomainsAccess(this.puter, 'read');
}
/**
* @deprecated Use {@link requestSubdomains} instead.
* @deprecated Use `request('subdomains', { access: 'write' })`.
* @this {PermsModule}
* @returns {Promise<boolean>}
*/
export function requestManageSubdomains () {
return this.requestSubdomains('write');
return requestSubdomainsAccess(this.puter, 'write');
}
+225 -170
View File
@@ -1,7 +1,7 @@
import { appDataRequest } from './appData.js';
import { appUidOf, statAppRootDir } from './appRootDir.js';
import { folderPathFor } from './folders.js';
import { holdsPermissions } from './lib/holds.js';
import { appUidOf, checkAppRootDir, pollAppRootDir } from './appRootDir.js';
import { folderPathFor, folderReadable } from './folders.js';
import { checkPermissions } from './lib/holds.js';
import {
appRootDirPermission,
appsPermission,
@@ -13,13 +13,43 @@ import { assertAccess, assertFolderName, invalidArgument } from './lib/validate.
import { requestPermissions } from './permissions.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('../../index.js').Puter} Puter */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/** @typedef {import('./types.js').PermsResource} PermsResource */
/** @typedef {import('./types.js').PermsRequestDetails} PermsRequestDetails */
/**
* Per-call scratch space. `whoami` is cached here so a ten-entry batch fetches
* it once, with `reread` for the one caller that needs it fresh: a grant is
* what puts the email on it, so the copy read before the prompt doesn't carry
* the address the prompt just released.
*
* @typedef {Object} PermsContext
* @property {Puter} puter
* @property {() => Promise<Record<string, any>>} whoami
* @property {() => Promise<Record<string, any>>} reread
*/
/**
* @param {Puter} puter
* @returns {PermsContext}
*/
const makeContext = (puter) => {
/** @type {Promise<Record<string, any>> | undefined} */
let pending;
return {
puter,
whoami: () => (pending ??= puter.auth.whoami()),
reread: () => (pending = puter.auth.whoami()),
};
};
/** @param {Record<string, unknown>} details @returns {PermsAccess} */
const accessOf = (details) => assertAccess(details.access ?? 'read');
/** @param {Record<string, unknown>} details @returns {string} */
const folderNameOf = (details) => assertFolderName(details.name);
/**
* The permission strings a `'permission'` request names, one or many.
*
@@ -49,192 +79,172 @@ const permissionsOf = (details) => {
};
/**
* Per resource: ask for it alone (`request`, delegating to the method that
* always served it), whether it is held (`check`), the strings a batch pools
* into one prompt (`permissions`), and the value once held (`resolve`).
* One entry's question, handed to that resource's `check` and `resolve`.
*
* @type {Record<string, {
* request: (perms: PermsModule, details: Record<string, unknown>) => Promise<unknown>,
* check: (perms: PermsModule, details: Record<string, unknown>) => Promise<boolean>,
* permissions: (perms: PermsModule, details: Record<string, unknown>) => Promise<string[]>,
* resolve: (perms: PermsModule, details: Record<string, unknown>, held: boolean) => Promise<unknown>,
* }>}
* @typedef {Object} PermsQuery
* @property {PermsContext} ctx
* @property {Record<string, unknown>} details
* @property {string[]} permissions - What this entry needs, already resolved.
* @property {(permissions: string[]) => Promise<boolean>} holds - Answered from
* the call's one pooled permission read.
*/
const RESOURCES = {
/**
* Per resource: the permission strings it needs (`permissions`, which also
* validates the details), whether they are already held (`check`), and the
* value once held (`resolve`). `pooled: false` marks a resource whose `check`
* asks the server itself, so its strings stay out of the pooled read.
*
* @typedef {Object} PermsResourceHandler
* @property {(query: { ctx: PermsContext, details: Record<string, unknown> }) => Promise<string[]>} permissions
* @property {(query: PermsQuery) => Promise<boolean>} check
* @property {(query: { ctx: PermsContext, details: Record<string, unknown> }, held: boolean) => Promise<unknown>} resolve
* @property {boolean} [pooled]
*/
/**
* The supported resources. Prototype-free so a permission string that happens
* to share a name with an `Object.prototype` member (`constructor`, `toString`)
* is still read as the permission string it is.
*
* @type {Record<string, PermsResourceHandler>}
*/
const RESOURCES = Object.assign(Object.create(null), {
email: {
request: (perms) => perms.requestEmail(),
check: async (perms) => {
permissions: async ({ ctx }) => [
emailPermission((await ctx.whoami()).uuid),
],
check: async ({ ctx, permissions, holds }) => {
// The grant is what puts the field on `whoami`, so `null` is granted.
const whoami = await perms.puter.auth.whoami();
if ( whoami.email !== undefined ) return true;
return await holdsPermissions(perms.puter, [
emailPermission(whoami.uuid),
]);
if ( (await ctx.whoami()).email !== undefined ) return true;
return holds(permissions);
},
permissions: async (perms) => {
const whoami = await perms.puter.auth.whoami();
return [emailPermission(whoami.uuid)];
},
resolve: async (perms, _details, held) => {
resolve: async ({ ctx }, held) => {
if ( ! held ) return undefined;
return (await perms.puter.auth.whoami()).email;
// Already there before the prompt, or released by it — one more
// read only in the second case.
const whoami = await ctx.whoami();
if ( whoami.email !== undefined ) return whoami.email;
return (await ctx.reread()).email;
},
},
folder: {
request: (perms, details) =>
perms.requestFolder(
/** @type {import('./types.js').PermsFolderName} */ (details.name),
permissions: async ({ ctx, details }) => [
fsPermission(
folderPathFor((await ctx.whoami()).username, folderNameOf(details)),
accessOf(details),
),
check: async (perms, details) => {
const permissions = await RESOURCES.folder.permissions(perms, details);
return await holdsPermissions(perms.puter, permissions);
],
check: async ({ ctx, details, permissions, holds }) => {
if ( accessOf(details) !== 'write' ) {
const path = folderPathFor(
(await ctx.whoami()).username,
folderNameOf(details),
);
if ( await folderReadable(ctx.puter, path) ) return true;
}
return holds(permissions);
},
permissions: async (perms, details) => {
const access = accessOf(details);
const path = await folderPathFor(
perms.puter,
assertFolderName(details.name),
);
return [fsPermission(path, access)];
},
resolve: async (perms, details, held) => {
resolve: async ({ ctx, details }, held) => {
if ( ! held ) return undefined;
return await folderPathFor(
perms.puter,
assertFolderName(details.name),
return folderPathFor(
(await ctx.whoami()).username,
folderNameOf(details),
);
},
},
apps: {
request: (perms, details) => perms.requestApps(accessOf(details)),
check: async (perms, details) =>
await holdsPermissions(
perms.puter,
await RESOURCES.apps.permissions(perms, details),
),
permissions: async (perms, details) => {
const access = accessOf(details);
const whoami = await perms.puter.auth.whoami();
return [appsPermission(whoami.uuid, access)];
},
resolve: async (_perms, _details, held) => held,
permissions: async ({ ctx, details }) => [
appsPermission((await ctx.whoami()).uuid, accessOf(details)),
],
check: async ({ permissions, holds }) => holds(permissions),
resolve: async (_query, held) => held,
},
subdomains: {
request: (perms, details) => perms.requestSubdomains(accessOf(details)),
check: async (perms, details) =>
await holdsPermissions(
perms.puter,
await RESOURCES.subdomains.permissions(perms, details),
),
permissions: async (perms, details) => {
const access = accessOf(details);
const whoami = await perms.puter.auth.whoami();
return [subdomainsPermission(whoami.uuid, access)];
},
resolve: async (_perms, _details, held) => held,
permissions: async ({ ctx, details }) => [
subdomainsPermission((await ctx.whoami()).uuid, accessOf(details)),
],
check: async ({ permissions, holds }) => holds(permissions),
resolve: async (_query, held) => held,
},
appData: {
request: (perms, details) =>
perms.requestAppData(
/** @type {string} */ (details.app),
/** @type {import('./types.js').AppDataScopes} */ (details.scopes),
),
check: async (perms, details) => {
const permissions = await RESOURCES.appData.permissions(perms, details);
// Its own data, which it may always use.
if ( permissions.length === 0 ) return true;
return await holdsPermissions(perms.puter, permissions);
},
permissions: (perms, details) =>
permissions: ({ ctx, details }) =>
appDataRequest(
perms.puter,
ctx.puter,
/** @type {string} */ (details.app),
/** @type {import('./types.js').AppDataScopes} */ (details.scopes),
),
resolve: async (_perms, _details, held) => held,
// An empty list is its own data, which it may always use.
check: async ({ permissions, holds }) =>
permissions.length === 0 || holds(permissions),
resolve: async (_query, held) => held,
},
appRootDir: {
request: (perms, details) =>
perms.requestAppRootDir(
/** @type {string} */ (details.app),
accessOf(details),
),
// `app-root-dir:…` only resolves while a grant is written, so ask the server.
check: async (perms, details) => {
const result = await statAppRootDir(
perms.puter,
appUidOf(details.app),
accessOf(details),
);
return ! result.error;
},
permissions: async (_perms, details) => [
permissions: async ({ details }) => [
appRootDirPermission(appUidOf(details.app), accessOf(details)),
],
// Only the server can name the directory, so this asks even once held.
resolve: async (perms, details, held) => {
// `app-root-dir:…` resolves to nothing in a permission scan, so only the
// server can answer, and it stays out of the pooled read.
pooled: false,
check: ({ ctx, details }) =>
checkAppRootDir(ctx.puter, appUidOf(details.app), accessOf(details)),
// Only the server can name the directory, so this asks even once held,
// riding out the cache lag behind a fresh grant.
resolve: async ({ ctx, details }, held) => {
if ( ! held ) return undefined;
const result = await statAppRootDir(
perms.puter,
return await pollAppRootDir(
ctx.puter,
appUidOf(details.app),
accessOf(details),
);
return result.error ? undefined : result;
},
},
permission: {
request: (perms, details) =>
requestPermissions(perms.puter, permissionsOf(details)),
check: (perms, details) =>
holdsPermissions(perms.puter, permissionsOf(details)),
permissions: async (_perms, details) => permissionsOf(details),
resolve: async (_perms, _details, held) => held,
permissions: async ({ details }) => permissionsOf(details),
check: async ({ permissions, holds }) => holds(permissions),
resolve: async (_query, held) => held,
},
};
});
/** The resource names, for the "expected one of" in an unknown-resource error. */
const RESOURCE_NAMES = Object.keys(RESOURCES);
/**
* Resolve a call to its resource handler, or to the legacy raw-permission form.
* Resolve a call to its resource, or to the legacy raw-permission form.
*
* A lone string naming no resource is a permission string: no resource name
* contains a `:` and every permission string does, so neither can be mistaken
* for the other. Details beside an unknown resource is a typo, and says so.
*
* @param {'request' | 'check'} op
* @param {unknown} resource
* @param {unknown} details
* @returns {{ handler: (perms: PermsModule, details: Record<string, unknown>) => Promise<unknown>, details: Record<string, unknown> }}
* @returns {{ resource: string, details: Record<string, unknown> }}
*/
const resolve = (op, resource, details) => {
const singleEntry = (resource, details) => {
if ( typeof resource !== 'string' || resource === '' ) {
throw invalidArgument('resource must be a non-empty string');
}
if ( details !== undefined && (typeof details !== 'object' || details === null || Array.isArray(details)) ) {
throw invalidArgument('details must be an object');
}
const entry = RESOURCES[resource];
if ( entry ) {
if ( RESOURCES[resource] ) {
return {
handler: entry[op],
resource,
details: /** @type {Record<string, unknown>} */ (details ?? {}),
};
}
if ( details !== undefined ) {
throw invalidArgument(
`unknown resource: ${resource} (expected one of: ${Object.keys(RESOURCES).join(', ')})`,
`unknown resource: ${resource} (expected one of: ${RESOURCE_NAMES.join(', ')})`,
);
}
return {
handler: RESOURCES.permission[op],
details: { permission: resource },
};
return { resource: 'permission', details: { permission: resource } };
};
/**
@@ -256,53 +266,109 @@ const batchEntry = (entry, index) => {
if ( ! RESOURCES[resource] ) {
throw invalidArgument(
`requests[${index}]: unknown resource: ${resource} ` +
`(expected one of: ${Object.keys(RESOURCES).join(', ')})`,
`(expected one of: ${RESOURCE_NAMES.join(', ')})`,
);
}
return { resource, details };
};
/**
* Ask for several resources under a single prompt, which lists only what is
* missing and never appears when the whole batch is already held. A denial
* denies every entry that needed the prompt; held entries keep their value.
* One read of everything the call asks about, answered per entry. Made on the
* first entry that needs it, so a resource that can settle on its own an
* email already on `whoami`, a folder it can stat costs no round trip, and
* read once however many entries then ask.
*
* @param {PermsModule} perms
* @param {unknown[]} requests
* @param {PermsContext} ctx
* @param {string[]} permissions
* @returns {(permissions: string[]) => Promise<boolean>}
*/
function pooledHolds (ctx, permissions) {
const wanted = [...new Set(permissions)];
/** @type {Promise<Record<string, boolean>> | undefined} */
let pending;
return async (needed) => {
if ( needed.length === 0 || wanted.length === 0 ) return false;
const held = await (pending ??= checkPermissions(ctx.puter, wanted));
return needed.every((name) => held[name] === true);
};
}
/**
* The one path behind `request` and `check`, and behind both their single and
* array forms, so what a request prompts for and what a check reports can't
* drift apart.
*
* @param {PermsContext} ctx
* @param {{ resource: string, details: Record<string, unknown> }[]} entries
* @param {boolean} prompt
* @returns {Promise<unknown[]>}
*/
async function requestBatch (perms, requests) {
const entries = requests.map(batchEntry);
// Validate every entry first, so a bad one can't follow a raised prompt.
async function runEntries (ctx, entries, prompt) {
// Resolved — and so validated — before anything is asked, so a bad entry
// can't surface after a prompt has gone up for the rest.
const permissions = await Promise.all(
entries.map(({ resource, details }) =>
RESOURCES[resource].permissions(perms, details),
),
);
const held = await Promise.all(
entries.map(({ resource, details }) =>
RESOURCES[resource].check(perms, details),
RESOURCES[resource].permissions({ ctx, details }),
),
);
const missing = [
...new Set(
entries.flatMap((_entry, i) => (held[i] ? [] : permissions[i])),
const holds = pooledHolds(
ctx,
entries.flatMap(({ resource }, i) =>
RESOURCES[resource].pooled === false ? [] : permissions[i],
),
);
// A check that couldn't be made is not a refusal. `request` has somewhere
// to go with that — the prompt it would have raised anyway, which is what
// it did before there was a check at all. `check` has nowhere to go, and
// answering "not granted" would prompt someone who already granted it.
const held = await Promise.all(
entries.map(async ({ resource, details }, i) => {
try {
return await RESOURCES[resource].check({
ctx,
details,
permissions: permissions[i],
holds,
});
} catch ( e ) {
if ( ! prompt ) throw e;
return false;
}
}),
);
if ( ! prompt ) return held;
const missing = [
...new Set(entries.flatMap((_entry, i) => (held[i] ? [] : permissions[i]))),
];
const granted =
missing.length === 0
? true
: await requestPermissions(perms.puter, missing);
: await requestPermissions(ctx.puter, missing);
return await Promise.all(
entries.map(({ resource, details }, i) =>
RESOURCES[resource].resolve(perms, details, held[i] || granted),
RESOURCES[resource].resolve({ ctx, details }, held[i] || granted),
),
);
}
/**
* @param {unknown} resource
* @param {unknown} details
* @returns {{ resource: string, details: Record<string, unknown> }[]}
*/
const entriesOf = (resource, details) => {
if ( ! Array.isArray(resource) ) return [singleEntry(resource, details)];
if ( details !== undefined ) {
throw invalidArgument('a batch takes no second argument');
}
return resource.map(batchEntry);
};
/**
* @overload
* @param {'email'} resource
@@ -349,9 +415,10 @@ async function requestBatch (perms, requests) {
* @returns {Promise<boolean>}
*/
/**
* Ask the user for access, prompting only when it isn't already held. The
* Ask the user for access, prompting only for what isn't already held. The
* resource decides which details are taken and what resolves: `'folder'` gives
* the path, `'email'` the address, the rest a boolean. Denied is always falsy.
* the path, `'email'` the address, `'appRootDir'` the directory, the rest a
* boolean. Denied is always falsy.
*
* await puter.perms.request('folder', { name: 'Documents', access: 'write' });
*
@@ -368,14 +435,9 @@ async function requestBatch (perms, requests) {
* @returns {Promise<unknown>}
*/
export async function request (resource, details) {
if ( Array.isArray(resource) ) {
if ( details !== undefined ) {
throw invalidArgument('a batch takes no second argument');
}
return await requestBatch(this, resource);
}
const resolved = resolve('request', resource, details);
return await resolved.handler(this, resolved.details);
const entries = entriesOf(resource, details);
const results = await runEntries(makeContext(this.puter), entries, true);
return Array.isArray(resource) ? results : results[0];
}
/**
@@ -424,13 +486,14 @@ export async function request (resource, details) {
* @returns {Promise<boolean>}
*/
/**
* Whether the access is already held, never prompting. Takes the same resource
* and details as {@link request}, so an app can offer an opt-in only where one
* is needed. A partly-granted set answers `false` the prompt is still needed.
* Whether the access is already held, never prompting and never changing
* anything. Takes the same resource and details as {@link request}, so an app
* can offer an opt-in only where one is needed. A partly-granted set answers
* `false` the prompt is still needed.
*
* if ( ! await puter.perms.check('folder', { name: 'Documents' }) ) ...
*
* The array form answers per entry, in order, naming which parts are missing.
* The array form answers per entry, in order.
*
* @this {PermsModule}
* @param {PermsResource | string | import('./types.js').PermsBatchEntry[]} resource
@@ -438,17 +501,9 @@ export async function request (resource, details) {
* @returns {Promise<boolean | boolean[]>}
*/
export async function check (resource, details) {
if ( Array.isArray(resource) ) {
if ( details !== undefined ) {
throw invalidArgument('a batch takes no second argument');
}
const entries = resource.map(batchEntry);
return await Promise.all(
entries.map(({ resource: name, details: entryDetails }) =>
RESOURCES[name].check(this, entryDetails),
),
);
}
const resolved = resolve('check', resource, details);
return /** @type {boolean} */ (await resolved.handler(this, resolved.details));
const entries = entriesOf(resource, details);
const held = /** @type {boolean[]} */ (
await runEntries(makeContext(this.puter), entries, false)
);
return Array.isArray(resource) ? held : held[0];
}
+245 -83
View File
@@ -1,23 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Both routes these hit go through the shared helper, so mocking it needs no server.
// Both routes these reach go through the shared helper, so mocking it needs no server.
const mockReq = vi.fn();
vi.mock('./lib/req.js', () => ({ req: (...args) => mockReq(...args) }));
const { check, request } = await import('./request.js');
const { requestApps, requestEmail, requestSubdomains } = await import(
'./permissions.js'
);
const { requestFolder } = await import('./folders.js');
const { requestAppRootDir } = await import('./appRootDir.js');
const { requestAppData } = await import('./appData.js');
const CHECK_ROUTE = '/auth/check-permissions';
const ROOT_DIR_ROUTE = '/auth/request-app-root-dir';
const WHOAMI = { username: 'alice', uuid: 'u-1' };
const SELF_UID = 'app-00000000-0000-4000-8000-000000000001';
const denied = async () => { throw new Error('no access'); };
// The real resource methods, with the environment they reach through stubbed.
/**
* Answer the mocked helper by route, so a test says what is held and what the
* app-root-dir route replies without depending on the order they are asked in.
* An app-root-dir reply queue runs out into a refusal, which is that route's
* answer for anything but the app itself.
*/
const routes = ({ held = {}, rootDir = [] } = {}) => {
const pending = [...rootDir];
mockReq.mockImplementation(async (_puter, route) => {
if ( route === CHECK_ROUTE ) return { permissions: held };
if ( route === ROOT_DIR_ROUTE ) {
return pending.length
? pending.shift()
: { error: true, code: 'forbidden' };
}
throw new Error(`unexpected route: ${route}`);
});
};
/** The real `request`/`check`, with the environment they reach through stubbed. */
const makeModule = ({
whoami = WHOAMI,
requestPermission = () => false,
@@ -34,20 +50,18 @@ const makeModule = ({
},
request,
check,
requestEmail,
requestFolder,
requestApps,
requestSubdomains,
requestAppRootDir,
requestAppData,
});
/** The permission map `/auth/check-permissions` answers with. */
const heldReply = (held) => ({ permissions: held });
/** The calls the mocked helper made to one route. */
const callsTo = (route) =>
mockReq.mock.calls.filter((call) => call[1] === route);
beforeEach(() => {
mockReq.mockReset();
routes();
});
describe('perms request(resource, details)', () => {
beforeEach(() => mockReq.mockReset());
// -- Folders --
it('asks for the folder permission and resolves to its path', async () => {
@@ -71,6 +85,8 @@ describe('perms request(resource, details)', () => {
expect(path).toBe('/alice/Documents');
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
// Statting answered it, so the permission read was never needed.
expect(callsTo(CHECK_ROUTE)).toHaveLength(0);
});
it('resolves undefined when a folder request is denied', async () => {
@@ -138,7 +154,8 @@ describe('perms request(resource, details)', () => {
// -- An app's root directory --
it('asks the server for the app root dir at the requested access', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' });
// The read-only probe, then the call that names (and provisions) the dir.
routes({ rootDir: [{ allowed: true }, { path: '/root' }] });
const mod = makeModule();
const result = await request.call(mod, 'appRootDir', {
@@ -147,11 +164,13 @@ describe('perms request(resource, details)', () => {
});
expect(result).toEqual({ path: '/root' });
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
expect(callsTo(ROOT_DIR_ROUTE).map((call) => call[2])).toEqual([
{ app_uid: 'app-1', access: 'write', check: true },
{ app_uid: 'app-1', access: 'write' },
);
]);
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
// `app-root-dir:…` never resolves in a permission scan, so nothing pools it.
expect(callsTo(CHECK_ROUTE)).toHaveLength(0);
});
// -- Raw permission strings --
@@ -191,6 +210,58 @@ describe('perms request(resource, details)', () => {
});
});
// A resource is looked up on an own property, so a permission string that
// shares a name with an `Object.prototype` member is still that string.
it('reads a permission string that collides with an Object member', async () => {
const mod = makeModule({ requestPermission: () => true });
for ( const name of ['constructor', 'toString', 'hasOwnProperty'] ) {
expect(await request.call(mod, name)).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenLastCalledWith({
permission: name,
});
}
});
// -- Prompting only for what is missing --
it('skips the prompt when the access is already held', async () => {
routes({
held: {
'fs:/alice/Documents:write': true,
'apps-of-user:u-1:write': true,
'x:read': true,
},
});
const mod = makeModule();
expect(
await request.call(mod, 'folder', {
name: 'Documents',
access: 'write',
}),
).toBe('/alice/Documents');
expect(await request.call(mod, 'apps', { access: 'write' })).toBe(true);
expect(await request.call(mod, 'x:read')).toBe(true);
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
// A read that failed says nothing about what is held, and a request has
// somewhere to fall back to: the prompt it would have raised anyway.
it('falls through to the prompt when the permission read fails', async () => {
mockReq.mockResolvedValue({
error: true,
message: 'nope',
code: 'internal_error',
});
const mod = makeModule({ requestPermission: () => true });
expect(await request.call(mod, 'apps')).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'apps-of-user:u-1:read',
});
});
// -- Rejected input --
it('rejects details passed with an unknown resource', async () => {
@@ -212,6 +283,9 @@ describe('perms request(resource, details)', () => {
await expect(
request.call(mod, 'apps', 'read'),
).rejects.toMatchObject({ code: 'invalid_argument' });
await expect(
request.call(mod, 'appRootDir', { app: 42 }),
).rejects.toMatchObject({ code: 'invalid_argument' });
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
@@ -227,11 +301,8 @@ describe('perms request(resource, details)', () => {
});
describe('perms request([...]) batching', () => {
beforeEach(() => mockReq.mockReset());
// The point of a batch: one dialog for the set, not one per entry.
it('pools every missing permission into a single prompt', async () => {
mockReq.mockResolvedValue(heldReply({}));
const mod = makeModule({ requestPermission: () => true });
const results = await request.call(mod, [
@@ -252,13 +323,38 @@ describe('perms request([...]) batching', () => {
expect(results).toEqual(['/alice/Documents', true, true]);
});
// One read for the whole set, and one `whoami` behind all of it.
it('reads what is held once for the whole batch', async () => {
const mod = makeModule({ requestPermission: () => true });
await request.call(mod, [
{ resource: 'folder', name: 'Documents', access: 'write' },
{ resource: 'apps' },
{ resource: 'subdomains' },
{ resource: 'email' },
]);
expect(callsTo(CHECK_ROUTE)).toHaveLength(1);
expect(callsTo(CHECK_ROUTE)[0][2]).toEqual({
permissions: [
'fs:/alice/Documents:write',
'apps-of-user:u-1:read',
'subdomains-of-user:u-1:read',
'user:u-1:email:read',
],
});
// Once before the prompt, once after — the grant is what puts the
// email on `whoami`, so the copy read before it is stale.
expect(mod.puter.auth.whoami).toHaveBeenCalledTimes(2);
});
it('never prompts when the whole batch is already held', async () => {
mockReq.mockResolvedValue(
heldReply({
routes({
held: {
'fs:/alice/Desktop:read': true,
'subdomains-of-user:u-1:write': true,
}),
);
},
});
const mod = makeModule();
const results = await request.call(mod, [
@@ -272,7 +368,7 @@ describe('perms request([...]) batching', () => {
// Only the missing half is asked about.
it('asks only for what is missing, and keeps held entries on a denial', async () => {
mockReq.mockResolvedValue(heldReply({ 'apps-of-user:u-1:read': true }));
routes({ held: { 'apps-of-user:u-1:read': true } });
const mod = makeModule({ requestPermission: () => false });
const results = await request.call(mod, [
@@ -286,8 +382,28 @@ describe('perms request([...]) batching', () => {
expect(results).toEqual([true, undefined]);
});
// A grant can lag the permission cache, so the first refusal after one
// isn't final — in a batch exactly as in a single request.
it('keeps asking for the app root dir after the pooled grant', async () => {
routes({
rootDir: [
{ error: true, code: 'forbidden' }, // the read-only probe
{ error: true }, // first ask after the grant: cache still stale
{ path: '/root' },
],
});
const mod = makeModule({ requestPermission: () => true });
expect(
await request.call(mod, [{ resource: 'appRootDir', app: 'app-1' }]),
).toEqual([{ path: '/root' }]);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'app-root-dir:app-1:read',
});
expect(callsTo(ROOT_DIR_ROUTE)).toHaveLength(3);
});
it('validates every entry before prompting for any of them', async () => {
mockReq.mockResolvedValue(heldReply({}));
const mod = makeModule({ requestPermission: () => true });
await expect(
@@ -312,13 +428,20 @@ describe('perms request([...]) batching', () => {
).rejects.toMatchObject({ code: 'invalid_argument' });
});
// An inherited `Object` member is not a resource here either.
it('rejects a batch entry naming an Object member as its resource', async () => {
await expect(
request.call(makeModule(), [{ resource: 'toString' }]),
).rejects.toMatchObject({ code: 'invalid_argument' });
});
it('answers a batch check per entry, in order', async () => {
mockReq.mockResolvedValue(
heldReply({
routes({
held: {
'fs:/alice/Desktop:read': true,
'apps-of-user:u-1:write': false,
}),
);
},
});
const mod = makeModule();
expect(
@@ -340,12 +463,8 @@ describe('perms request([...]) batching', () => {
});
describe('perms check(resource, details)', () => {
beforeEach(() => mockReq.mockReset());
it('answers from the permission check without prompting', async () => {
mockReq.mockResolvedValueOnce(
heldReply({ 'fs:/alice/Documents:write': true }),
);
routes({ held: { 'fs:/alice/Documents:write': true } });
const mod = makeModule();
expect(
@@ -354,55 +473,53 @@ describe('perms check(resource, details)', () => {
access: 'write',
}),
).toBe(true);
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/check-permissions',
{ permissions: ['fs:/alice/Documents:write'] },
);
expect(mockReq).toHaveBeenCalledWith(mod.puter, CHECK_ROUTE, {
permissions: ['fs:/alice/Documents:write'],
});
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('checks the same permission strings the request asks for', async () => {
mockReq.mockResolvedValue(heldReply({}));
const mod = makeModule();
await check.call(mod, 'apps', { access: 'write' });
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/check-permissions',
{ permissions: ['apps-of-user:u-1:write'] },
);
expect(mockReq).toHaveBeenLastCalledWith(mod.puter, CHECK_ROUTE, {
permissions: ['apps-of-user:u-1:write'],
});
await check.call(mod, 'subdomains');
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/check-permissions',
{ permissions: ['subdomains-of-user:u-1:read'] },
);
expect(mockReq).toHaveBeenLastCalledWith(mod.puter, CHECK_ROUTE, {
permissions: ['subdomains-of-user:u-1:read'],
});
await check.call(mod, 'fs:/alice:read');
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/check-permissions',
{ permissions: ['fs:/alice:read'] },
);
expect(mockReq).toHaveBeenLastCalledWith(mod.puter, CHECK_ROUTE, {
permissions: ['fs:/alice:read'],
});
});
// Read access can come from an ACL grant no `fs:` string names, so the same
// stat that settles a request settles the check too.
it('accepts a folder it can stat as readable', async () => {
const mod = makeModule({ stat: async () => ({ id: 1 }) });
expect(await check.call(mod, 'folder', { name: 'Desktop' })).toBe(true);
expect(callsTo(CHECK_ROUTE)).toHaveLength(0);
});
it('reports false when the permission is not held', async () => {
mockReq.mockResolvedValueOnce(
heldReply({ 'apps-of-user:u-1:read': false }),
);
routes({ held: { 'apps-of-user:u-1:read': false } });
expect(await check.call(makeModule(), 'apps')).toBe(false);
});
// A half-granted set still needs the prompt, so it cannot read as held.
it('reports false when only some of a set is held', async () => {
mockReq.mockResolvedValueOnce(
heldReply({
routes({
held: {
'app-data:app-target:kv:read': true,
'app-data:app-target:fs:read': false,
}),
);
},
});
expect(
await check.call(makeModule(), 'appData', {
app: 'contacts',
@@ -430,34 +547,32 @@ describe('perms check(resource, details)', () => {
});
it('falls back to the permission check when no email is on whoami', async () => {
mockReq.mockResolvedValueOnce(heldReply({ 'user:u-1:email:read': false }));
routes({ held: { 'user:u-1:email:read': false } });
expect(await check.call(makeModule(), 'email')).toBe(false);
});
// A permission check on `app-root-dir:…` always answers false; ask the server.
it('probes the server for app root dir access', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' });
// The read-only mode of the route: asking must not provision the directory.
it('probes the server for app root dir access without provisioning it', async () => {
routes({ rootDir: [{ allowed: true }] });
const mod = makeModule();
expect(
await check.call(mod, 'appRootDir', { app: { uid: 'app-1' } }),
).toBe(true);
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-1', access: 'read' },
);
expect(mockReq).toHaveBeenCalledWith(mod.puter, ROOT_DIR_ROUTE, {
app_uid: 'app-1',
access: 'read',
check: true,
});
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
mockReq.mockResolvedValueOnce({ error: true });
expect(
await check.call(mod, 'appRootDir', { app: 'app-1' }),
).toBe(false);
// The queue is empty now, so the route refuses: not held.
expect(await check.call(mod, 'appRootDir', { app: 'app-1' })).toBe(false);
});
// A failed check is not a denial — reporting one would prompt needlessly.
it('surfaces a failed check rather than reporting false', async () => {
mockReq.mockResolvedValueOnce({
mockReq.mockResolvedValue({
error: true,
message: 'nope',
code: 'unauthorized',
@@ -466,5 +581,52 @@ describe('perms check(resource, details)', () => {
message: 'nope',
code: 'unauthorized',
});
await expect(
check.call(makeModule(), 'appRootDir', { app: 'app-1' }),
).rejects.toMatchObject({ message: 'nope', code: 'unauthorized' });
});
});
// A check that couldn't be made is not a refusal, and a request has the prompt
// to fall back on — the one it would have raised before there was a check.
describe('perms request(...) when the server cannot answer', () => {
beforeEach(() => {
mockReq.mockResolvedValue({
error: true,
message: 'nope',
code: 'internal_error',
});
});
it('falls through to the prompt, alone and in a batch', async () => {
const mod = makeModule({ requestPermission: () => true });
expect(await request.call(mod, 'apps')).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenLastCalledWith({
permission: 'apps-of-user:u-1:read',
});
expect(
await request.call(mod, [
{ resource: 'apps' },
{ resource: 'subdomains' },
]),
).toEqual([true, true]);
});
it('falls through for an app root dir the server would not confirm', async () => {
vi.useFakeTimers();
try {
const mod = makeModule({ requestPermission: () => true });
const pending = request.call(mod, 'appRootDir', { app: 'app-1' });
await vi.runAllTimersAsync();
expect(await pending).toBeUndefined();
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'app-root-dir:app-1:read',
});
} finally {
vi.useRealTimers();
}
});
});
+103 -109
View File
@@ -3,6 +3,9 @@ import type { TestContext } from '../harness/types.ts';
const home = (t: TestContext) => `/${t.env.users.user.username}`;
/** A permission string nothing grants, so asking for it is always a denial. */
const UNHELD_PERMISSION = 'nonexistent-namespace:nothing:read';
export default suite('perms', {
// -- Grant / revoke against an app --
@@ -130,22 +133,15 @@ export default suite('perms', {
'request denies what is not granted, in both the resource and raw forms': {
platforms: ['node', 'workerd'],
fn: async (t) => {
t.assert.equal(
await t.puter.perms.request('folder', {
name: 'Videos',
access: 'write',
}),
undefined,
);
t.assert.equal(
await t.puter.perms.request('permission', {
permission: `fs:${home(t)}:write`,
permission: UNHELD_PERMISSION,
}),
false,
);
// A lone string is still the permission string it always was.
t.assert.equal(
await t.puter.perms.request(`fs:${home(t)}:write`),
await t.puter.perms.request(UNHELD_PERMISSION),
false,
);
},
@@ -225,7 +221,7 @@ export default suite('perms', {
// -- Special folders --
'requestFolder returns the path of an already-readable folder': async (t) => {
'each special folder resolves to its path when already readable': async (t) => {
for (const folder of [
'Desktop',
'Documents',
@@ -233,57 +229,40 @@ export default suite('perms', {
'Videos',
] as const) {
const expected = `${home(t)}/${folder}`;
// Guard first: without read access the helper would fall through
// to an interactive permission prompt.
// Guard first: without read access this would fall through to an
// interactive permission prompt.
t.assert.ok(
await t.puter.fs.stat({ path: expected }),
`${folder} should already exist for the seeded user`,
);
t.assert.equal(await t.puter.perms.requestFolder(folder), expected);
t.assert.equal(
await t.puter.perms.request('folder', { name: folder }),
expected,
);
t.assert.equal(
await t.puter.perms.check('folder', { name: folder }),
true,
);
}
},
'requestFolder rejects a folder it does not cover': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.requestFolder as (
folder: unknown,
) => Promise<unknown>
)('Trash'),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
'requestFolder rejects an unknown access level': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.requestFolder as (
folder: string,
access: unknown,
) => Promise<unknown>
)('Desktop', 'delete'),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
// Write access always prompts, even for a folder that is already readable.
// Restricted to the runtimes where the prompt resolves without a UI: on
// `web` it opens a popup window a headless suite cannot answer.
'requesting write access to a folder is denied without a grant': {
platforms: ['node', 'workerd'],
fn: async (t) => {
for (const folder of [
'Desktop',
'Documents',
'Pictures',
'Videos',
] as const) {
t.assert.equal(
await t.puter.perms.requestFolder(folder, 'write'),
undefined,
);
}
},
// A user's own credential already covers their own folders, so `request`
// settles from `check` and never prompts. The prompt is for an app asking
// on their behalf, which holds none of this to begin with.
'write access a user already holds resolves without a prompt': async (t) => {
for (const folder of [
'Desktop',
'Documents',
'Pictures',
'Videos',
] as const) {
const details = { name: folder, access: 'write' } as const;
t.assert.equal(await t.puter.perms.check('folder', details), true);
t.assert.equal(
await t.puter.perms.request('folder', details),
`${home(t)}/${folder}`,
);
}
},
// -- Permission requests --
@@ -293,44 +272,33 @@ export default suite('perms', {
t.assert.equal(await t.puter.perms.requestEmail(), whoami.email);
},
// Same reasoning as the folder write test: these go through the
// environment's permission prompt, which only resolves synchronously
// where there is no window to open.
'app and subdomain access requests are denied without a grant': {
platforms: ['node', 'workerd'],
fn: async (t) => {
t.assert.equal(await t.puter.perms.requestApps(), false);
t.assert.equal(await t.puter.perms.requestApps('write'), false);
t.assert.equal(await t.puter.perms.requestSubdomains(), false);
t.assert.equal(
await t.puter.perms.requestSubdomains('write'),
false,
);
t.assert.equal(
await t.puter.perms.request(`fs:${home(t)}:write`),
false,
);
},
// Their own apps and subdomains namespaces are theirs implicitly, at both
// access levels, so these settle from `check` too.
'a user already holds their own apps and subdomains namespaces': async (t) => {
for (const resource of ['apps', 'subdomains'] as const) {
for (const access of ['read', 'write'] as const) {
t.assert.equal(
await t.puter.perms.check(resource, { access }),
true,
);
t.assert.equal(
await t.puter.perms.request(resource, { access }),
true,
);
}
}
},
'requestApps rejects an unknown access level': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.requestApps as (
access: unknown,
) => Promise<unknown>
)('manage'),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
// The one-method-per-task names still ship for apps written against them.
// The one-method-per-task names still ship for apps written against them,
// behaviour unchanged: they 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.
'the deprecated request aliases keep delegating': {
platforms: ['node', 'workerd'],
fn: async (t) => {
const perms = t.puter.perms as unknown as Record<
string,
() => Promise<unknown>
(...args: unknown[]) => Promise<unknown>
>;
t.assert.equal(await perms.requestReadApps(), false);
t.assert.equal(await perms.requestManageApps(), false);
@@ -349,21 +317,33 @@ export default suite('perms', {
t.puter.perms as unknown as {
requestPermission: (p: string) => Promise<boolean>;
}
).requestPermission(`fs:${home(t)}:write`),
).requestPermission(UNHELD_PERMISSION),
false,
);
const app = await t.puter.apps.create(
t.puter.randName(),
'https://example.com/perms-alias-root-dir',
);
t.assert.equal(
await perms.requestReadAppRootDir(app.uid),
undefined,
);
t.assert.equal(
await perms.requestWriteAppRootDir(app.uid),
undefined,
);
},
},
// -- App root directory --
'requestAppRootDir rejects an app uid that is not a string': async (t) => {
'appRootDir rejects an app that is not a uid or an object with one': async (
t,
) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.requestAppRootDir as (
appUid: unknown,
) => Promise<unknown>
)(42),
t.puter.perms.request('appRootDir', {
app: 42 as unknown as string,
}),
)) as Error & { code?: string };
t.assert.ok(error instanceof Error, 'should be a real Error');
t.assert.equal(error.code, 'invalid_argument');
@@ -377,23 +357,37 @@ export default suite('perms', {
code: 'invalid_argument',
},
);
},
'requestAppRootDir rejects an object without a uid': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.requestAppRootDir as (
appUid: unknown,
) => Promise<unknown>
)({}),
const noUid = (await t.assert.rejects(() =>
t.puter.perms.request('appRootDir', {
app: {} as unknown as { uid: string },
}),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
t.assert.equal(noUid.code, 'invalid_argument');
},
// Only the app itself may claim its root dir, so a user-token caller is
// refused and then offered the permission prompt. Restricted to the
// runtimes where that prompt resolves without a window.
'requestAppRootDir resolves undefined when the grant is refused': {
// refused. `check` reports that without provisioning anything, and without
// the prompt a `request` would go on to raise.
'checking another app\'s root dir reports false and creates nothing': async (
t,
) => {
const app = await t.puter.apps.create(
t.puter.randName(),
'https://example.com/perms-root-dir-check',
);
t.assert.equal(
await t.puter.perms.check('appRootDir', { app: app.uid }),
false,
);
await t.assert.rejects(() =>
t.puter.fs.stat({ path: `${home(t)}/AppData/${app.uid}` }),
);
},
// Refused, then offered the permission prompt. Restricted to the runtimes
// where that prompt resolves without a window.
'appRootDir resolves undefined when the grant is refused': {
platforms: ['node', 'workerd'],
fn: async (t) => {
const app = await t.puter.apps.create(
@@ -401,14 +395,14 @@ export default suite('perms', {
'https://example.com/perms-root-dir',
);
t.assert.equal(
await t.puter.perms.requestAppRootDir(app.uid),
await t.puter.perms.request('appRootDir', { app: app.uid }),
undefined,
);
t.assert.equal(
await t.puter.perms.requestAppRootDir(
{ uid: app.uid },
'write',
),
await t.puter.perms.request('appRootDir', {
app: { uid: app.uid },
access: 'write',
}),
undefined,
);
},