Skip to content

feat: global outbound share listing (PUT-1664) - #3694

Open
Salazareo wants to merge 2 commits into
mainfrom
DS/put-1664
Open

feat: global outbound share listing (PUT-1664)#3694
Salazareo wants to merge 2 commits into
mainfrom
DS/put-1664

Conversation

@Salazareo

Copy link
Copy Markdown
Member

Bottom of a stacked-PR chain (targets main).

@Salazareo
Salazareo requested a review from jfcastro92 August 30, 2026 19:56
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.78%
⬆️ +0.02%
25149 / 26815
🔵 Statements 92.02%
⬆️ +0.02%
27175 / 29530
🔵 Functions 90.08%
⬆️ +0.07%
4406 / 4891
🔵 Branches 80.71%
⬆️ +0.06%
18376 / 22767
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/database/SqliteDatabaseClient.ts 89.14%
🟰 ±0%
57.45%
🟰 ±0%
81.6%
🟰 ±0%
95.58%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 44, 45, 46, 47, 48, 234-236, 484-487, 502
src/backend/controllers/share/ShareController.ts 91.6%
⬆️ +0.19%
64.52%
⬆️ +0.75%
81.81%
⬆️ +1.17%
95.61%
⬆️ +0.08%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 201-207, 230-232, 355-357, 363-365, 379, 415, 449
src/backend/services/share/ShareService.ts 92.61%
⬆️ +0.23%
82.91%
⬆️ +2.05%
95.62%
⬆️ +0.49%
96.42%
⬆️ +0.31%
77, 99, 101, 111, 113, 122-126, 131, 138-142, 156, 165, 170, 172, 227, 259, 260, 290, 349, 387, 388, 461-463, 495-500, 568, 579, 606, 620, 659, 737, 764, 800, 826, 890, 950, 951, 1038, 1058, 1123, 1173, 1181-1182, 1192-1193, 1208-1209, 1218, 1231-1232, 1240-1244, 1301-1302, 1367, 1411-1413, 1434-1436, 1465, 1612-1613
src/backend/stores/permission/PermissionStore.ts 94.01%
⬇️ -0.06%
82.14%
⬇️ -0.86%
93.81%
⬆️ +0.41%
96.77%
⬆️ +0.36%
29, 33, 35-38, 53, 57, 115, 155, 184, 207, 280, 582, 618-621, 640, 680, 779, 781, 786
src/backend/stores/share/ShareStore.js 89.86%
⬆️ +2.36%
78.4%
⬆️ +0.42%
92%
⬆️ +1.76%
92.68%
⬆️ +1.78%
179, 194-201, 240, 346-348, 508, 534, 600-604
Generated in workflow #962 for commit bd2380d by the Vitest Coverage Report Action

@jfcastro92 jfcastro92 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read through this carefully. Shape of the endpoint is good and the keyset merge in listOutbound is correct as far as I can tell. Comments inline — the first is the one I would fix before shipping; a few further down are pre-existing things this endpoint just surfaces more widely, and I say so where that applies.

Two things I checked and am not flagging: the total from countOutbound is fine (doc/pagination.md already defines totals this way, and the inbound listing does the same), and app credentials get no more here than from /share/shares#assertCanManage applies the same see check.

const entry = entries.get(Number(row.fsentry_id));
if (!entry) continue;
if (!reachable.has(entry.uuid)) continue;
const pending = !row.holder_user_id;

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would hold the PR on this one. Pending rows skip the stillReaches check by construction, and nothing else catches them: #revokeDownstream goes via listByFsentrySubtree (filters holder_user_id IS NOT NULL) and deleteActive is holder-keyed, so revoking a manage delegate never deletes the invites they sent. #reachableBy is a no-op for plain sessions, so there is no caller-side gate left.

So after the owner revokes D, D can still call this forever and read the file's current name, size, mtime and a fresh signed thumbnail URL. listSharesOf and listSharedWithMe both gate the caller; this path does not. Cleanest fix is probably deleting pending invites when their issuer's grant goes away, since those orphans are a problem either way.

const pending = !row.holder_user_id;
if (
!pending &&
!stillReaches.has(`${Number(row.holder_user_id)}:${entry.id}`)

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

stillReaches is keyed holder:entry, so it answers "can H still reach N through anyone", not "is my grant to H still alive". If O and D both shared N with H and O's permission row goes without the share row being deleted (onGrantRevoked bails early when issuerId is not a number), D's grant keeps the pair live and O sees a dead share they cannot clear.

#liveGrants already has issuer_user_id on hand (#hasGrantFrom uses it). listSharesOf is equally coarse, so I would fix it inside #reachingHolders rather than here.

}
: {}),
createdAt: row.created_at,
issuedByApp: issuedByApp(row),

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Always null for pending rows: upsertPending writes { issuerAppUid } into data (ShareStore.js:375), but this reads data.issuedByApp, the key upsertActive uses. So an app-sent invite looks self-issued, which is the one case the field exists for.

Pre-existing (listSharesOf:1595 too), but this is where someone would audit what an app did in their name.

// still be there, and an app sees only the part of it the credential
// reaches in its own right.
const [stillReaches, reachable] = await Promise.all([
this.#reachingHolders(

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fan-out worth a look: #reachingHolders does one #liveGrants per distinct holder in an unbounded Promise.all, ~2 reads each. Inbound only ever had one holder, this can be 200 a page, and 600 req/min on share:list makes it cheap to trigger. Cold cache that is ~200 SELECTs queued through the batcher per request.

The flat-perm keys already embed the holder id, so one mget plus a holder_user_id IN (...) read would do a whole page in ~2.

* which cannot answer it at all for a caller who doesn't know what to ask
* about.
*/
@Get('/shared-by-me', {

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AGENTS.md sends anything apps can call to doc/contributing-apis.md, which says the PR is not done until all seven steps are, including the puter.js method, docs page and SDK suite. Nothing under src/puter-js or src/docs here.

Not backend-only by precedent either: /shared-with-me ships as puter.fs.listShared() with docs. As-is no app can reach this listing.

@Get('/shared-by-me', {
subdomain: 'api',
requireVerified: true,
rateLimit: SHARE_LIST_LIMIT,

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This joins the shared 600/min share:list bucket, but rate-limits-and-quotas.md:138 still lists only getShares/listShared, and AGENTS.md says a limit is not done until the docs are. Since the bucket is shared, polling this quietly eats the budget other share reads need.

Comment thread src/backend/stores/share/ShareStore.js Outdated
* which is the only place those are visible, since the permission tables
* are keyed issuer to holder.
*
* Two indexed reads rather than one `OR` spanning the join: the optimizer

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The comment promises a plan the migrations do not back. SQLite has no issuer index at all (0014 creates none; 0067 adds holder, fsentry and the unique triple), so the issued half walks the PK there.

Bigger one: the delegated half joins fsentries.user_id then orders by share.id, which no index gives us in any dialect, so each page re-sorts every share on every node the user owns. idx_share_fsentry only helps the join probe. Either add the composite issuer index or reword this.

) {
continue;
}
items.push({

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fifth hand-built ResolvedShare in this file (inbound at 1307, three in listSharesOf), and they have drifted: inbound has name/type/thumbnail/owner but no issuedByApp/inheritedFrom, listSharesOf the reverse, this one the union. Same object, different shape per endpoint.

Something like #resolveShareRows(rows, { trash, grantCheck, pending }) would make the differences arguments instead of three near-identical loops.

requireVerified: true,
rateLimit: SHARE_LIST_LIMIT,
})
async listSharedByMe(req: Request, res: Response): Promise<void> {

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

listSharedWithMe above with one line changed. You added a test to keep the decorator options in sync, so the drift risk is clearly real, but nothing guards the parsing or response shaping. A small #listSharePage(req, res, list) used by both would cover all of it.

}

/** The id a keyset page resumes after; 0 for the first page. */
#afterId(cursor) {

@jfcastro92 jfcastro92 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor and pre-existing, but it now backs two endpoints. decodeCursor only 400s on unparseable input, so {}, {"id":"abc"} or another endpoint's cursor all decode and land here as 0, silently restarting from page 1 — a client that walked N pages re-ingests everything.

Same pattern is in AppDriver/FSEntryStore etc, so not a blocker; just cheaper to validate once here than in four places later.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
🔵 Lines 60.8%
⬆️ +0.06%
3970 / 6529
🔵 Statements 59.56%
⬆️ +0.07%
4185 / 7026
🔵 Functions 61.37%
⬆️ +0.07%
761 / 1240
🔵 Branches 53.65%
⬆️ +0.09%
2574 / 4797
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/FileSystem/index.js 82%
⬆️ +0.19%
47.36%
🟰 ±0%
76.92%
🟰 ±0%
81.81%
⬆️ +0.18%
141, 167, 174, 179-181, 186-188, 193-195, 200-202, 207-209, 261, 279-284
src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js 100% 85.71% 100% 100%
Generated in workflow #238 for commit bd2380d by the Vitest Coverage Report Action
- Check share-row liveness per (holder, entry, issuer) so a grant
  withdrawn outside unshare doesn't stay listed while another issuer
  still reaches the same holder; batch the permission reads across the
  whole page instead of per holder.
- Retire a revoked issuer's unclaimed invites in the revoke cascade,
  and hide invites whose issuer lost their authority at read time.
- Unify the pending/active app-attribution key on `issuedByApp` and
  dual-read the legacy `issuerAppUid` spelling.
- Add the missing share issuer index (sqlite, postgres) and correct
  the listOutbound plan comment.
- Refuse cursors that decode but name no id instead of silently
  restarting from page one.
- Consolidate the five hand-built ResolvedShare literals and the two
  listing endpoints' parse/shape code.
- Ship the SDK surface: puter.fs.listSharedByMe() with docs, types,
  suite coverage, and the rate-limit page entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants