mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
refactor(puter-js): collapse puter.perms request* to resource + access
Fifteen request methods differed only by a folder name or an access level, so every new resource meant another method. Replace them with requestFolder, requestApps, requestSubdomains and requestAppRootDir, each taking the access level as an argument. The old names stay as @deprecated aliases: puter.js ships unpinned from js.puter.com/v2, so removing them would break live apps. They remain in the generated declarations because stripInternal has no effect on declarations emitted from JavaScript, and hand-omitting them would break TypeScript callers the runtime still serves. Also drops the user-to-user and user-to-group grant wrappers (groups.js and the grantUser/grantGroup half of grants.js) plus the req_ shim, none of which were documented or called. The app, origin and dev-app grants stay: the dashboard uses puter.perms.revokeApp() to clear grants on app uninstall.
This commit is contained in:
Vendored
+2
@@ -172,6 +172,8 @@ export type {
|
||||
AppDataScopePair,
|
||||
AppDataScopes,
|
||||
AppDataStore,
|
||||
PermsAccess,
|
||||
PermsFolderName,
|
||||
} from './types/modules/perms/types.js';
|
||||
|
||||
// -- puter.ui --
|
||||
|
||||
@@ -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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | undefined>} The directory fs item, or `undefined` if denied.
|
||||
* @param {string | { uid: string }} appUid
|
||||
* @returns {Promise<Record<string, unknown> | undefined>}
|
||||
*/
|
||||
export async function requestWriteAppRootDir (appUid) {
|
||||
return await requestAppRootDir(this.puter, 'write', appUid);
|
||||
export function requestWriteAppRootDir (appUid) {
|
||||
return this.requestAppRootDir(appUid, 'write');
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | undefined>}
|
||||
*/
|
||||
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<string | undefined>} */
|
||||
/**
|
||||
* Requests access to one of the user's special folders, returning its path if
|
||||
* access is (or becomes) granted. Read access is inferred from being able to
|
||||
* stat the folder; write access always prompts if not already held.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @param {PermsFolderName} folderName - Desktop, Documents, Pictures, or Videos.
|
||||
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
|
||||
* @returns {Promise<string | undefined>} The folder path, or `undefined` if denied.
|
||||
*/
|
||||
export async function requestFolder (folderName, accessLevel = 'read') {
|
||||
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<string | undefined>}
|
||||
*/
|
||||
export function requestFolder_ (folderName, accessLevel) {
|
||||
return requestFolderPath(this.puter, folderName, accessLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestReadDesktop () {
|
||||
return this.requestFolder_('Desktop', 'read');
|
||||
return this.requestFolder('Desktop', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestWriteDesktop () {
|
||||
return this.requestFolder_('Desktop', 'write');
|
||||
return this.requestFolder('Desktop', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestReadDocuments () {
|
||||
return this.requestFolder_('Documents', 'read');
|
||||
return this.requestFolder('Documents', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestWriteDocuments () {
|
||||
return this.requestFolder_('Documents', 'write');
|
||||
return this.requestFolder('Documents', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestReadPictures () {
|
||||
return this.requestFolder_('Pictures', 'read');
|
||||
return this.requestFolder('Pictures', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestWritePictures () {
|
||||
return this.requestFolder_('Pictures', 'write');
|
||||
return this.requestFolder('Pictures', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestReadVideos () {
|
||||
return this.requestFolder_('Videos', 'read');
|
||||
return this.requestFolder('Videos', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
/**
|
||||
* @deprecated Use {@link requestFolder} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export function requestWriteVideos () {
|
||||
return this.requestFolder_('Videos', 'write');
|
||||
return this.requestFolder('Videos', 'write');
|
||||
}
|
||||
|
||||
@@ -3,34 +3,11 @@ import { req } from './lib/req.js';
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
/** @typedef {Promise<Record<string, unknown>>} 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
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { req } from './lib/req.js';
|
||||
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
/** @typedef {Promise<Record<string, unknown>>} PermResult */
|
||||
|
||||
/**
|
||||
* Creates a new group.
|
||||
* @this {PermsModule}
|
||||
* @param {Record<string, unknown>} [metadata]
|
||||
* @param {Record<string, unknown>} [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');
|
||||
}
|
||||
@@ -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<string, unknown>} [body]
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
req_ (route, body) {
|
||||
return req(this.puter, route, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<boolean>}
|
||||
* @param {PermsAccess} [accessLevel] - Defaults to `'read'`.
|
||||
* @returns {Promise<boolean>} `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<boolean>} `true` if the permission was granted.
|
||||
*/
|
||||
export async function requestSubdomains (accessLevel = 'read') {
|
||||
const access = assertAccess(accessLevel);
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
return await this.puter.ui.requestPermission({
|
||||
permission: `subdomains-of-user:${whoami.uuid}:${access}`,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Deprecated aliases --
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link requestApps} instead.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
export function requestManageSubdomains () {
|
||||
return this.requestSubdomains('write');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
}
|
||||
).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<unknown>
|
||||
)('Trash'),
|
||||
)) as Error & { code?: string };
|
||||
t.assert.equal(error.code, 'invalid_argument');
|
||||
},
|
||||
|
||||
'requestFolder rejects an unknown access level': async (t) => {
|
||||
const error = (await t.assert.rejects(() =>
|
||||
(
|
||||
t.puter.perms.requestFolder as (
|
||||
folder: string,
|
||||
access: unknown,
|
||||
) => Promise<unknown>
|
||||
)('Desktop', 'delete'),
|
||||
)) as Error & { code?: string };
|
||||
t.assert.equal(error.code, 'invalid_argument');
|
||||
},
|
||||
|
||||
// Write access always prompts, even for a folder that is already readable.
|
||||
// Restricted to the runtimes where the prompt resolves without a UI: on
|
||||
// `web` it opens a popup window a headless suite cannot answer.
|
||||
'requesting write access to a folder is denied without a grant': {
|
||||
platforms: ['node', 'workerd'],
|
||||
fn: async (t) => {
|
||||
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<unknown>
|
||||
)('manage'),
|
||||
)) as Error & { code?: string };
|
||||
t.assert.equal(error.code, 'invalid_argument');
|
||||
},
|
||||
|
||||
// The one-method-per-task names still ship for apps written against them.
|
||||
'the deprecated request aliases keep delegating': {
|
||||
platforms: ['node', 'workerd'],
|
||||
fn: async (t) => {
|
||||
const perms = t.puter.perms as unknown as Record<
|
||||
string,
|
||||
() => Promise<unknown>
|
||||
>;
|
||||
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<boolean>;
|
||||
}
|
||||
).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<unknown>
|
||||
)(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<unknown>
|
||||
)({}),
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user