From 6e6fddf629f3d80bcf068ca86eb5d32cad79f882 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 12 Aug 2026 19:27:29 -0400 Subject: [PATCH] feat(puter.js): add file sharing to puter.fs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit share(), unshare(), listShared() and getShares() on puter.fs, following the existing FS operation shape: positional and options-object forms through defineOperation, JSDoc overloads as the published signature, relative paths resolved against the app's root directory. A bare recipient string is read as an email when it contains @ and as a username otherwise. Sharing an item with someone who already has it replaces their access rather than stacking a second grant, so raising read to write is one more call. Adds a sharing suite to the API runner, which passes unchanged on node, browser and workerd. Documents all four methods with runnable examples, and corrects the FS overview callout that told readers one user cannot read another's files — true before this, not after. --- src/docs/src/FS.md | 6 +- src/docs/src/FS/getShares.md | 72 ++++++++++ src/docs/src/FS/listShared.md | 85 +++++++++++ src/docs/src/FS/share.md | 109 ++++++++++++++ src/docs/src/FS/unshare.md | 79 +++++++++++ src/docs/src/sidebar.js | 32 +++++ src/puter-js/index.d.ts | 8 ++ src/puter-js/src/modules/FileSystem/index.js | 10 ++ .../FileSystem/operations/getShares.js | 42 ++++++ .../FileSystem/operations/listShared.js | 44 ++++++ .../modules/FileSystem/operations/share.js | 60 ++++++++ .../FileSystem/operations/shareUtil.js | 68 +++++++++ .../modules/FileSystem/operations/unshare.js | 46 ++++++ src/puter-js/src/modules/FileSystem/types.js | 87 ++++++++++++ src/puter-js/tests/api/suites/index.ts | 2 + .../tests/api/suites/sharing.suite.ts | 134 ++++++++++++++++++ 16 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 src/docs/src/FS/getShares.md create mode 100644 src/docs/src/FS/listShared.md create mode 100644 src/docs/src/FS/share.md create mode 100644 src/docs/src/FS/unshare.md create mode 100644 src/puter-js/src/modules/FileSystem/operations/getShares.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/listShared.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/share.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/shareUtil.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/unshare.js create mode 100644 src/puter-js/tests/api/suites/sharing.suite.ts diff --git a/src/docs/src/FS.md b/src/docs/src/FS.md index 68d505bb3..031b875fb 100644 --- a/src/docs/src/FS.md +++ b/src/docs/src/FS.md @@ -9,7 +9,7 @@ It comes with a comprehensive but familiar file system operations including writ With Puter.js, you don't need to worry about setting up storage infrastructure such as configuring buckets, managing CDNs, or ensuring availability, since everything is handled for you. Additionally, with the [User-Pays Model](/user-pays-model/), you don't have to worry about storage or bandwidth costs, as users of your application cover their own usage. -
Need to share data across users? Each user's files live in their own account, so one user can't read another's data. To keep centralized files that every user reads from and writes to, use a Serverless Worker — its code can act on the worker owner's resources, giving all users one shared backend.
+
Need to share data across users? Each user's files live in their own account, so one user can't read another's by default. To hand specific items to specific people, use puter.fs.share(). To keep centralized files that every user reads from and writes to, use a Serverless Worker — its code can act on the worker owner's resources, giving all users one shared backend.
## Features @@ -307,6 +307,10 @@ These cloud storage features are supported out of the box when using Puter.js: - **[`puter.fs.delete()`](/FS/delete/)** - Delete a file or directory - **[`puter.fs.upload()`](/FS/upload/)** - Upload a file from the local system - **[`puter.fs.getReadURL()`](/FS/getReadURL/)** - Generate a URL that can be used to read a file +- **[`puter.fs.share()`](/FS/share/)** - Give another user access to a file or directory +- **[`puter.fs.unshare()`](/FS/unshare/)** - Withdraw a user's access +- **[`puter.fs.listShared()`](/FS/listShared/)** - List what others have shared with you +- **[`puter.fs.getShares()`](/FS/getShares/)** - List who has access to an item ## Examples diff --git a/src/docs/src/FS/getShares.md b/src/docs/src/FS/getShares.md new file mode 100644 index 000000000..2780c25ce --- /dev/null +++ b/src/docs/src/FS/getShares.md @@ -0,0 +1,72 @@ +--- +title: puter.fs.getShares() +description: List who has access to a shared file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method lists who can reach a file or directory you own, or one you have `manage` access to. + +## Syntax + +```js +puter.fs.getShares(path) +puter.fs.getShares(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory. If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - The item. Required when passing options as the only argument. +- `uid` (String) - The item, by UID. Can be used instead of `path`. + +## Return value + +A `Promise` that resolves to an array of share objects, each with `uid`, `mode`, `path`, `entryUid`, `isDir`, `issuer` and `holder`. + +The list includes shares granted by **anyone** holding `manage` on the item, not only your own. That is how an owner sees what someone they trusted has re-shared. + +If you cannot see the item at all, this rejects the same way a missing file would — it will not confirm that the item exists. + +## Examples + +See who can reach a file + +```html;fs-getShares + + + + + + +``` + +Withdraw everyone's access + +```js +const shares = await puter.fs.getShares('report.txt'); +for (const share of shares) { + await puter.fs.unshare('report.txt', share.holder); +} +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access diff --git a/src/docs/src/FS/listShared.md b/src/docs/src/FS/listShared.md new file mode 100644 index 000000000..3e4e0b0d3 --- /dev/null +++ b/src/docs/src/FS/listShared.md @@ -0,0 +1,85 @@ +--- +title: puter.fs.listShared() +description: List the files and directories other users have shared with you. +platforms: [websites, apps, nodejs, workers] +--- + +This method lists what other Puter users have shared with you, a page at a time. + +## Syntax + +```js +puter.fs.listShared() +puter.fs.listShared(options) +``` + +## Parameters + +#### `options` (Object) (optional) + +An object with the following properties: + +- `limit` (Number) - Maximum shares per page. +- `cursor` (String) - Continuation token from a previous page. +- `includeTotal` (Boolean) - Include the total count in the response. Defaults to `false`. + +## Return value + +A `Promise` that resolves to an object with: + +- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `issuer` and `holder`. +- `cursor` (String) - Pass to the next call to get the following page. **Present only while more pages remain.** +- `total` (Number) - Present only when `includeTotal` was set. + +Iterate until `cursor` is absent rather than comparing `items.length` to `limit`. A page can come back short — items you can no longer see are filtered out after the page is read — while more pages still remain. + +Items shared with you appear at their real path, under the owner's directory. Your own items are never listed here. + +## Examples + +List everything shared with you + +```html;fs-listShared + + + + + + +``` + +Page through every share + +```js +let cursor; +const all = []; +do { + const page = await puter.fs.listShared({ limit: 50, cursor }); + all.push(...page.items); + cursor = page.cursor; +} while (cursor); +``` + +Open a file someone shared with you + +```js +const page = await puter.fs.listShared(); +const shared = page.items.find((item) => !item.isDir); +if (shared) { + const blob = await puter.fs.read(shared.path); + puter.print(await blob.text()); +} +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item you manage diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md new file mode 100644 index 000000000..c8fc57a5a --- /dev/null +++ b/src/docs/src/FS/share.md @@ -0,0 +1,109 @@ +--- +title: puter.fs.share() +description: Give another Puter user access to a file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method gives another Puter user access to a file or directory you own, or one you have been given `manage` access to. + +## Syntax + +```js +puter.fs.share(path, recipient) +puter.fs.share(path, recipient, mode) +puter.fs.share(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory to share. +If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `recipient` (String | Object | Array) (required) + +Who to share with. A string containing `@` is treated as an email address, and any other string as a username. You can also pass `{ email }` or `{ username }`, or an array to share with several people at once. + +#### `mode` (String) (optional) + +How much access to grant. Defaults to `'read'`. + +- `'read'` - Read the item. +- `'write'` - Read and change the item. Does **not** allow re-sharing it. +- `'manage'` - Re-share the item with other people. Does **not** by itself allow writing. +- `'list'`, `'see'` - Weaker than `read`; useful for making an item discoverable without exposing its contents. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - Item to share. Required when passing options as the only argument. +- `uid` (String) - Item to share, by UID. Can be used instead of `path`. +- `paths` (Array) - Several items to share in one call. +- `recipient` (String | Object | Array) - Who to share with. +- `mode` (String) - Access to grant. Defaults to `'read'`. + +## Return value + +A `Promise` that resolves to an array of share objects, one per recipient/item pair that succeeded. Each has: + +- `uid` (String) - Identifier for this share. +- `mode` (String) - Access the recipient now has. +- `path` (String) - Path of the shared item. +- `entryUid` (String) - UID of the shared item. +- `isDir` (Boolean) - Whether the shared item is a directory. +- `issuer` (String) - Username of whoever granted the share. +- `holder` (String) - Username of whoever received it. + +Sharing the same item with the same person again **replaces** their access rather than adding a second share, so raising someone from `read` to `write` is just another call. + +If some recipients succeed and others fail, the promise resolves with the ones that worked. It rejects only when every pair failed. + +## Examples + +Share a file with another user + +```html;fs-share + + + + + + +``` + +Let someone edit, and let someone else re-share + +```js +// An editor can change the file but cannot pass it on. +await puter.fs.share('report.txt', 'editor@example.com', 'write'); + +// A manager can share it with other people. +await puter.fs.share('report.txt', 'manager@example.com', 'manage'); +``` + +Share one item with several people + +```js +await puter.fs.share({ + path: 'report.txt', + recipient: ['a@example.com', 'b@example.com'], + mode: 'read', +}); +``` + +## Related + +- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item +- [`puter.fs.listShared()`](/FS/listShared/) - See what others have shared with you diff --git a/src/docs/src/FS/unshare.md b/src/docs/src/FS/unshare.md new file mode 100644 index 000000000..44337fc6b --- /dev/null +++ b/src/docs/src/FS/unshare.md @@ -0,0 +1,79 @@ +--- +title: puter.fs.unshare() +description: Withdraw a user's access to a shared file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method withdraws a user's access to a file or directory. + +## Syntax + +```js +puter.fs.unshare(path, recipient) +puter.fs.unshare(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory. If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `recipient` (String | Object) (required) + +Whose access to withdraw. A string containing `@` is treated as an email address, and any other string as a username. + +Pass **yourself** to leave a share someone else gave you. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - The item. Required when passing options as the only argument. +- `uid` (String) - The item, by UID. Can be used instead of `path`. +- `recipient` (String | Object) - Whose access to withdraw. + +## Return value + +A `Promise` that resolves to `{ revoked }`, where `revoked` is how many grants were actually removed. It is `0` when there was nothing to withdraw, which is not an error. + +## Who can withdraw what + +- The item's **owner** can withdraw any share of it, whoever granted it. +- Anyone else can withdraw the shares **they** granted. +- **Anyone** can withdraw their own access, whoever granted it. + +An item's owner cannot be removed from their own item. + +## Examples + +Stop sharing a file + +```html;fs-unshare + + + + + + +``` + +Leave a share someone gave you + +```js +const me = await puter.auth.getUser(); +await puter.fs.unshare('/alice/report.txt', me.username); +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 19e788bca..b25bbe422 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -356,6 +356,38 @@ let sidebar = [ source: '/FS/upload.md', path: '/FS/upload', }, + { + title: 'share()', + page_title: 'puter.fs.share()', + title_tag: 'puter.fs.share()', + icon: '/assets/img/function.svg', + source: '/FS/share.md', + path: '/FS/share', + }, + { + title: 'unshare()', + page_title: 'puter.fs.unshare()', + title_tag: 'puter.fs.unshare()', + icon: '/assets/img/function.svg', + source: '/FS/unshare.md', + path: '/FS/unshare', + }, + { + title: 'listShared()', + page_title: 'puter.fs.listShared()', + title_tag: 'puter.fs.listShared()', + icon: '/assets/img/function.svg', + source: '/FS/listShared.md', + path: '/FS/listShared', + }, + { + title: 'getShares()', + page_title: 'puter.fs.getShares()', + title_tag: 'puter.fs.getShares()', + icon: '/assets/img/function.svg', + source: '/FS/getShares.md', + path: '/FS/getShares', + }, ], }, { diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 504e7726e..3cee3cafa 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -96,14 +96,22 @@ export type { export type { CopyOptions, DeleteOptions, + GetSharesOptions, + ListSharedOptions, MkdirOptions, MoveOptions, ReadOptions, ReaddirOptions, RenameOptions, + Share, + ShareMode, + ShareOptions, + SharePage, + ShareRecipient, SignResult, SpaceInfo, StatOptions, + UnshareOptions, UploadBatchError, UploadItems, UploadOperationResult, diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index 459781592..278e551f6 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -13,6 +13,8 @@ import FSItem from '../FSItem.js'; import copy from './operations/copy.js'; import deleteFSEntry from './operations/deleteFSEntry.js'; import getReadURL from './operations/getReadUrl.js'; +import getShares from './operations/getShares.js'; +import listShared from './operations/listShared.js'; import mkdir from './operations/mkdir.js'; import move from './operations/move.js'; import read from './operations/read.js'; @@ -20,9 +22,11 @@ import readdir from './operations/readdir.js'; import readdirSubdomains from './operations/readdirSubdomains.js'; import rename from './operations/rename.js'; import revokeReadURL from './operations/revokeReadUrl.js'; +import share from './operations/share.js'; import sign from './operations/sign.js'; import space from './operations/space.js'; import stat from './operations/stat.js'; +import unshare from './operations/unshare.js'; import upload from './operations/upload/index.js'; import write from './operations/write.js'; @@ -55,6 +59,12 @@ export class PuterJSFileSystemModule extends PuterModule { readdirSubdomains = readdirSubdomains; stat = stat; + // Sharing + share = share; + unshare = unshare; + listShared = listShared; + getShares = getShares; + FSItem = FSItem; /** diff --git a/src/puter-js/src/modules/FileSystem/operations/getShares.js b/src/puter-js/src/modules/FileSystem/operations/getShares.js new file mode 100644 index 000000000..7f72cdc92 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/getShares.js @@ -0,0 +1,42 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation } from './scaffold.js'; +import { toShare } from './shareUtil.js'; + +/** @typedef {import('../types.js').GetSharesOptions} GetSharesOptions */ +/** @typedef {import('../types.js').Share} Share */ + +/** + * Lists who can reach a file or directory you can manage. + * + * Includes shares granted by anyone holding `manage` on the item, not only + * your own — which is how an owner sees what a delegate has re-shared. + * + * @type {{ + * (options: GetSharesOptions): Promise, + * ( + * path: string, + * success?: (value: Share[]) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const getShares = defineOperation({ + positional: ['path'], + request (options) { + const query = new URLSearchParams(); + if ( options.uid !== undefined ) { + query.set('uid', String(options.uid)); + } else { + query.set('path', getAbsolutePathForApp(String(options.path))); + } + + return { + endpoint: `/share/shares?${query.toString()}`, + method: 'get', + transform: (/** @type {{ items?: Record[] }} */ response) => + (response.items ?? []).map(toShare), + }; + }, +}); + +export default getShares; diff --git a/src/puter-js/src/modules/FileSystem/operations/listShared.js b/src/puter-js/src/modules/FileSystem/operations/listShared.js new file mode 100644 index 000000000..51bf2d5f3 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/listShared.js @@ -0,0 +1,44 @@ +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShare } from './shareUtil.js'; + +/** @typedef {import('../types.js').ListSharedOptions} ListSharedOptions */ +/** @typedef {import('../types.js').SharePage} SharePage */ + +/** + * Lists what other users have shared with you, a page at a time. + * + * `cursor` comes back only while more pages remain, so iterate until it is + * absent rather than comparing `items.length` to `limit` — a page can be short + * once items the caller can no longer see are filtered out. + * + * @type {{ + * (options?: ListSharedOptions): Promise, + * ( + * success?: (value: SharePage) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const listShared = defineOperation({ + request (options) { + const query = new URLSearchParams(); + if ( options.limit !== undefined ) query.set('limit', String(options.limit)); + if ( options.cursor !== undefined ) query.set('cursor', String(options.cursor)); + if ( firstDefined(options, 'includeTotal', 'include_total') ) { + query.set('includeTotal', 'true'); + } + const suffix = query.toString(); + + return { + endpoint: `/share/shared-with-me${suffix ? `?${suffix}` : ''}`, + method: 'get', + transform: (/** @type {{ items?: Record[], cursor?: string, total?: number }} */ response) => ({ + items: (response.items ?? []).map(toShare), + ...(response.cursor === undefined ? {} : { cursor: response.cursor }), + ...(response.total === undefined ? {} : { total: response.total }), + }), + }; + }, +}); + +export default listShared; diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js new file mode 100644 index 000000000..ea3b42f1a --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/share.js @@ -0,0 +1,60 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShare, toShareItems, toShareRecipients } from './shareUtil.js'; + +/** @typedef {import('../types.js').ShareOptions} ShareOptions */ +/** @typedef {import('../types.js').ShareMode} ShareMode */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ +/** @typedef {import('../types.js').Share} Share */ + +/** + * Gives another Puter user access to a file or directory. Relative paths + * resolve against the app's root directory. + * + * Resolves with one {@link Share} per recipient/item pair that succeeded. A + * pair that fails — an unknown recipient, say — does not fail the others; its + * error is reported on the rejected pair only when every pair failed. + * + * @type {{ + * (options: ShareOptions): Promise, + * ( + * path: string, + * recipient: ShareRecipient | ShareRecipient[], + * mode?: ShareMode, + * success?: (value: Share[]) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const share = defineOperation({ + positional: ['path', 'recipient', 'mode'], + request (options) { + const recipients = toShareRecipients( + firstDefined(options, 'recipient', 'recipients'), + ); + const items = toShareItems(options, (path) => getAbsolutePathForApp(path)); + + return { + endpoint: '/share', + body: { + recipients, + items, + mode: options.mode ?? 'read', + }, + transform: (/** @type {{ status: string, results: Record[] }} */ response) => { + const results = response.results ?? []; + const ok = results.filter((r) => r.status === 'success'); + if ( ok.length === 0 && results.length > 0 ) { + const first = results[0]; + throw { + message: String(first.message ?? 'Share failed'), + code: String(first.code ?? 'share_failed'), + }; + } + return ok.map(toShare); + }, + }; + }, +}); + +export default share; diff --git a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js new file mode 100644 index 000000000..f2b8735a7 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js @@ -0,0 +1,68 @@ +// Shared helpers for the sharing operations. + +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; + +/** @typedef {import('../types.js').Share} Share */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ + +/** + * Normalizes recipients into the wire form. A bare string is read as an email + * when it contains `@`, and as a username otherwise. + * + * @param {unknown} value + * @returns {Array<{ email?: string, username?: string }>} + */ +export const toShareRecipients = (value) => { + const list = Array.isArray(value) ? value : [value]; + return list + .filter((entry) => entry !== undefined && entry !== null) + .map((entry) => { + if ( typeof entry === 'string' ) { + const trimmed = entry.trim(); + return trimmed.includes('@') + ? { email: trimmed } + : { username: trimmed }; + } + const record = /** @type {Record} */ (entry); + return { + ...(record.email ? { email: String(record.email) } : {}), + ...(record.username ? { username: String(record.username) } : {}), + }; + }); +}; + +/** + * Collects whichever of `path`, `paths` or `uid` the caller supplied into the + * wire form. Paths are made absolute; UIDs are passed through. + * + * @param {Record} options + * @param {(path: string) => string} [resolvePath] + * @returns {Array<{ path?: string, uid?: string }>} + */ +export const toShareItems = (options, resolvePath = getAbsolutePathForApp) => { + if ( options.uid !== undefined ) { + const uids = Array.isArray(options.uid) ? options.uid : [options.uid]; + return uids.map((uid) => ({ uid: String(uid) })); + } + const raw = options.paths !== undefined ? options.paths : options.path; + const paths = Array.isArray(raw) ? raw : [raw]; + return paths + .filter((path) => path !== undefined && path !== null) + .map((path) => ({ path: resolvePath(String(path)) })); +}; + +/** + * Turns one wire share into the shape the SDK publishes. + * + * @param {Record} row + * @returns {Share} + */ +export const toShare = (row) => ({ + uid: /** @type {string} */ (row.uid), + mode: /** @type {Share['mode']} */ (row.mode), + path: /** @type {string} */ (row.path), + entryUid: /** @type {string} */ (row.uid_entry ?? row.entryUid), + isDir: Boolean(row.is_dir ?? row.isDir), + issuer: /** @type {string | null} */ (row.issuer ?? null), + holder: /** @type {string | null} */ (row.holder ?? null), +}); diff --git a/src/puter-js/src/modules/FileSystem/operations/unshare.js b/src/puter-js/src/modules/FileSystem/operations/unshare.js new file mode 100644 index 000000000..73db094b3 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/unshare.js @@ -0,0 +1,46 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShareItems, toShareRecipients } from './shareUtil.js'; + +/** @typedef {import('../types.js').UnshareOptions} UnshareOptions */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ + +/** + * Withdraws a user's access to a file or directory. + * + * The item's owner can withdraw any share of it, whoever granted it. Anyone + * else can withdraw the shares they granted, or their own access — pass + * yourself as the recipient to leave a share someone else gave you. + * + * Resolves with the number of grants actually removed, which is `0` when there + * was nothing to withdraw. + * + * @type {{ + * (options: UnshareOptions): Promise<{ revoked: number }>, + * ( + * path: string, + * recipient: ShareRecipient, + * success?: (value: { revoked: number }) => void, + * error?: (reason: unknown) => void, + * ): Promise<{ revoked: number }>, + * }} + */ +const unshare = defineOperation({ + positional: ['path', 'recipient'], + request (options) { + return { + endpoint: '/share/revoke', + body: { + recipients: toShareRecipients( + firstDefined(options, 'recipient', 'recipients'), + ), + items: toShareItems(options, (path) => getAbsolutePathForApp(path)), + }, + transform: (/** @type {{ revoked?: number }} */ response) => ({ + revoked: Number(response.revoked ?? 0), + }), + }; + }, +}); + +export default unshare; diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index 4c1324bf7..1e86b565f 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -270,4 +270,91 @@ * | unknown[]} UploadItems */ +/** + * How much access a share grants. Stronger modes imply the weaker ones, so + * `write` also allows reading. `manage` is separate: it lets the recipient + * re-share the item, and does not by itself allow writing. + * + * @typedef {'see' | 'list' | 'read' | 'write' | 'manage'} ShareMode + */ + +/** + * Who a share is for. Give an `email` or a `username`; a bare string is read + * as an email when it contains `@` and a username otherwise. + * + * @typedef {string | { email?: string, username?: string }} ShareRecipient + */ + +/** + * One live share. + * + * @typedef {Object} Share + * @property {string} uid Identifier for this share. + * @property {ShareMode} mode Access the recipient has. + * @property {string} path Path of the shared item. + * @property {string} entryUid UID of the shared item. + * @property {boolean} isDir Whether the shared item is a directory. + * @property {string | null} issuer Username of whoever granted it. + * @property {string | null} holder Username of whoever received it. + */ + +/** + * @typedef {Object} ShareOptionsOwn + * @property {string} [path] Item to share. Relative paths resolve against the + * app's root directory. + * @property {string} [uid] Item to share, by UID. Use instead of `path`. + * @property {string[]} [paths] Several items to share in one call. + * @property {ShareRecipient | ShareRecipient[]} [recipient] Who to share with. + * @property {ShareRecipient | ShareRecipient[]} [recipients] Alias for + * `recipient`. + * @property {ShareMode} [mode] Access to grant. Defaults to `'read'`. + */ + +/** + * @typedef {ShareOptionsOwn & RequestCallbacks} ShareOptions + */ + +/** + * @typedef {Object} UnshareOptionsOwn + * @property {string} [path] Item to stop sharing. + * @property {string} [uid] Item to stop sharing, by UID. + * @property {ShareRecipient} [recipient] Who to withdraw access from. Pass + * yourself to leave a share someone else granted you. + */ + +/** + * @typedef {UnshareOptionsOwn & RequestCallbacks<{ revoked: number }>} UnshareOptions + */ + +/** + * @typedef {Object} ListSharedOptionsOwn + * @property {number} [limit] Maximum shares per page. + * @property {string} [cursor] Continuation token from a previous page. + * @property {boolean} [includeTotal] Include the total count in the response. + */ + +/** + * @typedef {ListSharedOptionsOwn & RequestCallbacks} ListSharedOptions + */ + +/** + * A page of shares. `cursor` is present only while more pages remain, so + * iterate until it is absent rather than counting items. + * + * @typedef {Object} SharePage + * @property {Share[]} items + * @property {string} [cursor] + * @property {number} [total] + */ + +/** + * @typedef {Object} GetSharesOptionsOwn + * @property {string} [path] Item to inspect. + * @property {string} [uid] Item to inspect, by UID. + */ + +/** + * @typedef {GetSharesOptionsOwn & RequestCallbacks} GetSharesOptions + */ + export {}; diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index e3312b902..5eda206c2 100644 --- a/src/puter-js/tests/api/suites/index.ts +++ b/src/puter-js/tests/api/suites/index.ts @@ -9,6 +9,7 @@ import kv from './kv.suite.ts'; import net from './net.suite.ts'; import os from './os.suite.ts'; import perms from './perms.suite.ts'; +import sharing from './sharing.suite.ts'; import system from './system.suite.ts'; import util from './util.suite.ts'; import workers from './workers.suite.ts'; @@ -28,6 +29,7 @@ export const suites: Suite[] = [ net, os, perms, + sharing, system, util, workers, diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts new file mode 100644 index 000000000..e47f1a803 --- /dev/null +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -0,0 +1,134 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +const home = (t: TestContext) => `/${t.env.users.user.username}`; + +/** A unique path under the acting user's home. */ +const scratch = (t: TestContext, label: string) => + `${home(t)}/sharing-${label}-${Math.random().toString(36).slice(2, 8)}.txt`; + +/** Read a file as the `other` user — plain fetch, so it works everywhere. */ +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('sharing', { + 'share gives another user access, unshare takes it back': async (t) => { + const path = scratch(t, 'roundtrip'); + await t.puter.fs.write(path, 'shared content'); + + const before = await readAsOther(t, path); + t.assert.ok( + before.status !== 200, + `should not read before sharing (got ${before.status})`, + ); + + const shares = await t.puter.fs.share( + path, + t.env.users.other.username, + 'read', + ); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].mode, 'read'); + t.assert.equal(shares[0].holder, t.env.users.other.username); + + const after = await readAsOther(t, path); + t.assert.equal(after.status, 200); + t.assert.equal(await after.text(), 'shared content'); + + const revoked = await t.puter.fs.unshare( + path, + t.env.users.other.username, + ); + t.assert.equal(revoked.revoked, 1); + + const afterRevoke = await readAsOther(t, path); + t.assert.ok( + afterRevoke.status !== 200, + `read should fail after unshare (got ${afterRevoke.status})`, + ); + }, + + 'share accepts an options object and defaults to read': async (t) => { + const path = scratch(t, 'options'); + await t.puter.fs.write(path, 'x'); + + const shares = await t.puter.fs.share({ + path, + recipient: { username: t.env.users.other.username }, + }); + t.assert.equal(shares[0].mode, 'read'); + t.assert.equal(shares[0].path, path); + }, + + 'getShares reports who can reach an item': async (t) => { + const path = scratch(t, 'getshares'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'write'); + + const shares = await t.puter.fs.getShares(path); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].holder, t.env.users.other.username); + t.assert.equal(shares[0].mode, 'write'); + t.assert.equal(shares[0].issuer, t.env.users.user.username); + }, + + 'changing the mode replaces the share rather than adding one': async (t) => { + const path = scratch(t, 'remode'); + await t.puter.fs.write(path, 'x'); + + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + await t.puter.fs.share(path, t.env.users.other.username, 'write'); + + const shares = await t.puter.fs.getShares(path); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].mode, 'write'); + }, + + 'listShared returns a page envelope with a total': async (t) => { + const path = scratch(t, 'listed'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + + const page = await t.puter.fs.listShared({ includeTotal: true }); + t.assert.ok(Array.isArray(page.items), 'items should be an array'); + t.assert.equal(typeof page.total, 'number'); + // The sharer is not the holder, so their own item is not listed here. + t.assert.ok( + !page.items.some((share) => share.path === path), + 'sharer should not see their own item in shared-with-me', + ); + }, + + 'sharing an unknown recipient rejects': async (t) => { + const path = scratch(t, 'nobody'); + await t.puter.fs.write(path, 'x'); + + let failed = false; + try { + await t.puter.fs.share(path, 'no-such-user-zzz', 'read'); + } catch (e) { + failed = true; + t.assert.ok( + typeof (e as { code?: string }).code === 'string', + 'error should carry a code', + ); + } + t.assert.ok(failed, 'sharing with an unknown user should reject'); + }, + + 'unsharing something never shared reports nothing revoked': async (t) => { + const path = scratch(t, 'noop'); + await t.puter.fs.write(path, 'x'); + + const result = await t.puter.fs.unshare( + path, + t.env.users.other.username, + ); + t.assert.equal(result.revoked, 0); + }, +});