Merge pull request #3872 from HeyPuter/juancastro/put-1813-team-share-blocklist-and-unshare-paging

fix: make the block list bite on team shares, and unshare every re-share (PUT-1813)

Approvals in place to move with the merge
This commit is contained in:
Juan Fernando Castro
2026-09-21 11:31:24 -04:00
committed by GitHub
28 changed files with 680 additions and 271 deletions
+1 -2
View File
@@ -303,8 +303,7 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
+4 -5
View File
@@ -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 string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -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;
+2 -9
View File
@@ -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 RouteOptions> = O extends {
? true
: O extends {
requireSubscription:
| true
| readonly string[]
| string[];
true | readonly string[] | string[];
}
? true
: O extends { requireReputation: string }
@@ -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
@@ -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<string, unknown>
| undefined;
Record<string, unknown> | 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<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const byteplusSharedCfg = providers['byteplus'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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,
);
+2 -4
View File
@@ -138,11 +138,9 @@ export class OCRDriver extends PuterDriver {
const providers = this.config.providers ?? {};
const textract = providers['aws-textract'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const textractAws = (textract?.aws ?? textract) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const textractAccessKey = textractAws?.access_key as string | undefined;
const textractSecretKey = textractAws?.secret_key as string | undefined;
const textractRegion =
@@ -93,8 +93,7 @@ export class VoiceChangerDriver extends PuterDriver {
override onServerStart() {
const elevenlabs = this.config.providers?.elevenlabs as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
this.#apiKey =
(elevenlabs?.apiKey as string | undefined) ??
+6 -12
View File
@@ -272,8 +272,7 @@ export class TTSDriver extends PuterDriver {
}
const elevenlabs = providers['elevenlabs'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const pollyAws = (polly?.aws ?? polly) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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<string, unknown>) {
const m = this.#aiMetering;
const gemini = (providers['gemini'] ?? providers['gemini-tts']) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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<string, unknown>) {
const m = this.#aiMetering;
const xai = (providers['xai'] ?? providers['xai-tts']) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const xaiKey =
(xai?.apiKey as string | undefined) ??
(xai?.api_key as string | undefined) ??
@@ -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<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const byteplusSharedCfg = providers['byteplus'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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,
);
+1 -5
View File
@@ -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 {
+1 -4
View File
@@ -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 (
+3 -7
View File
@@ -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;
+2 -6
View File
@@ -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;
+113 -21
View File
@@ -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<number> {
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<number>();
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);
+281 -5
View File
@@ -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);
});
});
+3 -7
View File
@@ -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<string, unknown>
| undefined;
Record<string, unknown> | 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;
+1 -2
View File
@@ -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;
@@ -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,
+59 -21
View File
@@ -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<number[]>}
*/
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(
+12 -25
View File
@@ -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<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (!startKey) {
exhausted = remaining > 0;
break;
@@ -1634,8 +1624,7 @@ export class SystemKVStore extends PuterStore {
>),
);
nextKey = response.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | 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<string, unknown>
| undefined;
Record<string, unknown> | undefined;
} while (countKey);
}
@@ -1992,8 +1980,7 @@ export class SystemKVStore extends PuterStore {
probeUsage,
writeUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
),
};
+23 -2
View File
@@ -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<number[]> {
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,
+3 -7
View File
@@ -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<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];
+2
View File
@@ -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)
+81 -93
View File
@@ -6,110 +6,98 @@ platforms: [websites, apps]
<div class="info">The Teams API is in beta. Method shapes, limits, and behavior may change between releases.</div>
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
<strong class="example-title">Detect whether the user is on a team</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
let teams = [];
try {
teams = await puter.teams.list();
} catch (e) {
// Teams are unavailable on this deployment.
}
puter.print(teams.length
? `On ${teams.length} team(s): ${teams.map(t => t.name).join(', ')}`
: 'Not on a team');
})();
</script>
</body>
</html>
```
## Availability
<strong class="example-title">Suggest a colleague to share with</strong>
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
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const [team] = await puter.teams.list();
if ( ! team ) return puter.print('Not on a team');
`puter.teams.list()` is how an app tells the two apart: it rejects when the
feature is off, and resolves to an empty array when it is on and the caller has
no team.
```js
let teams = [];
try {
teams = await puter.teams.list();
} catch (e) {
// Teams are unavailable here; show nothing.
}
try {
const colleagues = await puter.teams.listDirectory(team.uid);
colleagues.forEach(c => puter.print(`${c.username}<br>`));
} catch (e) {
// The owner has not opened the directory to apps.
puter.print('No directory for this team');
}
})();
</script>
</body>
</html>
```
## `uid`, not `handle`
<strong class="example-title">Share a file with the whole team</strong>
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
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const [team] = await puter.teams.list();
await puter.fs.write('report.txt', 'Quarterly numbers');
await puter.fs.share({
path: 'report.txt',
recipient: { team: team.uid },
mode: 'read',
});
puter.print(`Shared with everyone on ${team.name}`);
})();
</script>
</body>
</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
+33 -2
View File
@@ -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
+31 -4
View File
@@ -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
<strong class="example-title">Suggest colleagues to share with</strong>
+2 -2
View File
@@ -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',