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