diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 3cee3cafa..745eda3c0 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -172,6 +172,8 @@ export type { AppDataScopePair, AppDataScopes, AppDataStore, + PermsAccess, + PermsFolderName, } from './types/modules/perms/types.js'; // -- puter.ui -- diff --git a/src/puter-js/src/modules/perms/appRootDir.js b/src/puter-js/src/modules/perms/appRootDir.js index d3e5703ce..cddb89f2a 100644 --- a/src/puter-js/src/modules/perms/appRootDir.js +++ b/src/puter-js/src/modules/perms/appRootDir.js @@ -2,6 +2,7 @@ import { PuterJSError } from '../../lib/PuterJSError.js'; import { req } from './lib/req.js'; /** @typedef {import('./index.js').PermsModule} PermsModule */ +/** @typedef {import('./types.js').PermsAccess} PermsAccess */ /** * Requests access at the given level to the root directory of one of the @@ -10,11 +11,11 @@ import { req } from './lib/req.js'; * invalidation), returning the fs item on success or `undefined` if denied. * * @param {import('../../index.js').Puter} puter - * @param {'read' | 'write'} access + * @param {PermsAccess} access * @param {string | { uid: string }} appUidOrObject * @returns {Promise | undefined>} */ -async function requestAppRootDir (puter, access, appUidOrObject) { +async function requestAppRootDirAccess (puter, access, appUidOrObject) { const appUid = (typeof appUidOrObject === 'object' && appUidOrObject !== null) ? appUidOrObject.uid : appUidOrObject; @@ -56,23 +57,41 @@ async function requestAppRootDir (puter, access, appUidOrObject) { } /** - * Request read access to the root directory of one of the user's apps. + * Request access to the root directory of one of the user's apps. * * @this {PermsModule} * @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`. + * @param {PermsAccess} [accessLevel] - Defaults to `'read'`. * @returns {Promise | undefined>} The directory fs item, or `undefined` if denied. */ -export async function requestReadAppRootDir (appUid) { - return await requestAppRootDir(this.puter, 'read', appUid); +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); +} + +// -- Deprecated aliases -- + +/** + * @deprecated Use {@link requestAppRootDir} instead. + * @this {PermsModule} + * @param {string | { uid: string }} appUid + * @returns {Promise | undefined>} + */ +export function requestReadAppRootDir (appUid) { + return this.requestAppRootDir(appUid, 'read'); } /** - * Request write access to the root directory of one of the user's apps. - * + * @deprecated Use {@link requestAppRootDir} instead. * @this {PermsModule} - * @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`. - * @returns {Promise | undefined>} The directory fs item, or `undefined` if denied. + * @param {string | { uid: string }} appUid + * @returns {Promise | undefined>} */ -export async function requestWriteAppRootDir (appUid) { - return await requestAppRootDir(this.puter, 'write', appUid); +export function requestWriteAppRootDir (appUid) { + return this.requestAppRootDir(appUid, 'write'); } diff --git a/src/puter-js/src/modules/perms/appRootDir.test.js b/src/puter-js/src/modules/perms/appRootDir.test.js index a95c62057..bdd4aa6bd 100644 --- a/src/puter-js/src/modules/perms/appRootDir.test.js +++ b/src/puter-js/src/modules/perms/appRootDir.test.js @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockReq = vi.fn(); vi.mock('./lib/req.js', () => ({ req: (...args) => mockReq(...args) })); -const { requestReadAppRootDir, requestWriteAppRootDir } = await import('./appRootDir.js'); +const { + requestAppRootDir, + requestReadAppRootDir, + requestWriteAppRootDir, +} = await import('./appRootDir.js'); const { PuterJSError } = await import('../../lib/PuterJSError.js'); const makeModule = (requestPermission) => ({ @@ -12,6 +16,7 @@ const makeModule = (requestPermission) => ({ APIOrigin: 'https://api.test', ui: { requestPermission: vi.fn(requestPermission) }, }, + requestAppRootDir, }); describe('perms appRootDir', () => { @@ -21,7 +26,7 @@ describe('perms appRootDir', () => { mockReq.mockResolvedValueOnce({ path: '/root' }); // succeeds first try const mod = makeModule(); - const result = await requestWriteAppRootDir.call(mod, 'app-123'); + const result = await requestAppRootDir.call(mod, 'app-123', 'write'); expect(result).toEqual({ path: '/root' }); expect(mockReq).toHaveBeenCalledWith( @@ -33,11 +38,11 @@ describe('perms appRootDir', () => { expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled(); }); - it('requests read access with access:"read" in the body', async () => { + it('defaults to read access', async () => { mockReq.mockResolvedValueOnce({ path: '/root' }); const mod = makeModule(); - await requestReadAppRootDir.call(mod, 'app-xyz'); + await requestAppRootDir.call(mod, 'app-xyz'); expect(mockReq).toHaveBeenCalledWith( mod.puter, @@ -53,7 +58,7 @@ describe('perms appRootDir', () => { .mockResolvedValueOnce({ path: '/root' }); const mod = makeModule(() => true); - const result = await requestWriteAppRootDir.call(mod, 'app-123'); + const result = await requestAppRootDir.call(mod, 'app-123', 'write'); expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({ permission: 'app-root-dir:app-123:write', @@ -66,7 +71,7 @@ describe('perms appRootDir', () => { mockReq.mockResolvedValueOnce({ path: '/root' }); const mod = makeModule(); - await requestReadAppRootDir.call(mod, { uid: 'app-obj' }); + await requestAppRootDir.call(mod, { uid: 'app-obj' }); expect(mockReq).toHaveBeenCalledWith( mod.puter, @@ -79,16 +84,43 @@ describe('perms appRootDir', () => { mockReq.mockResolvedValue({ error: true }); const mod = makeModule(() => false); - const result = await requestWriteAppRootDir.call(mod, 'app-123'); + const result = await requestAppRootDir.call(mod, 'app-123', 'write'); expect(result).toBeUndefined(); }); it('rejects a non-string, non-object app uid with a coded error', async () => { const mod = makeModule(); - await expect(requestReadAppRootDir.call(mod, 42)).rejects.toBeInstanceOf(PuterJSError); - await expect(requestReadAppRootDir.call(mod, 42)).rejects.toMatchObject({ + await expect(requestAppRootDir.call(mod, 42)).rejects.toBeInstanceOf(PuterJSError); + await expect(requestAppRootDir.call(mod, 42)).rejects.toMatchObject({ code: 'invalid_argument', }); }); + + it('rejects an access level that is neither read nor write', async () => { + const mod = makeModule(); + await expect( + requestAppRootDir.call(mod, 'app-123', 'delete'), + ).rejects.toMatchObject({ code: 'invalid_argument' }); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('keeps the deprecated read/write aliases delegating', async () => { + mockReq.mockResolvedValue({ path: '/root' }); + const mod = makeModule(); + + await requestReadAppRootDir.call(mod, 'app-1'); + expect(mockReq).toHaveBeenLastCalledWith( + mod.puter, + '/auth/request-app-root-dir', + { app_uid: 'app-1', access: 'read' }, + ); + + await requestWriteAppRootDir.call(mod, 'app-1'); + expect(mockReq).toHaveBeenLastCalledWith( + mod.puter, + '/auth/request-app-root-dir', + { app_uid: 'app-1', access: 'write' }, + ); + }); }); diff --git a/src/puter-js/src/modules/perms/folders.js b/src/puter-js/src/modules/perms/folders.js index 18c78879f..428c901e2 100644 --- a/src/puter-js/src/modules/perms/folders.js +++ b/src/puter-js/src/modules/perms/folders.js @@ -1,22 +1,28 @@ +import { PuterJSError } from '../../lib/PuterJSError.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']; /** - * Requests access to one of the user's special folders, returning its path if - * access is (or becomes) granted. Read access is inferred from being able to - * stat the folder; write access always prompts if not already held. + * Resolve a folder request to its path, prompting only when the access isn't + * already held. * - * @this {PermsModule} - * @param {string} folderName - Desktop, Documents, Pictures, or Videos. - * @param {'read' | 'write'} accessLevel + * @param {import('../../index.js').Puter} puter + * @param {string} folderName + * @param {PermsAccess} accessLevel * @returns {Promise} */ -export async function requestFolder_ (folderName, accessLevel) { - const whoami = await this.puter.auth.whoami(); +async function requestFolderPath (puter, folderName, accessLevel) { + const whoami = await puter.auth.whoami(); const folderPath = `/${whoami.username}/${folderName}`; // Being able to stat the folder means we already have at least read access. try { - await this.puter.fs.stat({ path: folderPath }); + await puter.fs.stat({ path: folderPath }); if ( accessLevel !== 'write' ) { return folderPath; } @@ -24,48 +30,123 @@ export async function requestFolder_ (folderName, accessLevel) { // No access yet, fall through to request permission. } - const granted = await this.puter.ui.requestPermission({ + const granted = await puter.ui.requestPermission({ permission: `fs:${folderPath}:${accessLevel}`, }); return granted ? folderPath : undefined; } -/** @this {PermsModule} @returns {Promise} */ +/** + * Requests access to one of the user's special folders, returning its path if + * access is (or becomes) granted. Read access is inferred from being able to + * stat the folder; write access always prompts if not already held. + * + * @this {PermsModule} + * @param {PermsFolderName} folderName - Desktop, Documents, Pictures, or Videos. + * @param {PermsAccess} [accessLevel] - Defaults to `'read'`. + * @returns {Promise} 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); +} + +// -- Deprecated aliases -- +// +// Kept so apps written against the old one-method-per-folder surface keep +// working. `requestFolder_` bypasses the name check the public method applies, +// preserving its any-folder behavior. + +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @param {string} folderName + * @param {PermsAccess} accessLevel + * @returns {Promise} + */ +export function requestFolder_ (folderName, accessLevel) { + return requestFolderPath(this.puter, folderName, accessLevel); +} + +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestReadDesktop () { - return this.requestFolder_('Desktop', 'read'); + return this.requestFolder('Desktop', 'read'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestWriteDesktop () { - return this.requestFolder_('Desktop', 'write'); + return this.requestFolder('Desktop', 'write'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestReadDocuments () { - return this.requestFolder_('Documents', 'read'); + return this.requestFolder('Documents', 'read'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestWriteDocuments () { - return this.requestFolder_('Documents', 'write'); + return this.requestFolder('Documents', 'write'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestReadPictures () { - return this.requestFolder_('Pictures', 'read'); + return this.requestFolder('Pictures', 'read'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestWritePictures () { - return this.requestFolder_('Pictures', 'write'); + return this.requestFolder('Pictures', 'write'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestReadVideos () { - return this.requestFolder_('Videos', 'read'); + return this.requestFolder('Videos', 'read'); } -/** @this {PermsModule} @returns {Promise} */ +/** + * @deprecated Use {@link requestFolder} instead. + * @this {PermsModule} + * @returns {Promise} + */ export function requestWriteVideos () { - return this.requestFolder_('Videos', 'write'); + return this.requestFolder('Videos', 'write'); } diff --git a/src/puter-js/src/modules/perms/grants.js b/src/puter-js/src/modules/perms/grants.js index 2d3d8d4b7..6be84b918 100644 --- a/src/puter-js/src/modules/perms/grants.js +++ b/src/puter-js/src/modules/perms/grants.js @@ -3,34 +3,11 @@ import { req } from './lib/req.js'; /** @typedef {import('./index.js').PermsModule} PermsModule */ /** @typedef {Promise>} PermResult */ +// These resolve to a parsed result object (with `error: true` set on failure) +// rather than rejecting — callers inspect `result.error` instead of catching. + // -- Grant -- -/** - * Grants a permission to another user. - * - * @deprecated Retired server-side — the endpoint now returns 501. Share files - * with {@link https://docs.puter.com/FS/share/ puter.fs.share()} instead, which - * records the share so it can be listed and revoked. - * @this {PermsModule} - * @param {string} username - * @param {string} permission - * @returns {PermResult} - */ -export async function grantUser (username, permission) { - return await req(this.puter, '/auth/grant-user-user', { target_username: username, permission }); -} - -/** - * Grants a permission to a group. - * @this {PermsModule} - * @param {string} groupUid - * @param {string} permission - * @returns {PermResult} - */ -export async function grantGroup (groupUid, permission) { - return await req(this.puter, '/auth/grant-user-group', { group_uid: groupUid, permission }); -} - /** * Grants a permission to an app. * @this {PermsModule} @@ -67,29 +44,8 @@ export async function grantOrigin (origin, permission) { // -- Revoke -- /** - * Revokes a permission from another user. - * @this {PermsModule} - * @param {string} username - * @param {string} permission - * @returns {PermResult} - */ -export async function revokeUser (username, permission) { - return await req(this.puter, '/auth/revoke-user-user', { target_username: username, permission }); -} - -/** - * Revokes a permission from a group. - * @this {PermsModule} - * @param {string} groupUid - * @param {string} permission - * @returns {PermResult} - */ -export async function revokeGroup (groupUid, permission) { - return await req(this.puter, '/auth/revoke-user-group', { group_uid: groupUid, permission }); -} - -/** - * Revokes a permission from an app. + * Revokes a permission from an app. `'*'` revokes every permission the user has + * granted it, which is what uninstalling an app does. * @this {PermsModule} * @param {string} appUid * @param {string} permission diff --git a/src/puter-js/src/modules/perms/groups.js b/src/puter-js/src/modules/perms/groups.js deleted file mode 100644 index bfea8ba2b..000000000 --- a/src/puter-js/src/modules/perms/groups.js +++ /dev/null @@ -1,46 +0,0 @@ -import { req } from './lib/req.js'; - -/** @typedef {import('./index.js').PermsModule} PermsModule */ -/** @typedef {Promise>} PermResult */ - -/** - * Creates a new group. - * @this {PermsModule} - * @param {Record} [metadata] - * @param {Record} [extra] - * @returns {PermResult} - */ -export async function createGroup (metadata = {}, extra = {}) { - return await req(this.puter, '/group/create', { metadata, extra }); -} - -/** - * Adds users to a group by username. - * @this {PermsModule} - * @param {string} uid - * @param {string[]} usernames - * @returns {PermResult} - */ -export async function addUsersToGroup (uid, usernames) { - return await req(this.puter, '/group/add-users', { uid, users: usernames ?? [] }); -} - -/** - * Removes users from a group by username. - * @this {PermsModule} - * @param {string} uid - * @param {string[]} usernames - * @returns {PermResult} - */ -export async function removeUsersFromGroup (uid, usernames) { - return await req(this.puter, '/group/remove-users', { uid, users: usernames ?? [] }); -} - -/** - * Lists the caller's groups. - * @this {PermsModule} - * @returns {PermResult} - */ -export async function listGroups () { - return await req(this.puter, '/group/list'); -} diff --git a/src/puter-js/src/modules/perms/index.js b/src/puter-js/src/modules/perms/index.js index 4132831d3..a5e7e37a1 100644 --- a/src/puter-js/src/modules/perms/index.js +++ b/src/puter-js/src/modules/perms/index.js @@ -1,42 +1,47 @@ import { PuterModule } from '../../lib/PuterModule.js'; import { requestAppData } from './appData.js'; -import { requestReadAppRootDir, requestWriteAppRootDir } from './appRootDir.js'; import { - requestFolder_, + requestAppRootDir, + requestReadAppRootDir, requestWriteAppRootDir, +} from './appRootDir.js'; +import { + requestFolder, requestFolder_, requestReadDesktop, requestWriteDesktop, requestReadDocuments, requestWriteDocuments, requestReadPictures, requestWritePictures, requestReadVideos, requestWriteVideos, } from './folders.js'; import { - grantApp, grantAppAnyUser, grantGroup, grantOrigin, grantUser, - revokeApp, revokeAppAnyUser, revokeGroup, revokeOrigin, revokeUser, + grantApp, grantAppAnyUser, grantOrigin, + revokeApp, revokeAppAnyUser, revokeOrigin, } from './grants.js'; -import { addUsersToGroup, createGroup, listGroups, removeUsersFromGroup } from './groups.js'; -import { req } from './lib/req.js'; import { - request, requestEmail, requestManageApps, requestManageSubdomains, + request, requestApps, requestEmail, + requestManageApps, requestManageSubdomains, requestPermission, requestReadApps, requestReadSubdomains, + requestSubdomains, } from './permissions.js'; /** @typedef {import('../../index.js').Puter} Puter */ // Every `this`-context method exposed on the module, rebound in the -// constructor so both `puter.perms.grantUser(...)` and destructured -// `const { grantUser } = puter.perms` calls keep the right `this`. +// constructor so both `puter.perms.requestFolder(...)` and destructured +// `const { requestFolder } = puter.perms` calls keep the right `this`. const METHODS = [ - 'grantUser', 'grantGroup', 'grantApp', 'grantAppAnyUser', 'grantOrigin', - 'revokeUser', 'revokeGroup', 'revokeApp', 'revokeAppAnyUser', 'revokeOrigin', - 'createGroup', 'addUsersToGroup', 'removeUsersFromGroup', 'listGroups', - 'request', 'requestPermission', 'requestEmail', - 'requestReadApps', 'requestManageApps', 'requestReadSubdomains', 'requestManageSubdomains', - 'requestFolder_', + 'grantApp', 'grantAppAnyUser', 'grantOrigin', + 'revokeApp', 'revokeAppAnyUser', 'revokeOrigin', + 'request', 'requestEmail', + 'requestFolder', 'requestApps', 'requestSubdomains', + 'requestAppRootDir', 'requestAppData', + // Deprecated aliases; bound for the same reason as the rest. + 'requestPermission', 'requestFolder_', 'requestReadDesktop', 'requestWriteDesktop', 'requestReadDocuments', 'requestWriteDocuments', 'requestReadPictures', 'requestWritePictures', 'requestReadVideos', 'requestWriteVideos', + 'requestReadApps', 'requestManageApps', + 'requestReadSubdomains', 'requestManageSubdomains', 'requestReadAppRootDir', 'requestWriteAppRootDir', - 'requestAppData', ]; /** @@ -47,51 +52,72 @@ const METHODS = [ * `types/` is generated from it, never edited by hand. */ export class PermsModule extends PuterModule { - // Grant / revoke - grantUser = grantUser; - grantGroup = grantGroup; + // Grant / revoke against an app, its origin, or every user of it grantApp = grantApp; grantAppAnyUser = grantAppAnyUser; grantOrigin = grantOrigin; - revokeUser = revokeUser; - revokeGroup = revokeGroup; revokeApp = revokeApp; revokeAppAnyUser = revokeAppAnyUser; revokeOrigin = revokeOrigin; - // Group management - createGroup = createGroup; - addUsersToGroup = addUsersToGroup; - removeUsersFromGroup = removeUsersFromGroup; - listGroups = listGroups; - // Permission requests request = request; - requestPermission = requestPermission; requestEmail = requestEmail; - requestReadApps = requestReadApps; - requestManageApps = requestManageApps; - requestReadSubdomains = requestReadSubdomains; - requestManageSubdomains = requestManageSubdomains; - // Folder access - requestFolder_ = requestFolder_; - requestReadDesktop = requestReadDesktop; - requestWriteDesktop = requestWriteDesktop; - requestReadDocuments = requestReadDocuments; - requestWriteDocuments = requestWriteDocuments; - requestReadPictures = requestReadPictures; - requestWritePictures = requestWritePictures; - requestReadVideos = requestReadVideos; - requestWriteVideos = requestWriteVideos; + // Special folders + requestFolder = requestFolder; - // App root directory access - requestReadAppRootDir = requestReadAppRootDir; - requestWriteAppRootDir = requestWriteAppRootDir; + // 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; + // -- 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. + + /** @deprecated Use {@link request}. */ + requestPermission = requestPermission; + /** @deprecated Use {@link requestFolder}. */ + requestFolder_ = requestFolder_; + /** @deprecated Use {@link requestFolder}. */ + requestReadDesktop = requestReadDesktop; + /** @deprecated Use {@link requestFolder}. */ + requestWriteDesktop = requestWriteDesktop; + /** @deprecated Use {@link requestFolder}. */ + requestReadDocuments = requestReadDocuments; + /** @deprecated Use {@link requestFolder}. */ + requestWriteDocuments = requestWriteDocuments; + /** @deprecated Use {@link requestFolder}. */ + requestReadPictures = requestReadPictures; + /** @deprecated Use {@link requestFolder}. */ + requestWritePictures = requestWritePictures; + /** @deprecated Use {@link requestFolder}. */ + requestReadVideos = requestReadVideos; + /** @deprecated Use {@link requestFolder}. */ + requestWriteVideos = requestWriteVideos; + /** @deprecated Use {@link requestApps}. */ + requestReadApps = requestReadApps; + /** @deprecated Use {@link requestApps}. */ + requestManageApps = requestManageApps; + /** @deprecated Use {@link requestSubdomains}. */ + requestReadSubdomains = requestReadSubdomains; + /** @deprecated Use {@link requestSubdomains}. */ + requestManageSubdomains = requestManageSubdomains; + /** @deprecated Use {@link requestAppRootDir}. */ + requestReadAppRootDir = requestReadAppRootDir; + /** @deprecated Use {@link requestAppRootDir}. */ + requestWriteAppRootDir = requestWriteAppRootDir; + /** @param {Puter} puter */ constructor (puter) { super(puter); @@ -103,19 +129,6 @@ export class PermsModule extends PuterModule { methods[name] = methods[name].bind(this); } } - - /** - * Low-level request helper against the auth/group endpoints, kept on the - * instance for backward compatibility. Returns the parsed result object - * (with `error: true` set on failure) rather than rejecting. - * - * @param {string} route - * @param {Record} [body] - * @returns {Promise>} - */ - req_ (route, body) { - return req(this.puter, route, body); - } } /** diff --git a/src/puter-js/src/modules/perms/permissions.js b/src/puter-js/src/modules/perms/permissions.js index d5e3e677a..b43af4c14 100644 --- a/src/puter-js/src/modules/perms/permissions.js +++ b/src/puter-js/src/modules/perms/permissions.js @@ -1,4 +1,18 @@ +import { PuterJSError } from '../../lib/PuterJSError.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 @@ -46,41 +60,71 @@ export async function requestEmail () { } /** - * Request read access to the user's apps. + * Request access to the user's apps. `write` covers managing them (create, + * update, delete) as well as reading them. + * * @this {PermsModule} - * @returns {Promise} + * @param {PermsAccess} [accessLevel] - Defaults to `'read'`. + * @returns {Promise} `true` if the permission was granted. */ -export async function requestReadApps () { +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}:read` }); + return await this.puter.ui.requestPermission({ + permission: `apps-of-user:${whoami.uuid}:${access}`, + }); } /** - * Request write (manage) access to the user's apps. + * Request access to the user's subdomains. `write` covers managing them as well + * as reading them. + * + * @this {PermsModule} + * @param {PermsAccess} [accessLevel] - Defaults to `'read'`. + * @returns {Promise} `true` if the permission was granted. + */ +export async function requestSubdomains (accessLevel = 'read') { + const access = assertAccess(accessLevel); + const whoami = await this.puter.auth.whoami(); + return await this.puter.ui.requestPermission({ + permission: `subdomains-of-user:${whoami.uuid}:${access}`, + }); +} + +// -- Deprecated aliases -- + +/** + * @deprecated Use {@link requestApps} instead. * @this {PermsModule} * @returns {Promise} */ -export async function requestManageApps () { - const whoami = await this.puter.auth.whoami(); - return await this.puter.ui.requestPermission({ permission: `apps-of-user:${whoami.uuid}:write` }); +export function requestReadApps () { + return this.requestApps('read'); } /** - * Request read access to the user's subdomains. + * @deprecated Use {@link requestApps} instead. * @this {PermsModule} * @returns {Promise} */ -export async function requestReadSubdomains () { - const whoami = await this.puter.auth.whoami(); - return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:read` }); +export function requestManageApps () { + return this.requestApps('write'); } /** - * Request write (manage) access to the user's subdomains. + * @deprecated Use {@link requestSubdomains} instead. * @this {PermsModule} * @returns {Promise} */ -export async function requestManageSubdomains () { - const whoami = await this.puter.auth.whoami(); - return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:write` }); +export function requestReadSubdomains () { + return this.requestSubdomains('read'); +} + +/** + * @deprecated Use {@link requestSubdomains} instead. + * @this {PermsModule} + * @returns {Promise} + */ +export function requestManageSubdomains () { + return this.requestSubdomains('write'); } diff --git a/src/puter-js/src/modules/perms/types.js b/src/puter-js/src/modules/perms/types.js index edf6c3b9d..e0eac62b8 100644 --- a/src/puter-js/src/modules/perms/types.js +++ b/src/puter-js/src/modules/perms/types.js @@ -1,5 +1,19 @@ // Shapes shared across the `puter.perms` operations. JSDoc-only; no runtime exports. +/** + * An access level a request can ask for. `write` implies `read`. + * + * @typedef {'read' | 'write'} PermsAccess + */ + +/** + * A special folder `requestFolder` can name. Trash and AppData are deliberately + * absent: nothing should ask for blanket access to either, and another app's + * AppData is reached through `requestAppData` instead. + * + * @typedef {'Desktop' | 'Documents' | 'Pictures' | 'Videos'} PermsFolderName + */ + /** * The stores an `app-data` scope can name. * diff --git a/src/puter-js/test/perms.test.js b/src/puter-js/test/perms.test.js index 3966808b4..a1759e1e8 100644 --- a/src/puter-js/test/perms.test.js +++ b/src/puter-js/test/perms.test.js @@ -18,85 +18,72 @@ window.permsTests = [ } }, { - name: "testRequestReadApps", - description: "[interactive] requestReadApps() resolves to a boolean", + name: "testRequestApps", + description: "[interactive] requestApps() resolves to a boolean", test: async function() { try { - const granted = await puter.perms.requestReadApps(); - assert(typeof granted === 'boolean', "requestReadApps should resolve to a boolean"); - pass("testRequestReadApps passed: " + granted); + const granted = await puter.perms.requestApps(); + assert(typeof granted === 'boolean', "requestApps should resolve to a boolean"); + pass("testRequestApps passed: " + granted); } catch (error) { - fail("testRequestReadApps failed:", error); + fail("testRequestApps failed:", error); } } }, { - name: "testRequestReadDesktop", - description: "[interactive] requestReadDesktop() returns the path or undefined", + name: "testRequestFolderRead", + description: "[interactive] requestFolder('Desktop') returns the path or undefined", test: async function() { try { - const path = await puter.perms.requestReadDesktop(); + const path = await puter.perms.requestFolder('Desktop'); assert(path === undefined || typeof path === 'string', "unexpected path value"); - pass("testRequestReadDesktop passed: " + String(path)); + pass("testRequestFolderRead passed: " + String(path)); } catch (error) { - fail("testRequestReadDesktop failed:", error); + fail("testRequestFolderRead failed:", error); } } }, { - name: "testRequestWriteDesktop", - description: "[interactive] requestWriteDesktop() returns the path or undefined", + name: "testRequestFolderWrite", + description: "[interactive] requestFolder('Desktop', 'write') returns the path or undefined", test: async function() { try { - const path = await puter.perms.requestWriteDesktop(); + const path = await puter.perms.requestFolder('Desktop', 'write'); assert(path === undefined || typeof path === 'string', "unexpected path value"); - pass("testRequestWriteDesktop passed: " + String(path)); + pass("testRequestFolderWrite passed: " + String(path)); } catch (error) { - fail("testRequestWriteDesktop failed:", error); + fail("testRequestFolderWrite failed:", error); } } }, { - name: "testRequestManageSubdomains", - description: "[interactive] requestManageSubdomains() resolves to a boolean", + name: "testRequestSubdomainsWrite", + description: "[interactive] requestSubdomains('write') resolves to a boolean", test: async function() { try { - const granted = await puter.perms.requestManageSubdomains(); + const granted = await puter.perms.requestSubdomains('write'); assert(typeof granted === 'boolean', "should resolve to a boolean"); - pass("testRequestManageSubdomains passed: " + granted); + pass("testRequestSubdomainsWrite passed: " + granted); } catch (error) { - fail("testRequestManageSubdomains failed:", error); + fail("testRequestSubdomainsWrite failed:", error); } } }, { - name: "testListGroups", - description: "listGroups() returns a result without erroring", - test: async function() { - try { - const result = await puter.perms.listGroups(); - assert(result && !result.error, "listGroups should not report an error: " + JSON.stringify(result)); - pass("testListGroups passed"); - } catch (error) { - fail("testListGroups failed:", error); - } - } - }, - { - name: "testRequestWriteAppRootDirAccess", - description: "[interactive] requestWriteAppRootDir(app) actually requests WRITE (bug fix). Set window.__testAppUid to an app uid first.", + name: "testRequestAppRootDirWriteAccess", + description: "[interactive] requestAppRootDir(app, 'write') actually requests WRITE. Set window.__testAppUid to an app uid first.", test: async function() { const appUid = window.__testAppUid; if (!appUid) { - pass("testRequestWriteAppRootDirAccess skipped: set window.__testAppUid to an owned app uid to run"); + pass("testRequestAppRootDirWriteAccess skipped: set window.__testAppUid to an owned app uid to run"); return; } try { - const result = await puter.perms.requestWriteAppRootDir(appUid); + const result = await puter.perms.requestAppRootDir(appUid, 'write'); assert(result === undefined || typeof result === 'object', "unexpected result"); - pass("testRequestWriteAppRootDirAccess passed: " + JSON.stringify(result)); + pass("testRequestAppRootDirWriteAccess passed: " + JSON.stringify(result)); } catch (error) { - fail("testRequestWriteAppRootDirAccess failed:", error); + fail("testRequestAppRootDirWriteAccess failed:", error); } } }, diff --git a/src/puter-js/tests/api/suites/perms.suite.ts b/src/puter-js/tests/api/suites/perms.suite.ts index dc3bb46df..3dddda82c 100644 --- a/src/puter-js/tests/api/suites/perms.suite.ts +++ b/src/puter-js/tests/api/suites/perms.suite.ts @@ -3,132 +3,8 @@ import type { TestContext } from '../harness/types.ts'; const home = (t: TestContext) => `/${t.env.users.user.username}`; -/** Read a file as the `other` user via plain fetch — works on every platform. */ -const readAsOther = (t: TestContext, path: string) => - fetch( - `${t.env.apiOrigin}/read?${new URLSearchParams({ file: path })}`, - { - headers: { - Authorization: `Bearer ${t.env.users.other.token}`, - Origin: t.env.apiOrigin, - }, - }, - ); - export default suite('perms', { - 'grantUser is retired in favour of fs.share': async (t) => { - // Direct user-to-user grants left no share row, so the owner could - // neither see nor revoke them. Cross-user access is covered end to end - // by the sharing suite. - const res = await t.puter.perms.grantUser( - t.env.users.other.username, - `fs:${home(t)}/perms-suite-shared.txt:read`, - ); - t.assert.ok(res.error, 'grantUser should be refused'); - t.assert.equal(res.code, 'not_implemented'); - }, - - 'revokeUser takes a shared permission away': async (t) => { - // Revoking still works: it is how access granted before `grantUser` - // was retired, or through a share, gets taken back at the permission - // layer. - const path = `${home(t)}/perms-suite-revoked.txt`; - await t.puter.fs.write(path, 'soon private again'); - - await t.puter.fs.share({ - path, - recipient: t.env.users.other.username, - mode: 'read', - }); - const whileGranted = await readAsOther(t, path); - t.assert.equal(whileGranted.status, 200); - - const revoked = await t.puter.perms.revokeUser( - t.env.users.other.username, - `fs:${path}:read`, - ); - t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); - - const afterRevoke = await readAsOther(t, path); - t.assert.ok( - afterRevoke.status !== 200, - `read should fail after revoke (got ${afterRevoke.status})`, - ); - }, - - 'createGroup returns a group uid': async (t) => { - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-group', - }); - t.assert.ok(!created.error, `create failed: ${JSON.stringify(created)}`); - t.assert.ok(created.uid, 'created group should have a uid'); - }, - - 'listGroups includes a created group': async (t) => { - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-listed-group', - }); - const groups = await t.puter.perms.listGroups(); - t.assert.ok(!groups.error, `list failed: ${JSON.stringify(groups)}`); - const all = JSON.stringify(groups); - t.assert.ok( - all.includes(created.uid), - 'listGroups should mention the created group uid', - ); - }, - - 'addUsersToGroup and removeUsersFromGroup succeed': async (t) => { - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-membership', - }); - const added = await t.puter.perms.addUsersToGroup(created.uid, [ - t.env.users.other.username, - ]); - t.assert.ok(!added.error, `add failed: ${JSON.stringify(added)}`); - const removed = await t.puter.perms.removeUsersFromGroup(created.uid, [ - t.env.users.other.username, - ]); - t.assert.ok(!removed.error, `remove failed: ${JSON.stringify(removed)}`); - }, - - 'grantGroup lets group members read a file': async (t) => { - const path = `${home(t)}/perms-suite-group-shared.txt`; - await t.puter.fs.write(path, 'group content'); - - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-readers', - }); - await t.puter.perms.addUsersToGroup(created.uid, [ - t.env.users.other.username, - ]); - const granted = await t.puter.perms.grantGroup( - created.uid, - `fs:${path}:read`, - ); - t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); - - const res = await readAsOther(t, path); - t.assert.equal(res.status, 200); - t.assert.equal(await res.text(), 'group content'); - }, - - 'grantGroup then revokeGroup both succeed': async (t) => { - const path = `${home(t)}/perms-suite-group-revoke.txt`; - await t.puter.fs.write(path, 'group revoke content'); - const permission = `fs:${path}:read`; - - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-revoke-readers', - }); - await t.puter.perms.addUsersToGroup(created.uid, [ - t.env.users.other.username, - ]); - const granted = await t.puter.perms.grantGroup(created.uid, permission); - t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); - - const revoked = await t.puter.perms.revokeGroup(created.uid, permission); - t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); - }, + // -- Grant / revoke against an app -- 'grantApp records an app permission': async (t) => { const app = await t.puter.apps.create( @@ -149,6 +25,20 @@ export default suite('perms', { t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); }, + // Uninstalling an app clears every grant it holds in one call. + 'revokeApp with "*" clears every grant for the app': async (t) => { + const app = await t.puter.apps.create( + t.puter.randName(), + 'https://example.com/perms-revoke-all', + ); + const path = `${home(t)}/perms-suite-revoke-all.txt`; + await t.puter.fs.write(path, 'x'); + await t.puter.perms.grantApp(app.uid, `fs:${path}:read`); + + const revoked = await t.puter.perms.revokeApp(app.uid, '*'); + t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); + }, + // A third-party site is identified by origin rather than app uid, which // the backend resolves back to the app registered at that origin. 'grantOrigin and revokeOrigin address the app registered at that origin': async ( @@ -193,64 +83,14 @@ export default suite('perms', { t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); }, - // -- Groups -- - - 'createGroup defaults its metadata when called with no arguments': async (t) => { - const created = await t.puter.perms.createGroup(); - t.assert.ok(!created.error, `create failed: ${JSON.stringify(created)}`); - t.assert.equal(typeof created.uid, 'string'); - - const groups = (await t.puter.perms.listGroups()) as { - owned_groups?: Array<{ uid: string; metadata: unknown; extra: unknown }>; - }; - const mine = groups.owned_groups?.find((g) => g.uid === created.uid); - t.assert.ok(mine, 'the new group should be listed as owned'); - t.assert.deepEqual(mine!.metadata, {}); - t.assert.deepEqual(mine!.extra, {}); - }, - - 'addUsersToGroup with no usernames is accepted as an empty list': async (t) => { - const created = await t.puter.perms.createGroup({ - title: 'perms-suite-empty-membership', - }); - const added = await ( - t.puter.perms.addUsersToGroup as (uid: string) => Promise<{ - error?: unknown; - }> - )(created.uid); - t.assert.ok(!added.error, `add failed: ${JSON.stringify(added)}`); - const removed = await ( - t.puter.perms.removeUsersFromGroup as (uid: string) => Promise<{ - error?: unknown; - }> - )(created.uid); - t.assert.ok(!removed.error, `remove failed: ${JSON.stringify(removed)}`); - }, - - // -- Low-level request helper -- - - 'req_ reports an unknown route as an error result rather than throwing': async ( - t, - ) => { - const result = await ( - t.puter.perms as unknown as { - req_: (route: string) => Promise>; - } - ).req_('/perms-suite/no-such-route'); - t.assert.equal(result.error, true); - t.assert.equal(result.code, 'not_found'); - }, - // -- Special folders -- - 'requesting read access to an already-readable folder returns its path': async ( - t, - ) => { - for (const [folder, request] of [ - ['Desktop', () => t.puter.perms.requestReadDesktop()], - ['Documents', () => t.puter.perms.requestReadDocuments()], - ['Pictures', () => t.puter.perms.requestReadPictures()], - ['Videos', () => t.puter.perms.requestReadVideos()], + 'requestFolder returns the path of an already-readable folder': async (t) => { + for (const folder of [ + 'Desktop', + 'Documents', + 'Pictures', + 'Videos', ] as const) { const expected = `${home(t)}/${folder}`; // Guard first: without read access the helper would fall through @@ -259,20 +99,50 @@ export default suite('perms', { await t.puter.fs.stat({ path: expected }), `${folder} should already exist for the seeded user`, ); - t.assert.equal(await request(), expected); + t.assert.equal(await t.puter.perms.requestFolder(folder), expected); } }, + 'requestFolder rejects a folder it does not cover': async (t) => { + const error = (await t.assert.rejects(() => + ( + t.puter.perms.requestFolder as ( + folder: unknown, + ) => Promise + )('Trash'), + )) as Error & { code?: string }; + t.assert.equal(error.code, 'invalid_argument'); + }, + + 'requestFolder rejects an unknown access level': async (t) => { + const error = (await t.assert.rejects(() => + ( + t.puter.perms.requestFolder as ( + folder: string, + access: unknown, + ) => Promise + )('Desktop', 'delete'), + )) as Error & { code?: string }; + t.assert.equal(error.code, 'invalid_argument'); + }, + // Write access always prompts, even for a folder that is already readable. // Restricted to the runtimes where the prompt resolves without a UI: on // `web` it opens a popup window a headless suite cannot answer. 'requesting write access to a folder is denied without a grant': { platforms: ['node', 'workerd'], fn: async (t) => { - t.assert.equal(await t.puter.perms.requestWriteDesktop(), undefined); - t.assert.equal(await t.puter.perms.requestWriteDocuments(), undefined); - t.assert.equal(await t.puter.perms.requestWritePictures(), undefined); - t.assert.equal(await t.puter.perms.requestWriteVideos(), undefined); + for (const folder of [ + 'Desktop', + 'Documents', + 'Pictures', + 'Videos', + ] as const) { + t.assert.equal( + await t.puter.perms.requestFolder(folder, 'write'), + undefined, + ); + } }, }, @@ -289,17 +159,57 @@ export default suite('perms', { 'app and subdomain access requests are denied without a grant': { platforms: ['node', 'workerd'], fn: async (t) => { - t.assert.equal(await t.puter.perms.requestReadApps(), false); - t.assert.equal(await t.puter.perms.requestManageApps(), false); - t.assert.equal(await t.puter.perms.requestReadSubdomains(), false); - t.assert.equal(await t.puter.perms.requestManageSubdomains(), false); + t.assert.equal(await t.puter.perms.requestApps(), false); + t.assert.equal(await t.puter.perms.requestApps('write'), false); + t.assert.equal(await t.puter.perms.requestSubdomains(), false); + t.assert.equal( + await t.puter.perms.requestSubdomains('write'), + false, + ); t.assert.equal( await t.puter.perms.request(`fs:${home(t)}:write`), false, ); - // The deprecated alias must keep delegating to `request`. + }, + }, + + 'requestApps rejects an unknown access level': async (t) => { + const error = (await t.assert.rejects(() => + ( + t.puter.perms.requestApps as ( + access: unknown, + ) => Promise + )('manage'), + )) as Error & { code?: string }; + t.assert.equal(error.code, 'invalid_argument'); + }, + + // The one-method-per-task names still ship for apps written against them. + 'the deprecated request aliases keep delegating': { + platforms: ['node', 'workerd'], + fn: async (t) => { + const perms = t.puter.perms as unknown as Record< + string, + () => Promise + >; + t.assert.equal(await perms.requestReadApps(), false); + t.assert.equal(await perms.requestManageApps(), false); + t.assert.equal(await perms.requestReadSubdomains(), false); + t.assert.equal(await perms.requestManageSubdomains(), false); + t.assert.equal(await perms.requestWriteDesktop(), undefined); + t.assert.equal(await perms.requestWriteDocuments(), undefined); + t.assert.equal(await perms.requestWritePictures(), undefined); + t.assert.equal(await perms.requestWriteVideos(), undefined); t.assert.equal( - await t.puter.perms.requestPermission(`fs:${home(t)}:write`), + await perms.requestReadDesktop(), + `${home(t)}/Desktop`, + ); + t.assert.equal( + await ( + t.puter.perms as unknown as { + requestPermission: (p: string) => Promise; + } + ).requestPermission(`fs:${home(t)}:write`), false, ); }, @@ -307,10 +217,10 @@ export default suite('perms', { // -- App root directory -- - 'requestReadAppRootDir rejects an app uid that is not a string': async (t) => { + 'requestAppRootDir rejects an app uid that is not a string': async (t) => { const error = (await t.assert.rejects(() => ( - t.puter.perms.requestReadAppRootDir as ( + t.puter.perms.requestAppRootDir as ( appUid: unknown, ) => Promise )(42), @@ -329,10 +239,10 @@ export default suite('perms', { ); }, - 'requestWriteAppRootDir rejects an object without a uid': async (t) => { + 'requestAppRootDir rejects an object without a uid': async (t) => { const error = (await t.assert.rejects(() => ( - t.puter.perms.requestWriteAppRootDir as ( + t.puter.perms.requestAppRootDir as ( appUid: unknown, ) => Promise )({}), @@ -343,7 +253,7 @@ export default suite('perms', { // Only the app itself may claim its root dir, so a user-token caller is // refused and then offered the permission prompt. Restricted to the // runtimes where that prompt resolves without a window. - 'requestReadAppRootDir resolves undefined when the grant is refused': { + 'requestAppRootDir resolves undefined when the grant is refused': { platforms: ['node', 'workerd'], fn: async (t) => { const app = await t.puter.apps.create( @@ -351,11 +261,14 @@ export default suite('perms', { 'https://example.com/perms-root-dir', ); t.assert.equal( - await t.puter.perms.requestReadAppRootDir(app.uid), + await t.puter.perms.requestAppRootDir(app.uid), undefined, ); t.assert.equal( - await t.puter.perms.requestWriteAppRootDir({ uid: app.uid }), + await t.puter.perms.requestAppRootDir( + { uid: app.uid }, + 'write', + ), undefined, ); },