feat(puter-js): collapse puter.perms onto request(resource, details) + check()

One method per task meant a new method, doc page and sidebar entry for every
resource. `request` now takes the resource and a payload whose accepted fields
depend on it, and `check` answers the same question without prompting.

    request('folder', { name: 'Documents', access: 'write' })  -> path
    request('apps', { access: 'read' })                        -> boolean
    request('email')                                           -> address
    check('folder', { name: 'Documents', access: 'write' })     -> boolean

Returns stay per-resource: a folder gives its path, email the address, the rest
a boolean, and anything denied is falsy so one `if` covers both.

An array asks for several at once. Everything already held is settled first, so
the prompt covers only what is missing and does not appear when the whole set is
held - the user answers once for the lot. `check` answers per entry, in order,
so a caller can tell which parts are missing rather than only that some are.

Each resource declares four things in one registry entry: how to ask for it
alone, whether it is held, the strings a batch pools into a prompt, and the
value once held. The strings themselves are defined once in
lib/permissionStrings.js, so a request and its check cannot name them
differently. `check` is built on /auth/check-permissions, already live and
already used by UI.js, and it throws rather than answering false when the check
cannot run: a caller that cannot tell "denied" from "never ran" would prompt
someone who had already granted it.

Backward compatibility: all 22 older methods stay callable and typed, marked
@deprecated with the call that replaces them. A lone string still routes to the
raw-permission path - no resource name contains a `:` and every permission
string does, so the two forms cannot collide. The grant/revoke app methods are
untouched; the consent dialog and the dashboard's uninstall path use them.

Also drops three copies of the access-level assertion onto one shared
validator, and gives `appRootDir` a non-prompting server probe, since
`app-root-dir:` only resolves while a grant is being written and a permission
check on it always answers false.
This commit is contained in:
Juan Castro
2026-08-20 16:32:54 -04:00
parent 37ed2859a3
commit a6cc595b4c
13 changed files with 1390 additions and 122 deletions
+8
View File
@@ -173,7 +173,15 @@ export type {
AppDataScopes,
AppDataStore,
PermsAccess,
PermsAccessRequest,
PermsAppDataRequest,
PermsAppRootDirRequest,
PermsBatchEntry,
PermsFolderName,
PermsFolderRequest,
PermsPermissionRequest,
PermsRequestDetails,
PermsResource,
} from './types/modules/perms/types.js';
// -- puter.ui --
+34 -22
View File
@@ -1,4 +1,5 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import { invalidArgument } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('./types.js').AppDataScopes} AppDataScopes */
@@ -23,7 +24,7 @@ const KV_FORBIDDEN_OPS = ['flush'];
const APP_UID_RE =
/^app-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const err = (message) => new PuterJSError(message, 'invalid_argument');
const err = invalidArgument;
/**
* Normalise one store's requested scopes into a deduped list of op or class
@@ -133,29 +134,19 @@ const normaliseScopes = (scopes) => {
};
/**
* Ask the user to let this app use another app's data — its KV namespace and its
* AppData directory.
* The permission strings a cross-app data request needs, shared with `check`.
*
* The target may be named by uid or by registered app name. Scopes accept a
* shorthand applying to both stores, explicit `store:name` pairs, or a per-store
* object:
*
* await puter.perms.requestAppData('contacts', 'read');
* await puter.perms.requestAppData('contacts', ['kv:get', 'fs:read']);
* await puter.perms.requestAppData('contacts', { kv: ['get', 'set'], fs: 'read' });
*
* Deleting entries is a separate scope from writing them, so an app that only
* adds data cannot remove any: request `delete` explicitly when it needs to.
*
* @this {PermsModule}
* @param {import('../../index.js').Puter} puter
* @param {string | { uid: string } | { name: string }} appIdentifier
* @param {AppDataScopes} scopes
* @returns {Promise<boolean>} `true` if the app may now use that data.
* @returns {Promise<string[]>} The permissions to ask for, or an empty list
* when the request names this app's own data.
*/
export async function requestAppData (appIdentifier, scopes) {
export async function appDataRequest (puter, appIdentifier, scopes) {
const identifier =
typeof appIdentifier === 'object' && appIdentifier !== null
? (appIdentifier.uid ?? appIdentifier.name)
? (/** @type {{ uid?: string, name?: string }} */ (appIdentifier).uid ??
/** @type {{ uid?: string, name?: string }} */ (appIdentifier).name)
: appIdentifier;
if (typeof identifier !== 'string' || identifier === '') {
throw err('parameter appIdentifier must be a non-empty string');
@@ -165,14 +156,35 @@ export async function requestAppData (appIdentifier, scopes) {
// `app-store` as a uid, and `puter.apps.get` resolves names only.
const appUid = APP_UID_RE.test(identifier)
? identifier
: (await this.puter.apps.get(identifier))?.uid;
: (await puter.apps.get(identifier))?.uid;
if (typeof appUid !== 'string' || appUid === '') {
throw new PuterJSError(`app not found: ${identifier}`, 'not_found');
}
// Already true for its own data, so prompting would ask for nothing.
if (appUid === this.puter.appID) return true;
// Already allowed for its own data, so there is nothing to ask for.
if (appUid === puter.appID) return [];
const permissions = appDataPermissions(appUid, normaliseScopes(scopes));
return appDataPermissions(appUid, normaliseScopes(scopes));
}
/**
* Ask the user to let this app use another app's KV namespace and AppData
* directory. Scopes take a shorthand for both stores, `store:name` pairs, or a
* per-store object. `delete` is separate from `write` and must be asked for.
*
* await puter.perms.request('appData', { app: 'contacts', scopes: 'read' });
*
* @this {PermsModule}
* @param {string | { uid: string } | { name: string }} appIdentifier
* @param {AppDataScopes} scopes
* @returns {Promise<boolean>} `true` if the app may now use that data.
*/
export async function requestAppData (appIdentifier, scopes) {
const permissions = await appDataRequest(
this.puter,
appIdentifier,
scopes,
);
if (permissions.length === 0) return true;
return await this.puter.ui.requestPermission({ permissions });
}
+40 -17
View File
@@ -1,9 +1,43 @@
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';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/**
* The uid out of either accepted form of app identifier.
*
* @param {unknown} appUidOrObject
* @returns {string}
*/
export function appUidOf (appUidOrObject) {
const appUid = (typeof appUidOrObject === 'object' && appUidOrObject !== null)
? /** @type {{ uid?: unknown }} */ (appUidOrObject).uid
: appUidOrObject;
if ( typeof appUid !== 'string' ) {
throw invalidArgument('parameter app_uid must be a string');
}
return appUid;
}
/**
* 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.
*
* @param {import('../../index.js').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', {
app_uid: appUid,
access,
});
}
/**
* Requests access at the given level to the root directory of one of the
* user's apps. Tries the request first; if it fails, prompts for the
@@ -16,24 +50,18 @@ import { req } from './lib/req.js';
* @returns {Promise<Record<string, unknown> | undefined>}
*/
async function requestAppRootDirAccess (puter, access, appUidOrObject) {
const appUid = (typeof appUidOrObject === 'object' && appUidOrObject !== null)
? appUidOrObject.uid
: appUidOrObject;
if ( typeof appUid !== 'string' ) {
throw new PuterJSError('parameter app_uid must be a string', 'invalid_argument');
}
const appUid = appUidOf(appUidOrObject);
let result;
const fetchIt = async () => {
result = await req(puter, '/auth/request-app-root-dir', { app_uid: appUid, access });
result = await statAppRootDir(puter, appUid, access);
};
await fetchIt();
if ( ! result.error ) return result;
const granted = await puter.ui.requestPermission({
permission: `app-root-dir:${appUid}:${access}`,
permission: appRootDirPermission(appUid, access),
});
if ( granted ) {
@@ -65,13 +93,8 @@ async function requestAppRootDirAccess (puter, access, appUidOrObject) {
* @returns {Promise<Record<string, unknown> | undefined>} The directory fs item, or `undefined` if denied.
*/
export async function requestAppRootDir (appUid, accessLevel = 'read') {
if ( accessLevel !== 'read' && accessLevel !== 'write' ) {
throw new PuterJSError(
'parameter accessLevel must be `read` or `write`',
'invalid_argument',
);
}
return await requestAppRootDirAccess(this.puter, accessLevel, appUid);
const access = assertAccess(accessLevel);
return await requestAppRootDirAccess(this.puter, access, appUid);
}
// -- Deprecated aliases --
+18 -19
View File
@@ -1,11 +1,21 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import { fsPermission } from './lib/permissionStrings.js';
import { assertAccess, assertFolderName } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/** @typedef {import('./types.js').PermsFolderName} PermsFolderName */
/** The special folders a permission can be requested for by name. */
const FOLDERS = ['Desktop', 'Documents', 'Pictures', 'Videos'];
/**
* Where one of the user's special folders lives, shared with `check`.
*
* @param {import('../../index.js').Puter} puter
* @param {string} folderName
* @returns {Promise<string>}
*/
export async function folderPathFor (puter, folderName) {
const whoami = await puter.auth.whoami();
return `/${whoami.username}/${folderName}`;
}
/**
* Resolve a folder request to its path, prompting only when the access isn't
@@ -17,8 +27,7 @@ const FOLDERS = ['Desktop', 'Documents', 'Pictures', 'Videos'];
* @returns {Promise<string | undefined>}
*/
async function requestFolderPath (puter, folderName, accessLevel) {
const whoami = await puter.auth.whoami();
const folderPath = `/${whoami.username}/${folderName}`;
const folderPath = await folderPathFor(puter, folderName);
// Being able to stat the folder means we already have at least read access.
try {
@@ -31,7 +40,7 @@ async function requestFolderPath (puter, folderName, accessLevel) {
}
const granted = await puter.ui.requestPermission({
permission: `fs:${folderPath}:${accessLevel}`,
permission: fsPermission(folderPath, accessLevel),
});
return granted ? folderPath : undefined;
}
@@ -47,19 +56,9 @@ async function requestFolderPath (puter, folderName, accessLevel) {
* @returns {Promise<string | undefined>} The folder path, or `undefined` if denied.
*/
export async function requestFolder (folderName, accessLevel = 'read') {
if ( ! FOLDERS.includes(folderName) ) {
throw new PuterJSError(
`parameter folderName must be one of: ${FOLDERS.join(', ')}`,
'invalid_argument',
);
}
if ( accessLevel !== 'read' && accessLevel !== 'write' ) {
throw new PuterJSError(
'parameter accessLevel must be `read` or `write`',
'invalid_argument',
);
}
return await requestFolderPath(this.puter, folderName, accessLevel);
const name = assertFolderName(folderName);
const access = assertAccess(accessLevel);
return await requestFolderPath(this.puter, name, access);
}
// -- Deprecated aliases --
+38 -38
View File
@@ -16,11 +16,12 @@ import {
revokeApp, revokeAppAnyUser, revokeOrigin,
} from './grants.js';
import {
request, requestApps, requestEmail,
requestApps, requestEmail,
requestManageApps, requestManageSubdomains,
requestPermission, requestReadApps, requestReadSubdomains,
requestSubdomains,
} from './permissions.js';
import { check, request } from './request.js';
/** @typedef {import('../../index.js').Puter} Puter */
@@ -30,10 +31,11 @@ import {
const METHODS = [
'grantApp', 'grantAppAnyUser', 'grantOrigin',
'revokeApp', 'revokeAppAnyUser', 'revokeOrigin',
'request', 'requestEmail',
'request', 'check',
// Deprecated aliases; bound for the same reason as the rest.
'requestEmail',
'requestFolder', 'requestApps', 'requestSubdomains',
'requestAppRootDir', 'requestAppData',
// Deprecated aliases; bound for the same reason as the rest.
'requestPermission', 'requestFolder_',
'requestReadDesktop', 'requestWriteDesktop',
'requestReadDocuments', 'requestWriteDocuments',
@@ -60,62 +62,60 @@ export class PermsModule extends PuterModule {
revokeAppAnyUser = revokeAppAnyUser;
revokeOrigin = revokeOrigin;
// Permission requests
// The whole supported surface; everything below is a deprecated alias.
request = request;
requestEmail = requestEmail;
// Special folders
requestFolder = requestFolder;
// The user's apps and subdomains
requestApps = requestApps;
requestSubdomains = requestSubdomains;
// An app's root directory
requestAppRootDir = requestAppRootDir;
// Another app's data (KV namespace + AppData directory)
requestAppData = requestAppData;
check = check;
// -- Deprecated aliases --
//
// Still bound and callable so apps written against the one-method-per-task
// surface keep working. They stay in the generated declarations rather than
// being hidden: `stripInternal` has no effect on declarations emitted from
// JavaScript, and dropping them by hand would break TypeScript callers that
// the runtime still serves.
// Still bound and callable, and still in the generated declarations:
// `stripInternal` does nothing for declarations emitted from JavaScript,
// and hiding them by hand would break TypeScript callers we still serve.
/** @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;
/** @deprecated Use {@link request}. */
requestPermission = requestPermission;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name, access })`. */
requestFolder_ = requestFolder_;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Desktop' })`. */
requestReadDesktop = requestReadDesktop;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Desktop', access: 'write' })`. */
requestWriteDesktop = requestWriteDesktop;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Documents' })`. */
requestReadDocuments = requestReadDocuments;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Documents', access: 'write' })`. */
requestWriteDocuments = requestWriteDocuments;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Pictures' })`. */
requestReadPictures = requestReadPictures;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Pictures', access: 'write' })`. */
requestWritePictures = requestWritePictures;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Videos' })`. */
requestReadVideos = requestReadVideos;
/** @deprecated Use {@link requestFolder}. */
/** @deprecated Use `request('folder', { name: 'Videos', access: 'write' })`. */
requestWriteVideos = requestWriteVideos;
/** @deprecated Use {@link requestApps}. */
/** @deprecated Use `request('apps')`. */
requestReadApps = requestReadApps;
/** @deprecated Use {@link requestApps}. */
/** @deprecated Use `request('apps', { access: 'write' })`. */
requestManageApps = requestManageApps;
/** @deprecated Use {@link requestSubdomains}. */
/** @deprecated Use `request('subdomains')`. */
requestReadSubdomains = requestReadSubdomains;
/** @deprecated Use {@link requestSubdomains}. */
/** @deprecated Use `request('subdomains', { access: 'write' })`. */
requestManageSubdomains = requestManageSubdomains;
/** @deprecated Use {@link requestAppRootDir}. */
/** @deprecated Use `request('appRootDir', { app })`. */
requestReadAppRootDir = requestReadAppRootDir;
/** @deprecated Use {@link requestAppRootDir}. */
/** @deprecated Use `request('appRootDir', { app, access: 'write' })`. */
requestWriteAppRootDir = requestWriteAppRootDir;
/** @param {Puter} puter */
@@ -0,0 +1,28 @@
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.
*
* @param {import('../../../index.js').Puter} puter
* @param {string[]} permissions
* @returns {Promise<boolean>}
*/
export async function holdsPermissions (puter, permissions) {
if ( permissions.length === 0 ) return false;
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);
}
@@ -0,0 +1,22 @@
// How each resource maps onto a backend permission string, defined once so a
// request and its matching `check` can never name it differently.
/** @typedef {import('../types.js').PermsAccess} PermsAccess */
/** @param {string} userUuid */
export const emailPermission = (userUuid) => `user:${userUuid}:email:read`;
/** @param {string} userUuid @param {PermsAccess} access */
export const appsPermission = (userUuid, access) =>
`apps-of-user:${userUuid}:${access}`;
/** @param {string} userUuid @param {PermsAccess} access */
export const subdomainsPermission = (userUuid, access) =>
`subdomains-of-user:${userUuid}:${access}`;
/** @param {string} path @param {PermsAccess} access */
export const fsPermission = (path, access) => `fs:${path}:${access}`;
/** @param {string} appUid @param {PermsAccess} access */
export const appRootDirPermission = (appUid, access) =>
`app-root-dir:${appUid}:${access}`;
@@ -0,0 +1,38 @@
import { PuterJSError } from '../../../lib/PuterJSError.js';
/** @typedef {import('../types.js').PermsAccess} PermsAccess */
/** @typedef {import('../types.js').PermsFolderName} PermsFolderName */
/** The special folders a permission can be requested for by name. */
export const FOLDER_NAMES = ['Desktop', 'Documents', 'Pictures', 'Videos'];
/**
* @param {string} message
* @returns {PuterJSError}
*/
export const invalidArgument = (message) =>
new PuterJSError(message, 'invalid_argument');
/**
* @param {unknown} accessLevel
* @returns {PermsAccess}
*/
export const assertAccess = (accessLevel) => {
if ( accessLevel !== 'read' && accessLevel !== 'write' ) {
throw invalidArgument('access must be `read` or `write`');
}
return accessLevel;
};
/**
* @param {unknown} folderName
* @returns {PermsFolderName}
*/
export const assertFolderName = (folderName) => {
if ( typeof folderName !== 'string' || ! FOLDER_NAMES.includes(folderName) ) {
throw invalidArgument(
`folder name must be one of: ${FOLDER_NAMES.join(', ')}`,
);
}
return /** @type {PermsFolderName} */ (folderName);
};
+23 -26
View File
@@ -1,41 +1,38 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import {
appsPermission,
emailPermission,
subdomainsPermission,
} from './lib/permissionStrings.js';
import { assertAccess } from './lib/validate.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/** @param {unknown} accessLevel @returns {PermsAccess} */
const assertAccess = (accessLevel) => {
if ( accessLevel !== 'read' && accessLevel !== 'write' ) {
throw new PuterJSError(
'parameter accessLevel must be `read` or `write`',
'invalid_argument',
);
}
return accessLevel;
};
/**
* Request a specific permission string to be granted. Note that some
* permission strings are not supported and will be denied silently.
* Ask for a raw permission string, or several under one prompt. Unsupported
* strings are denied silently. Stays on `puter.ui`, which owns the IPC.
*
* @this {PermsModule}
* @param {string} permission - The permission string to request.
* @returns {Promise<boolean>} `true` if the permission was granted.
* @param {import('../../index.js').Puter} puter
* @param {string[]} permissions
* @returns {Promise<boolean>}
*/
export async function request (permission) {
// Note: this cannot move fully off of `puter.ui` without a significant
// refactor, because the UI module owns all of the IPC communication logic.
return await this.puter.ui.requestPermission({ permission });
export async function requestPermissions (puter, permissions) {
// Scalar form for a lone permission: the shape the dialog dedupes on.
return permissions.length === 1
? await puter.ui.requestPermission({ permission: permissions[0] })
: await puter.ui.requestPermission({ permissions });
}
/**
* @deprecated Use {@link request} instead.
* @deprecated Use {@link import('./request.js').request} instead.
* @this {PermsModule}
* @param {...unknown} args
* @returns {Promise<boolean>}
*/
export function requestPermission (...args) {
return this.request(...args);
return this.request(
.../** @type {[string, Record<string, unknown> | undefined]} */ (args),
);
}
/**
@@ -51,7 +48,7 @@ export async function requestEmail () {
if ( whoami.email !== undefined ) return whoami.email;
const granted = await this.puter.ui.requestPermission({
permission: `user:${whoami.uuid}:email:read`,
permission: emailPermission(whoami.uuid),
});
if ( granted ) {
whoami = await this.puter.auth.whoami();
@@ -71,7 +68,7 @@ export async function requestApps (accessLevel = 'read') {
const access = assertAccess(accessLevel);
const whoami = await this.puter.auth.whoami();
return await this.puter.ui.requestPermission({
permission: `apps-of-user:${whoami.uuid}:${access}`,
permission: appsPermission(whoami.uuid, access),
});
}
@@ -87,7 +84,7 @@ export async function requestSubdomains (accessLevel = 'read') {
const access = assertAccess(accessLevel);
const whoami = await this.puter.auth.whoami();
return await this.puter.ui.requestPermission({
permission: `subdomains-of-user:${whoami.uuid}:${access}`,
permission: subdomainsPermission(whoami.uuid, access),
});
}
+454
View File
@@ -0,0 +1,454 @@
import { appDataRequest } from './appData.js';
import { appUidOf, statAppRootDir } from './appRootDir.js';
import { folderPathFor } from './folders.js';
import { holdsPermissions } from './lib/holds.js';
import {
appRootDirPermission,
appsPermission,
emailPermission,
fsPermission,
subdomainsPermission,
} from './lib/permissionStrings.js';
import { assertAccess, assertFolderName, invalidArgument } from './lib/validate.js';
import { requestPermissions } from './permissions.js';
/** @typedef {import('./index.js').PermsModule} PermsModule */
/** @typedef {import('./types.js').PermsAccess} PermsAccess */
/** @typedef {import('./types.js').PermsResource} PermsResource */
/** @typedef {import('./types.js').PermsRequestDetails} PermsRequestDetails */
/** @param {Record<string, unknown>} details @returns {PermsAccess} */
const accessOf = (details) => assertAccess(details.access ?? 'read');
/**
* The permission strings a `'permission'` request names, one or many.
*
* @param {Record<string, unknown>} details
* @returns {string[]}
*/
const permissionsOf = (details) => {
const { permission, permissions } = details;
if ( permissions !== undefined ) {
if ( permission !== undefined ) {
throw invalidArgument('pass `permission` or `permissions`, not both');
}
if ( ! Array.isArray(permissions) || permissions.length === 0 ) {
throw invalidArgument('`permissions` must be a non-empty array');
}
for ( const entry of permissions ) {
if ( typeof entry !== 'string' || entry === '' ) {
throw invalidArgument('`permissions` entries must be non-empty strings');
}
}
return [...new Set(/** @type {string[]} */ (permissions))];
}
if ( typeof permission !== 'string' || permission === '' ) {
throw invalidArgument('`permission` must be a non-empty string');
}
return [permission];
};
/**
* 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`).
*
* @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>,
* }>}
*/
const RESOURCES = {
email: {
request: (perms) => perms.requestEmail(),
check: async (perms) => {
// 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),
]);
},
permissions: async (perms) => {
const whoami = await perms.puter.auth.whoami();
return [emailPermission(whoami.uuid)];
},
resolve: async (perms, _details, held) => {
if ( ! held ) return undefined;
return (await perms.puter.auth.whoami()).email;
},
},
folder: {
request: (perms, details) =>
perms.requestFolder(
/** @type {import('./types.js').PermsFolderName} */ (details.name),
accessOf(details),
),
check: async (perms, details) => {
const permissions = await RESOURCES.folder.permissions(perms, details);
return await holdsPermissions(perms.puter, 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) => {
if ( ! held ) return undefined;
return await folderPathFor(
perms.puter,
assertFolderName(details.name),
);
},
},
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,
},
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,
},
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) =>
appDataRequest(
perms.puter,
/** @type {string} */ (details.app),
/** @type {import('./types.js').AppDataScopes} */ (details.scopes),
),
resolve: async (_perms, _details, 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) => [
appRootDirPermission(appUidOf(details.app), accessOf(details)),
],
// Only the server can name the directory, so this asks even once held.
resolve: async (perms, details, held) => {
if ( ! held ) return undefined;
const result = await statAppRootDir(
perms.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,
},
};
/**
* Resolve a call to its resource handler, 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> }}
*/
const resolve = (op, 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 ) {
return {
handler: entry[op],
details: /** @type {Record<string, unknown>} */ (details ?? {}),
};
}
if ( details !== undefined ) {
throw invalidArgument(
`unknown resource: ${resource} (expected one of: ${Object.keys(RESOURCES).join(', ')})`,
);
}
return {
handler: RESOURCES.permission[op],
details: { permission: resource },
};
};
/**
* Split a batch entry into its resource and its remaining fields. The resource
* travels inside the object so one array can carry differently-shaped entries.
*
* @param {unknown} entry
* @param {number} index
* @returns {{ resource: string, details: Record<string, unknown> }}
*/
const batchEntry = (entry, index) => {
if ( typeof entry !== 'object' || entry === null || Array.isArray(entry) ) {
throw invalidArgument(`requests[${index}] must be an object`);
}
const { resource, ...details } = /** @type {Record<string, unknown>} */ (entry);
if ( typeof resource !== 'string' || resource === '' ) {
throw invalidArgument(`requests[${index}].resource must be a non-empty string`);
}
if ( ! RESOURCES[resource] ) {
throw invalidArgument(
`requests[${index}]: unknown resource: ${resource} ` +
`(expected one of: ${Object.keys(RESOURCES).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.
*
* @param {PermsModule} perms
* @param {unknown[]} requests
* @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.
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),
),
);
const missing = [
...new Set(
entries.flatMap((_entry, i) => (held[i] ? [] : permissions[i])),
),
];
const granted =
missing.length === 0
? true
: await requestPermissions(perms.puter, missing);
return await Promise.all(
entries.map(({ resource, details }, i) =>
RESOURCES[resource].resolve(perms, details, held[i] || granted),
),
);
}
/**
* @overload
* @param {'email'} resource
* @returns {Promise<string | null | undefined>}
*/
/**
* @overload
* @param {'folder'} resource
* @param {import('./types.js').PermsFolderRequest} details
* @returns {Promise<string | undefined>}
*/
/**
* @overload
* @param {'apps' | 'subdomains'} resource
* @param {import('./types.js').PermsAccessRequest} [details]
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'appData'} resource
* @param {import('./types.js').PermsAppDataRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'appRootDir'} resource
* @param {import('./types.js').PermsAppRootDirRequest} details
* @returns {Promise<Record<string, unknown> | undefined>}
*/
/**
* @overload
* @param {'permission'} resource
* @param {import('./types.js').PermsPermissionRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {import('./types.js').PermsBatchEntry[]} requests
* @returns {Promise<unknown[]>}
*/
/**
* @overload
* @param {string} permission
* @returns {Promise<boolean>}
*/
/**
* Ask the user for access, prompting only when it 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.
*
* await puter.perms.request('folder', { name: 'Documents', access: 'write' });
*
* An array asks for several at once, behind one prompt, resolving in order.
*
* await puter.perms.request([
* { resource: 'folder', name: 'Documents' },
* { resource: 'apps' },
* ]);
*
* @this {PermsModule}
* @param {PermsResource | string | import('./types.js').PermsBatchEntry[]} resource
* @param {PermsRequestDetails} [details]
* @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);
}
/**
* @overload
* @param {'email'} resource
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'folder'} resource
* @param {import('./types.js').PermsFolderRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'apps' | 'subdomains'} resource
* @param {import('./types.js').PermsAccessRequest} [details]
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'appData'} resource
* @param {import('./types.js').PermsAppDataRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'appRootDir'} resource
* @param {import('./types.js').PermsAppRootDirRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {'permission'} resource
* @param {import('./types.js').PermsPermissionRequest} details
* @returns {Promise<boolean>}
*/
/**
* @overload
* @param {import('./types.js').PermsBatchEntry[]} requests
* @returns {Promise<boolean[]>}
*/
/**
* @overload
* @param {string} permission
* @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.
*
* if ( ! await puter.perms.check('folder', { name: 'Documents' }) ) ...
*
* The array form answers per entry, in order, naming which parts are missing.
*
* @this {PermsModule}
* @param {PermsResource | string | import('./types.js').PermsBatchEntry[]} resource
* @param {PermsRequestDetails} [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));
}
@@ -0,0 +1,470 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Both routes these hit 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 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.
const makeModule = ({
whoami = WHOAMI,
requestPermission = () => false,
stat = denied,
appsGet = async () => ({ uid: 'app-target' }),
} = {}) => ({
puter: {
APIOrigin: 'https://api.test',
appID: SELF_UID,
auth: { whoami: vi.fn(async () => ({ ...whoami })) },
ui: { requestPermission: vi.fn(requestPermission) },
fs: { stat: vi.fn(stat) },
apps: { get: vi.fn(appsGet) },
},
request,
check,
requestEmail,
requestFolder,
requestApps,
requestSubdomains,
requestAppRootDir,
requestAppData,
});
/** The permission map `/auth/check-permissions` answers with. */
const heldReply = (held) => ({ permissions: held });
describe('perms request(resource, details)', () => {
beforeEach(() => mockReq.mockReset());
// -- Folders --
it('asks for the folder permission and resolves to its path', async () => {
const mod = makeModule({ requestPermission: () => true });
const path = await request.call(mod, 'folder', {
name: 'Documents',
access: 'write',
});
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'fs:/alice/Documents:write',
});
expect(path).toBe('/alice/Documents');
});
it('defaults folder access to read, and skips the prompt when readable', async () => {
const mod = makeModule({ stat: async () => ({ id: 1 }) });
const path = await request.call(mod, 'folder', { name: 'Documents' });
expect(path).toBe('/alice/Documents');
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('resolves undefined when a folder request is denied', async () => {
const mod = makeModule({ requestPermission: () => false });
expect(
await request.call(mod, 'folder', { name: 'Videos', access: 'write' }),
).toBeUndefined();
});
// -- Apps, subdomains, email --
it('asks for apps and subdomains at the requested access level', 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',
});
await request.call(mod, 'apps', { access: 'write' });
expect(mod.puter.ui.requestPermission).toHaveBeenLastCalledWith({
permission: 'apps-of-user:u-1:write',
});
await request.call(mod, 'subdomains', { access: 'write' });
expect(mod.puter.ui.requestPermission).toHaveBeenLastCalledWith({
permission: 'subdomains-of-user:u-1:write',
});
});
it('returns the email already on file without prompting', async () => {
const mod = makeModule({
whoami: { ...WHOAMI, email: 'alice@example.com' },
});
expect(await request.call(mod, 'email')).toBe('alice@example.com');
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('prompts for the email permission when it is not held', async () => {
const mod = makeModule({ requestPermission: () => false });
expect(await request.call(mod, 'email')).toBeUndefined();
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'user:u-1:email:read',
});
});
// -- Another app's data --
it('asks for the target app-data permissions', async () => {
const mod = makeModule({ requestPermission: () => true });
expect(
await request.call(mod, 'appData', {
app: 'contacts',
scopes: 'read',
}),
).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permissions: ['app-data:app-target:fs:read', 'app-data:app-target:kv:read'],
});
});
// -- An app's root directory --
it('asks the server for the app root dir at the requested access', async () => {
mockReq.mockResolvedValueOnce({ path: '/root' });
const mod = makeModule();
const result = await request.call(mod, 'appRootDir', {
app: 'app-1',
access: 'write',
});
expect(result).toEqual({ path: '/root' });
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/request-app-root-dir',
{ app_uid: 'app-1', access: 'write' },
);
});
// -- Raw permission strings --
it('asks for one raw permission under the scalar prompt shape', async () => {
const mod = makeModule({ requestPermission: () => true });
expect(
await request.call(mod, 'permission', {
permission: 'fs:/alice/x:read',
}),
).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'fs:/alice/x:read',
});
});
it('puts several raw permissions under one prompt, deduped', async () => {
const mod = makeModule({ requestPermission: () => true });
await request.call(mod, 'permission', {
permissions: ['a:read', 'b:read', 'a:read'],
});
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permissions: ['a:read', 'b:read'],
});
});
// A lone string names no resource, so it is a permission string.
it('still accepts a bare permission string', async () => {
const mod = makeModule({ requestPermission: () => true });
expect(await request.call(mod, 'fs:/alice:write')).toBe(true);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'fs:/alice:write',
});
});
// -- Rejected input --
it('rejects details passed with an unknown resource', async () => {
const mod = makeModule();
await expect(
request.call(mod, 'flders', { name: 'Documents' }),
).rejects.toMatchObject({ code: 'invalid_argument' });
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('rejects a missing folder name, an unknown access level, and non-object details', async () => {
const mod = makeModule();
await expect(request.call(mod, 'folder', {})).rejects.toMatchObject({
code: 'invalid_argument',
});
await expect(
request.call(mod, 'apps', { access: 'delete' }),
).rejects.toMatchObject({ code: 'invalid_argument' });
await expect(
request.call(mod, 'apps', 'read'),
).rejects.toMatchObject({ code: 'invalid_argument' });
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('rejects `permission` and `permissions` together', async () => {
const mod = makeModule();
await expect(
request.call(mod, 'permission', {
permission: 'a:read',
permissions: ['b:read'],
}),
).rejects.toMatchObject({ code: 'invalid_argument' });
});
});
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, [
{ resource: 'folder', name: 'Documents', access: 'write' },
{ resource: 'apps' },
{ resource: 'permission', permission: 'x:read' },
]);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledTimes(1);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permissions: [
'fs:/alice/Documents:write',
'apps-of-user:u-1:read',
'x:read',
],
});
// Each entry resolves to what its single-resource form returns.
expect(results).toEqual(['/alice/Documents', true, true]);
});
it('never prompts when the whole batch is already held', async () => {
mockReq.mockResolvedValue(
heldReply({
'fs:/alice/Desktop:read': true,
'subdomains-of-user:u-1:write': true,
}),
);
const mod = makeModule();
const results = await request.call(mod, [
{ resource: 'folder', name: 'Desktop' },
{ resource: 'subdomains', access: 'write' },
]);
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
expect(results).toEqual(['/alice/Desktop', true]);
});
// 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 }));
const mod = makeModule({ requestPermission: () => false });
const results = await request.call(mod, [
{ resource: 'apps' },
{ resource: 'folder', name: 'Videos', access: 'write' },
]);
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
permission: 'fs:/alice/Videos:write',
});
expect(results).toEqual([true, undefined]);
});
it('validates every entry before prompting for any of them', async () => {
mockReq.mockResolvedValue(heldReply({}));
const mod = makeModule({ requestPermission: () => true });
await expect(
request.call(mod, [
{ resource: 'apps' },
{ resource: 'folder', name: 'Trash' },
]),
).rejects.toMatchObject({ code: 'invalid_argument' });
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('rejects an entry with no resource, an unknown one, or a stray second argument', async () => {
const mod = makeModule();
await expect(request.call(mod, [{ name: 'Desktop' }])).rejects.toMatchObject({
code: 'invalid_argument',
});
await expect(
request.call(mod, [{ resource: 'folders', name: 'Desktop' }]),
).rejects.toMatchObject({ code: 'invalid_argument' });
await expect(
request.call(mod, [{ resource: 'apps' }], { access: 'read' }),
).rejects.toMatchObject({ code: 'invalid_argument' });
});
it('answers a batch check per entry, in order', async () => {
mockReq.mockResolvedValue(
heldReply({
'fs:/alice/Desktop:read': true,
'apps-of-user:u-1:write': false,
}),
);
const mod = makeModule();
expect(
await check.call(mod, [
{ resource: 'folder', name: 'Desktop' },
{ resource: 'apps', access: 'write' },
]),
).toEqual([true, false]);
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
it('resolves an empty batch to an empty list without asking anything', async () => {
const mod = makeModule();
expect(await request.call(mod, [])).toEqual([]);
expect(await check.call(mod, [])).toEqual([]);
expect(mockReq).not.toHaveBeenCalled();
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
});
});
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 }),
);
const mod = makeModule();
expect(
await check.call(mod, 'folder', {
name: 'Documents',
access: 'write',
}),
).toBe(true);
expect(mockReq).toHaveBeenCalledWith(
mod.puter,
'/auth/check-permissions',
{ 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'] },
);
await check.call(mod, 'subdomains');
expect(mockReq).toHaveBeenLastCalledWith(
mod.puter,
'/auth/check-permissions',
{ 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'] },
);
});
it('reports false when the permission is not held', async () => {
mockReq.mockResolvedValueOnce(
heldReply({ '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({
'app-data:app-target:kv:read': true,
'app-data:app-target:fs:read': false,
}),
);
expect(
await check.call(makeModule(), 'appData', {
app: 'contacts',
scopes: 'read',
}),
).toBe(false);
});
it('treats an app asking about its own data as already allowed', async () => {
expect(
await check.call(makeModule(), 'appData', {
app: SELF_UID,
scopes: 'read',
}),
).toBe(true);
expect(mockReq).not.toHaveBeenCalled();
});
it('reads email access off whoami when the address is present', async () => {
const mod = makeModule({
whoami: { ...WHOAMI, email: 'alice@example.com' },
});
expect(await check.call(mod, 'email')).toBe(true);
expect(mockReq).not.toHaveBeenCalled();
});
it('falls back to the permission check when no email is on whoami', async () => {
mockReq.mockResolvedValueOnce(heldReply({ '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' });
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(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
mockReq.mockResolvedValueOnce({ error: true });
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({
error: true,
message: 'nope',
code: 'unauthorized',
});
await expect(check.call(makeModule(), 'apps')).rejects.toMatchObject({
message: 'nope',
code: 'unauthorized',
});
});
});
+77
View File
@@ -14,6 +14,83 @@
* @typedef {'Desktop' | 'Documents' | 'Pictures' | 'Videos'} PermsFolderName
*/
/**
* What a `request`/`check` call is about; decides the details and the result.
*
* @typedef {'email'
* | 'folder'
* | 'apps'
* | 'subdomains'
* | 'appData'
* | 'appRootDir'
* | 'permission'} PermsResource
*/
/**
* Details for a resource that only takes an access level — `'apps'` and
* `'subdomains'`.
*
* @typedef {Object} PermsAccessRequest
* @property {PermsAccess} [access] - Defaults to `'read'`. `write` implies read.
*/
/**
* Details for `'folder'`.
*
* @typedef {Object} PermsFolderRequest
* @property {PermsFolderName} name - Desktop, Documents, Pictures, or Videos.
* @property {PermsAccess} [access] - Defaults to `'read'`.
*/
/**
* Details for `'appData'` — another app's key-value namespace and AppData files.
*
* @typedef {Object} PermsAppDataRequest
* @property {string | { uid: string } | { name: string }} app - The target app,
* by uid or by registered name.
* @property {AppDataScopes} scopes - What this app wants to do with that data.
*/
/**
* Details for `'appRootDir'` — the root directory of one of the user's own apps.
*
* @typedef {Object} PermsAppRootDirRequest
* @property {string | { uid: string }} app - The app, by uid or an object with one.
* @property {PermsAccess} [access] - Defaults to `'read'`.
*/
/**
* Details for `'permission'`, the raw-permission-string escape hatch. Several
* at once go under a single prompt.
*
* @typedef {Object} PermsPermissionRequest
* @property {string} [permission] - One permission string.
* @property {string[]} [permissions] - Several, instead of `permission`.
*/
/**
* Every details shape a `request`/`check` call accepts; the resource decides
* which one applies — see the per-resource overloads.
*
* @typedef {PermsAccessRequest
* | PermsFolderRequest
* | PermsAppDataRequest
* | PermsAppRootDirRequest
* | PermsPermissionRequest} PermsRequestDetails
*/
/**
* One batch entry: the resource, with its own details in the same object, so
* one array can carry entries that each take different fields.
*
* @typedef {{ resource: 'email' }
* | ({ resource: 'folder' } & PermsFolderRequest)
* | ({ resource: 'apps' | 'subdomains' } & PermsAccessRequest)
* | ({ resource: 'appData' } & PermsAppDataRequest)
* | ({ resource: 'appRootDir' } & PermsAppRootDirRequest)
* | ({ resource: 'permission' } & PermsPermissionRequest)} PermsBatchEntry
*/
/**
* The stores an `app-data` scope can name.
*
@@ -83,6 +83,146 @@ export default suite('perms', {
t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`);
},
// -- request(resource, details) --
'request resolves a folder to its path and email to the address': async (
t,
) => {
const whoami = await t.puter.auth.whoami();
t.assert.equal(
await t.puter.perms.request('folder', { name: 'Desktop' }),
`${home(t)}/Desktop`,
);
t.assert.equal(await t.puter.perms.request('email'), whoami.email);
},
'request rejects a folder it does not cover and an unknown access level': async (
t,
) => {
const folder = (await t.assert.rejects(() =>
t.puter.perms.request('folder', {
name: 'Trash' as unknown as 'Desktop',
}),
)) as Error & { code?: string };
t.assert.equal(folder.code, 'invalid_argument');
const access = (await t.assert.rejects(() =>
t.puter.perms.request('apps', {
access: 'delete' as unknown as 'read',
}),
)) as Error & { code?: string };
t.assert.equal(access.code, 'invalid_argument');
},
// Details beside a non-resource name is a typo, not the legacy form.
'request rejects details passed with an unknown resource': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.request as (
resource: string,
details: unknown,
) => Promise<unknown>
)('folders', { name: 'Desktop' }),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
'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`,
}),
false,
);
// A lone string is still the permission string it always was.
t.assert.equal(
await t.puter.perms.request(`fs:${home(t)}:write`),
false,
);
},
},
// -- check(resource, details) --
'check answers without prompting': async (t) => {
t.assert.equal(
await t.puter.perms.check('folder', { name: 'Desktop' }),
true,
);
t.assert.equal(await t.puter.perms.check('email'), true);
t.assert.equal(
typeof (await t.puter.perms.check('apps', { access: 'write' })),
'boolean',
);
},
// All-or-nothing: a set is held only when every permission in it is.
'check reports false when part of a set is missing': async (t) => {
t.assert.equal(
await t.puter.perms.check('permission', {
permissions: [
`fs:${home(t)}/Desktop:read`,
'nonexistent-namespace:nothing:read',
],
}),
false,
);
},
'check validates its details the same way request does': async (t) => {
const error = (await t.assert.rejects(() =>
t.puter.perms.check('folder', {
name: 'Trash' as unknown as 'Desktop',
}),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
// -- Batching --
// Both folders are already readable, so no prompt: runs on every platform.
'a batch resolves each entry in order': async (t) => {
const results = await t.puter.perms.request([
{ resource: 'folder', name: 'Desktop' },
{ resource: 'folder', name: 'Documents' },
]);
t.assert.deepEqual(results, [
`${home(t)}/Desktop`,
`${home(t)}/Documents`,
]);
t.assert.deepEqual(
await t.puter.perms.check([
{ resource: 'folder', name: 'Desktop' },
{ resource: 'folder', name: 'Documents' },
]),
[true, true],
);
},
'an empty batch resolves to an empty list': async (t) => {
t.assert.deepEqual(await t.puter.perms.request([]), []);
t.assert.deepEqual(await t.puter.perms.check([]), []);
},
'a batch rejects an entry naming no resource': async (t) => {
const error = (await t.assert.rejects(() =>
(
t.puter.perms.request as (requests: unknown[]) => Promise<unknown>
)([{ name: 'Desktop' }]),
)) as Error & { code?: string };
t.assert.equal(error.code, 'invalid_argument');
},
// -- Special folders --
'requestFolder returns the path of an already-readable folder': async (t) => {