Skip to content

feat: app-scoped share listing and revoke surface (PUT-1670) - #3695

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

feat: app-scoped share listing and revoke surface (PUT-1670)#3695
Salazareo wants to merge 2 commits into
DS/put-1664from
DS/put-1670

Conversation

@Salazareo

Copy link
Copy Markdown
Member

Stacked on DS/put-1664 — review only this PR's diff; merges bottom-up.

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

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.8%
⬆️ +0.04%
25233 / 26899
🔵 Statements 92.04%
⬆️ +0.04%
27268 / 29626
🔵 Functions 90.12%
⬆️ +0.11%
4425 / 4910
🔵 Branches 80.73%
⬆️ +0.08%
18450 / 22852
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/controllers/share/ShareController.ts 92.15%
⬆️ +0.74%
65.12%
⬆️ +1.35%
82.6%
⬆️ +1.96%
95.93%
⬆️ +0.40%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 211-217, 264-266, 389-391, 397-399, 432, 468, 502
src/backend/services/share/ShareService.ts 92.55%
⬆️ +0.17%
83.17%
⬆️ +2.31%
95.85%
⬆️ +0.72%
96.48%
⬆️ +0.37%
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, 746, 773, 809, 835, 899, 971, 972, 1060, 1064, 1072, 1075-1077, 1173, 1193, 1258, 1308, 1316-1317, 1327-1328, 1343-1344, 1353, 1366-1367, 1375-1379, 1436-1437, 1502, 1546-1548, 1569-1571, 1600, 1747-1748
src/backend/stores/share/ShareStore.js 91.57%
⬆️ +4.07%
80.79%
⬆️ +2.81%
92.98%
⬆️ +2.74%
94%
⬆️ +3.10%
263, 278-285, 324, 430-432, 592, 618, 684-688
Generated in workflow #961 for commit 7de955a 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.

Took a close look at this. Most of what I found traces back to one decision: data.issuedByApp is a mutable JSON field, and this PR makes it the authority for listing scope and DELETE authorization. The upsert dedupe paths clobber or freeze it, rows already on main store it under the old issuerAppUid key, and unshare doesn't honor the app scoping the new DELETE promises. Details inline — the last comment on ShareStore.js sketches the schema-level fix (a real indexed column + backfill) that would take out several of these at once.

: { email: row.recipient_email };
if (!recipient.username && !recipient.email) throw notFound();

return this.unshare(actor, { uid: entry.uuid, recipient });

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 app-scope check above only gates which row we find — once we get here, unshare re-targets by (entry, recipient), and for an owner its isOwner branch collects every issuer's rows for that pair, with userRelatedActor stripping the app scope before #revokeFor runs. So an app token deleting its own row can also take out a grant a manage-delegate issued for the same file+recipient, and the response reports the inflated count. Same shape in #cancelInvite: an app-scoped delete of one pending invite removes every issuer's invite for that email+entry. That contradicts the scoping this surface documents. None of the new tests put two issuers on the same (file, recipient) pair, which is why it doesn't show up.

* @param {string | null | undefined} appUid
* @param {string} dataColumn
*/
#appFilter(appUid, dataColumn) {

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.

Now that this filter makes data.issuedByApp the thing deciding listing scope and DELETE authorization, the upserts become a problem: upsertActive dedupes on (holder, fsentry, issuer) and overwrites data wholesale on conflict. Share via app A, then re-share the same file to the same person from the browser, and the field is gone — app A's listing drops the grant and its DELETE starts 404ing on a grant it issued. In the other order, the manual grant gets stamped with app A, leaves the none group, gets counted under A in /shared-by-me/apps, and A's token can revoke it. The new tests give each app its own file, so neither direction is exercised.

Comment thread src/backend/stores/share/ShareStore.js Outdated
...(issuerAppUid ? { issuerAppUid } : {}),
// Same key an active share records it under, so one filter
// covers an invite and the grant it becomes.
...(issuerAppUid ? { issuedByApp: issuerAppUid } : {}),

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 renames the key from issuerAppUid with no migration and no dual-read, and the old key already shipped on main (2c852bf, b795b21). Any app-issued invite already in the table gets read as "no app": the app's scoped listing won't show it and its DELETE 404s, so the app can never withdraw its own outstanding invite, while the user's none group claims it as self-made. applyPending copies data forward verbatim, so the misattribution survives into the active grant once the invite is claimed. Either read both keys or backfill.

Comment thread src/backend/stores/share/ShareStore.js Outdated
@@ -372,7 +451,9 @@ export class ShareStore extends PuterStore {
fsentryId,
mode,
JSON.stringify({

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.

Related: this only runs on the insert branch. The dedupe branch up top (keyed on email+fsentry+issuer, no app) updates mode and never touches data, so issuedByApp stays whatever the first invite wrote. App invites someone, user later re-invites the same address by hand — the row still says the app issued it, the user's invite is missing from the none group, and the app token can cancel it via DELETE. In the other order, the app never sees the invite it just created. upsertActive does refresh data on conflict, so the pending path is the odd one out.

*/
#appUidFilter(query: Record<string, unknown>): string | null | undefined {
const value = query.appUid;
if (typeof value !== 'string' || value === '') return undefined;

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 fails open: a duplicated query param (?appUid=X&appUid=X — Express hands you an array) or an empty string silently becomes undefined, i.e. the unscoped all-apps listing, while the caller believes it's filtered. normalizeLimit and decodeCursor both 400 on malformed input; this should too. Also, a client that round-trips group.appUid from the /apps summary gets null for the no-app group, and null isn't 'none', so it silently receives every share instead of the advertised count. (App tokens are clamped server-side, so this is about user sessions.)

}
if (!(await this.#hasOwnReach(actor, entry, 'see'))) throw notFound();

const holder = row.holder_user_id

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.

If the invitee has signed up and confirmed by now but the claim never ran (claimPendingShares is fire-and-forget and swallows per-row errors, and an invite inserted after its snapshot is never claimed), this resolves the email to a real user — and unshare's user branch only deletes rows matching holder_user_id, never the pending row. The invite stays in the listing (pending rows skip the stillReaches filter) and DELETE returns 200 {revoked: 0} forever. The new test uses a never-registered address, so only the clean cancel-invite path is covered.

) {
throw notFound();
}
if (!(await this.#hasOwnReach(actor, entry, 'see'))) throw notFound();

@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 one: the docblock promises everything the caller may not act on 404s alike, but there are a couple of edge cases where the gates past this point answer differently — a delegate who issued a re-share and later got downgraded to read still passes this check (see) and then gets a 403 from unshare's #assertCanManage, a row the listing hides answers 200 {revoked: 0}, and the pending path can 400 (cannot_revoke_owner). Probably fine in practice — I'd just soften the docstring so it doesn't promise a uniformity the code doesn't quite hold.


/** The app this credential acts as, or null for a plain user session. */
#actingAppUid(actor: Actor): string | null {
return (actor.effectiveApp ?? actor.app)?.uid ?? null;

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 ?? actor.app re-creates the derivation core/actor.ts says lives in one place — "a gate must not read the second as the first", with makeActor as the only spot that derives it. Every other consumer (KVStoreDriver, SubdomainDriver, SystemKVStore, FSController, gates.ts) reads effectiveApp bare, and this PR deleted the last actor literal the fallback would have defended against, so the fallback arm is dead code today — but it's a second derivation site someone will copy. actor.effectiveApp?.uid ?? null does the job. Also worth flagging that routing the write path at 868/2138 through this helper quietly changes what it records, from actor.app to the effective app.

* whatever it was handed and legacy rows carry plain strings — extracting
* from one of those aborts the whole query, so there the read is guarded.
*/
#issuedByAppExpr(dataColumn) {

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.

Bigger picture: filtering and grouping on a JSON extract means every one of these queries computes json_valid + json_extract per row with no possible index — listOutboundApps runs the UNION ALL scan with the extract as its GROUP/ORDER key, and countOutboundApps repeats the identical scan when includeTotal is set. Elsewhere we denormalize the app onto a real column (sessions.app_uid + index, app_feedback.app_uid, fsentries.associated_app_id). A real issuer_app_uid column with a backfill migration would also have forced the answer to the legacy-key problem above, and takes the upsert-clobbering issues with it. Not a bug — but the cost is O(user's outbound rows) per page rather than an index seek.

@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.81%
⬆️ +0.07%
3971 / 6530
🔵 Statements 59.56%
⬆️ +0.07%
4186 / 7028
🔵 Functions 61.37%
⬆️ +0.07%
761 / 1240
🔵 Branches 53.65%
⬆️ +0.09%
2575 / 4799
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js 92.3% 81.25% 100% 100% 32
Generated in workflow #239 for commit 7de955a by the Vitest Coverage Report Action
- Scope the uid-addressed revoke to the named row: only that row's
  issuer's grant is withdrawn, and only that one invite cancelled —
  an app or owner addressing one row no longer takes another issuer's
  grant on the same (item, recipient) pair with it.
- Delete a pending row directly on uid-addressed revoke, so an invite
  whose address registered but never claimed can still be withdrawn.
- Read the legacy `issuerAppUid` data key in the SQL app filter and
  grouping, alongside the unified `issuedByApp`.
- Refuse malformed `appUid` input (duplicated param, empty string)
  instead of silently listing everything, and refuse app-listing
  cursors that decode but name no appUid.
- Derive the acting app from `effectiveApp` alone, per the actor
  contract; drop the second derivation site.
- Pin the attribution semantics with tests: one row records one
  issuance, so re-sharing the same pair re-attributes it to whoever
  issued last, in both directions.
- Soften the uniform-404 docblocks to what the gates actually answer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants