Skip to content

πŸ—οΈ PUT-1726 + PUT-1727 + PUT-1729: share with a workspace - #3729

Open
jfcastro92 wants to merge 1 commit into
juancastro/put-1719-put-1725-two-workspace-fixture-and-group-grantsfrom
juancastro/put-1726-put-1727-put-1729-team-share-recipient
Open

πŸ—οΈ PUT-1726 + PUT-1727 + PUT-1729: share with a workspace#3729
jfcastro92 wants to merge 1 commit into
juancastro/put-1719-put-1725-two-workspace-fixture-and-group-grantsfrom
juancastro/put-1726-put-1727-put-1729-team-share-recipient

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Fifteenth in the stack: #3704 β†’ … β†’ #3725 β†’ #3726 β†’ this. Review bottom-up.

Covers PUT-1726, PUT-1727 and PUT-1729. Together because a recipient without the listing work produces a share that is created correctly, resolves correctly, and never appears in "Shared with me" β€” a state nobody would ship, and therefore not one worth reviewing on its own.

This is the PR that makes puter.fs.share('/Docs', { team: uid }, 'write') actually work end to end.

The recipient

ShareRecipient gains team (uid) and teamHandle, resolved before the email and username branches and never falling through to them.

await puter.fs.share('/me/Docs', { team: '<uid>' }, 'write');            // canonical
await puter.fs.share('/me/Docs', { teamHandle: 'design-team' }, 'write'); // convenience

Passing both is a 400 rather than a precedence rule, so a call site always shows which was used. That is the whole reason they are separate fields: a handle is released on soft delete and can be reclaimed by an unrelated workspace, so a scripted share to a handle keeps working and silently starts sharing with strangers. getByHandle filters soft-deleted, so a released handle 404s until someone reclaims it.

There is no bare-string spelling. toShareRecipients reads a bare string as an email when it contains @ and a username otherwise, so any prefix convention would change how an existing string is interpreted β€” a breaking change to a released SDK. There is a test that a workspace handle passed as a username does not resolve to the workspace.

One grant, not one per member

ACLService.setUserGroup mirrors setUserUser: same read-modify-write, same one-mode-per-node rule, under a node lock keyed on the group.

The grant is a single row against the workspace, so:

  • membership changes apply without touching it β€” a member who joins afterwards gets access, which is asserted;
  • a share spends one unit of the daily quota however many members there are, and re-sharing the same workspace reports isNew: false.

The index row carries holder_group_id and leaves holder_user_id NULL, so 0078's group index constrains it rather than the user-holder one.

⚠ The listing is the silent failure

#grantEvidence gains group grants as a third source alongside the linked and flat reads. Without it, a team share is created correctly, resolves correctly for every member, and is then filtered out of every listing as dead β€” nothing errors, the share is simply absent from the one place a user would look.

listByHolder and countByHolder union the caller's workspaces into the same keyset page. ORDER BY id still holds, so pagination is unaffected by which kind of holder a row names. The count matters too, or includeTotal undercounts a member's workspace shares.

Unsharing revokes the grant, not just the row

Deleting the index row alone would hide the share while leaving every member holding real access β€” worse than a listing bug, because it looks fixed. An owner may clear any issuer's grant of their node; anyone else only their own, as for user shares.


Verification

$ npx vitest run --config src/backend/vitest.config.ts \
      src/backend/services/{share,permission,acl}/ src/backend/stores/share/
 Test Files  12 passed (12)
      Tests  454 passed (454)

$ PUTER_TEST_DB_ENGINE=postgres … TeamShare.test.ts GroupGrants.test.ts
      Tests  29 passed (29)

$ npm run test:backend
 Test Files  284 passed | 24 skipped (308)
      Tests  7364 passed | 26 skipped (7390)

$ npm run typecheck
Type check passed β€” no new errors (32 known, baselined).
Per-site falsification
baseline                       0 failed | 16 passed
#liveGrants group evidence     3 failed | 13 passed
listing union (groupIds)       3 failed | 13 passed
group index row write         12 failed |  4 passed
setUserGroup grant             4 failed | 12 passed
both-fields rejection          1 failed | 15 passed
handle resolves by handle      1 failed | 15 passed
unshare revokes the grant      1 failed | 15 passed
count unions groups            1 failed | 15 passed
restored                       0 failed | 16 passed

unshare revokes the grant initially reported 0 failed, and the test was at fault: it asserted only that the share left the listing, which also happens when the index row is deleted. So a version that dropped the row without revoking the permission passed β€” the exact silent-retained-access bug worth catching. It now asserts the grant itself is gone.

Not here

Member notifications (PUT-1761). As things stand, sharing with a workspace tells nobody: notifyShared keys off ResolvedShare.holderId, a single user id, and a team share has none. holderGroupId and holderTeam are populated on the resolved share so that ticket has what it needs.

The GUI (PUT-1762) is what makes this reachable without writing code.


Closes PUT-1726, PUT-1727 and PUT-1729.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.8%
⬇️ -0.02%
28952 / 30865
πŸ”΅ Statements 91.82%
⬇️ -0.02%
31434 / 34233
πŸ”΅ Functions 90.49%
⬆️ +0.07%
5321 / 5880
πŸ”΅ Branches 80.74%
⬇️ -0.01%
20813 / 25775
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/controllers/share/ShareController.ts 92.01%
⬇️ -0.09%
67.53%
⬆️ +2.24%
83.33%
⬆️ +0.24%
95.47%
⬇️ -0.23%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 216-222, 269-271, 286, 423-425, 431-433, 470, 483-487, 519, 553
src/backend/controllers/share/clientShare.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/acl/ACLService.ts 90.47%
⬇️ -5.68%
83.22%
⬇️ -9.76%
95.45%
⬇️ -4.55%
91.5%
⬇️ -5.95%
140-142, 166-168, 172-176, 197-201, 211-217, 321, 356-364
src/backend/services/permission/PermissionService.ts 89.5%
⬇️ -0.23%
74.35%
⬇️ -0.05%
73.46%
⬆️ +0.27%
93%
⬆️ +0.06%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 45, 46, 48, 152-153, 221-231, 331, 366-368, 390-393, 399, 401, 411-413, 422-424, 441-444, 453-455, 459-461, 476-479, 508-510, 539-542, 566-568, 570-572, 576-578, 594-597, 634-636, 667-669, 671-673, 703-705, 732-734, 758-760, 762-764, 781-783, 790-792, 937, 944, 1030, 1044, 1056, 1075, 1085, 1123, 1215
src/backend/services/share/ShareService.ts 92.52%
⬇️ -0.08%
83.87%
⬆️ +0.39%
96.13%
⬆️ +0.13%
96.29%
⬇️ -0.28%
48, 84, 106, 108, 118, 120, 129-133, 138, 145-149, 163, 172, 177, 179, 234, 266, 267, 297, 356, 394, 395, 484-486, 518-523, 596, 609, 647, 661, 700, 791, 818, 854, 891, 957, 1048, 1049, 1149, 1153, 1158, 1175, 1178-1180, 1360, 1380, 1445, 1495, 1503-1504, 1514-1515, 1530-1531, 1540, 1553-1554, 1562-1566, 1643-1654, 1691, 1712-1713, 1745-1746, 1817, 1843, 1887-1889, 1910-1912, 1967, 2114-2115
src/backend/stores/permission/PermissionStore.ts 94.08%
⬇️ -0.19%
84.24%
⬇️ -0.47%
95.2%
⬆️ +0.33%
96.59%
⬆️ +0.17%
39, 43, 45-48, 63, 67, 125, 165, 194, 217, 291, 309, 428-429, 451, 670, 806, 842-845, 864, 904, 1003, 1005, 1010
src/backend/stores/share/ShareStore.js 92.27%
⬆️ +0.70%
81.46%
⬆️ +0.01%
94.2%
⬆️ +1.22%
94.25%
⬆️ +0.25%
289-296, 335, 353, 502-504, 635-637, 756, 782, 851-855
src/backend/stores/team/TeamStore.ts 98.19%
⬆️ +0.08%
86.07%
⬇️ -0.94%
100%
🟰 ±0%
99.03%
⬆️ +0.03%
241, 267
Generated in workflow #1595 for commit 6dd8ac1 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 21438c3 to 2f5069d Compare September 3, 2026 14:30
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 2f5069d to c939ee3 Compare September 3, 2026 15:42
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from c939ee3 to c50c04a Compare September 3, 2026 18:56
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from c50c04a to d87ff02 Compare September 3, 2026 19:20
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from d87ff02 to c5e7af1 Compare September 3, 2026 19:45
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from c5e7af1 to 6ddbe61 Compare September 3, 2026 20:02
@jfcastro92

jfcastro92 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

Superseded β€” do not rely on the results below.

Captured at c50c04a9e, before a code review found 13 issues in this stack, before the fixes for them, and before three rebases (the last onto a main that had moved 9 commits). The behaviour described here is not the behaviour of the current head.

Kept for history rather than deleted, so the comments that reference it still make sense. A fresh pass will be posted against the merged code.


Local validation

Engine: mysql 8 Β· real file, real share, real read β€” end to end over HTTP.

Setup. Owner writes an actual file:

POST /fs/write {"fileMetadata":{"path":"/tmowner/Documents/probe.txt","size":24,
                "contentType":"text/plain"},"fileContent":"workspace share probe v1"}
  -> 200  uid=20adac9e-5b81-4643-b4a7-1b22dfc2d626

Before sharing, a seat cannot see it:

acmeseat1  POST /stat -> 404 {"code":"subject_does_not_exist"}

Share with the workspace by uid:

POST /share {"recipients":[{"team":"13341565-..."}],
             "items":["/tmowner/Documents/probe.txt"],"mode":"read"}
  -> 200 {"status":"success","results":[{"uid":"acf13a97-...","mode":"read",
          "path":"/tmowner/Documents/probe.txt","is_new":true,
          "uid_entry":"20adac9e-...","issuer":"tmowner","holder":null, ...}]}

A seat then reads the actual content:

acmeseat1  POST /stat            -> 200, full fsEntry
acmeseat1  GET  /fs/read         -> 200
           workspace share probe v1

A non-member still cannot:

tmoutsider POST /stat -> 404 {"code":"subject_does_not_exist"}

The share row uses the holder_group_id column with holder_user_id left NULL,
which is what makes the existing idx_share_holder_entry_issuer unique index
non-binding for team rows (NULLs distinct):

uid                                   holder_user_id  holder_group_id  team_uid
c47a95fd-309e-4484-aa0e-e82996c7c0fc  NULL            139              13341565-...

Revoke takes effect immediately, no restart:

POST /share/revoke -> 200 {"status":"success","revoked":1}
attempt 1: stat -> 404   read -> 404
attempt 2: stat -> 404   read -> 404
attempt 3: stat -> 404   read -> 404

One small thing for review: the response labels the recipient as an empty
string for team shares β€” "recipient":"" in the create result above, and
{"recipient":"","path":"...","status":"success"} on revoke. ShareController
builds it as:

const label = recipient.email ?? recipient.username ?? '';

with no branch for team / teamHandle. Harmless to the operation, but a client
rendering "shared with X" from that field gets a blank. Cheap to add a team branch
here, or to leave deliberately if the client is expected to use the uid it sent.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch 2 times, most recently from 64a185b to f2f661a Compare September 3, 2026 20:56
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Fixed: team shares reported an empty recipient

Following up on the observation in the validation comment above.

ShareController built the outcome label at two sites with no team branch, so a
share to a workspace came back as "recipient": "" on both create and revoke:

const label = recipient.email ?? recipient.username ?? '';

Both now go through one helper:

/** Echoes back the identifier the caller named, so results are matchable. */
#recipientLabel(recipient: ShareRecipient): string {
    return (
        recipient.email ??
        recipient.username ??
        recipient.teamHandle ??
        recipient.team ??
        ''
    );
}

Echoing the identifier the caller supplied rather than resolving a display name:
a caller that sent {teamHandle: 'acme'} gets acme back and can match results to
its own request positionally and by value. Resolving to the workspace's name
would look nicer but breaks that matching, and name is not unique.

teamHandle is checked before team for the same reason β€” if the caller named a
handle, the handle is what they can match against.

Two tests, both falsified. Added to TeamController.http.test.ts, which already
runs with teams_enabled: true and has a workspace fixture (the ShareController
suite boots with the flag off, so it cannot create one):

βœ“ names the workspace it shared with instead of an empty recipient
βœ“ names the workspace by handle when the caller shared by handle

Reverting the helper to the old expression fails exactly those two:

 Γ— names the workspace it shared with instead of an empty recipient
 Γ— names the workspace by handle when the caller shared by handle
Tests  2 failed | 11 passed (13)

Full suite 7525 passed | 26 skipped, typecheck clean.


One related thing left alone deliberately. clientShare.ts maps
holder: share.holder.username, so a team share also reports "holder": null β€”
visible in the share result quoted in the comment above. ShareService does resolve
holderTeam: {uid, name, handle}, so the information exists and could be surfaced as
e.g. holder_team.

I did not do that here because it adds a field to the share response shape, which
every share listing returns and which puter-js and the GUI both consume. That is a
wider call than fixing a blank label, and it belongs with whoever owns the client
contract. Happy to add it in this PR if you want it, or file it as a follow-up.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from f2f661a to 1146079 Compare September 3, 2026 22:40
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 1146079 to deba782 Compare September 3, 2026 23:55
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Review fixes

A code review across the three phase 4 PRs raised 13 issues. All 13 are fixed; eight land here.

The root cause behind four of them

The codebase had an invariant β€” holder_user_id IS NULL means "pending invite" β€” and this PR gave that column a second reason to be null: a workspace share, whose holder is holder_group_id. Every site still reading the old meaning misclassified workspace shares:

Site What it did
revokeSharedByMe took the invite branch: deleted the index row, returned revoked: 1, never touched the grant β€” members kept access, and with the row gone it could not be listed or revoked again
listSharedByMe pending = !row.holder_user_id β€” the owner's outbound listing showed every workspace share as a pending invite to a blank address, and checked its liveness against invite authority rather than the group grant
listPendingOnFsentry WHERE holder_user_id IS NULL β€” the share dialog showed a phantom pending invite and never the workspace
deletePendingByIssuerSubtree same predicate β€” deleted the index row while listByFsentrySubtree (which does the actual revoking) filters IS NOT NULL, so the grant was left behind, dormant until the issuer regained authority

Each now checks both columns. revokeSharedByMe routes group rows to #unshareTeam, #resolvedShareRow no longer marks them pending, and the listing gained #liveGroupGrants β€” the group analogue of #reachingGrants, because an index row outlives the grant it records.

Three further store predicates use holder_user_id IS NULL (listPendingByEmail, upsertPending, applyPending) but are protected by an additional predicate β€” group rows carry recipient_email = '', and applyPending is keyed by uid. listPendingByEmail got the guard anyway, since an empty address would otherwise match.

Sharing with two workspaces shared with one

#dedupePairs keyed on [email, username, uid, path] β€” no team/teamHandle. Two workspace recipients for one item produced the identical key [null,null,null,"/x"], so the second collapsed into the first. Both slots then reported the first's fulfilled outcome, so the response said success for a workspace that got nothing. Same collapse on /share/revoke, leaving real access standing while claiming it was withdrawn.

⚠ Note for review: asserting on the response cannot catch this. results is built from the request's pairs, so a collapsed pair still returns two entries with the right labels and status: success. Only the shares that exist afterwards reveal it β€” which is what the test checks.

Unsharing aborted when a delegate had lost authority

#unshareTeam revoked each prior issuer's grant by impersonating them, but revokeUserGroupPermission gates on canManagePermission and throws 403. So: owner grants Bob manage on /Docs β†’ Bob shares /Docs with Acme β†’ owner revokes Bob's manage β†’ owner unshares /Docs from Acme β†’ the loop hits Bob first, throws, and aborts before the owner's own grant is revoked or any row deleted.

Now mirrors the user-to-user path: authority is the caller's throughout, with { issuerUserId } naming whose grant to clear. revokeUserGroupPermission gained that override (the user-to-user one already had it). It also fixes the audit, which previously attributed the revoke to the impersonated issuer.

Members got no live events, and grants outlived deleted files

  • listReaching filters holder_user_id IS NOT NULL and is the sole feed for outer.gui.item.*. Workspace members saw a shared folder but received no create/update/move/delete events inside it β€” it went stale until a manual refresh. New listGroupReachingMembers returns one row per member, shaped as a holder row so the fan-out needs no group branch.
  • onEntryDeleted deleted only user_to_user_permissions rows. Group grants on the deleted entry's uuid survived and members' cached readings were never invalidated, so a member's fs:<uuid>:* reading kept answering "allowed" until the TTL, with orphan rows accumulating permanently.

Also

toClientShare never passed holderTeam through, so a workspace share reached the wire as holder: null with nothing naming the workspace. It now carries holder_team.

Tests β€” all falsified

Each fix was reverted, the test confirmed failing, then restored:

 Γ— lists a workspace share as a real share, not a pending invite
 Γ— does not show a phantom invite on the item it was shared with
 Γ— revoking from the outbound listing withdraws the grant, not just the row
 Γ— shares with two workspaces rather than collapsing them into one
 Γ— clears the caller own grant even when another issuer lost authority
 Γ— counts members as reached, so they get live events on a shared folder
 Γ— clears the group grant when the entry itself is deleted

⚠ Two of these were vacuous on the first attempt and are worth knowing about, because the same trap applies to anything testing this area:

  • The revoke test probed the member's inbox. It passed with the fix reverted β€” deleting the index row also empties the listing. It now probes readUserGroupPerms, the grant members actually resolve.
  • The phantom-invite test passed with the store guard reverted, because the #resolvedShareRow fix masks the symptom. It now also asserts at the store.

Full suite 7732 passed | 26 skipped, typecheck clean. Rebased onto current main, which advanced 9 commits during this work.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from deba782 to fd23663 Compare September 4, 2026 00:09
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation (post-review)

Engine: mysql 8 Β· teams_enabled: true Β· fresh database restored from a pre-teams snapshot, then migrated by boot. Supersedes the earlier comment on this PR, which predates the review fixes.

Fixture: one workspace, three provisioned seats, all activated and email-confirmed, plus a non-member. Three is the minimum that distinguishes "told the members" from "told a member".

username         email_confirmed  suspended  org_owned
p4own80144             1            NULL         0
p4seat1x80144          1            NULL         1
p4seat2x80144          1            NULL         1
p4seat3x80144          1            NULL         1

The migrations replayed from a pre-teams schema

The snapshot predates 0077_teams.sql, so restoring it removed the team columns and boot had to re-add them β€” which re-exercised the whole chain against real duplicate data:

[mysql] 34 migration files applied
group.kind restored:                 yes
jct_user_group rows:  81 -> 63       (the dedup, on real rows)
duplicate (user_id, group_id) pairs:  0

The listing union β€” every member sees it

This is what the earlier pass never covered: it tested stat and fs/read, i.e. resolution, and never listed. A share can resolve perfectly and still be invisible where a member would look.

Before sharing, GET /share/shared-with-me for each seat: 0. After sharing with the workspace:

p4seat1x80144    count=1  issuer=p4own80144  mode=read
p4seat2x80144    count=1  issuer=p4own80144  mode=read
p4seat3x80144    count=1  issuer=p4own80144  mode=read
non-member       count=0

The owner's outbound listing (findings 3 and 12)

GET /share/shared-by-me
{'pending': None, 'recipient_email': None, 'holder': None,
 'holder_team': '3ee4033b-7c0c-4c99-b66f-a8d46cdbdd29'}
rows: 1

pending: None β€” no longer a phantom invite to a blank address β€” and the workspace is named.

The share dialog (finding 4) β€” and a gap the live run caught

GET /share/shares?uid=<file> returned 0 rows for a live workspace share. The fix for finding 4 removed the phantom invite, but nothing put the workspace in its place: listByFsentries filters holder_user_id IS NOT NULL and the invite feed now excludes group rows, so a workspace share matched neither feed.

Before the review it showed a wrong row; after, it showed none. Both are wrong β€” the item's manager could not see it was shared with a workspace, so could not revoke it there. That is also PUT-1762's "Done when".

Added listGroupOnFsentry and wired it into listSharesOf with the same #liveGroupGrants liveness check as the outbound listing. After the fix:

rows: 1
{'pending': None, 'holder': None, 'holder_team': '3ee4033b-...', 'mode': 'read'}

Test shows the workspace in the share dialog for that item, falsified β€” dropping the group rows from the return fails it and nothing else.

This was only found by listing live. No unit test covered the dialog for a workspace share.

Sharing names the workspace, not an empty string

POST /share {"recipients":[{"team":"<uid>"}], ...}
  -> status: success  recipient: '3ee4033b-7c0c-4c99-b66f-a8d46cdbdd29'
                      holder_team: 3ee4033b-7c0c-4c99-b66f-a8d46cdbdd29

Both the recipient label and holder_team reach the wire β€” neither had ever been checked live before.

Revocation clears every member's listing, no restart

POST /share/revoke -> success, revoked=1
p4seat1x80144    still-listed=0
p4seat2x80144    still-listed=0
p4seat3x80144    still-listed=0

Same process, immediately after. A stale scan cache or a surviving index row would both show here.


Full suite 7733 passed | 26 skipped, typecheck clean, on a main that advanced 9 commits during this work.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from fd23663 to bb313c0 Compare September 4, 2026 00:20
@jfcastro92
jfcastro92 requested a review from Salazareo September 4, 2026 00:20
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

SDK bridge for workspace shares

Follow-up: the backend accepted {team} / {teamHandle} and returned holder_team, but puter.js mapped neither, so the wire contract existed with nothing able to speak it. Both halves are now in shareUtil.js.

Outbound β€” the recipient was silently dropped

toShareRecipients copied only email and username off an object recipient, so puter.fs.share(path, { team: uid }) sent {}. The call reached the backend with an empty recipient and could report success having shared with nobody.

...(record.team ? { team: String(record.team) } : {}),
...(record.teamHandle ? { teamHandle: String(record.teamHandle) } : {}),

Object form only β€” no bare-string spelling. A #handle or team: prefix would reinterpret strings that already mean an email or a username, which is breaking for a released SDK. PUT-1726 rules that out explicitly.

Inbound β€” the workspace had no name

toShare never read holder_team. Since a workspace share has holder: null by design, a client saw a share that reached nobody:

...(row.holder_team || row.holderTeam
    ? { holderTeam: row.holder_team ?? row.holderTeam }
    : {}),

Published types

ShareRecipient and Share in types.js updated, since these are the SDK's public surface:

@typedef {string | { email?: string, username?: string, team?: string, teamHandle?: string }} ShareRecipient

@property {string | null} holder  ... Null for a workspace share, which has no
  individual holder -- see `holderTeam`.
@property {{ uid: string, name: string | null, handle: string | null }} [holderTeam]

npm run check:puterjs:types β€” declarations generate and type-check cleanly.

Tests β€” falsified in both directions

βœ“ sends a workspace recipient through instead of dropping it
βœ“ sends a workspace handle under its own field
βœ“ publishes the workspace a share reached

Dropping the outbound mapping fails the first two; dropping the inbound one fails the third; neither affects the other.

[A] outbound removed:  2 failed | 205 passed
[B] inbound removed:   1 failed | 206 passed
    restored:          207 passed

Backend suite 7733 passed | 26 skipped, typecheck clean.


This unblocks PUT-1762 (share with a workspace from the share dialog), which is written against { team: uid } and holder_team and could not have worked without these two mappings.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
πŸ”΅ Lines 61.94%
⬇️ -0.04%
4428 / 7148
πŸ”΅ Statements 60.52%
⬇️ -0.03%
4706 / 7775
πŸ”΅ Functions 62.32%
🟰 ±0%
837 / 1343
πŸ”΅ Branches 54.58%
🟰 ±0%
2919 / 5348
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/FileSystem/operations/shareUtil.js 80%
🟰 ±0%
66.19%
⬇️ -2.66%
88.88%
🟰 ±0%
85.18%
🟰 ±0%
51-52, 113, 116-117
Generated in workflow #396 for commit 6dd8ac1 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from bb313c0 to 4792bb4 Compare September 4, 2026 01:02
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 4792bb4 to da78107 Compare September 4, 2026 03:23
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from da78107 to 425f166 Compare September 4, 2026 03:42
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (6d7be8bd6), which landed the KV share-grant series (#3690–#3693).

One collision worth naming: those commits took migration 0080 / mysql_mig_35 / postgres_mig_24 β€” the exact three numbers this stack's temp_password_expires_at migration held. Renumbered to 0081 / mysql_mig_36 / postgres_mig_25, registry entry [76, ['0081_temp-password-expiry.sql']], and CURRENT_SCHEMA_VERSION is now 77. Main's three files are byte-identical to what it merged; only ours moved.

Verified per commit rather than at the tip β€” each of the 9 typechecks on its own, so no intermediate commit is broken. Full suite 7883 passed | 26 skipped.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 425f166 to 54628c2 Compare September 4, 2026 04:31

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

code looks good but is this broadcasting to whole team?

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 54628c2 to 91f57d0 Compare September 4, 2026 13:35
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Terminology: "workspace" is now "team", everywhere

The feature said puter.teams in the namespace and "workspace" in every string, comment and error code. That divergence was deliberate at design time; it is now settled the other way, and the whole stack says team.

Applied to every commit rather than added on top, so each PR shows its own code already named correctly and no intermediate commit references a name a later one changes. All 13 commits still typecheck individually.

The contract-bearing renames

These were free to change now and expensive after release, which is why they went in this pass:

Before After
not_the_workspace_owner not_the_team_owner
workspace_limit_reached team_limit_reached
workspace_deleted team_deleted
max_workspaces_per_user max_teams_per_user
max_seats_per_workspace max_seats_per_team
disabled_by_workspace disabled_by_team
createWorkspace / updateWorkspace / deleteWorkspace createTeam / updateTeam / deleteTeam

Plus twoWorkspaces.ts β†’ twoTeams.ts, shareWorkspaces.js β†’ shareTeams.js, the teams-workspace-* CSS classes, and the team_workspace_deleted email template, which the mechanical pass turned into the stutter team_team_deleted and is now team_closed.

Scoped, and checked

"workspace" means other things in this repo. These were deliberately left alone, and verified untouched:

  • kvShares.ts and the KV integration tests, where workspace:abc: is a key prefix in an example, not a team
  • Events.md and KV.md, which use it the same way
  • UIPermissionDialog tests, same
  • package.json / webpack/BaseConfig.cjs / .gitignore, where it is npm workspaces

Audited three ways: every changed line mentions team or workspace (no collateral edits), no stutters or mangled words (team_team, teamspace, …), and both old config keys are gone with the new ones consistent across all 12 sites that read them.

One thing not renamed

The git branch names still say workspace (put-1740-1762-workspace-gui). Renaming a branch changes a PR's head ref and closes the PR, so they stay as they are β€” they are refs, not product surface.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 91f57d0 to 000a4bd Compare September 4, 2026 14:30
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

code looks good but is this broadcasting to whole team?

Yes β€” a share to a team notifies every member, and that is the intent: the grant reaches all of them, so telling only some would misrepresent what happened.

It is bounded rather than unbounded. listMemberIdsByGroupId reads NOTIFY_FANOUT_CAP (500) with LIMIT cap + 1, so a pathological team cannot turn one share into an unbounded send. Above the cap the extra members are not notified β€” the grant still reaches them, only the notification is dropped.

Two things keep the volume down in practice:

  • One notice per member per share, not per file. ShareNotificationService folds a multi-file share into a single notification.
  • allowedByGroup caches the block check per team, so a fan-out does not re-run isBlocked per member per share.

⚠ Worth flagging while you are looking at it: the cap is silent. A team of 600 gets 500 notifications and no signal that 100 were skipped. That is the behaviour today; if you would rather it logged or paged, say so and I will add it.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from 000a4bd to d814a18 Compare September 4, 2026 15:27
Covers PUT-1726, PUT-1727 and PUT-1729. Together because a recipient without
the listing work produces a share that is created correctly, resolves
correctly, and never appears in "Shared with me" β€” a state nobody would ship.

The recipient. `ShareRecipient` gains `team` (uid) and `teamHandle`, resolved
before the email and username branches and never falling through to them.
Passing both is an error rather than a precedence rule, so a call site always
shows which was chosen: handles are released on soft delete and can be
reclaimed by an unrelated team, and a scripted share to a handle would
silently retarget. There is no bare-string spelling, since that would change
how existing strings are interpreted.

ACLService.setUserGroup mirrors setUserUser: same read-modify-write, same
one-mode-per-node rule, under a node lock keyed on the group.

One grant against the team, not one per member, so membership changes
apply without touching the grant and a share spends one unit of the daily
quota however many members there are. A member who joins afterwards gets
access, which is asserted.

The index row carries `holder_group_id` and leaves `holder_user_id` NULL, so
`0077`'s group index constrains it rather than the user-holder one.

Listing. `listByHolder` and `countByHolder` union the caller's teams into
the same keyset page β€” `ORDER BY id` still holds β€” and `#grantEvidence` gains
group grants as a third source. Without that third source the share is
filtered out of every listing as dead: nothing errors, the share simply is not
there, which is the one place a user would look for it.

Unsharing revokes the grant as well as deleting the index row. Deleting the
row alone would hide the share while leaving every member holding real access.

Closes PUT-1726, PUT-1727 and PUT-1729.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1726-put-1727-put-1729-team-share-recipient branch from d814a18 to 6dd8ac1 Compare September 4, 2026 19:45
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