Skip to content

🔧 PUT-1585: Share flag in readdir shared users in stat - #3644

Merged
jfcastro92 merged 17 commits into
mainfrom
juancastro/put-1585-share-flag-in-readdir-shared-users-in-stat
Aug 26, 2026
Merged

🔧 PUT-1585: Share flag in readdir shared users in stat#3644
jfcastro92 merged 17 commits into
mainfrom
juancastro/put-1585-share-flag-in-readdir-shared-users-in-stat

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A file's listing entry now says whether it has been shared, and stat() can name who it was shared with.

  • isShared on /fs/stat and every branch of /fs/readdir; is_shared on their legacy counterparts and on the v1 entry shape the SDK publishes.
  • return_shares on stat fills a shares array with the same objects getShares() returns — inherited access and unclaimed invitations included. The legacy /stat has accepted this flag and answered shares: [] for as long as it has existed; it now answers for real.
  • puter.js: is_shared on stat/readdir results, returnShares on stat(), both typed.

The performance compromise

The ticket asks whether children of a shared folder should be flagged, and whether that can be fetched without hurting readdir. The answer here is direct shares only:

  • One batched query per listing — SELECT DISTINCT fsentry_id … IN (…) on idx_share_fsentry — never a query per entry, and it runs alongside the suggested-apps and thumbnail work the listing already does rather than after it.
  • Ids are chunked at 1000, because a readdir page can be 10,000 entries.
  • Entries the caller doesn't own are filtered out before the query, so listing someone else's directory costs nothing at all.
  • No ancestor walk on the read path. A file inside a shared folder is reachable without being shared itself, so it reports false, and getShares() / stat({ returnShares: true }) remain the place inherited access shows up, via inheritedFrom.

Permissions callout

  • isShared is owner-scoped. For an entry the caller doesn't own it is null, not false — a share recipient browsing an owner's folder is never told whether other people can also reach those files.
  • shares is gated on manage, the same authority GET /share/shares requires. Without it the array is empty rather than an error, so asking for it can't turn a stat() you were allowed to make into a failure.
  • The flag counts shares granted by anyone holding manage, not only the owner's own — the same set getShares() reports. That is deliberate, and the docs say so.

Reading the index required making the index honest

listSharesOf filters its rows against live grants, because a share row can outlive the grant it records. A listing can't afford that filter — it is exactly the per-entry work this design avoids — so the row itself has to be truthful.

One route left it untruthful: the deprecated POST /auth/revoke-user-user withdraws a grant without touching the index. Reproduced directly — isShared: true alongside listSharesOf: [], i.e. a file reporting itself as shared to nobody, permanently. The route now retires the index row with the grant (ShareService.onGrantRevoked), with a regression test. Two other paths I suspected turned out to be clean already: revoking a delegate, and a delegate leaving, both delete their rows today, which I verified rather than assumed.

Why both API versions changed

puter.fs.stat() calls the legacy POST /stat, not /fs/stat. Only touching the v2 controller would have shipped a flag no SDK caller could see, so LegacyFSController.stat/readdir and toLegacyEntry carry it too. Where the two disagreed I matched v1: shares is always an array when requested, since that is the shape the legacy route has been returning.

ShareController#toClientShare moved to controllers/share/clientShare.ts so the FS controllers publish the identical share shape instead of growing a second copy that could drift.

One SDK bug this surfaced

Nothing invalidated the puter.js entry cache on share()/unshare() — the socket handlers only fire on item mutations. is_shared rides in that cached entry, so every consistency: 'eventual' read kept serving the pre-share value, and the GUI's own listing refresh defaults to eventual. The badge this feature exists to drive would not have appeared until an unrelated write flushed the cache. Both operations now drop the affected item:/readdir: keys; the tests fail without the fix.

Verification

  • Backend: full suite, 6423 passing. New coverage at three levels — store (chunking, pending invites count, revocation clears the flag), service (owner-only; a file in a shared folder is not flagged while canRead confirms the recipient can reach it; the index/grant agreement above), and real HTTP for both API versions.
  • puter.js: 1845 passing across node, browser and workerd, plus unit tests for the v2→v1 mapping, the stat cache rule, and share/unshare invalidation.
  • check:puterjs:types clean; docs build clean.

Notes for reviewers

  • Give the puter.js API suites rate-limit headroom is separable. ~500 API-suite tests run through one seeded free-tier account, and adding a single readdir was enough to trip fs:readdir-burst on the browser runner. It resolves the seeded users to the unlimited policy, next to the subscription gate the harness already disables for the same reason. Test config only — no published limit moved, so rate-limits-and-quotas.md is untouched. The cost is that no api suite exercises free-tier windows any more. Drop that commit and the suite's readdir assertion if you'd rather keep them; the backend HTTP tests and the mapper unit test still cover the flag.
  • Known, not fixed: return_shares runs the full listSharesOf under FS_STAT_LIMIT (1200/min) rather than SHARE_LIST_LIMIT (600/min), and the two counters are separate, so a caller can do that work faster through stat than through the route built for it. Consuming a second scope conditionally isn't expressible in the current gate — worth a follow-up rather than something improvised here.

Directory listings need a shared/not-shared flag per entry. Ask the share
index once per batch, chunked, rather than a query per entry, and answer
only for entries the caller owns so listing a foreign directory never
queries at all. Direct shares only — inherited access stays with
listSharesOf, which already reports it.
isShared on /fs/stat and /fs/readdir, is_shared on their legacy
counterparts, and return_shares now fills the shares array the legacy
stat has been stubbing with []. Null for entries the caller does not own,
so a share recipient is never told who else can reach the owner's files.
The share-to-wire mapper moves out of ShareController so both controllers
publish one shape.
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.
Around 500 tests run through one seeded free-tier account, so the
per-tier windows were throttling the suite rather than anything it
tests — adding a single readdir was enough to trip fs:readdir-burst.
Resolve the seeded users to the unlimited policy, alongside the
subscription gate the harness already turns off for the same reason.
Test config only; no published limit changes.
@jfcastro92 jfcastro92 changed the title Juancastro/put 1585 share flag in readdir shared users in stat 🔧 PUT-1585: Share flag in readdir shared users in stat Aug 25, 2026
@jfcastro92
jfcastro92 requested a review from Salazareo August 25, 2026 21:46
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
🔵 Lines 60.94%
⬆️ +0.10%
3954 / 6488
🔵 Statements 59.69%
⬆️ +0.09%
4166 / 6979
🔵 Functions 61.6%
⬆️ +0.03%
759 / 1232
🔵 Branches 53.74%
⬆️ +0.10%
2553 / 4750
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/FileSystem/operations/readdir.js 93.42%
🟰 ±0%
88.46%
🟰 ±0%
100%
🟰 ±0%
93.93%
🟰 ±0%
24, 111-113, 159
src/puter-js/src/modules/FileSystem/operations/share.js 100%
🟰 ±0%
78.57%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/FileSystem/operations/shareUtil.js 80%
⬇️ -5.71%
68.85%
⬇️ -0.24%
88.88%
⬆️ +1.38%
85.18%
⬇️ -4.29%
45-46, 99, 102-103
src/puter-js/src/modules/FileSystem/operations/stat.js 100%
🟰 ±0%
96.42%
⬆️ +5.12%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/FileSystem/operations/unshare.js 100%
🟰 ±0%
75%
⬆️ +25.00%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/FileSystem/utils/mapV2EntryToV1.js 88.88%
🟰 ±0%
76%
⬇️ -1.08%
100%
🟰 ±0%
100%
🟰 ±0%
14
Generated in workflow #199 for commit 7bd6a2d by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.66%
⬇️ -0.02%
24573 / 26235
🔵 Statements 91.94%
⬆️ +0.01%
26544 / 28869
🔵 Functions 89.88%
🟰 ±0%
4292 / 4775
🔵 Branches 80.57%
⬆️ +0.03%
17935 / 22258
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/controllers/auth/AuthController.ts 90.66%
⬆️ +0.01%
78.83%
🟰 ±0%
77.58%
🟰 ±0%
91.61%
⬆️ +0.01%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 474-476, 490-492, 620-621, 695-697, 714-716, 854, 916-920, 938, 979-984, 993, 1004, 1018-1026, 1067-1068, 1071-1076, 1110, 1216, 1247-1248, 1267-1271, 1309-1311, 1313-1315, 1363, 1391-1402, 1477-1479, 1492, 1552-1554, 1588, 1601-1603, 1629-1631, 1662, 1666-1668, 1766, 1852, 1911, 1956, 2015, 2028-2031, 2073-2075, 2093-2096, 2150-2152, 2164-2166, 2182-2184, 2198-2201, 2207, 2226, 2237, 2358-2362, 2391, 2422-2424, 2436-2442, 2502-2507, 2581-2583, 2646, 2674, 2715-2717, 2783, 2813, 2892-2894, 2896-2898, 2902-2905, 2918-2921, 2986-2991, 2994-2996, 3023, 3042, 3060, 3078, 3091, 3114, 3126, 3143, 3153, 3164, 3313-3317, 3338-3340, 3354, 3357-3361, 3436
src/backend/controllers/fs/FSController.ts 91.89%
🟰 ±0%
76.34%
⬆️ +0.55%
91.42%
🟰 ±0%
93.36%
🟰 ±0%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 102-110, 307, 389, 427, 433, 438-445, 448, 479-483, 553-557, 577-581, 633-637, 702-704, 723-727, 940, 1207, 1208, 1209, 1225-1227, 1259, 1263, 1295-1297, 1310, 1314, 1322, 1373-1375, 1384-1386, 1392, 1410-1411, 1418-1419, 1430, 1454, 1470, 1474, 1478, 1501, 1564-1566, 1614, 1621-1624, 1629-1631, 1634-1637, 1691-1693, 1778, 1807, 1832, 1873-1877, 1880, 1883, 1927-1930, 1938, 2010-2014, 2067-2069, 2078-2086
src/backend/controllers/fs/LegacyFSController.ts 92.41%
⬆️ +0.12%
80.49%
⬇️ -0.14%
80.24%
🟰 ±0%
93.29%
⬆️ +0.01%
101-111, 411-417, 1009-1010, 1202, 1213, 1285-1287, 1354-1363, 1368-1374, 1387-1390, 1432-1434, 1748, 1884-1886, 2013, 2069-2070, 2084, 2116-2120, 2125, 2266-2268, 2308-2310, 2453-2458, 2462-2467, 2471-2476, 2512-2518, 2522, 2536, 2544, 2554, 2558, 2579-2580, 2585-2588, 2607-2609, 2621, 2622, 2624, 2655-2665
src/backend/controllers/fs/legacyFsHelpers.ts 94.69%
🟰 ±0%
87.42%
⬆️ +0.15%
100%
🟰 ±0%
94.16%
🟰 ±0%
145-156, 238-240
src/backend/controllers/fs/requestTypes.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/share/ShareController.ts 91.41%
⬇️ -0.03%
63.77%
⬇️ -2.06%
80.64%
🟰 ±0%
95.53%
⬇️ -0.02%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 194-200, 233-235, 342-344, 350-352, 366, 402, 436
src/backend/controllers/share/clientShare.ts 100% 100% 100% 100%
src/backend/services/share/ShareService.ts 92.54%
⬆️ +0.18%
81.3%
⬆️ +0.75%
95.12%
⬆️ +0.20%
95.39%
⬇️ -0.73%
77, 88, 100-104, 109, 116-120, 133, 183, 184, 267-269, 301-306, 355, 360, 370, 459, 482, 518, 544, 608, 750, 770, 835, 885, 893-894, 904-905, 920-921, 930, 943-944, 952-956, 1013-1014, 1046-1048, 1069-1071, 1100, 1247-1248
src/backend/stores/share/ShareStore.js 87.5%
⬆️ +1.52%
77.98%
🟰 ±0%
90.24%
⬆️ +0.77%
90.9%
⬆️ +1.02%
113, 128-135, 174, 280-282, 444, 470, 501-505
Generated in workflow #846 for commit 7bd6a2d by the Vitest Coverage Report Action

revoke-user-user withdraws a grant without touching the share index, so
the row outlived the access — invisible until now, because listSharesOf
filters against live grants, but the new flag reads the index and would
report a file as shared to nobody, permanently.

Drop the row where the grant goes. The alternative, filtering liveness on
the read side, is the per-entry work the flag exists to avoid.
Nothing invalidated the SDK entry cache on share or unshare — the socket
handlers only fire on item mutations — so is_shared, which now rides in
the cached entry, stayed stale for every consistency: 'eventual' read.
That includes the GUI's own listing refresh, which defaults to it, so a
badge would not have appeared until an unrelated write flushed the cache.
stat() and readdir() return FSItemRead, so the is_shared the docs lead
with typechecks for TypeScript consumers rather than erroring on FSItem.

The docs said "you have shared", but the query has no issuer predicate:
a manage delegate's re-share sets the owner's flag too, which is the
useful answer and matches getShares().
A shared file looked exactly like a regular one. The data to tell them
apart arrived with the readdir/stat share flag; nothing rendered it.

Adds the badge to both listings — UIItem (desktop, explorer windows,
file dialogs) and the dashboard's Files rows — fed from is_shared, and
keeps it in step with the share dialogs: both funnel every grant, mode
change and revoke through one render, so the badge follows without
waiting for a re-listing.

Inherited access is deliberately not badged. It is a state of the folder
that was shared, so marking every file inside would repeat one fact on
hundreds of items; the backend flag is direct-only for the same reason.

The icon (owner-shared.svg) and the strings (item_shared_by_you, in 40
locales) were already in the tree, unused — only the wiring was missing.
The blue ring is doing the work: list view shrinks badges to 8px, where
a glyph is illegible and the white circle the sibling badges use
disappears into the row.
Review feedback: too big, and in the wrong place. It sat in the badge
cluster, which the dashboard pins to the row's top-left corner rather
than to the icon — and at 12px it dominated a 24px row icon.

Now a dot on the icon itself, lower-right: 9px on the desktop's 45px
icons, 7px on the dashboard's 24px rows. Anchoring to .item-icon rather
than to the badge cluster is what keeps it on the corner at both sizes.

Dropping the people glyph with it — unreadable at either size, and
colour was the signal the ticket asked for.
It sat on the icon box's corner, which is 5px outside the artwork on
every side, so the dot read as clipped — half of it hanging over empty
padding with the icon's own drop-shadow falling across it. Nudged in on
both axes: 7px on the desktop, 2px on the dashboard rows.
A recipient given "can edit & share" could not pass that level on: the
dialog offered it, the server refused it, and the refusal was a bare
403 Forbidden that reads as a bug. Handing out manage needs authority
over manage, which only the owner has — the refusal is right, the dead
end and the silence were not.

The dropdown now withholds it from anyone who does not own the item; a
row already set to it keeps it, so opening the dialog cannot downgrade
the owner's own grant, and a mixed selection follows its strictest item.

The server says why, and only to someone who can already share the item
— a stranger still gets the ACL's own safe error, which does not admit
the node exists.

Verified against a running server: a delegate grants read and write as
before, and manage now answers cannot_delegate_manage with a sentence
naming the owner as the one who can.
Sharing a file with someone who already had it answered "Shared with
X", the same as a first share, so the dialog claimed to have done
something it had not.

The service already knew — it computes isNew to decide whether to
notify the recipient — but the flag stopped at the controller. It now
travels on share results only; a listing describes standing access and
says nothing about it.

Which of the three things happened is settled client-side, since the
mode each recipient holds is already on screen: created access, raised
or lowered it, or changed nothing. That keeps the previous mode off the
wire, and an older backend that omits isNew still reads as a share,
which is what these dialogs said before.
Comment thread src/backend/controllers/fs/FSController.ts

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me

…-dialog-correctness

🐛 PUT-1586 + PUT-1599: Share dialog says what it can grant, and what it did
…n-readdir-shared-users-in-stat' into juancastro/put-1590-sharing-shared-files-are-not-different-than-regular-files

# Conflicts:
#	src/gui/src/UI/Dashboard/UIShareModal.js
#	src/gui/src/UI/UIWindowShare.js
…ared-files-are-not-different-than-regular-files

🔧 PUT-1590: Mark shared items in the file listings
@jfcastro92
jfcastro92 merged commit 5a6a102 into main Aug 26, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants