Skip to content

✨ PUT-1412 File sharing backend api - #3553

Open
jfcastro92 wants to merge 60 commits into
HeyPuter:mainfrom
jfcastro92:juancastro/put-1412-file-sharing-revive-backend-api
Open

✨ PUT-1412 File sharing backend api#3553
jfcastro92 wants to merge 60 commits into
HeyPuter:mainfrom
jfcastro92:juancastro/put-1412-file-sharing-revive-backend-api

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Revives file sharing as a first-class feature: a user shares a file or folder with another account by email or username at read / write / manage, and the recipient finds everything shared with them under Shared in the file browser.

Puter shipped sharing once and removed the surface, leaving the machinery behind. ACLService and its enforcement were largely intact; discovery, the write API, and the whole UI were missing. Reviving them surfaced six correctness bugs in the surviving machinery, three of which are live on main today and independent of sharing — those are called out below.

Closes PUT-1412. Addresses PUT-1489, PUT-1490 (partly), PUT-1491, PUT-1492, PUT-1413, PUT-1414.


⚠️ Security callout

This PR is almost entirely permission logic. Reviewers should weight these:

Fixes an existing privilege-retention bug. deleteUserUserPermByHolder had no issuer clause, so a revoke deleted every issuer's grant on that permission — and, symmetrically, a manage delegate could delete a grant the owner issued. Now scoped: an owner may revoke any grant on their entry, a delegate only what they issued, and anyone may drop their own access. Regression test written first, fails without the fix (ba48efd28).

Revocation now cascades. Revoking a manage delegate also revokes what that delegate re-shared, recursively, with a cycle guard. Previously A→B→C left C with working access after B was removed (06248b667).

Signed URLs were permanent. signFile defaulted to ttlSeconds = 9_999_999_999_999, and signEntryThumbnail mints one per readdir entry — so a revoked recipient kept a working URL forever. Non-owner signatures now expire after 3600 s (NON_OWNER_SIGNATURE_TTL_SECONDS). See Accepted risks — this is a bound, not revocation.

Denials are not existence oracles. Every share path reports through getSafeAclError, so a caller who cannot see an entry gets 404, not 403. Covered by tests for both getShares and self-revoke.

Apps cannot exceed their user on a shared path. An app-under-user actor is bounded by its user even with its own grant; grants are uuid-keyed, so rename/move cannot widen an app's reach; a shared AppData directory stays with the app it belongs to. Asserted rather than assumed (ACLService.test.ts:837, 851, 883, 906).

Responses carry no internal identifiers. Recipients are sanitized to { username }; no user IDs, no e-mail addresses, no foreign paths.


User-visible behaviour changes

  1. The filesystem root no longer aggregates issuer homes. rootListing previously listed the home directory of anyone who had granted you something, which produced folders you could see but not open. The Shared folder replaces it (e7c856dd3).
  2. Non-owner signed URLs now expire (see above). Already-issued signatures remain valid until their embedded expiry.
  3. Delete on an item shared with you is now "Remove from Shared." Deleting used to attempt a move into your trash, which the backend refuses with a raw 403. It now drops your own access instead. True delete is hidden for non-owners.

What's in it

Data layer. The dormant share table is extended into an index of active shares — holder_user_id, fsentry_id, mode, applied_at, with KEY (holder_user_id, id) for keyset pagination, KEY (fsentry_id) for the reverse lookup, and a unique key on (holder_user_id, fsentry_id, issuer_user_id). recipient_email stays, so link sharing (PUT-1497) still fits. Paths are not denormalized — fsentry_id is stored and the display path resolved at read time, so shares survive rename and move.

ShareService owns the invariant that a share is a permission write and an index write together. The permission goes first; if the index write then fails because the entry died mid-flight, the grant is rolled back rather than left standing invisibly. Authorization reuses PermissionService.canManagePermission — no new authorization concept.

ShareController exposes POST /share, POST /share/revoke, GET /share/shared-with-me (keyset-paginated per doc/pagination.md), GET /share/shares.

puter.js gains puter.fs.share(), unshare(), listShared(), getShares(), with JSDoc @overload signatures and docs under src/docs/src/FS/.

GUI gains a Shared sidebar entry, a share dialog with a per-row mode picker and remove control, and context-menu entries. Access inherited from a parent folder is shown greyed with "via {folder}" and no controls, because it is managed on the folder, not the item.

Anti-abuse. 200 shares/user/day (share_daily_limit), 10 recipients and 50 items per request (share_max_recipients, share_max_items). Quota counts creations, so revoking and re-sharing cannot recycle a slot; changing an existing share's mode is not new reach and does not spend budget.

Concurrency. setUserUser was a non-atomic stat → compare → grant → revoke, so two concurrent calls on the same (holder, entry) could persist both modes or neither — a pre-existing race the share dialog makes easy to hit. Now serialized under a short Redis lock per (holder, node).

Cross-region. bumpCacheGeneration was a raw redis.incr that emitted nothing, and no outer.cacheUpdate applier existed, so permission invalidations never left their region. Adds the applier and the outer.permission.* events (d8380f4a4, abce4949e). Intra-cluster was already correct — the generation counter is in shared Redis and every scan-cache key folds it in.

Migrations

Three dialects, additive only: sqlite/0067_share_entries.sql (schema version → 63), mysql/mysql_mig_22.sql, postgres/postgres_mig_11.sql. The MySQL one is written idempotently via _puter_add_col and INFORMATION_SCHEMA-guarded procedures, because the runner has no applied-state tracking and every statement must tolerate a re-run.

Testing

  • Full backend suite green: 5699 passed, 21 skipped.
  • npm run typecheck clean against the baseline; npm run check:puterjs:types clean.
  • New: ShareService.test.ts (25 cases), ShareController.http.test.ts, CacheReplicationService.test.ts, sharing.suite.ts for the SDK across runners.
  • Each of the six bug fixes has a regression test that fails without the fix.
  • Run end-to-end with two local accounts: share a file, confirm it appears under Shared for the recipient, open it, confirm write can write and read cannot, revoke, confirm it disappears. Chained revocation (A→B→C) verified manually — revoked: 3, both recipients 404 afterwards.

Not covered, deliberately:

  • No automated GUI coverage — the dialog and sidebar are manual-only, consistent with the rest of the GUI tree.
  • The end-to-end run predates the merge of ~28 commits from main; TabFiles.js, UIItem.js, UIWindow.js and style.css auto-merged into the sharing work without conflict, and auto-merged is not the same as visually correct. Worth a second manual pass before merge.
  • inheritedFrom has unit coverage but no end-to-end assertion through the SDK.

Accepted risks / follow-ups

  1. Signed-URL exposure is 3600 s, not immediate. verifySignature takes no actor and never consults the ACL, so a URL fetched before revocation keeps working until it expires. A per-entry signature epoch is the durable fix and is not in this PR. Reviewers are agreeing to a number here, not to "short".

  2. Cross-region relies on DynamoDB last-writer-wins. store-kv-v1 is a Global Table (confirmed with @dsalazar), so a revoke does replicate — the "indefinite access" concern is resolved. What remains is LWW conflict resolution, and the sharp case is read-vs-revoke, not grant-vs-revoke: a DELETE in one region loses to any later-stamped PUT, and the cache-warm write at PermissionService.ts:708 is exactly such a PUT, fired by an ordinary read. A scan in the peer region against its own not-yet-converged SQL view can re-create a revoked key, and because a flat hit is terminal (#flatValidateUserPerms never corroborates against SQL) the resurrected key wins. The exposure is bounded, not permanent: warm writes carry a 60 s TTL (FLAT_PERM_WARM_TTL_SECONDS) precisely for this reason, so a resurrected key ages out and the next scan re-derives from SQL. Clock skew picks the winner, and the asymmetry runs the wrong way — one direction drops a grant (safe), the other resurrects a revocation.

    Mitigations, none of which are in this PR: tombstone-on-revoke instead of delete (FlatPermValue.deleted already exists and the read path already honours it), a TTL on grant keys (setFlatUserPerm already accepts expireAt; the grant path at :857 just doesn't pass one), and a cross-region reconcile job. Single-region behaviour in this PR is correct and tested.

  3. The setUserUser lock fails open when Redis errors, so a Redis outage restores the pre-existing race rather than blocking writes. Deliberate — availability over strict serialization on a path that was previously unserialized entirely.

  4. The SQL fallback in the permission scan is still present. PUT-1490 asks for its removal or a concrete removal plan; the plan exists but is not in this PR, and #linkedValidateUserPerms remains in the read path.

  5. fsentry.is_shared is still read by the GUI (helpers.js:1987,2020) and still never populated by the backend. Unchanged by this PR; needs to be populated or the reads deleted.

Cross-store consistency evidence

src/backend/services/share/ShareConsistency.test.ts snapshots all four layers — flat KV, user_to_user_permissions, the share index, and the Redis cache generation — before and after each of nine scenarios with three users, and asserts the effective acl.check alongside the rows. FILE-SHARING-CONSISTENCY-EVIDENCE.md is its generated output.

It found a real bug, now fixed in this PR: deleting a shared entry retired the rows in both stores but never bumped the holders' cache generation, so recipients kept a cached "allowed" answer after the file was gone. unshare bumps; the delete path did not. Both stores read clean while acl.check still returned true — which is why the plan's invariants now assert the effective answer, not just the rows.

Note on scope

Two commits at the base of this branch — 3eb9d64bb (drop hardcoded group permission map) and 26841a9bd (driver credential-gate test) — belong to PUT-1072, not to this ticket. They are here because this branch was cut from the PUT-1072 branch. If that work merges first they will drop out on rebase; otherwise they need review here.

…-flatten-driver-permissions-to-hardcoded-values
A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.

Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.

The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.
POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.

Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.

Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.
signFile defaults to a ~317k-year TTL and verifySignature checks only uid,
expires and signature — never the ACL. A recipient who ever signed a shared
file therefore held a permanent, revocation-proof URL to its bytes: revoking
the share did nothing to it.

signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS
(1 hour) when the signer is not the entry's owner. Owners keep the permanent
default, so no existing client changes behavior.

The signature-authenticated directory listing bounds its children
unconditionally: that route has no session actor, and a signature proves
possession rather than ownership, so a recipient holding a short-lived
directory signature could otherwise mint permanent URLs for every child.

A bounded window is not revocation — the durable fix is a per-entry signature
epoch folded into the HMAC and bumped on any permission change.
@jfcastro92 jfcastro92 changed the title Juancastro/put 1412 file sharing revive backend api ✨ PUT-1412 file sharing revive backend api Aug 12, 2026
@jfcastro92 jfcastro92 changed the title ✨ PUT-1412 file sharing revive backend api ✨ PUT-1412 File sharing backend api Aug 12, 2026
listUserPermissionIssuers and its store method listUserPermissionIssuerIds
existed to synthesize the filesystem root from the home directories of everyone
who had granted the caller a permission. That listing is gone — it advertised
folders readdir then refused to open — and the share index answers "who shared
with me" directly, so nothing wants them back.

One removed test only asserted that the call returned an array; the other
covered readLinkedUserUserPerms round-tripping and is kept, rewritten without
the issuer lookup.
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.
A sidebar entry listing everything other users have shared with you, backed by
puter.fs.listShared().

The path is the sentinel `puter://shared` rather than /<user>/Shared: this is a
query, not a directory, and a path-shaped value could collide with a folder
someone actually creates. refresh_item_container and update_window_path both
branch on it to skip the stat there is no fsentry for, and the listing swaps
readdir for listShared.

Entries render at their real paths under their owners' directories — the item
container already preferred an explicit fsentry.path over joining onto the
container, so nothing else had to change. Each carries who shared it and at
what level, which the context menu reads next.
A sharing dialog shaped like its neighbours — options object, HTML-string
template, jQuery wiring, delegating to UIWindow() — with a recipient field, a
read/edit/share dropdown, and the current access list with revoke buttons.

Reached from a new "Share…" context menu entry, which is hidden on items shared
*with* you: re-sharing needs manage, so the dialog would only surface an error.

Those items get "Remove from Shared" in place of Delete. Delete moves an item
to *your* trash, which for someone else's file means moving their data out of
their tree — FSService refuses it, and the user saw a bare 403. Removing your
own access is what the action was reaching for, so that is what it now does.
jfcastro92 and others added 29 commits August 13, 2026 17:15
Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it.

A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d².

That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose.

Also pins that manage is not transitive: granting it needs manage:manage:fs:<uid>, which only the owner holds, so delegation is one level deep by construction.
The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too.
The single-item context menu handler calls is_owned_by_me and
shared_mode_for, but the imports were only ever added to
generate_file_context_menu.js — so every right-click on an item threw a
ReferenceError before the menu could build, and the non-owner Delete
gating never ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
share() looked up the recipient in parallel with the entry, before the
manage check — and the two failures carried different error codes. Any
verified user with a real entry uid could probe arbitrary emails and
usernames for account existence, at no quota cost. Resolve the entry,
authorize, and only then resolve the recipient: an unauthorized caller
now sees the identical safe 404 whether or not the recipient exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r regions

Every publishCacheKeys call for the u2u, u2a, and access-token row
caches omitted broadcast, so a revoke only cleared the mutating
region's Redis. A peer region applied the replicated generation bump,
re-scanned, read the deleted row from its own still-warm 5-minute row
cache, and re-warmed the flat view from it — revoked access outlived
the revoke by the row-cache TTL instead of the intended 60-second
bound. CacheReplicationService already consumes these events; the
emits were just never sent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remove and move both refuse to act on an entry the caller does not
own, even when the ACL allows the write — rename had no such guard, so
a write-mode share recipient could rename the owner's file, or the
shared folder itself, rewriting the owner's whole subtree's paths.
rename now takes the acting user and applies the same policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g replica

revokeUserUserPermission deletes the SQL grant, then only drops the
flat KV entry once no issuer still grants the permission. That
remaining-check read through the row cache the delete had just
invalidated, straight to a replica — under any lag the deleted row
reappeared, the flat delete was skipped, and the stale rows were
re-cached for another five minutes. Grant-path flat entries carry no
TTL, so the holder kept working access with zero SQL rows behind it,
invisible to every listing. The check now reads the primary and
re-warms the cache with what it actually saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g the process

The outer.permission.flatInvalidated applier was fire-and-forget with
no catch, and it awaits a KV delete — one transient KV error while
applying a peer region's revoke became an unhandled rejection, which
is process-fatal under default Node. Its sibling appliers were already
guarded; this one now logs and moves on, leaving the entry to the next
invalidation or its TTL, same as a lost event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…irst

revokeShare destructured only the first recipient and first item while
the parsers accept arrays up to the request caps — unshare({items:
[a, b, c]}) returned success having revoked only a, leaving access the
caller believes is gone. Revoke now fans out over every (recipient,
item) pair exactly like POST /share, reports per-pair outcomes, and
sums the revoked count; the response stays backward compatible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recipient resolution by email accepted unconfirmed accounts, so
pre-registering someone else's address (unconfirmed) was enough to
receive shares meant for them once no confirmed account held it.
An email now only resolves to an account that has confirmed it;
username shares are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK resolves relative paths to ~/..., but the share routes never
expanded the tilde — a ~-prefixed string was read as a uid and every
relative-path call 404'd. Item parsing now treats ~ as path-shaped and
expands it to the actor's home with the same helper the legacy FS
routes use, on share, revoke, and the shares listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
listByFsentrySubtree matched descendants with fsentry_id = ? OR path
LIKE ?, which has two problems: fsentries.path is lazily backfilled
and NULL on old rows, so those descendants' shares silently survived a
directory revoke, and the OR'd predicates forced a scan of every
active share. A recursive CTE over parent_id — the same shape the
lineage resolver already uses — covers every descendant and runs on
idx_parentId_name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
single_instance keyed the dialog on the app id alone, so opening
Share… on a second file focused the first file's dialog — typing a
recipient there granted access to the wrong file, with only the title
hinting at it. The dialog is now instanced per path: same item
refocuses, different item opens fresh. Also stops pre-encoding the
title, which UIWindow encodes again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A manage grant let its holder re-share a folder but not work in it: the
ACL mode family stops at write, and the fs exploder had no rule for the
narrowest mode, so `manage:fs:<uid>` never satisfied `fs:<uid>:write`.

Fold manage into the candidate list for every non-manage mode, in both
the access-token branch and the scan branch, and give `write` an (empty)
exploder rule so the manage arm is emitted for it too.
rename, remove and move refused outright when the entry belonged to
someone else, so a recipient with write could neither delete nor rename
anything inside a folder shared with them. The GUI compounded it by
hiding Delete for any item it did not own.

Authorize the three by ACL write on the entry's parent. For an owner
that is the same answer; for a recipient it grants the inside of a
shared folder and withholds the folder itself, whose parent is the
owner's private tree.

Deleting sends the item to its owner's trash rather than the deleter's,
so it leaves the recipient's view without leaving the owner's account
and without changing hands. A move may not otherwise carry someone
else's entry out of their tree.
A file a share recipient added to a shared folder was recorded as
theirs while living in the owner's tree, so a subtree could hold rows
belonging to several people — and the storage it consumed was checked
against the writer while being counted against the owner.

Take the owner from the parent row at every insert, charge the
allowance to that owner, and hand a moved entry over to the tree it
moves into. An entry now always belongs to whoever owns the directory
holding it.
A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.

Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.
fe535d5 made `~/share/<uid>` the actual address for every shared
entry. That reached far past the intent: item names became uuids, the
Shared views rendered uuids instead of filenames, and navigation
addressed entries through a namespace nothing else understood.

Put real paths back everywhere — responses, the share listing, and
request handling — and do the masking where it was wanted, in the
window's directory bar. A recipient sees `Shared › Contents › sub`
while every crumb keeps the real path it navigates to.

The share listing now carries the entry's name, content type, owner and
a signed thumbnail. A share row has no fsentry behind it for a client
to stat, and the stored thumbnail is an `s3://bucket/key` URI that no
client can render and none should see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ile-sharing-revive-backend-api

# Conflicts:
#	src/gui/src/UI/UIWindow.js
The substring check tripped on the scratch files' own names, which start
with `sharing-`; the exact-equality assertion on `/<owner>/<uid>/<name>`
already proves nothing above the share leaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uota/bucket invariants

- Rename: a directly-shared FILE renames with write on it; a shared
  folder root stays fixed (its name is the owner's tree structure).
  GUI can_rename mirrors the backend, guards all editor entry points.
- Up from a share root goes to the Shared view on both surfaces; the
  Shared view gets a single crumb, a disabled Up button, and refuses
  drops, New/Paste, uploads and ctrl+V everywhere (it is a query, not
  a directory).
- WebDAV: PROPFIND on /owner/uuid answers as a virtual collection
  holding the share root (ACL-gated; 404 for strangers).
- Storage allowance override no longer crosses user boundaries: a
  recipient's plan cannot raise the owner's cap.
- Overwrites stay in the bucket the entry already lives in instead of
  repointing to the handling server's bucket and stranding the old
  object.
- manage mode documented as implying write (matches enforcement);
  share dialog label now "Can edit & share". Documented that fs socket
  events are owner-only.
- Sidebar: saved orders gain the Shared entry once the user has shares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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.

fixed some last few things about the path obfuscation, and some gui issues with claude, but think this looks ready to go

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