From 9da03555ae39b26281e6cfeda00dffa63b4f7744 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 12:58:07 -0400 Subject: [PATCH 1/4] fix: make the block list bite on team shares, and unshare every re-share (PUT-1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sender a member blocked could still land items in that member's shared-with-me — and grant them real access — by sharing with a common team. The group grant is one row and cannot exclude a member, so the block is now enforced where delivery is derived per member: the permission scan, the inbound listing and its count, and the fs-event fan-out all skip team rows whose issuer the member blocked. Nothing is revoked, so unblocking restores everything. Blocking now also bumps the blocker's permission cache so it bites immediately. Direct shares keep their contract: existing ones stand. #unshareTeam swept re-shares by listing members with a 200 cap and never following the cursor, so members past the cap kept their orphaned rows. It now walks the share rows in the subtree — the set that can actually need sweeping — and filters those issuers to members, which has no cap by construction. --- src/backend/services/share/ShareService.ts | 49 +++- src/backend/services/share/TeamShare.test.ts | 238 +++++++++++++++++- .../stores/permission/PermissionStore.ts | 23 +- src/backend/stores/share/ShareStore.js | 59 ++++- src/backend/stores/team/TeamStore.ts | 20 ++ src/docs/src/FS/share.md | 2 + src/docs/src/Teams.md | 4 + src/gui/src/i18n/translations/en.js | 2 +- 8 files changed, 366 insertions(+), 31 deletions(-) diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index aaaa9078a..1d7aa8ae3 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -30,7 +30,7 @@ import { } from '../../util/email.js'; import type { FSEntry } from '../../stores/fs/FSEntry'; import type { UserUserAuditFilter } from '../../stores/permission/PermissionStore'; -import { MEMBER_PAGE_CAP, type TeamRow } from '../../stores/team/TeamStore'; +import { type TeamRow } from '../../stores/team/TeamStore'; import type { UserRow } from '../../stores/user/UserStore'; import type { AclMode } from '../acl/ACLService'; import { @@ -2193,7 +2193,9 @@ export class ShareService extends PuterService { * asking the user to rebuild it. * * `updateMetadata` merges rather than replaces, and refreshes the cached - * row, so the switch bites on the very next share. + * row, so the switch bites on the very next share. Deliberately does not + * gate team-delivered shares; blocking the sender, or leaving the team, + * does. */ async setBlockAllSenders( actor: Actor, @@ -2207,9 +2209,11 @@ export class ShareService extends PuterService { } /** - * Refuse further shares from `username`. Existing shares stand: access - * someone already has is theirs until it is withdrawn, and a control - * labelled "block" silently revoking it would be a surprise. + * Refuse further shares from `username`. Existing direct shares stand: + * access someone already has is theirs until it is withdrawn, and a control + * labelled "block" silently revoking it would be a surprise. Team-delivered + * shares are derived per member on every read, so those are suspended while + * the block stands — nothing revoked, unblock restores. */ async blockSender( actor: Actor, @@ -2226,6 +2230,8 @@ export class ShareService extends PuterService { blockerId, target.id, ); + // Group-delivered access changed, so cached scans must not outlive it. + if (created) await this.#bumpBlockerCache(actor); return { username: target.username as string, created }; } @@ -2240,9 +2246,23 @@ export class ShareService extends PuterService { blockerId, target.id, ); + if (unblocked) await this.#bumpBlockerCache(actor); return { username: target.username as string, unblocked }; } + /** A block gates team-delivered access, so it has to bite immediately. */ + async #bumpBlockerCache(actor: Actor): Promise { + const username = actor.user?.username; + if (!username) return; + try { + await this.services.permission.bumpPermissionCacheForUsernames([ + username, + ]); + } catch { + // The TTL still bounds a stale reading; the block itself is saved. + } + } + /** * Who the caller refuses shares from, and whether they refuse everyone. * Usernames only — ids aren't theirs. @@ -2589,16 +2609,19 @@ export class ShareService extends PuterService { // Whatever a member re-shared goes with them, as on the user path, and // first: clearing the group grant would strip the `manage` it needs. + // Swept by the rows that exist, not by a capped member page. let revoked = 0; - const members = await this.stores.team.listMembers(team.uid, { - limit: MEMBER_PAGE_CAP, - }); const issuerSet = new Set(issuers); - for (const member of members.items) { - const memberId = Number(member.user_id); - // The owner's own grants do not derive from this one, and an - // issuer's are handled by the revoke loop below. - if (memberId === entry.userId || issuerSet.has(memberId)) continue; + const candidates = ( + await this.stores.share.listIssuerIdsBySubtree(entry.id) + ).filter( + // The owner and the issuers are handled by the revoke loop below. + (id: number) => id !== entry.userId && !issuerSet.has(id), + ); + for (const memberId of await this.stores.team.memberIdsAmong( + team.uid, + candidates, + )) { revoked += await this.#revokeDownstream(me, entry, memberId); } diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 5f1b0a212..7e29742e0 100644 --- a/src/backend/services/share/TeamShare.test.ts +++ b/src/backend/services/share/TeamShare.test.ts @@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { Actor } from '../../core/actor'; import { setupTwoTeams, + type FixtureUser, type TwoTeams, } from '../../testFixtures/twoTeams.js'; @@ -55,16 +56,22 @@ describe('sharing with a team', () => { return { path, uid }; }; - /** A directory and a file inside it, both as real fsentry rows. */ + /** A directory and a file inside it, parent-linked as real rows are. */ const makeNestedFile = async (ownerId: number) => { const owner = await fx.env.server.stores.user.getById(ownerId); const dirUid = crypto.randomUUID(); const dirName = `d_${dirUid.slice(0, 8)}`; const dirPath = `/${owner!.username}/${dirName}`; - const insert = (uid: string, name: string, path: string, isDir: boolean) => + const insert = ( + uid: string, + name: string, + path: string, + isDir: boolean, + parentId: number | null = null, + ) => fx.env.server.clients.db.write( - 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' + - 'VALUES (?, ?, ?, ?, ?, ?)', + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?)', [ uid, name, @@ -72,12 +79,21 @@ describe('sharing with a team', () => { ownerId, fx.env.server.clients.db.booleanValue(isDir), Math.floor(Date.now() / 1000), + parentId, ], ); await insert(dirUid, dirName, dirPath, true); + const dirEntry = + await fx.env.server.stores.fsEntry.getEntryByUuid(dirUid); const fileUid = crypto.randomUUID(); const fileName = `f_${fileUid.slice(0, 8)}.txt`; - await insert(fileUid, fileName, `${dirPath}/${fileName}`, false); + await insert( + fileUid, + fileName, + `${dirPath}/${fileName}`, + false, + dirEntry!.id, + ); return { dirPath, dirUid, fileUid }; }; @@ -593,4 +609,216 @@ describe('sharing with a team', () => { ); expect(rows.some((r) => r.holderTeam?.uid === fx.a.uid)).toBe(true); }); + + // -- the recipient block list (PUT-1813) ---------------------------- + // Enforced where delivery is derived per member: listing, grant, fan-out. + + const block = (blocker: FixtureUser, blocked: FixtureUser) => + fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId); + const unblock = (blocker: FixtureUser, blocked: FixtureUser) => + fx.env.server.stores.userBlock.deleteByPair( + blocker.userId, + blocked.userId, + ); + + it('does not deliver a team share to a member who blocked the sender', async () => { + const [blockingSeat, otherSeat] = fx.a.seats; + await block(blockingSeat, fx.a.owner); + try { + const before = await shares().listSharedWithMe( + await actorFor(blockingSeat.userId), + { limit: 100, includeTotal: true }, + ); + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { + team: fx.a.uid, + }); + + // Neither the listing entry nor the count moves for the blocker. + const after = await shares().listSharedWithMe( + await actorFor(blockingSeat.userId), + { limit: 100, includeTotal: true }, + ); + expect(after.items.map((i) => i.entryUid)).not.toContain(file.uid); + expect(after.total).toBe(before.total); + + // The block refuses access, not just the listing row. + const perms = + await fx.env.server.stores.permission.readUserGroupPerms( + blockingSeat.userId, + [`fs:${file.uid}:read`], + ); + expect(perms).toHaveLength(0); + + // The rest of the team is unaffected. + const others = (await inbox(otherSeat.userId)).items; + expect(others.map((i) => i.entryUid)).toContain(file.uid); + } finally { + await unblock(blockingSeat, fx.a.owner); + } + }); + + it('suspends delivery on block and restores it on unblock', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); + expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( + file.uid, + ); + + await block(seat, fx.a.owner); + try { + expect( + (await inbox(seat.userId)).items.map((i) => i.entryUid), + ).not.toContain(file.uid); + } finally { + await unblock(seat, fx.a.owner); + } + + // Nothing was revoked, so lifting the block needs no re-share. + expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( + file.uid, + ); + }); + + it('keeps a blocked member out of the live-event fan-out', async () => { + const [blockingSeat, otherSeat] = fx.a.seats; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); + const entry = await fx.env.server.stores.fsEntry.getEntryByUuid( + file.uid, + ); + + await block(blockingSeat, fx.a.owner); + try { + // Pushing changes to them would tell them what the block hides. + const rows = + await fx.env.server.stores.share.listGroupReachingMembers([ + entry.id, + ]); + const reached = rows.map((r) => Number(r.holder_user_id)); + expect(reached).not.toContain(blockingSeat.userId); + expect(reached).toContain(otherSeat.userId); + } finally { + await unblock(blockingSeat, fx.a.owner); + } + }); + + it('only suspends the blocked pair, not the member\'s other shares', async () => { + const seat = fx.a.seats[0]; + const peerFile = await makeFile(fx.a.seats[1].userId); + await shareWithTeam(fx.a.seats[1].userId, peerFile.path, { + team: fx.a.uid, + }); + + await block(seat, fx.a.owner); + try { + const items = (await inbox(seat.userId)).items; + expect(items.map((i) => i.entryUid)).toContain(peerFile.uid); + } finally { + await unblock(seat, fx.a.owner); + } + }); + + // -- unshare sweeps by rows, not by a member page (PUT-1813) -------- + + it('sweeps a member re-share on team unshare', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + // `manage` is what lets a member re-share in the first place. + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { username: fx.outsider.username }, + mode: 'read', + } as never); + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).toContain(file.uid); + + await shares().unshare(await actorFor(fx.a.owner.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + // The re-share rested on the group grant and goes with it. + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).not.toContain(file.uid); + }); + + it('sweeps a member\'s unclaimed invite on team unshare', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + const invited = `invitee_${Math.random().toString(36).slice(2, 8)}@test.local`; + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { email: invited }, + mode: 'read', + } as never); + expect( + await fx.env.server.stores.share.listPendingByEmail(invited), + ).toHaveLength(1); + + await shares().unshare(await actorFor(fx.a.owner.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + // The invite rests on the same authority the unshare withdrew. + expect( + await fx.env.server.stores.share.listPendingByEmail(invited), + ).toHaveLength(0); + }); + + it('finds every issuer in the subtree, and filters them to members', async () => { + const { dirPath, dirUid, fileUid } = await makeNestedFile( + fx.a.owner.userId, + ); + const seat = fx.a.seats[0]; + await shareWithTeam( + fx.a.owner.userId, + dirPath, + { team: fx.a.uid }, + 'manage', + ); + // A re-share on the nested file, visible only if the walk descends. + const fileEntry = await fx.env.server.stores.fsEntry.getEntryByUuid( + fileUid, + ); + await fx.env.server.stores.share.upsertActive({ + issuerUserId: seat.userId, + holderUserId: fx.outsider.userId, + fsentryId: fileEntry!.id, + mode: 'read', + }); + + const dirEntry = await fx.env.server.stores.fsEntry.getEntryByUuid( + dirUid, + ); + const issuers = await fx.env.server.stores.share.listIssuerIdsBySubtree( + dirEntry!.id, + ); + expect(issuers).toContain(fx.a.owner.userId); + expect(issuers).toContain(seat.userId); + + // The outsider issued nothing and is no member; both fall out here. + const members = await fx.env.server.stores.team.memberIdsAmong( + fx.a.uid, + [...issuers, fx.outsider.userId], + ); + expect(members).toContain(seat.userId); + expect(members).not.toContain(fx.outsider.userId); + }); }); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 571238745..e93daa806 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -29,6 +29,7 @@ import { } from '../../services/permission/consts'; import { kv } from '../../util/kvSingleton'; import { decodeCursor, encodeCursor } from '../../util/pagination'; +import { TEAM_KIND } from '../team/TeamStore'; import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters @@ -992,14 +993,27 @@ export class PermissionStore extends PuterStore { 'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' + 'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' + 'JOIN `group` g ON g.`id` = ug.group_id ' + - `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`, - [userId, ...permissions], + `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause} ` + + this.#notBlockedByHolderSql(), + [userId, ...permissions, TEAM_KIND], ); return rows.map((row) => this.#decodeExtra(row), ); } + /** + * Skips team rows whose issuer the member blocked; seeded groups (NULL + * kind) untouched. + */ + #notBlockedByHolderSql(): string { + return ( + 'AND (g.`kind` IS NULL OR g.`kind` <> ? OR NOT EXISTS (' + + 'SELECT 1 FROM `user_block` ub WHERE ub.`blocker_user_id` = ug.`user_id` ' + + 'AND ub.`blocked_user_id` = p.`user_id`))' + ); + } + /** Any group, seeded or team: a grant does not care which kind it is. */ async resolveGroupId(groupUid: string): Promise { const rows = (await this.clients.db.read( @@ -1048,8 +1062,9 @@ export class PermissionStore extends PuterStore { 'JOIN `group` g ON g.`id` = ug.`group_id` ' + `WHERE ug.\`user_id\` IN (${holders.map(() => '?').join(', ')}) ` + 'AND g.`deleted_at` IS NULL ' + - `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')})`, - [...holders, ...perms], + `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')}) ` + + this.#notBlockedByHolderSql(), + [...holders, ...perms, TEAM_KIND], ); return rows as unknown as Array<{ holder_user_id: number; diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index da40456ec..afa8db484 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -86,18 +86,22 @@ export class ShareStore extends PuterStore { const afterId = this.#afterId(cursor); const groups = [...new Set(groupIds)].filter(Boolean); - // Same keyset page: `ORDER BY id` holds whatever the holder is. + // Same keyset page: `ORDER BY id` holds whatever the holder is. The + // group arm skips issuers this holder blocked. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}))` + .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` : '`holder_user_id` = ?'; + const holderParams = groups.length + ? [holderUserId, ...groups, holderUserId] + : [holderUserId]; // One extra row tells us whether another page exists. const rows = await this.clients.db.read( `SELECT * FROM \`share\` WHERE ${holderClause} AND \`id\` > ? ` + 'ORDER BY `id` LIMIT ?', - [holderUserId, ...groups, afterId, size + 1], + [...holderParams, afterId, size + 1], ); const hasMore = rows.length > size; @@ -352,13 +356,18 @@ export class ShareStore extends PuterStore { async listGroupReachingMembers(fsentryIds) { if (fsentryIds.length === 0) return []; const placeholders = fsentryIds.map(() => '?').join(', '); + // A member who blocked the issuer is not pushed that issuer's shares. const rows = await this.clients.db.read( 'SELECT `share`.*, `ug`.`user_id` AS `member_user_id` FROM `share` ' + 'JOIN `jct_user_group` `ug` ON `ug`.`group_id` = `share`.`holder_group_id` ' + 'JOIN `group` `g` ON `g`.`id` = `share`.`holder_group_id` ' + `WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` + 'AND `share`.`holder_group_id` IS NOT NULL ' + - 'AND `g`.`deleted_at` IS NULL ORDER BY `share`.`id`', + 'AND `g`.`deleted_at` IS NULL ' + + 'AND NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + + 'WHERE `ub`.`blocker_user_id` = `ug`.`user_id` ' + + 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`) ' + + 'ORDER BY `share`.`id`', fsentryIds, ); // Shaped as a holder row, so the caller's fan-out needs no group branch. @@ -368,6 +377,28 @@ export class ShareStore extends PuterStore { })); } + /** + * Everyone who issued any share row (active, pending or team-held) on a + * directory or anything beneath it. + * + * @param {number} fsentryId + * @returns {Promise} + */ + async listIssuerIdsBySubtree(fsentryId) { + const rows = await this.clients.db.read( + 'WITH RECURSIVE `subtree`(`id`) AS (' + + 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + + 'UNION ALL ' + + 'SELECT `f`.`id` FROM `fsentries` `f` ' + + 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + + ') ' + + 'SELECT DISTINCT `share`.`issuer_user_id` FROM `share` ' + + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id`', + [fsentryId], + ); + return rows.map((row) => Number(row.issuer_user_id)); + } + /** * Which of `fsentryIds` carry a share, pending invites included. * @@ -401,14 +432,17 @@ export class ShareStore extends PuterStore { */ async countByHolder(holderUserId, { groupIds = [] } = {}) { const groups = [...new Set(groupIds)].filter(Boolean); + // Group arm filtered as `listByHolder` is, or the total overcounts. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}))` + .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` : '`holder_user_id` = ?'; const rows = await this.clients.db.read( `SELECT COUNT(*) AS \`count\` FROM \`share\` WHERE ${holderClause}`, - [holderUserId, ...groups], + groups.length + ? [holderUserId, ...groups, holderUserId] + : [holderUserId], ); return Number(rows[0]?.count ?? 0); } @@ -880,6 +914,15 @@ export class ShareStore extends PuterStore { // -- Internals ---------------------------------------------------- + /** Group rows only exist for teams, so no kind guard is needed here. */ + #issuerNotBlockedSql() { + return ( + 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + + 'WHERE `ub`.`blocker_user_id` = ? ' + + 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`)' + ); + } + /** @param {number} [limit] */ #pageSize(limit) { return Math.min( diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index c26a90e74..f30f52026 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -367,6 +367,26 @@ export class TeamStore extends PuterStore { return (await this.getMembership(teamUid, userId)) !== null; } + /** + * Which of `userIds` belong to this team; bounded by its input, no page + * cap. + */ + async memberIdsAmong( + teamUid: string, + userIds: number[], + ): Promise { + const ids = [...new Set(userIds)].filter((id) => Number.isFinite(id)); + if (ids.length === 0) return []; + const rows = (await this.clients.db.read( + 'SELECT ug.`user_id` FROM `jct_user_group` ug ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + `WHERE g.\`uid\` = ? AND g.${this.#live()} ` + + `AND ug.\`user_id\` IN (${ids.map(() => '?').join(', ')})`, + [teamUid, TEAM_KIND, ...ids], + )) as { user_id: number }[]; + return rows.map((row) => Number(row.user_id)); + } + /** A team's members, keyset-paginated on `id` per doc/pagination.md. */ async listMembers( teamUid: string, diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index 3eccff753..2f5a61827 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -35,6 +35,8 @@ Who to share with. A string containing `@` is treated as an email address, and a Where the deployment has [Teams](/Teams/), pass `{ team: uid }` to share with every member of a team the caller belongs to — including anyone added to it later. There is no string form for a team: a bare string is always read as an email or username. +A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed for them, accessible to them, or announced to them while their block stands; the rest of the team is unaffected, and nothing tells the sharer. + #### `mode` (String) (optional) How much access to grant. Defaults to `'read'`. diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md index a73ad27f3..3dcb0b21e 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -60,6 +60,10 @@ including anyone added later. Pass the team's `uid` as the recipient: await puter.fs.share({ path, recipient: { team: team.uid }, mode: 'read' }); ``` +A member who has blocked the sharer is the one exception: while the block +stands, that member neither sees nor can open anything the sharer put into the +team, and the sharer is not told. + See [`puter.fs.share()`](/FS/share/) for the full sharing API. ## Pagination diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 97fb5a57e..9028201a2 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -477,7 +477,7 @@ const en = { blocked_senders: 'Blocked people', blocked_senders_summary: 'People who can’t share with you', blocked_senders_note: - 'Blocked people can’t share anything new with you. What they already shared stays until you remove it.', + 'Blocked people can’t share anything new with you, and anything they share through a team you’re in stays hidden from you. What they already shared directly stays until you remove it.', blocked_all: 'Don’t let anyone share with me', blocked_all_note: 'Refuses every new share, whoever it’s from. What’s already shared with you stays.', From aadcda073d42d27e9504b5d03254ffcde34ccccb Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 14:15:25 -0400 Subject: [PATCH 2/4] fix: keep blocks out of the authority graph, and sweep what unshare missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on the previous commit confirmed three ways the block filter inside readUserGroupPerms turned a reversible block into permanent loss: canManagePermission reads the same rows, so a block-suspended grant looked revoked to #revokeDownstream's cascade, to unshare's authority gate, and to invite claiming — deleting re-shares and invites that were supposed to come back on unblock. The scan is now deliberately blind to blocks; a block suspends delivery only (listing, count, fan-out, telling), which the share store filters itself through one shared notBlockedSql fragment owned by UserBlockStore. The team-unshare sweep also now covers what it claimed to: a member's re-share *to another team* is revoked with them (group rows swept in #revokeDownstream, user path included), and the sweep is skipped entirely while another issuer's grant still backs the team — members' re-shares rest on that authority and revoking them would orphan live access. The blanket block-all note in settings now says team shares still arrive, matching what the code deliberately does. --- src/backend/services/share/ShareService.ts | 125 +++++++++++++----- src/backend/services/share/TeamShare.test.ts | 116 +++++++++++++++- .../stores/permission/PermissionStore.ts | 26 +--- src/backend/stores/share/ShareStore.js | 62 +++++---- .../stores/userBlock/UserBlockStore.ts | 13 ++ src/docs/src/FS/share.md | 2 +- src/docs/src/Teams.md | 5 +- src/gui/src/i18n/translations/en.js | 2 +- 8 files changed, 265 insertions(+), 86 deletions(-) diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 1d7aa8ae3..bd5c28150 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -1363,6 +1363,10 @@ export class ShareService extends PuterService { entry.id, ); + // What they re-shared to teams goes too: a group grant left behind is + // dormant, and springs back if the issuer ever requalifies. + let revoked = await this.#revokeGroupSharesBy(actor, entry, issuerId); + // The whole subtree, not just this node: `manage` inherits downwards, // so a grant on a descendant can rest on authority held here. const rows = ( @@ -1371,7 +1375,7 @@ export class ShareService extends PuterService { (row: { issuer_user_id: number }) => Number(row.issuer_user_id) === issuerId, ); - if (rows.length === 0) return 0; + if (rows.length === 0) return revoked; const [nodes, holders] = await Promise.all([ this.stores.fsEntry.getEntriesByIds( @@ -1386,7 +1390,6 @@ export class ShareService extends PuterService { ), ]); - let revoked = 0; for (const row of rows) { const holderId = Number(row.holder_user_id); const node = nodes.get(Number(row.fsentry_id)); @@ -1428,6 +1431,61 @@ export class ShareService extends PuterService { return revoked; } + /** Withdraw every team-held grant `issuerId` made in this subtree. */ + async #revokeGroupSharesBy( + actor: Actor, + entry: FSEntry, + issuerId: number, + ): Promise { + const rows = ( + await this.stores.share.listGroupSharesBySubtree(entry.id) + ).filter( + (row: { issuer_user_id: number }) => + Number(row.issuer_user_id) === issuerId, + ); + if (rows.length === 0) return 0; + + const nodes = await this.stores.fsEntry.getEntriesByIds( + rows.map((row: { fsentry_id: number }) => Number(row.fsentry_id)), + ); + let revoked = 0; + for (const row of rows) { + const node = nodes.get(Number(row.fsentry_id)); + // Deleted teams included: their grants are still revocable. + const team = await this.stores.team.getByIdIncludingDeleted( + Number(row.holder_group_id), + ); + if (!node || !team) continue; + for (const permission of entryPermissions(node.uuid)) { + if ( + !(await this.services.permission.canManagePermission( + actor, + permission, + )) + ) { + continue; + } + if ( + await this.services.permission.revokeUserGroupPermission( + actor, + team.uid, + permission, + { reason: 'unshared' }, + { issuerUserId: issuerId }, + ) + ) { + revoked++; + } + } + await this.stores.share.deleteActiveGroup({ + holderGroupId: Number(row.holder_group_id), + fsentryId: node.id, + issuerUserId: issuerId, + }); + } + return revoked; + } + /** * Retire the grants pointing at a node that no longer exists. Returns the * rows removed, which is the only record of who had access — the index rows @@ -2209,11 +2267,11 @@ export class ShareService extends PuterService { } /** - * Refuse further shares from `username`. Existing direct shares stand: - * access someone already has is theirs until it is withdrawn, and a control + * Refuse further shares from `username`. Existing shares stand: access + * someone already has is theirs until it is withdrawn, and a control * labelled "block" silently revoking it would be a surprise. Team-delivered - * shares are derived per member on every read, so those are suspended while - * the block stands — nothing revoked, unblock restores. + * items from this sender stop being listed, announced or pushed while the + * block stands; the grants are untouched, so unblocking restores the view. */ async blockSender( actor: Actor, @@ -2230,8 +2288,6 @@ export class ShareService extends PuterService { blockerId, target.id, ); - // Group-delivered access changed, so cached scans must not outlive it. - if (created) await this.#bumpBlockerCache(actor); return { username: target.username as string, created }; } @@ -2246,23 +2302,9 @@ export class ShareService extends PuterService { blockerId, target.id, ); - if (unblocked) await this.#bumpBlockerCache(actor); return { username: target.username as string, unblocked }; } - /** A block gates team-delivered access, so it has to bite immediately. */ - async #bumpBlockerCache(actor: Actor): Promise { - const username = actor.user?.username; - if (!username) return; - try { - await this.services.permission.bumpPermissionCacheForUsernames([ - username, - ]); - } catch { - // The TTL still bounds a stale reading; the block itself is saved. - } - } - /** * Who the caller refuses shares from, and whether they refuse everyone. * Usernames only — ids aren't theirs. @@ -2612,17 +2654,36 @@ export class ShareService extends PuterService { // Swept by the rows that exist, not by a capped member page. let revoked = 0; const issuerSet = new Set(issuers); - const candidates = ( - await this.stores.share.listIssuerIdsBySubtree(entry.id) - ).filter( - // The owner and the issuers are handled by the revoke loop below. - (id: number) => id !== entry.userId && !issuerSet.has(id), + // Sweep only when this call takes the team's last grant on the entry: + // while another issuer's grant stands, members keep the very authority + // their re-shares rest on, and revoking those would orphan live access. + const teamStillGranted = ( + await this.stores.share.listGroupSharesByFsentry(entry.id) + ).some( + (row: { holder_group_id: number; issuer_user_id: number }) => + Number(row.holder_group_id) === team.id && + !issuerSet.has(Number(row.issuer_user_id)), ); - for (const memberId of await this.stores.team.memberIdsAmong( - team.uid, - candidates, - )) { - revoked += await this.#revokeDownstream(me, entry, memberId); + if (!teamStillGranted) { + const candidates = ( + await this.stores.share.listIssuerIdsBySubtree(entry.id) + ).filter( + // The owner and the issuers are handled by the revoke loop below. + (id: number) => id !== entry.userId && !issuerSet.has(id), + ); + // One walk: two members can have re-shared to each other. + const seen = new Set(); + for (const memberId of await this.stores.team.memberIdsAmong( + team.uid, + candidates, + )) { + revoked += await this.#revokeDownstream( + me, + entry, + memberId, + seen, + ); + } } const permissions = entryPermissions(entry.uuid); diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 7e29742e0..69e63ddf2 100644 --- a/src/backend/services/share/TeamShare.test.ts +++ b/src/backend/services/share/TeamShare.test.ts @@ -610,8 +610,10 @@ describe('sharing with a team', () => { expect(rows.some((r) => r.holderTeam?.uid === fx.a.uid)).toBe(true); }); - // -- the recipient block list (PUT-1813) ---------------------------- - // Enforced where delivery is derived per member: listing, grant, fan-out. + // -- the recipient block list --------------------------------------- + // A block suspends delivery only — listing, count, fan-out, telling. The + // grant and the authority graph stay whole, or a reversible block turns + // into permanent revocations downstream. const block = (blocker: FixtureUser, blocked: FixtureUser) => fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId); @@ -642,13 +644,13 @@ describe('sharing with a team', () => { expect(after.items.map((i) => i.entryUid)).not.toContain(file.uid); expect(after.total).toBe(before.total); - // The block refuses access, not just the listing row. + // The grant itself stands — nothing is revoked by a block. const perms = await fx.env.server.stores.permission.readUserGroupPerms( blockingSeat.userId, [`fs:${file.uid}:read`], ); - expect(perms).toHaveLength(0); + expect(perms).toHaveLength(1); // The rest of the team is unaffected. const others = (await inbox(otherSeat.userId)).items; @@ -720,7 +722,38 @@ describe('sharing with a team', () => { } }); - // -- unshare sweeps by rows, not by a member page (PUT-1813) -------- + it('leaves a blocked member their authority, so they can still withdraw', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { username: fx.outsider.username }, + mode: 'read', + } as never); + + await block(seat, fx.a.owner); + try { + // Authority must survive the block, or what they granted becomes + // theirs to keep but not theirs to take back. + await shares().unshare(await actorFor(seat.userId), { + path: file.path, + recipient: { username: fx.outsider.username }, + } as never); + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).not.toContain(file.uid); + } finally { + await unblock(seat, fx.a.owner); + } + }); + + // -- unshare sweeps by rows, not by a member page -------------------- it('sweeps a member re-share on team unshare', async () => { const seat = fx.a.seats[0]; @@ -821,4 +854,77 @@ describe('sharing with a team', () => { expect(members).toContain(seat.userId); expect(members).not.toContain(fx.outsider.userId); }); + + it('sweeps a member re-share made to another team', async () => { + const seat = fx.a.seats[0]; + // The seat belongs to both teams, so it may re-share A's file into B. + await fx.env.server.stores.team.addMember(fx.b.uid, seat.userId, { + orgOwned: false, + }); + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { team: fx.b.uid }, + mode: 'read', + } as never); + expect( + (await inbox(fx.b.seats[0].userId)).items.map((i) => i.entryUid), + ).toContain(file.uid); + + await shares().unshare(await actorFor(fx.a.owner.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + // Left standing, team B's grant is dormant and springs back whenever + // the seat requalifies — the row and the grant both have to go. + expect( + await fx.env.server.stores.permission.readUserGroupPerms( + fx.b.seats[0].userId, + [`fs:${file.uid}:read`], + ), + ).toHaveLength(0); + const entry = await fx.env.server.stores.fsEntry.getEntryByUuid( + file.uid, + ); + expect( + await fx.env.server.stores.share.listGroupSharesByFsentry( + entry!.id, + ), + ).toHaveLength(0); + }); + + it('keeps member re-shares while another issuer still grants the team', async () => { + const [m1, m2] = fx.a.seats; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + // Two grants back the team; m2's re-share rests on either of them. + await shareWithTeam(m1.userId, file.path, { team: fx.a.uid }); + await shares().share(await actorFor(m2.userId), { + path: file.path, + recipient: { username: fx.outsider.username }, + mode: 'read', + } as never); + + // m1 withdraws only their own grant; the owner's still delivers. + await shares().unshare(await actorFor(m1.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).toContain(file.uid); + }); }); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index e93daa806..d41f179ce 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -29,7 +29,6 @@ import { } from '../../services/permission/consts'; import { kv } from '../../util/kvSingleton'; import { decodeCursor, encodeCursor } from '../../util/pagination'; -import { TEAM_KIND } from '../team/TeamStore'; import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters @@ -979,6 +978,9 @@ export class PermissionStore extends PuterStore { * Reads group permissions granted to groups the user is a member of, for a * given set of permission strings. Result already joined against * `jct_user_group` so callers don't need group membership resolution. + * Deliberately blind to the block list: this reading also answers authority + * checks that gate permanent revocations, and a block only suspends + * delivery (which the share listings and fan-out filter themselves). */ async readUserGroupPerms( userId: number, @@ -993,27 +995,14 @@ export class PermissionStore extends PuterStore { 'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' + 'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' + 'JOIN `group` g ON g.`id` = ug.group_id ' + - `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause} ` + - this.#notBlockedByHolderSql(), - [userId, ...permissions, TEAM_KIND], + `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`, + [userId, ...permissions], ); return rows.map((row) => this.#decodeExtra(row), ); } - /** - * Skips team rows whose issuer the member blocked; seeded groups (NULL - * kind) untouched. - */ - #notBlockedByHolderSql(): string { - return ( - 'AND (g.`kind` IS NULL OR g.`kind` <> ? OR NOT EXISTS (' + - 'SELECT 1 FROM `user_block` ub WHERE ub.`blocker_user_id` = ug.`user_id` ' + - 'AND ub.`blocked_user_id` = p.`user_id`))' - ); - } - /** Any group, seeded or team: a grant does not care which kind it is. */ async resolveGroupId(groupUid: string): Promise { const rows = (await this.clients.db.read( @@ -1062,9 +1051,8 @@ export class PermissionStore extends PuterStore { 'JOIN `group` g ON g.`id` = ug.`group_id` ' + `WHERE ug.\`user_id\` IN (${holders.map(() => '?').join(', ')}) ` + 'AND g.`deleted_at` IS NULL ' + - `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')}) ` + - this.#notBlockedByHolderSql(), - [...holders, ...perms, TEAM_KIND], + `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')})`, + [...holders, ...perms], ); return rows as unknown as Array<{ holder_user_id: number; diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index afa8db484..f6890c65f 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -20,6 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { HttpError } from '../../core/http/HttpError.js'; import { encodeCursor, decodeCursor } from '../../util/pagination'; +import { notBlockedSql } from '../userBlock/UserBlockStore'; import { PuterStore } from '../types'; /** Default page size for the keyset listings. */ @@ -309,12 +310,7 @@ export class ShareStore extends PuterStore { */ async listByFsentrySubtree(fsentryId) { const rows = await this.clients.db.read( - 'WITH RECURSIVE `subtree`(`id`) AS (' + - 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + - 'UNION ALL ' + - 'SELECT `f`.`id` FROM `fsentries` `f` ' + - 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + - ') ' + + this.#subtreeCte() + 'SELECT `share`.* FROM `share` ' + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' + 'WHERE `share`.`holder_user_id` IS NOT NULL ' + @@ -324,6 +320,24 @@ export class ShareStore extends PuterStore { return rows.map((r) => this.#normalizeRow(r)); } + /** + * Team-held rows on a directory or anything beneath it. What a revoked + * issuer re-shared to _teams_; `listByFsentrySubtree` only sees holders. + * + * @param {number} fsentryId + */ + async listGroupSharesBySubtree(fsentryId) { + const rows = await this.clients.db.read( + this.#subtreeCte() + + 'SELECT `share`.* FROM `share` ' + + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' + + 'WHERE `share`.`holder_group_id` IS NOT NULL ' + + 'ORDER BY `share`.`id`', + [fsentryId], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + /** * Active shares on any of `fsentryIds` — a node plus its ancestors, which * the caller has already resolved to row ids. @@ -364,9 +378,7 @@ export class ShareStore extends PuterStore { `WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` + 'AND `share`.`holder_group_id` IS NOT NULL ' + 'AND `g`.`deleted_at` IS NULL ' + - 'AND NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + - 'WHERE `ub`.`blocker_user_id` = `ug`.`user_id` ' + - 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`) ' + + `AND ${notBlockedSql('`ug`.`user_id`', '`share`.`issuer_user_id`')} ` + 'ORDER BY `share`.`id`', fsentryIds, ); @@ -386,12 +398,7 @@ export class ShareStore extends PuterStore { */ async listIssuerIdsBySubtree(fsentryId) { const rows = await this.clients.db.read( - 'WITH RECURSIVE `subtree`(`id`) AS (' + - 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + - 'UNION ALL ' + - 'SELECT `f`.`id` FROM `fsentries` `f` ' + - 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + - ') ' + + this.#subtreeCte() + 'SELECT DISTINCT `share`.`issuer_user_id` FROM `share` ' + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id`', [fsentryId], @@ -836,12 +843,7 @@ export class ShareStore extends PuterStore { // dialects disagree on. The gap between the two only ever leaves an // invite standing, and the claim path re-checks authority anyway. const rows = await this.clients.db.read( - 'WITH RECURSIVE `subtree`(`id`) AS (' + - 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + - 'UNION ALL ' + - 'SELECT `f`.`id` FROM `fsentries` `f` ' + - 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + - ') ' + + this.#subtreeCte() + 'SELECT `share`.`uid` FROM `share` ' + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' + // Group rows also have no holder user; deleting one here would @@ -914,13 +916,21 @@ export class ShareStore extends PuterStore { // -- Internals ---------------------------------------------------- + /** The recursive walk of a directory's row ids, by parent linkage. */ + #subtreeCte() { + return ( + 'WITH RECURSIVE `subtree`(`id`) AS (' + + 'SELECT `id` FROM `fsentries` WHERE `id` = ? ' + + 'UNION ALL ' + + 'SELECT `f`.`id` FROM `fsentries` `f` ' + + 'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' + + ') ' + ); + } + /** Group rows only exist for teams, so no kind guard is needed here. */ #issuerNotBlockedSql() { - return ( - 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + - 'WHERE `ub`.`blocker_user_id` = ? ' + - 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`)' - ); + return notBlockedSql('?', '`share`.`issuer_user_id`'); } /** @param {number} [limit] */ diff --git a/src/backend/stores/userBlock/UserBlockStore.ts b/src/backend/stores/userBlock/UserBlockStore.ts index 5a296990c..7167c682f 100644 --- a/src/backend/stores/userBlock/UserBlockStore.ts +++ b/src/backend/stores/userBlock/UserBlockStore.ts @@ -19,6 +19,19 @@ import { PuterStore } from '../types'; +/** + * SQL fragment: true when `blockerExpr` has no block against `blockedExpr`. The + * one spelling of the rule, so every filtered surface stays in step. Exprs are + * SQL (a column or a `?`), never user input. + */ +export const notBlockedSql = ( + blockerExpr: string, + blockedExpr: string, +): string => + 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + + `WHERE \`ub\`.\`blocker_user_id\` = ${blockerExpr} ` + + `AND \`ub\`.\`blocked_user_id\` = ${blockedExpr})`; + /** One row of `user_block`. `created_at` is unix seconds. */ export interface UserBlockRow { id: number; diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index 2f5a61827..39282c62a 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -35,7 +35,7 @@ Who to share with. A string containing `@` is treated as an email address, and a Where the deployment has [Teams](/Teams/), pass `{ team: uid }` to share with every member of a team the caller belongs to — including anyone added to it later. There is no string form for a team: a bare string is always read as an email or username. -A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed for them, accessible to them, or announced to them while their block stands; the rest of the team is unaffected, and nothing tells the sharer. +A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed, announced, or pushed to them while their block stands; the grant itself is untouched, so lifting the block restores their view without a re-share. The rest of the team is unaffected, and nothing tells the sharer. The blanket "block everyone" switch does not extend to teams the recipient belongs to — blocking the sender, or leaving the team, is what stops those. #### `mode` (String) (optional) diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md index 3dcb0b21e..20d3c3e21 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -61,8 +61,9 @@ await puter.fs.share({ path, recipient: { team: team.uid }, mode: 'read' }); ``` A member who has blocked the sharer is the one exception: while the block -stands, that member neither sees nor can open anything the sharer put into the -team, and the sharer is not told. +stands, nothing that sharer puts into the team is listed or announced to that +member, and the sharer is not told. The underlying grant is untouched, so +lifting the block restores the member's view. See [`puter.fs.share()`](/FS/share/) for the full sharing API. diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 9028201a2..20368d27a 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -480,7 +480,7 @@ const en = { 'Blocked people can’t share anything new with you, and anything they share through a team you’re in stays hidden from you. What they already shared directly stays until you remove it.', blocked_all: 'Don’t let anyone share with me', blocked_all_note: - 'Refuses every new share, whoever it’s from. What’s already shared with you stays.', + 'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — block the sender, or leave the team.', blocked_all_on: 'New shares are now refused from everyone', blocked_all_off: 'You’re accepting shares again', blocked_add: 'Block someone', From 27e858d760a6f9fc82538f41cb54b453468d6360 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 18:57:50 -0400 Subject: [PATCH 3/4] fix: gate the group-share index delete on authority, qualify the live filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #3872: deleteActiveGroup ran even when every canManagePermission check was denied, deleting the index row while the team's grant survived — unlisted live access, the class this PR exists to remove. And memberIdsAmong's join left deleted_at unqualified, which only works while jct_user_group has no such column; #live now takes an alias. --- src/backend/services/share/ShareService.ts | 4 ++++ src/backend/stores/team/TeamStore.ts | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index f96324935..414dc8931 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -1456,6 +1456,7 @@ export class ShareService extends PuterService { Number(row.holder_group_id), ); if (!node || !team) continue; + let authorized = false; for (const permission of entryPermissions(node.uuid)) { if ( !(await this.services.permission.canManagePermission( @@ -1465,6 +1466,7 @@ export class ShareService extends PuterService { ) { continue; } + authorized = true; if ( await this.services.permission.revokeUserGroupPermission( actor, @@ -1477,6 +1479,8 @@ export class ShareService extends PuterService { revoked++; } } + // Gated as the user path is: a surviving grant keeps its index row. + if (!authorized) continue; await this.stores.share.deleteActiveGroup({ holderGroupId: Number(row.holder_group_id), fsentryId: node.id, diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index f30f52026..a7c7f45ef 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -178,8 +178,9 @@ export class TeamStore extends PuterStore { } /** Makes the seeded `kind IS NULL` groups unreachable, not merely absent. */ - #live(): string { - return '`kind` = ? AND `deleted_at` IS NULL'; + #live(alias = ''): string { + const prefix = alias ? `${alias}.` : ''; + return `${prefix}\`kind\` = ? AND ${prefix}\`deleted_at\` IS NULL`; } // -- Reads -------------------------------------------------------- @@ -380,7 +381,7 @@ export class TeamStore extends PuterStore { const rows = (await this.clients.db.read( 'SELECT ug.`user_id` FROM `jct_user_group` ug ' + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + - `WHERE g.\`uid\` = ? AND g.${this.#live()} ` + + `WHERE g.\`uid\` = ? AND ${this.#live('g')} ` + `AND ug.\`user_id\` IN (${ids.map(() => '?').join(', ')})`, [teamUid, TEAM_KIND, ...ids], )) as { user_id: number }[]; From 3f335939b3e1fa43a009014f6188fd9865fe3680 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Mon, 21 Sep 2026 10:28:50 -0400 Subject: [PATCH 4/4] fix: team shares are not subject to a recipient's per-sender block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review call from the ticket's author: a team share is the team's, not one colleague's to withhold from another, so a personal block should not hide it. This drops the enforcement added earlier on the branch — the listing, its total and the fs-event fan-out no longer filter team rows by the recipient's block list, and the SQL fragment that did it goes with them. What a block still does for a team share is suppress the notification, which was already true before this branch: it stops the interruption without pretending to stop the access. Leaving the team is what ends that. Said so in the block API docs, the settings copy and the code, since the mismatch between the two was the original complaint. The unshare sweep is untouched — that half of the ticket stands. --- src/backend/services/share/ShareService.ts | 16 +- src/backend/services/share/TeamShare.test.ts | 112 +++-------- src/backend/stores/share/ShareStore.js | 32 ++-- .../stores/userBlock/UserBlockStore.ts | 13 -- src/docs/src/FS/share.md | 3 +- src/docs/src/Teams.md | 179 ++++++++---------- src/docs/src/Teams/list.md | 35 +++- src/docs/src/Teams/listDirectory.md | 35 +++- src/gui/src/i18n/translations/en.js | 4 +- 9 files changed, 197 insertions(+), 232 deletions(-) diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 130862e61..3f4a8eca7 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -2468,9 +2468,9 @@ export class ShareService extends PuterService { * asking the user to rebuild it. * * `updateMetadata` merges rather than replaces, and refreshes the cached - * row, so the switch bites on the very next share. Deliberately does not - * gate team-delivered shares; blocking the sender, or leaving the team, - * does. + * row, so the switch bites on the very next share. Shares made to a team + * are out of scope, as they are for `blockSender`: leaving the team is what + * ends those. */ async setBlockAllSenders( actor: Actor, @@ -2486,9 +2486,13 @@ export class ShareService extends PuterService { /** * Refuse further shares from `username`. Existing shares stand: access * someone already has is theirs until it is withdrawn, and a control - * labelled "block" silently revoking it would be a surprise. Team-delivered - * items from this sender stop being listed, announced or pushed while the - * block stands; the grants are untouched, so unblocking restores the view. + * labelled "block" silently revoking it would be a surprise. + * + * Shares made to a team both accounts belong to are deliberately out of + * scope: the grant is the team's, and one colleague does not get to + * withhold the team's files from another. Leaving the team ends those. The + * notification is still suppressed, so a block always stops the + * interruption even where it does not stop the access. */ async blockSender( actor: Actor, diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 69e63ddf2..93f700c58 100644 --- a/src/backend/services/share/TeamShare.test.ts +++ b/src/backend/services/share/TeamShare.test.ts @@ -611,9 +611,9 @@ describe('sharing with a team', () => { }); // -- the recipient block list --------------------------------------- - // A block suspends delivery only — listing, count, fan-out, telling. The - // grant and the authority graph stay whole, or a reversible block turns - // into permanent revocations downstream. + // A team share is the team's, so a per-sender block does not withhold it + // from a colleague; only the notification is suppressed. Leaving the team + // is what ends the access. const block = (blocker: FixtureUser, blocked: FixtureUser) => fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId); @@ -623,36 +623,30 @@ describe('sharing with a team', () => { blocked.userId, ); - it('does not deliver a team share to a member who blocked the sender', async () => { + it('still delivers a team share to a member who blocked the sender', async () => { const [blockingSeat, otherSeat] = fx.a.seats; await block(blockingSeat, fx.a.owner); try { - const before = await shares().listSharedWithMe( - await actorFor(blockingSeat.userId), - { limit: 100, includeTotal: true }, - ); const file = await makeFile(fx.a.owner.userId); await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid, }); - // Neither the listing entry nor the count moves for the blocker. - const after = await shares().listSharedWithMe( + // The block is about one person's contact, not the team's files. + const mine = await shares().listSharedWithMe( await actorFor(blockingSeat.userId), { limit: 100, includeTotal: true }, ); - expect(after.items.map((i) => i.entryUid)).not.toContain(file.uid); - expect(after.total).toBe(before.total); + expect(mine.items.map((i) => i.entryUid)).toContain(file.uid); - // The grant itself stands — nothing is revoked by a block. - const perms = + // And the grant is there to back it, not just the listing row. + expect( await fx.env.server.stores.permission.readUserGroupPerms( blockingSeat.userId, [`fs:${file.uid}:read`], - ); - expect(perms).toHaveLength(1); + ), + ).toHaveLength(1); - // The rest of the team is unaffected. const others = (await inbox(otherSeat.userId)).items; expect(others.map((i) => i.entryUid)).toContain(file.uid); } finally { @@ -660,31 +654,26 @@ describe('sharing with a team', () => { } }); - it('suspends delivery on block and restores it on unblock', async () => { + it('counts a team share for a blocking member, so page and total agree', async () => { const seat = fx.a.seats[0]; - const file = await makeFile(fx.a.owner.userId); - await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); - expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( - file.uid, - ); - await block(seat, fx.a.owner); try { - expect( - (await inbox(seat.userId)).items.map((i) => i.entryUid), - ).not.toContain(file.uid); + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { + team: fx.a.uid, + }); + const res = await shares().listSharedWithMe( + await actorFor(seat.userId), + { limit: 100, includeTotal: true }, + ); + expect(res.total).toBeGreaterThanOrEqual(res.items.length); } finally { await unblock(seat, fx.a.owner); } - - // Nothing was revoked, so lifting the block needs no re-share. - expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( - file.uid, - ); }); - it('keeps a blocked member out of the live-event fan-out', async () => { - const [blockingSeat, otherSeat] = fx.a.seats; + it('keeps a blocking member in the live-event fan-out', async () => { + const [blockingSeat] = fx.a.seats; const file = await makeFile(fx.a.owner.userId); await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); const entry = await fx.env.server.stores.fsEntry.getEntryByUuid( @@ -693,66 +682,19 @@ describe('sharing with a team', () => { await block(blockingSeat, fx.a.owner); try { - // Pushing changes to them would tell them what the block hides. + // They can open the file, so they have to be told it changed. const rows = await fx.env.server.stores.share.listGroupReachingMembers([ entry.id, ]); - const reached = rows.map((r) => Number(r.holder_user_id)); - expect(reached).not.toContain(blockingSeat.userId); - expect(reached).toContain(otherSeat.userId); + expect(rows.map((r) => Number(r.holder_user_id))).toContain( + blockingSeat.userId, + ); } finally { await unblock(blockingSeat, fx.a.owner); } }); - it('only suspends the blocked pair, not the member\'s other shares', async () => { - const seat = fx.a.seats[0]; - const peerFile = await makeFile(fx.a.seats[1].userId); - await shareWithTeam(fx.a.seats[1].userId, peerFile.path, { - team: fx.a.uid, - }); - - await block(seat, fx.a.owner); - try { - const items = (await inbox(seat.userId)).items; - expect(items.map((i) => i.entryUid)).toContain(peerFile.uid); - } finally { - await unblock(seat, fx.a.owner); - } - }); - - it('leaves a blocked member their authority, so they can still withdraw', async () => { - const seat = fx.a.seats[0]; - const file = await makeFile(fx.a.owner.userId); - await shareWithTeam( - fx.a.owner.userId, - file.path, - { team: fx.a.uid }, - 'manage', - ); - await shares().share(await actorFor(seat.userId), { - path: file.path, - recipient: { username: fx.outsider.username }, - mode: 'read', - } as never); - - await block(seat, fx.a.owner); - try { - // Authority must survive the block, or what they granted becomes - // theirs to keep but not theirs to take back. - await shares().unshare(await actorFor(seat.userId), { - path: file.path, - recipient: { username: fx.outsider.username }, - } as never); - expect( - (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), - ).not.toContain(file.uid); - } finally { - await unblock(seat, fx.a.owner); - } - }); - // -- unshare sweeps by rows, not by a member page -------------------- it('sweeps a member re-share on team unshare', async () => { diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 5939d7d07..04f1ec188 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -20,7 +20,6 @@ import { v4 as uuidv4 } from 'uuid'; import { HttpError } from '../../core/http/HttpError.js'; import { encodeCursor, decodeCursor } from '../../util/pagination'; -import { notBlockedSql } from '../userBlock/UserBlockStore'; import { PuterStore } from '../types'; /** Default page size for the keyset listings. */ @@ -89,16 +88,15 @@ export class ShareStore extends PuterStore { const afterId = this.#afterId(cursor); const groups = [...new Set(groupIds)].filter(Boolean); - // Same keyset page: `ORDER BY id` holds whatever the holder is. The - // group arm skips issuers this holder blocked. + // Same keyset page: `ORDER BY id` holds whatever the holder is. A + // per-sender block deliberately does not apply here — a team share is + // the team's, not one colleague's to withhold from another. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` + .join(', ')}))` : '`holder_user_id` = ?'; - const holderParams = groups.length - ? [holderUserId, ...groups, holderUserId] - : [holderUserId]; + const holderParams = [holderUserId, ...groups]; // One extra row tells us whether another page exists. const rows = await this.clients.db.read( @@ -391,7 +389,6 @@ export class ShareStore extends PuterStore { async listGroupReachingMembers(fsentryIds) { if (fsentryIds.length === 0) return []; const placeholders = fsentryIds.map(() => '?').join(', '); - // A member who blocked the issuer is not pushed that issuer's shares. const rows = await this.clients.db.read( 'SELECT `share`.*, `ug`.`user_id` AS `member_user_id` FROM `share` ' + 'JOIN `jct_user_group` `ug` ON `ug`.`group_id` = `share`.`holder_group_id` ' + @@ -399,7 +396,6 @@ export class ShareStore extends PuterStore { `WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` + 'AND `share`.`holder_group_id` IS NOT NULL ' + 'AND `g`.`deleted_at` IS NULL ' + - `AND ${notBlockedSql('`ug`.`user_id`', '`share`.`issuer_user_id`')} ` + 'ORDER BY `share`.`id`', fsentryIds, ); @@ -464,17 +460,16 @@ export class ShareStore extends PuterStore { */ async countByHolder(holderUserId, { groupIds = [] } = {}) { const groups = [...new Set(groupIds)].filter(Boolean); - // Group arm filtered as `listByHolder` is, or the total overcounts. + // Same union as `listByHolder`, blocks included, or the total and the + // page disagree. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` + .join(', ')}))` : '`holder_user_id` = ?'; const rows = await this.clients.db.read( `SELECT COUNT(*) AS \`count\` FROM \`share\` WHERE ${holderClause}`, - groups.length - ? [holderUserId, ...groups, holderUserId] - : [holderUserId], + [holderUserId, ...groups], ); return Number(rows[0]?.count ?? 0); } @@ -1074,11 +1069,6 @@ export class ShareStore extends PuterStore { ); } - /** Group rows only exist for teams, so no kind guard is needed here. */ - #issuerNotBlockedSql() { - return notBlockedSql('?', '`share`.`issuer_user_id`'); - } - /** @param {number} [limit] */ #pageSize(limit) { return Math.min( diff --git a/src/backend/stores/userBlock/UserBlockStore.ts b/src/backend/stores/userBlock/UserBlockStore.ts index 7167c682f..5a296990c 100644 --- a/src/backend/stores/userBlock/UserBlockStore.ts +++ b/src/backend/stores/userBlock/UserBlockStore.ts @@ -19,19 +19,6 @@ import { PuterStore } from '../types'; -/** - * SQL fragment: true when `blockerExpr` has no block against `blockedExpr`. The - * one spelling of the rule, so every filtered surface stays in step. Exprs are - * SQL (a column or a `?`), never user input. - */ -export const notBlockedSql = ( - blockerExpr: string, - blockedExpr: string, -): string => - 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + - `WHERE \`ub\`.\`blocker_user_id\` = ${blockerExpr} ` + - `AND \`ub\`.\`blocked_user_id\` = ${blockedExpr})`; - /** One row of `user_block`. `created_at` is unix seconds. */ export interface UserBlockRow { id: number; diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index a8a20739e..59f6d769f 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -37,7 +37,8 @@ Who to share with. A string containing `@` is treated as an email address, and a Where the deployment has [Teams](/Teams/), pass `{ team: uid }` to share with every member of a team the caller belongs to — including anyone added to it later. There is no string form for a team: a bare string is always read as an email or username. -A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed, announced, or pushed to them while their block stands; the grant itself is untouched, so lifting the block restores their view without a re-share. The rest of the team is unaffected, and nothing tells the sharer. The blanket "block everyone" switch does not extend to teams the recipient belongs to — blocking the sender, or leaving the team, is what stops those. +A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares`, and **recipient blocks do not apply to it**: the grant is the team's, not one colleague's to withhold from another. A member who has blocked you still reaches anything you share with a team you both belong to — they are simply not notified about it. Leaving the team is what ends that access. + Pass `{ anyone: true }` to share with **anyone with the link** — see below. Only the object form is read as that; the word `anyone` typed as a string is a username like any other. #### `mode` (String) (optional) diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md index 20d3c3e21..24f7531e7 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -6,115 +6,98 @@ platforms: [websites, apps]
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
-A team is a Puter account that pays for other accounts. Members are ordinary -Puter accounts — your app talks to them like any other user, and the team never -gains access to a member's files. +The Puter.js Teams feature lets your app see the team context around the user in front of it: whether they belong to one, and who their colleagues are. -Team *administration* — creating teams, provisioning accounts, suspending them — -happens in the account console, not through apps: those routes refuse app and -API-token callers outright. What `puter.teams` offers an app is the read-only -context around the user in front of it. +A team is a Puter account that pays for other accounts. Members are ordinary Puter accounts — your app talks to them like any other user, and the team never gains access to a member's files. Team *administration* (creating teams, provisioning accounts, suspending them) happens in the account console rather than through apps, so what `puter.teams` offers is read-only. -```js -const teams = await puter.teams.list(); -const colleagues = await puter.teams.listDirectory(teams[0]?.uid); +## Features + +**Team context.** `list()` tells you whether the signed-in user belongs to a team, and is also how you detect whether the deployment has Teams at all: it rejects with `not_found` where the feature is off, and resolves to an empty array where it is on and the user has no team. + +**Colleague lookup.** `listDirectory()` returns the team's members so your app can suggest people by name instead of asking users to type usernames. It is opt-in per team — until an owner opens the directory, it answers `team_not_found`, which is indistinguishable from having no team. + +**Sharing with a team.** Anything shared with a team reaches every member with one grant, including anyone added later. Pass the team's `uid` as the recipient of [`puter.fs.share()`](/FS/share/); there is no string form, since a bare string is always read as an email or username. + +**Stable identifiers.** A team has a `uid` and an optional `handle`. Only the `uid` is stable — a handle is a mutable label, and deleting the team releases it for anyone else to take. Display the `name` and `handle`; pass the `uid`. + +## Functions + +- **[`puter.teams.list()`](/Teams/list/)** - List the teams the signed-in user belongs to, and detect whether Teams is available at all +- **[`puter.teams.listDirectory()`](/Teams/listDirectory/)** - List a team's members, where the owner has opened the directory to apps + +Both are keyset-paginated and take the same options; see either method page for the paging forms and the full error list. + +## Examples + +Detect whether the user is on a team + +```html + + + + + + ``` -## Availability +Suggest a colleague to share with -Teams are an opt-in deployment feature. Where they are turned off, the routes -behind `puter.teams` do not exist and every method rejects with `not_found`. +```html + + + + + + ``` -## `uid`, not `handle` +Share a file with the whole team -A team has both a `uid` and an optional `handle`. Only the `uid` is stable: a -handle is a mutable label, and deleting the team releases it for anyone else to -take. Display the `name` and `handle`; pass the `uid`. - -## Methods - -| Method | Returns | -| -- | -- | -| [`list(options)`](/Teams/list/) | The caller's teams | -| [`listDirectory(uid, options)`](/Teams/listDirectory/) | The team's member directory, where the owner has opened it to apps | - -## Sharing with a team - -A team can receive a share like a person can — one grant reaches every member, -including anyone added later. Pass the team's `uid` as the recipient: - -```js -await puter.fs.share({ path, recipient: { team: team.uid }, mode: 'read' }); +```html + + + + + + ``` -A member who has blocked the sharer is the one exception: while the block -stands, nothing that sharer puts into the team is listed or announced to that -member, and the sharer is not told. The underlying grant is untouched, so -lifting the block restores the member's view. - -See [`puter.fs.share()`](/FS/share/) for the full sharing API. - -## Pagination - -`list()` and `listDirectory()` take the same options and offer the same three -forms: - -| Call | Resolves to | -| -- | -- | -| No options | The whole set as an array, fetched page by page under the hood | -| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | -| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | - -`{ limit }` on its own still resolves to an array, capped at one page. - -These routes are keyset-paginated, so `offset` is not accepted — passing it -throws `invalid_request`. Pass `cursor` to resume from a position. - -## Objects - -#### `Team` - -| Field | Type | Description | -| -- | -- | -- | -| `uid` | `string` | The team's stable identifier. | -| `name` | `string \| null` | Its display name. | -| `handle` | `string \| null` | Its short handle, unique while it exists. | -| `isOwner` | `boolean` | Whether the caller is the owner account. | -| `createdAt` | `string` | When it was created. | - -#### `TeamDirectoryEntry` - -| Field | Type | Description | -| -- | -- | -- | -| `username` | `string` | A colleague's Puter username. | -| `uuid` | `string` | Their stable account identifier. | - -## Errors - -Every method rejects with an `Error` carrying a stable `code`: - -| Code | Meaning | -| -- | -- | -| `invalid_request` | The call was refused before reaching the server — a blank `uid`, an `offset` on a keyset list. | -| `unauthorized` | Not signed in. | -| `account_is_not_verified` | The caller's email has not been confirmed. | -| `not_found` | Teams are turned off on this deployment. | -| `team_not_found` | No such team, the caller is not a member of it, or its directory is not open to apps. | -| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | - ## What is deliberately absent - **No sharing-policy controls.** A team cannot restrict who its members share diff --git a/src/docs/src/Teams/list.md b/src/docs/src/Teams/list.md index c050f98c1..fbdf7f22d 100644 --- a/src/docs/src/Teams/list.md +++ b/src/docs/src/Teams/list.md @@ -21,11 +21,42 @@ puter.teams.list(options) #### `options` (Object) (optional) -The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination) for what each form returns. `offset` is not accepted. +The standard list options. All four are optional, and they decide the shape of what resolves: + +| Call | Resolves to | +| -- | -- | +| No options | The whole set as an array, fetched page by page under the hood | +| `{ limit }` | An array, capped at one page | +| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | +| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | + +This route is keyset-paginated, so `offset` is not accepted — passing it throws `invalid_request`. Pass `cursor` to resume from a position. ## Return value -A `Promise` that resolves to an array of [`Team`](/Teams/#team) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. +A `Promise` that resolves to an array of `Team` objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +#### `Team` + +| Field | Type | Description | +| -- | -- | -- | +| `uid` | `string` | The team's stable identifier — pass this, not the handle. | +| `name` | `string \| null` | Its display name. | +| `handle` | `string \| null` | Its short handle, unique while the team exists. | +| `isOwner` | `boolean` | Whether the caller is the owner account. | +| `createdAt` | `string` | When it was created. | + +## Errors + +A rejection carries an `Error` with a stable `code`: + +| Code | Meaning | +| -- | -- | +| `invalid_request` | Refused before reaching the server — a blank `uid`, or an `offset` on a keyset list. | +| `unauthorized` | Not signed in. | +| `account_is_not_verified` | The caller's email has not been confirmed. | +| `not_found` | Teams are turned off on this deployment. | +| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | ## Examples diff --git a/src/docs/src/Teams/listDirectory.md b/src/docs/src/Teams/listDirectory.md index 4193d482d..e72d0b610 100644 --- a/src/docs/src/Teams/listDirectory.md +++ b/src/docs/src/Teams/listDirectory.md @@ -30,17 +30,44 @@ The team's `uid`, from [`list()`](/Teams/list/). #### `options` (Object) (optional) -The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See -[Pagination](/Teams/#pagination) for what each form returns. `offset` is not -accepted. +The standard list options. All four are optional, and they decide the shape of what resolves: + +| Call | Resolves to | +| -- | -- | +| No options | The whole set as an array, fetched page by page under the hood | +| `{ limit }` | An array, capped at one page | +| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | +| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | + +This route is keyset-paginated, so `offset` is not accepted — passing it throws `invalid_request`. Pass `cursor` to resume from a position. ## Return value A `Promise` that resolves to an array of -[`TeamDirectoryEntry`](/Teams/#teamdirectoryentry) objects, or to a +`TeamDirectoryEntry` objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. +#### `TeamDirectoryEntry` + +| Field | Type | Description | +| -- | -- | -- | +| `username` | `string` | A colleague's Puter username. | +| `uuid` | `string` | Their stable account identifier. | + +## Errors + +A rejection carries an `Error` with a stable `code`: + +| Code | Meaning | +| -- | -- | +| `invalid_request` | Refused before reaching the server — a blank `uid`, or an `offset` on a keyset list. | +| `unauthorized` | Not signed in. | +| `account_is_not_verified` | The caller's email has not been confirmed. | +| `not_found` | Teams are turned off on this deployment. | +| `team_not_found` | No such team, the caller is not a member of it, or the owner has not opened the directory to apps. | +| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | + ## Examples Suggest colleagues to share with diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 920242498..760d58229 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -485,10 +485,10 @@ const en = { blocked_senders: 'Blocked people', blocked_senders_summary: 'People who can’t share with you', blocked_senders_note: - 'Blocked people can’t share anything new with you, and anything they share through a team you’re in stays hidden from you. What they already shared directly stays until you remove it.', + 'Blocked people can’t share anything new with you, and you stop being notified about what they share. Files they share with a team you’re both on still reach you — that grant is the team’s. What they already shared directly stays until you remove it.', blocked_all: 'Don’t let anyone share with me', blocked_all_note: - 'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — block the sender, or leave the team.', + 'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — leaving the team is what ends those.', blocked_all_on: 'New shares are now refused from everyone', blocked_all_off: 'You’re accepting shares again', blocked_add: 'Block someone',