diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 745eda3c0..dc1264bd6 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -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 -- diff --git a/src/puter-js/src/modules/perms/appData.js b/src/puter-js/src/modules/perms/appData.js index 9ae848a3d..f69ba4f47 100644 --- a/src/puter-js/src/modules/perms/appData.js +++ b/src/puter-js/src/modules/perms/appData.js @@ -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} `true` if the app may now use that data. + * @returns {Promise} 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} `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 }); } diff --git a/src/puter-js/src/modules/perms/appRootDir.js b/src/puter-js/src/modules/perms/appRootDir.js index cddb89f2a..8380f05cd 100644 --- a/src/puter-js/src/modules/perms/appRootDir.js +++ b/src/puter-js/src/modules/perms/appRootDir.js @@ -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>} + */ +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 | 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 | 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 -- diff --git a/src/puter-js/src/modules/perms/folders.js b/src/puter-js/src/modules/perms/folders.js index 428c901e2..3b8dc4969 100644 --- a/src/puter-js/src/modules/perms/folders.js +++ b/src/puter-js/src/modules/perms/folders.js @@ -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} + */ +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} */ 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} 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 -- diff --git a/src/puter-js/src/modules/perms/index.js b/src/puter-js/src/modules/perms/index.js index a5e7e37a1..ab19f413c 100644 --- a/src/puter-js/src/modules/perms/index.js +++ b/src/puter-js/src/modules/perms/index.js @@ -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 */ diff --git a/src/puter-js/src/modules/perms/lib/holds.js b/src/puter-js/src/modules/perms/lib/holds.js new file mode 100644 index 000000000..d02b2c329 --- /dev/null +++ b/src/puter-js/src/modules/perms/lib/holds.js @@ -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} + */ +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} */ ( + result.permissions ?? {} + ); + return permissions.every((permission) => held[permission] === true); +} diff --git a/src/puter-js/src/modules/perms/lib/permissionStrings.js b/src/puter-js/src/modules/perms/lib/permissionStrings.js new file mode 100644 index 000000000..a7841b304 --- /dev/null +++ b/src/puter-js/src/modules/perms/lib/permissionStrings.js @@ -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}`; diff --git a/src/puter-js/src/modules/perms/lib/validate.js b/src/puter-js/src/modules/perms/lib/validate.js new file mode 100644 index 000000000..eb81c6af3 --- /dev/null +++ b/src/puter-js/src/modules/perms/lib/validate.js @@ -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); +}; diff --git a/src/puter-js/src/modules/perms/permissions.js b/src/puter-js/src/modules/perms/permissions.js index b43af4c14..7d54d0011 100644 --- a/src/puter-js/src/modules/perms/permissions.js +++ b/src/puter-js/src/modules/perms/permissions.js @@ -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} `true` if the permission was granted. + * @param {import('../../index.js').Puter} puter + * @param {string[]} permissions + * @returns {Promise} */ -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} */ export function requestPermission (...args) { - return this.request(...args); + return this.request( + .../** @type {[string, Record | 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), }); } diff --git a/src/puter-js/src/modules/perms/request.js b/src/puter-js/src/modules/perms/request.js new file mode 100644 index 000000000..c2d26681e --- /dev/null +++ b/src/puter-js/src/modules/perms/request.js @@ -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} details @returns {PermsAccess} */ +const accessOf = (details) => assertAccess(details.access ?? 'read'); + +/** + * The permission strings a `'permission'` request names, one or many. + * + * @param {Record} 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) => Promise, + * check: (perms: PermsModule, details: Record) => Promise, + * permissions: (perms: PermsModule, details: Record) => Promise, + * resolve: (perms: PermsModule, details: Record, held: boolean) => Promise, + * }>} + */ +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) => Promise, details: Record }} + */ +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} */ (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 }} + */ +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} */ (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} + */ +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} + */ +/** + * @overload + * @param {'folder'} resource + * @param {import('./types.js').PermsFolderRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {'apps' | 'subdomains'} resource + * @param {import('./types.js').PermsAccessRequest} [details] + * @returns {Promise} + */ +/** + * @overload + * @param {'appData'} resource + * @param {import('./types.js').PermsAppDataRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {'appRootDir'} resource + * @param {import('./types.js').PermsAppRootDirRequest} details + * @returns {Promise | undefined>} + */ +/** + * @overload + * @param {'permission'} resource + * @param {import('./types.js').PermsPermissionRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {import('./types.js').PermsBatchEntry[]} requests + * @returns {Promise} + */ +/** + * @overload + * @param {string} permission + * @returns {Promise} + */ +/** + * 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} + */ +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} + */ +/** + * @overload + * @param {'folder'} resource + * @param {import('./types.js').PermsFolderRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {'apps' | 'subdomains'} resource + * @param {import('./types.js').PermsAccessRequest} [details] + * @returns {Promise} + */ +/** + * @overload + * @param {'appData'} resource + * @param {import('./types.js').PermsAppDataRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {'appRootDir'} resource + * @param {import('./types.js').PermsAppRootDirRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {'permission'} resource + * @param {import('./types.js').PermsPermissionRequest} details + * @returns {Promise} + */ +/** + * @overload + * @param {import('./types.js').PermsBatchEntry[]} requests + * @returns {Promise} + */ +/** + * @overload + * @param {string} permission + * @returns {Promise} + */ +/** + * 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} + */ +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)); +} diff --git a/src/puter-js/src/modules/perms/request.test.js b/src/puter-js/src/modules/perms/request.test.js new file mode 100644 index 000000000..aed202fa0 --- /dev/null +++ b/src/puter-js/src/modules/perms/request.test.js @@ -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', + }); + }); +}); diff --git a/src/puter-js/src/modules/perms/types.js b/src/puter-js/src/modules/perms/types.js index e0eac62b8..41247e2ad 100644 --- a/src/puter-js/src/modules/perms/types.js +++ b/src/puter-js/src/modules/perms/types.js @@ -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. * diff --git a/src/puter-js/tests/api/suites/perms.suite.ts b/src/puter-js/tests/api/suites/perms.suite.ts index 3dddda82c..3d40146de 100644 --- a/src/puter-js/tests/api/suites/perms.suite.ts +++ b/src/puter-js/tests/api/suites/perms.suite.ts @@ -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 + )('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 + )([{ 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) => {