diff --git a/extensions/whoami.ts b/extensions/whoami.ts index 50c62d15e..d171b321a 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -303,8 +303,7 @@ export const handleWhoami = async ( } const subscription = details.subscription as - | { offering?: Record } - | undefined; + { offering?: Record } | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 310c9bc8a..6e47134c9 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -808,11 +808,10 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? - | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/controllers/events/workerDeploy.ts b/src/backend/controllers/events/workerDeploy.ts index cef035ea9..7f490e3c6 100644 --- a/src/backend/controllers/events/workerDeploy.ts +++ b/src/backend/controllers/events/workerDeploy.ts @@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js'; /** Why a deploy could not happen, for the callers that answer differently. */ export type EventsDeployOutcome = - | 'deployed' - | 'stale' - | 'no-handlers' - | 'no-owner' - | 'throttled' - | 'failed'; + 'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed'; interface DeployLayers { config: IConfig; diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 42c413288..e93ea4e96 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -27,12 +27,7 @@ import type { Actor } from '../actor'; * it instead of accepting any token that authenticates. */ export type TokenSource = - | 'body' - | 'header' - | 'x-api-key' - | 'cookie' - | 'query' - | 'handshake'; + 'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake'; /** Express router methods plus the WebDAV verbs some endpoints use. */ export type RouteMethod = @@ -340,9 +335,7 @@ export type AuthRequired = O extends { ? true : O extends { requireSubscription: - | true - | readonly string[] - | string[]; + true | readonly string[] | string[]; } ? true : O extends { requireReputation: string } diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts index 60fbcaedd..ac2524364 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts @@ -154,8 +154,7 @@ export class MetaProvider implements IChatProvider { // Reasoning is always on for Muse Spark — `reasoning_effort: 'none'` // is a 400 — so a request to switch it off is dropped, not forwarded. const requestedEffort = (reasoning_effort ?? reasoning?.effort) as - | string - | undefined; + string | undefined; const effort = requestedEffort && requestedEffort !== 'none' ? requestedEffort diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.ts index 644787fd3..a5032b080 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.ts @@ -305,8 +305,7 @@ export class ImageGenerationDriver extends PuterDriver { const cloudflare = (providers['cloudflare-image-generation'] ?? providers['cloudflare-workers-ai-image'] ?? providers['cloudflare-workers-ai']) as - | Record - | undefined; + Record | undefined; const cfToken = (cloudflare?.apiToken as string | undefined) ?? (cloudflare?.apiKey as string | undefined) ?? @@ -321,8 +320,7 @@ export class ImageGenerationDriver extends PuterDriver { apiToken: cfToken, accountId: cfAccount, apiBaseUrl: cloudflare?.apiBaseUrl as - | string - | undefined, + string | undefined, }, m, ); @@ -354,11 +352,9 @@ export class ImageGenerationDriver extends PuterDriver { // pair its missing apiBaseUrl with the shared block's key (or vice // versa) and point a region-scoped key at the wrong endpoint. const byteplusImageCfg = providers['byteplus-image-generation'] as - | Record - | undefined; + Record | undefined; const byteplusSharedCfg = providers['byteplus'] as - | Record - | undefined; + Record | undefined; const byteplusKey = readKey(byteplusImageCfg, byteplusSharedCfg); if (byteplusKey) { this.#providers['byteplus-image-generation'] = @@ -367,8 +363,7 @@ export class ImageGenerationDriver extends PuterDriver { apiKey: byteplusKey, apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ?? byteplusSharedCfg?.apiBaseUrl) as - | string - | undefined, + string | undefined, }, m, ); diff --git a/src/backend/drivers/ai-ocr/OCRDriver.ts b/src/backend/drivers/ai-ocr/OCRDriver.ts index 3aef5db5a..b77e0426c 100644 --- a/src/backend/drivers/ai-ocr/OCRDriver.ts +++ b/src/backend/drivers/ai-ocr/OCRDriver.ts @@ -138,11 +138,9 @@ export class OCRDriver extends PuterDriver { const providers = this.config.providers ?? {}; const textract = providers['aws-textract'] as - | Record - | undefined; + Record | undefined; const textractAws = (textract?.aws ?? textract) as - | Record - | undefined; + Record | undefined; const textractAccessKey = textractAws?.access_key as string | undefined; const textractSecretKey = textractAws?.secret_key as string | undefined; const textractRegion = diff --git a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts index d90f28f5a..ef407d921 100644 --- a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts +++ b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts @@ -93,8 +93,7 @@ export class VoiceChangerDriver extends PuterDriver { override onServerStart() { const elevenlabs = this.config.providers?.elevenlabs as - | Record - | undefined; + Record | undefined; this.#apiKey = (elevenlabs?.apiKey as string | undefined) ?? diff --git a/src/backend/drivers/ai-tts/TTSDriver.ts b/src/backend/drivers/ai-tts/TTSDriver.ts index ea0e43c90..8da0d2bee 100644 --- a/src/backend/drivers/ai-tts/TTSDriver.ts +++ b/src/backend/drivers/ai-tts/TTSDriver.ts @@ -272,8 +272,7 @@ export class TTSDriver extends PuterDriver { } const elevenlabs = providers['elevenlabs'] as - | Record - | undefined; + Record | undefined; const elevenKey = (elevenlabs?.apiKey as string | undefined) ?? (elevenlabs?.api_key as string | undefined) ?? @@ -284,8 +283,7 @@ export class TTSDriver extends PuterDriver { apiKey: elevenKey, apiBaseUrl: elevenlabs?.apiBaseUrl as string | undefined, defaultVoiceId: elevenlabs?.defaultVoiceId as - | string - | undefined, + string | undefined, }); } catch (e) { console.warn( @@ -296,11 +294,9 @@ export class TTSDriver extends PuterDriver { } const polly = providers['aws-polly'] as - | Record - | undefined; + Record | undefined; const pollyAws = (polly?.aws ?? polly) as - | Record - | undefined; + Record | undefined; const pollyAccessKey = pollyAws?.access_key as string | undefined; const pollySecretKey = pollyAws?.secret_key as string | undefined; const pollyRegion = @@ -329,8 +325,7 @@ export class TTSDriver extends PuterDriver { #registerGeminiProvider(providers: Record) { const m = this.#aiMetering; const gemini = (providers['gemini'] ?? providers['gemini-tts']) as - | Record - | undefined; + Record | undefined; const geminiKey = (gemini?.apiKey as string | undefined) ?? (gemini?.api_key as string | undefined) ?? @@ -352,8 +347,7 @@ export class TTSDriver extends PuterDriver { #registerXAIProvider(providers: Record) { const m = this.#aiMetering; const xai = (providers['xai'] ?? providers['xai-tts']) as - | Record - | undefined; + Record | undefined; const xaiKey = (xai?.apiKey as string | undefined) ?? (xai?.api_key as string | undefined) ?? diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 2c21694ee..3a1939e70 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -351,11 +351,9 @@ export class VideoGenerationDriver extends PuterDriver { // pair its missing apiBaseUrl with the shared block's key (or vice // versa) and point a region-scoped key at the wrong endpoint. const byteplusVideoCfg = providers['byteplus-video-generation'] as - | Record - | undefined; + Record | undefined; const byteplusSharedCfg = providers['byteplus'] as - | Record - | undefined; + Record | undefined; const byteplusKey = readKey(byteplusVideoCfg, byteplusSharedCfg); if (byteplusKey) { this.#providers['byteplus-video-generation'] = @@ -364,8 +362,7 @@ export class VideoGenerationDriver extends PuterDriver { apiKey: byteplusKey, apiBaseUrl: (byteplusVideoCfg?.apiBaseUrl ?? byteplusSharedCfg?.apiBaseUrl) as - | string - | undefined, + string | undefined, }, m, ); diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts index 2049656df..08b51429d 100644 --- a/src/backend/services/acl/ACLService.ts +++ b/src/backend/services/acl/ACLService.ts @@ -47,11 +47,7 @@ export interface ResourceDescriptor { } export type AclMode = - | 'see' - | 'list' - | 'read' - | 'write' - | typeof MANAGE_PERM_PREFIX; + 'see' | 'list' | 'read' | 'write' | typeof MANAGE_PERM_PREFIX; /** Duck-typed error shape compatible with APIError consumers (fsv2). */ export interface AclError { diff --git a/src/backend/services/events/authorization.ts b/src/backend/services/events/authorization.ts index 0e851b7c0..c7db1116d 100644 --- a/src/backend/services/events/authorization.ts +++ b/src/backend/services/events/authorization.ts @@ -294,10 +294,7 @@ export interface CrossAppKvDeps { } export type CrossAppKvDenial = - | 'disabled' - | 'unknown_app' - | 'sharing_off' - | 'not_granted'; + 'disabled' | 'unknown_app' | 'sharing_off' | 'not_granted'; /** Why this actor may not watch `targetAppUid`, or `null` when it may. */ export const crossAppKvDenial = async ( diff --git a/src/backend/services/events/forwardQueue.ts b/src/backend/services/events/forwardQueue.ts index 99d621d9b..c47198da8 100644 --- a/src/backend/services/events/forwardQueue.ts +++ b/src/backend/services/events/forwardQueue.ts @@ -117,11 +117,7 @@ export interface ForwardBump { } export type ForwardItem = - | ForwardDelivery - | ForwardAck - | ForwardWatch - | ForwardEvent - | ForwardBump; + ForwardDelivery | ForwardAck | ForwardWatch | ForwardEvent | ForwardBump; /** One batch, as a peer receives it. */ export interface ForwardBatch { @@ -334,7 +330,7 @@ const isGapMarker = (item: ForwardItem): boolean => */ const shed = (queue: PeerQueue, count: number): ForwardItem[] => { const dropped: ForwardItem[] = []; - for (let i = 0; i < queue.items.length && dropped.length < count; ) { + for (let i = 0; i < queue.items.length && dropped.length < count;) { if (isGapMarker(queue.items[i])) { i++; continue; @@ -357,7 +353,7 @@ const shed = (queue: PeerQueue, count: number): ForwardItem[] => { const shedBytes = (queue: PeerQueue, maxBytesHeld: number): ForwardItem[] => { const dropped: ForwardItem[] = []; let remaining = queue.bytes; - for (let i = 0; i < queue.items.length && remaining > maxBytesHeld; ) { + for (let i = 0; i < queue.items.length && remaining > maxBytesHeld;) { if (isGapMarker(queue.items[i])) { i++; continue; diff --git a/src/backend/services/events/registry.ts b/src/backend/services/events/registry.ts index fa056d74d..b2b83e9e1 100644 --- a/src/backend/services/events/registry.ts +++ b/src/backend/services/events/registry.ts @@ -106,9 +106,7 @@ export interface ProjectedNotifEvent extends ProjectedEventBase { } export type ProjectedEvent = - | ProjectedFsEvent - | ProjectedKvEvent - | ProjectedNotifEvent; + ProjectedFsEvent | ProjectedKvEvent | ProjectedNotifEvent; export type GapReason = | 'matched_subscription_limit' @@ -265,9 +263,7 @@ export interface NotifPublicSubject extends SubjectSpec< } export type PublicSubject = - | FsPublicSubject - | KvPublicSubject - | NotifPublicSubject; + FsPublicSubject | KvPublicSubject | NotifPublicSubject; export interface UnpublishedInternalEvent { event: EventKey; diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 01e493ede..3f4a8eca7 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -35,7 +35,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 { @@ -1472,6 +1472,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 = ( @@ -1480,7 +1484,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( @@ -1495,7 +1499,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)); @@ -1537,6 +1540,65 @@ 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; + let authorized = false; + for (const permission of entryPermissions(node.uuid)) { + if ( + !(await this.services.permission.canManagePermission( + actor, + permission, + )) + ) { + continue; + } + authorized = true; + if ( + await this.services.permission.revokeUserGroupPermission( + actor, + team.uid, + permission, + { reason: 'unshared' }, + { issuerUserId: issuerId }, + ) + ) { + 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, + 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 @@ -2406,7 +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. + * 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, @@ -2423,6 +2487,12 @@ 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. + * + * 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, @@ -2904,26 +2974,48 @@ 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; - // Nor does what a member re-shared on `manage` held elsewhere — - // another person's grant, another team's, or a folder above. - if ( - await this.#managedFromOutside(entry, memberId, { - groupId: team.id, - }) - ) { - continue; + // 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)), + ); + 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, + )) { + // Nor does what a member re-shared on `manage` held elsewhere — + // another person's grant, another team's, or a folder above. + if ( + await this.#managedFromOutside(entry, memberId, { + groupId: team.id, + }) + ) { + continue; + } + revoked += await this.#revokeDownstream( + me, + entry, + memberId, + seen, + ); } - revoked += await this.#revokeDownstream(me, entry, memberId); } const permissions = entryPermissions(entry.uuid); diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 5f1b0a212..93f700c58 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,264 @@ describe('sharing with a team', () => { ); expect(rows.some((r) => r.holderTeam?.uid === fx.a.uid)).toBe(true); }); + + // -- the recipient block list --------------------------------------- + // 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); + const unblock = (blocker: FixtureUser, blocked: FixtureUser) => + fx.env.server.stores.userBlock.deleteByPair( + blocker.userId, + blocked.userId, + ); + + 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 file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { + team: fx.a.uid, + }); + + // 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(mine.items.map((i) => i.entryUid)).toContain(file.uid); + + // 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`], + ), + ).toHaveLength(1); + + const others = (await inbox(otherSeat.userId)).items; + expect(others.map((i) => i.entryUid)).toContain(file.uid); + } finally { + await unblock(blockingSeat, fx.a.owner); + } + }); + + it('counts a team share for a blocking member, so page and total agree', async () => { + const seat = fx.a.seats[0]; + await block(seat, fx.a.owner); + try { + 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); + } + }); + + 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( + file.uid, + ); + + await block(blockingSeat, fx.a.owner); + try { + // They can open the file, so they have to be told it changed. + const rows = + await fx.env.server.stores.share.listGroupReachingMembers([ + entry.id, + ]); + expect(rows.map((r) => Number(r.holder_user_id))).toContain( + blockingSeat.userId, + ); + } finally { + await unblock(blockingSeat, 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]; + 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); + }); + + 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/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index 47ae48e0a..b240b0abb 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -383,8 +383,7 @@ export class SocketService extends PuterService { // `{ auth: { ... } }`, not the query string. puter-js uses // `io(url, { auth: { auth_token } })`. const handshakeAuth = socket.handshake.auth as - | Record - | undefined; + Record | undefined; const tokenRaw = typeof handshakeAuth?.auth_token === 'string' ? handshakeAuth.auth_token @@ -826,8 +825,7 @@ export class SocketService extends PuterService { // their next poll of /cache/last-change-timestamp. const originalSocketId = ( data.response as - | { original_client_socket_id?: string } - | undefined + { original_client_socket_id?: string } | undefined )?.original_client_socket_id; await this.send({ room: userId }, 'cache.updated', { timestamp, @@ -841,9 +839,7 @@ export class SocketService extends PuterService { #handleUploadProgress(data: UploadProgressPayload): void { const meta = data.meta ?? {}; const userId = (meta.user_id ?? meta.userId) as - | number - | string - | undefined; + number | string | undefined; if (!userId) { console.warn('[socket] upload-progress missing user_id', { meta }); return; diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index da731e3f8..280e78341 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -2608,8 +2608,7 @@ export class FSEntryStore extends PuterStore { } = {}, ): Promise<{ entries: FSEntry[]; cursor?: string }> { const payload = decodeCursor(options.cursor) as - | { v: unknown; id: number; s?: string; o?: string } - | undefined; + { v: unknown; id: number; s?: string; o?: string } | undefined; const requestedSort = options.sortBy ?? null; const requestedOrder = options.sortOrder ?? null; diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 82431471f..7729df6ea 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -986,6 +986,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, diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 707987f4e..04f1ec188 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -88,18 +88,21 @@ 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. 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 .map(() => '?') .join(', ')}))` : '`holder_user_id` = ?'; + const holderParams = [holderUserId, ...groups]; // 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; @@ -307,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 ' + @@ -322,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)); + } + /** * Every share row on a node and everything beneath it, whatever kind — * user, group and link shares plus unclaimed invites. @@ -332,12 +348,7 @@ export class ShareStore extends PuterStore { */ async listAllByFsentrySubtree(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` ' + 'ORDER BY `share`.`id`', @@ -384,7 +395,8 @@ export class ShareStore extends PuterStore { '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 ' + + 'ORDER BY `share`.`id`', fsentryIds, ); // Shaped as a holder row, so the caller's fan-out needs no group branch. @@ -394,6 +406,23 @@ 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( + this.#subtreeCte() + + '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. Link * shares count unless `includeAnyone` is false — what the caller passes @@ -431,6 +460,8 @@ export class ShareStore extends PuterStore { */ async countByHolder(holderUserId, { groupIds = [] } = {}) { const groups = [...new Set(groupIds)].filter(Boolean); + // 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 .map(() => '?') @@ -832,12 +863,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 and link rows also have no holder user; deleting one @@ -1031,6 +1057,18 @@ 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`' + + ') ' + ); + } + /** @param {number} [limit] */ #pageSize(limit) { return Math.min( diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index ac54adc44..eb87afa87 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -286,8 +286,7 @@ const unsafeKeyError = (key: string, subject: string): HttpError => }); type PathToken = - | { type: 'key'; value: string } - | { type: 'index'; value: number }; + { type: 'key'; value: string } | { type: 'index'; value: number }; const invalidPathError = (): HttpError => new HttpError(400, 'kv: path has invalid syntax', { @@ -1163,8 +1162,7 @@ export class SystemKVStore extends PuterStore { fetched = response.Item ? [response.Item as KvCachedItem] : []; fetchUnits = Number( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 0, + number | undefined) ?? 0, ); } @@ -1236,8 +1234,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( response.ConsumedCapacity?.CapacityUnits as - | number - | undefined, + number | undefined, ), ), }; @@ -1337,8 +1334,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ), }; @@ -1366,8 +1362,7 @@ export class SystemKVStore extends PuterStore { await this.#committed(actor, namespace, [key], 'del', [null]); const old = response.Attributes as - | { value?: unknown; ttl?: number } - | undefined; + { value?: unknown; ttl?: number } | undefined; const now = Date.now() / 1000; const res = old === undefined || (old.ttl && old.ttl <= now) @@ -1380,8 +1375,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ), }; @@ -1467,9 +1461,7 @@ export class SystemKVStore extends PuterStore { | { key: string; value: unknown }[] | { items: - | string[] - | unknown[] - | { key: string; value: unknown }[]; + string[] | unknown[] | { key: string; value: unknown }[]; cursor?: string; total?: number; } @@ -1592,8 +1584,7 @@ export class SystemKVStore extends PuterStore { usage, readUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ); return response; @@ -1610,8 +1601,7 @@ export class SystemKVStore extends PuterStore { const skip = await runQuery(remaining, startKey, 'COUNT'); remaining -= Number(skip.Count ?? 0); startKey = skip.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; if (!startKey) { exhausted = remaining > 0; break; @@ -1634,8 +1624,7 @@ export class SystemKVStore extends PuterStore { >), ); nextKey = response.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; pages++; if (normalizedLimit === undefined) { // Legacy full listing: follow continuation pages so the @@ -1671,8 +1660,7 @@ export class SystemKVStore extends PuterStore { const counted = await runQuery(0, countKey, 'COUNT'); total += Number(counted.Count ?? 0); countKey = counted.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; } while (countKey); } @@ -1992,8 +1980,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ), }; diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index c26a90e74..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 -------------------------------------------------------- @@ -367,6 +368,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 ${this.#live('g')} ` + + `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/backend/types.ts b/src/backend/types.ts index ecf2a2f07..75927c77b 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -209,12 +209,7 @@ export interface IPreludeConfig { * an RCS agent provisioned in the Prelude account to actually use RCS. */ preferredChannel?: - | 'sms' - | 'rcs' - | 'whatsapp' - | 'viber' - | 'zalo' - | 'telegram'; + 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram'; } /** @@ -1208,7 +1203,8 @@ export interface WithLifecycle extends Object { } export interface WithCostsReporting extends WithLifecycle { - getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any + getReportedCosts?: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any | Promise[]> // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record[]; diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index 15fcecfa6..59f6d769f 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -37,6 +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`, 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 a73ad27f3..24f7531e7 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -6,110 +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 + + + + + + ``` -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 804f7e1d6..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. What they already shared 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.', + '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',