mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-29 08:38:06 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user