Expose is_shared and returnShares in puter.js

Carry the flag into the v1 entry shape and add returnShares to stat(),
whose shares are mapped into the same Share objects getShares() returns.
returnShares joins the dedupe key and bypasses the entry cache in both
directions — a result carrying share data is never written to it, so a
later plain stat cannot serve other people's names from cache.
This commit is contained in:
Juan Castro
2026-08-25 17:44:50 -04:00
parent ee2f14576b
commit 0591f13cc2
6 changed files with 104 additions and 2 deletions
+1
View File
@@ -96,6 +96,7 @@ export type {
export type {
CopyOptions,
DeleteOptions,
FSItemWithShares,
GetSharesOptions,
ListSharedOptions,
MkdirOptions,
@@ -382,6 +382,43 @@ describe('stat', () => {
await fs.stat({ path: '/a/file.txt', consistency: 'eventual' });
expect(FakeXHR.requests).toHaveLength(0);
});
it('asks for shares and publishes them in the SDK shape', async () => {
FakeXHR.respondWith = () => ({
uid: 'u1',
is_dir: false,
is_shared: true,
shares: [
{
uid: 's1',
mode: 'write',
uid_entry: 'u1',
is_dir: false,
holder: 'someone',
inherited_from: null,
},
],
});
const item = await fs.stat('/a/file.txt', { returnShares: true });
expect(lastBody()).toMatchObject({ return_shares: true });
expect(item.is_shared).toBe(true);
expect(item.shares[0]).toMatchObject({
uid: 's1',
mode: 'write',
entryUid: 'u1',
holder: 'someone',
inheritedFrom: null,
});
});
it('keeps a share-carrying result out of the cache', async () => {
FakeXHR.respondWith = () => ({ uid: 'u1', is_dir: false, shares: [] });
await fs.stat('/a/cached.txt', { returnShares: true });
FakeXHR.requests = [];
// Would be served from the cache had the previous call populated it.
await fs.stat({ path: '/a/cached.txt', consistency: 'eventual' });
expect(FakeXHR.requests).toHaveLength(1);
});
});
describe('readdir', () => {
@@ -396,6 +433,15 @@ describe('readdir', () => {
expect(entries[0]).toMatchObject({ name: 'file.txt', is_dir: false, uid: 'u1' });
});
it('carries the share flag into the v1 shape', async () => {
FakeXHR.respondWith = () => [
{ uuid: 'u1', name: 'mine.txt', path: '/a/mine.txt', isShared: true },
{ uuid: 'u2', name: 'theirs.txt', path: '/a/theirs.txt', isShared: null },
];
const entries = await fs.readdir('/a');
expect(entries.map((e) => e.is_shared)).toEqual([true, null]);
});
it('readdir(path, options) applies the options', async () => {
await fs.readdir('/a', { limit: 5, sortBy: 'modified', sortOrder: 'desc' });
expect(lastBody()).toMatchObject({ limit: 5, sortBy: 'modified', sortOrder: 'desc' });
@@ -1,15 +1,24 @@
import { dedupe } from '../../../lib/networkUtils.js';
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
import { fsRequest, parseOperationArgs } from './scaffold.js';
import { toShare } from './shareUtil.js';
/** @typedef {import('../types.js').StatOptions} StatOptions */
/** @typedef {import('../types.js').FSItemWithShares} FSItemWithShares */
/** @typedef {import('../../FSItem.js').FSItem} FSItem */
/**
* @typedef {{
* (options: StatOptions & { returnShares: true }): Promise<FSItemWithShares>,
* (options: StatOptions): Promise<FSItem>,
* (
* path: string,
* options: StatOptions & { returnShares: true },
* success?: (value: FSItemWithShares) => void,
* error?: (reason: unknown) => void,
* ): Promise<FSItemWithShares>,
* (
* path: string,
* options?: StatOptions,
* success?: (value: FSItem) => void,
* error?: (reason: unknown) => void,
@@ -50,7 +59,7 @@ const statImpl = async function (...args) {
cacheKey = `item:${ options.path}`;
}
if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize ) {
if ( options.consistency === 'eventual' && !options.returnSubdomains && !options.returnPermissions && !options.returnVersions && !options.returnSize && !options.returnShares ) {
const cachedResult = await puter._cache.get(cacheKey);
if ( cachedResult ) {
return cachedResult;
@@ -65,6 +74,7 @@ const statImpl = async function (...args) {
returnPermissions: options.returnPermissions,
returnVersions: options.returnVersions,
returnSize: options.returnSize,
returnShares: options.returnShares,
consistency: options.consistency,
});
@@ -80,6 +90,7 @@ const statImpl = async function (...args) {
body.return_permissions = options.returnPermissions;
body.return_versions = options.returnVersions;
body.return_size = options.returnSize;
body.return_shares = options.returnShares;
body.auth_token = this.authToken;
return fsRequest.call(this, {
@@ -90,7 +101,11 @@ const statImpl = async function (...args) {
success: options.success,
error: options.error,
transform: (result) => {
if ( JSON.stringify(result).length <= MAX_CACHE_SIZE ) {
if ( Array.isArray(result?.shares) ) {
result.shares = result.shares.map(toShare);
}
// Not cached — a later plain stat must not serve share data.
if ( ! options.returnShares && JSON.stringify(result).length <= MAX_CACHE_SIZE ) {
puter._cache.set(cacheKey, result);
}
return result;
@@ -164,6 +164,8 @@
* @property {boolean} [returnPermissions] Whether to return permission information. Defaults to `false`.
* @property {boolean} [returnVersions] Whether to return version information. Defaults to `false`.
* @property {boolean} [returnSize] Whether to return size information. Defaults to `false`.
* @property {boolean} [returnShares] Whether to return who the item is shared with, as a `shares`
* array on the result. Empty unless you can manage the item. Defaults to `false`.
*/
/**
@@ -172,6 +174,12 @@
* @typedef {StatOptionsOwn & RequestCallbacks<FSItem>} StatOptions
*/
/**
* A `stat()` result with `returnShares` set.
*
* @typedef {FSItem & { shares: Share[] }} FSItemWithShares
*/
/**
* @typedef {Object} UploadOptionsOwn
* @property {boolean} [overwrite] Whether to overwrite the destination file if it already exists.
@@ -39,6 +39,7 @@ const mapV2EntryToV1 = (entry) => {
type: entry.type ?? null,
writable: true,
is_public: entry.isPublic ?? null,
is_shared: entry.isShared ?? null,
thumbnail: entry.thumbnail ?? null,
immutable: Boolean(entry.immutable),
metadata: entry.metadata ?? null,
@@ -79,6 +79,37 @@ export default suite('sharing', {
t.assert.equal(shares[0].issuer, t.env.users.user.username);
},
'stat and readdir report whether an item is shared': async (t) => {
// A directory of its own, so the listing is just these two entries.
const dir = scratch(t, 'flag').replace(/\.txt$/, '');
const shared = `${dir}/shared.txt`;
const sibling = `${dir}/sibling.txt`;
await t.puter.fs.write(shared, 'x', { createMissingParents: true });
await t.puter.fs.write(sibling, 'x');
await t.puter.fs.share(shared, t.env.users.other.username, 'write');
const listing = await t.puter.fs.readdir(dir);
const flags = Object.fromEntries(
listing.map((item) => [item.name, item.is_shared]),
);
t.assert.equal(flags['shared.txt'], true);
t.assert.equal(flags['sibling.txt'], false);
const item = await t.puter.fs.stat(shared, { returnShares: true });
t.assert.equal(item.is_shared, true);
t.assert.equal(item.shares.length, 1);
t.assert.equal(item.shares[0].holder, t.env.users.other.username);
t.assert.equal(item.shares[0].mode, 'write');
// Mapped into the same shape `getShares()` publishes.
t.assert.equal(item.shares[0].entryUid, item.uid);
t.assert.equal(item.shares[0].inheritedFrom, null);
await t.puter.fs.unshare(shared, t.env.users.other.username);
const after = await t.puter.fs.stat(shared);
t.assert.equal(after.is_shared, false);
t.assert.equal(after.shares, undefined);
},
'changing the mode replaces the share rather than adding one': async (t) => {
const path = scratch(t, 'remode');
await t.puter.fs.write(path, 'x');