diff --git a/config.default.json b/config.default.json index 29c081f29..bceb16cff 100644 --- a/config.default.json +++ b/config.default.json @@ -23,6 +23,8 @@ "storage_capacity": 104857600, "disable_user_signup": false, "teams_enabled": false, + "max_teams_per_user": 1, + "max_seats_per_team": 50, "strict_email_verification_required": false, "gui_assets_root": "./src/gui", "puterjs_root": "./src/puter-js/dist", diff --git a/config.template.jsonc b/config.template.jsonc index fb9f7a5df..7f4cb0640 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -280,6 +280,17 @@ // existing and refusing. It is also the backout: turning it off removes // the feature without touching data. "teams_enabled": false, + // + // Live teams one user may own. Soft-deleted ones do not count, so + // deleting yours frees the slot. Default 1. + "max_teams_per_user": 1, + // + // Seats one team may provision. Each seat is a real Puter account + // taking a name from the global username pool, so this is what bounds a + // team's blast radius until per-seat billing lands. Lowering it below + // a team's current seat count blocks new provisioning and disables + // nobody. Default 50. + "max_seats_per_team": 50, // ── Notifications ─────────────────────────────────────────────────── // How long a notification is kept, in days from creation. Acknowledged or diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index d4540a5dc..d5c759e76 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -35,6 +35,24 @@ type GuiEvent> = { // The entry arrives under several aliases, for handlers of any vintage. type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string }; +/** + * Who pays, and for which team. Deliberately no payment identity: the + * payer's row is alive at emit time, so a consumer resolves it when it acts -- + * a snapshot taken here would be stale, and two events racing on it would each + * create their own customer. + */ +export type TeamBillingContext = { + team_uid: string; + owner_user_id: number; +}; + +/** A team event about one seat. */ +export type TeamBillingEvent = TeamBillingContext & { + user_id: number; + user_uuid: string; + username: string; +}; + /** * Extension-augmentable half of {@link EventMap}. Extensions that emit their own * events declare the payload here by declaration merging, so both the emitter @@ -336,6 +354,23 @@ export type EventMap = { stripe_customer_id?: string | null; }; + // ---- Team billing ---- the trigger, never the charge ---- + 'team.account.created': TeamBillingEvent; + /** `held_bytes` is what to bill storage on while the seat is off. */ + 'team.account.disabled': TeamBillingEvent & { held_bytes: number }; + 'team.account.enabled': TeamBillingEvent & { held_bytes: number }; + /** Emitted pre-delete: the membership row cascades away with the user. */ + 'team.account.deleted': TeamBillingEvent; + /** Per-seat charges stop; byte charges do not, the accounts remain. */ + 'team.deleted': TeamBillingContext & { account_count: number }; + /** A budget line was crossed. Emitted on transition only, never per request. */ + 'metering.credit-state': { + user_uuid: string; + state: 'near-limit' | 'exhausted'; + allowance_used: number; + month_usage_allowance: number; + }; + // ---- Filesystem ---- 'fs.copy.node': { source: unknown; @@ -758,11 +793,10 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? - | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts index 081004896..ba084c71e 100644 --- a/src/backend/controllers/team/TeamController.http.test.ts +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -27,7 +27,11 @@ describe('team endpoints over HTTP', () => { let env: PuterTestEnv; beforeAll(async () => { - env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + // The cap has its own suite; these tests need many teams. + env = await setupPuterTestEnv({ + teams_enabled: true, + max_teams_per_user: 100, + } as IConfig); }, 120_000); afterAll(async () => { diff --git a/src/backend/controllers/team/TeamIsolation.http.test.ts b/src/backend/controllers/team/TeamIsolation.http.test.ts index 2a37a02d0..c6a03f172 100644 --- a/src/backend/controllers/team/TeamIsolation.http.test.ts +++ b/src/backend/controllers/team/TeamIsolation.http.test.ts @@ -79,7 +79,11 @@ describe('a team cannot read its members data', () => { }); beforeAll(async () => { - env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + // The cap has its own suite; these tests need many teams. + env = await setupPuterTestEnv({ + teams_enabled: true, + max_teams_per_user: 100, + } as IConfig); // A provisioned account cannot authenticate until it activates. const res = await fetch(new URL('/teams', env.apiOrigin), { diff --git a/src/backend/core/http/HttpError.ts b/src/backend/core/http/HttpError.ts index 7c6fb08fd..73329db83 100644 --- a/src/backend/core/http/HttpError.ts +++ b/src/backend/core/http/HttpError.ts @@ -57,7 +57,9 @@ export type LegacyErrorCodes = | 'app_or_api_token_required' | 'team_not_found' | 'not_the_team_owner' - | 'not_an_org_account'; + | 'not_an_org_account' + | 'team_limit_reached' + | 'seat_limit_reached'; /** * Copyright (C) 2024-present Puter Technologies Inc. diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 7813d7b62..c946a3789 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -146,6 +146,9 @@ export class MeteringService extends PuterService { static CREDIT_CACHE_MS = 15_000; static CREDIT_CACHE_LIMIT = 50_000; + /** Where "about to run out" starts, as a fraction of the month allowance. */ + static NEAR_LIMIT_FRACTION = 0.9; + /** * How long usage that isn't decided on may sit in memory before it is * written, and how many actor buckets are held at once. Egress and @@ -172,6 +175,12 @@ export class MeteringService extends PuterService { { policy: SubscriptionPolicy; expiresAt: number } >(); + /** Uuid → the last budget state announced, so a retry loop emits once. */ + private creditAlertState = new Map< + string, + 'ok' | 'near-limit' | 'exhausted' + >(); + /** Uuid → whether the actor had budget left. See CREDIT_CACHE_MS. */ private creditCache = new Map< string, @@ -1276,6 +1285,8 @@ export class MeteringService extends PuterService { /** Local-only drop. The announcement path is `invalidateActorCredits`. */ #dropCachedCredits(userUuid: string): void { this.creditCache.delete(userUuid); + // Added capacity re-arms the alert: the next exhaustion is news again. + this.creditAlertState.delete(userUuid); } async #refreshCredits(actor: Actor): Promise { @@ -1333,14 +1344,66 @@ export class MeteringService extends PuterService { this.rememberHasCredits(userId, true); return; } - this.rememberHasCredits( - userId, - MeteringService.remainingFrom( - allowanceUsed, - monthUsageAllowance, - addons, - ) > 0, + const remaining = MeteringService.remainingFrom( + allowanceUsed, + monthUsageAllowance, + addons, ); + this.rememberHasCredits(userId, remaining > 0); + this.#noteCreditState( + userId, + remaining, + allowanceUsed, + monthUsageAllowance, + addons, + ); + } + + /** Transitions only: a blocked actor retries, and every retry lands here. */ + #noteCreditState( + userUuid: string, + remaining: number, + allowanceUsed: number, + monthUsageAllowance: number, + addons: UsageAddons | null | undefined, + ): void { + // Purchased credits are spendable, so the allowance alone warns early. + const capacity = + (monthUsageAllowance || 0) + (addons?.purchasedCredits || 0); + const state = + remaining <= 0 + ? 'exhausted' + : remaining <= + capacity * (1 - MeteringService.NEAR_LIMIT_FRACTION) + ? 'near-limit' + : 'ok'; + + if (this.creditAlertState.get(userUuid) === state) return; + // Same FIFO bound as `creditCache`; this map has one entry per actor. + if ( + this.creditAlertState.size >= MeteringService.CREDIT_CACHE_LIMIT && + !this.creditAlertState.has(userUuid) + ) { + const oldest = this.creditAlertState.keys().next().value; + if (oldest !== undefined) this.creditAlertState.delete(oldest); + } + this.creditAlertState.set(userUuid, state); + if (state === 'ok') return; + + try { + this.clients.event.emit( + 'metering.credit-state', + { + user_uuid: userUuid, + state, + allowance_used: allowanceUsed, + month_usage_allowance: monthUsageAllowance, + }, + {}, + ); + } catch (e) { + console.warn('[metering] credit-state emit failed:', e); + } } private rememberHasCredits(userId: string, hasCredits: boolean): void { diff --git a/src/backend/services/team/CreditState.test.ts b/src/backend/services/team/CreditState.test.ts new file mode 100644 index 000000000..c67fbfaea --- /dev/null +++ b/src/backend/services/team/CreditState.test.ts @@ -0,0 +1,180 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('credit-state transitions', () => { + let server: PuterServer; + let service: PuterServer['services']['team']; + let owner: { id: number }; + + /** Resolved per seat: a seat is on the common tier like any account. */ + const allowanceOf = async (actor: Actor) => + (await server.services.metering.getActorSubscription(actor)) + .monthUsageAllowance; + + /** Credit-state events seen since the last reset. */ + const states: Array<{ state: string; user_uuid: string }> = []; + + const makeUser = async () => { + const username = `cr_${Math.random().toString(36).slice(2, 10)}`; + const created = (await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + })) as unknown as { id: number }; + return { id: created.id, username }; + }; + + const freeHandle = () => `cw-${Math.random().toString(36).slice(2, 10)}`; + + /** + * A team with its own owner and one real provisioned seat. The owner + * is per-test so mail is attributable: these handlers run detached, and a + * shared recipient would let a late send land in the next test's tally. + */ + const makeSeat = async () => { + const owner = await makeUser(); + const team = await service.createTeam(owner.id, { + name: 'Credit Co', + handle: freeHandle(), + }); + const username = `seat_${Math.random().toString(36).slice(2, 10)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + const user = await server.stores.user.getById(created.userId); + const ownerRow = await server.stores.user.getById(owner.id); + return { + team, + user: user!, + owner: ownerRow!, + actor: { user } as unknown as Actor, + }; + }; + + /** Spend `fraction` of the seat's own monthly allowance. */ + const spend = async (actor: Actor, fraction: number) => + server.services.metering.incrementUsage( + actor, + 'kv:read', + 1, + Math.floor((await allowanceOf(actor)) * fraction), + ); + + /** Forget what this process has announced, as a second node would not know. */ + const asAnotherNode = (uuid: string) => { + ( + server.services.metering as unknown as { + creditAlertState: Map; + } + ).creditAlertState.delete(uuid); + }; + + beforeAll(async () => { + // The cap has its own suite; these tests need many teams. + server = await setupTestServer({ + teams_enabled: true, + max_teams_per_user: 100, + } as never); + service = server.services.team; + owner = await makeUser(); + + server.clients.event.on('metering.credit-state', (( + _k: string, + d: { state: string; user_uuid: string }, + ) => { + states.push(d); + }) as never); + + }); + + beforeEach(() => { + states.length = 0; + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await server?.shutdown(); + }); + + // -- the transition signal ---------------------------------------- + + it('announces near-limit when the member passes 90%', async () => { + const { actor, user } = await makeSeat(); + states.length = 0; + + await spend(actor, 0.95); + + const mine = states.filter((s) => s.user_uuid === user.uuid); + expect(mine.map((s) => s.state)).toEqual(['near-limit']); + }); + + it('does not warn a member whose purchased credits carry them past the allowance', async () => { + const { actor, user } = await makeSeat(); + const allowance = await allowanceOf(actor); + await server.services.metering.updateAddonCredit(user.uuid, allowance); + states.length = 0; + + await spend(actor, 0.95); + + expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0); + }); + + it('says nothing while the member is comfortably inside the allowance', async () => { + const { actor, user } = await makeSeat(); + states.length = 0; + + await spend(actor, 0.5); + + expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0); + }); + + it('announces exhausted once, however many times the member retries', async () => { + const { actor, user } = await makeSeat(); + await spend(actor, 1); + states.length = 0; + + // Increments, not cached reads: a cache hit never recomputes the + // state, so a read loop would pass this test without exercising it. + for (let i = 0; i < 50; i++) { + await spend(actor, 0.01); + } + + expect(states.filter((s) => s.user_uuid === user.uuid)).toHaveLength(0); + }); + + it('crosses both lines in order, one announcement each', async () => { + const { actor, user } = await makeSeat(); + states.length = 0; + + await spend(actor, 0.95); + await spend(actor, 0.1); + + const mine = states.filter((s) => s.user_uuid === user.uuid); + expect(mine.map((s) => s.state)).toEqual(['near-limit', 'exhausted']); + }); + +}); diff --git a/src/backend/services/team/TeamCaps.test.ts b/src/backend/services/team/TeamCaps.test.ts new file mode 100644 index 000000000..0ee331ca1 --- /dev/null +++ b/src/backend/services/team/TeamCaps.test.ts @@ -0,0 +1,263 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('team and seat caps', () => { + let server: PuterServer; + let service: PuterServer['services']['team']; + + /** Small enough to reach; the shipped default is 50. */ + const SEAT_CAP = 3; + + const makeUser = async () => { + const username = `cap_${Math.random().toString(36).slice(2, 10)}`; + const created = (await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + })) as unknown as { id: number }; + return { id: created.id, username }; + }; + + const freeHandle = () => `cp-${Math.random().toString(36).slice(2, 10)}`; + + const makeTeam = (ownerId: number) => + service.createTeam(ownerId, { + name: 'Capped Co', + handle: freeHandle(), + }); + + const provision = (teamUid: string, ownerId: number) => { + const username = `st_${Math.random().toString(36).slice(2, 10)}`; + return service.provisionAccount(teamUid, ownerId, { + username, + email: `${username}@test.local`, + }); + }; + + beforeAll(async () => { + // The shipped team default, and a reachable seat limit. + server = await setupTestServer({ + teams_enabled: true, + max_seats_per_team: SEAT_CAP, + } as never); + service = server.services.team; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + // -- teams per user ------------------------------------------ + + it('allows one team and refuses the second', async () => { + const owner = await makeUser(); + await makeTeam(owner.id); + + await expect(makeTeam(owner.id)).rejects.toMatchObject({ + statusCode: 409, + legacyCode: 'team_limit_reached', + }); + }); + + it('holds the team cap against concurrent creates', async () => { + const owner = await makeUser(); + + // Counting then inserting without serializing lets every one of these + // read a count below the cap and all succeed. + const results = await Promise.allSettled( + Array.from({ length: 5 }, () => makeTeam(owner.id)), + ); + + expect( + results.filter((r) => r.status === 'fulfilled'), + ).toHaveLength(1); + }); + + it('refuses on the cap before complaining about the handle', async () => { + const owner = await makeUser(); + await makeTeam(owner.id); + + // A capped user hears about the cap, not that their name was taken. + await expect( + service.createTeam(owner.id, { + name: 'Bad', + handle: 'NOT A VALID HANDLE', + }), + ).rejects.toMatchObject({ legacyCode: 'team_limit_reached' }); + }); + + it('frees the slot when the team is deleted', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + await service.deleteTeam(team.uid, owner.id); + + // Soft-deleted teams do not count against the cap. + await expect(makeTeam(owner.id)).resolves.toMatchObject({ + owner_user_id: owner.id, + }); + }); + + it('counts per user, not globally', async () => { + const a = await makeUser(); + const b = await makeUser(); + await makeTeam(a.id); + + await expect(makeTeam(b.id)).resolves.toBeTruthy(); + }); + + it('falls back to one team when the config omits the key', async () => { + // `config.default.json` is the merge base and sets this, so the code + // fallback only ever runs for a config that dropped the key. + const owner = await makeUser(); + const cfg = service.config as { max_teams_per_user?: number }; + const had = cfg.max_teams_per_user; + delete cfg.max_teams_per_user; + try { + await makeTeam(owner.id); + await expect(makeTeam(owner.id)).rejects.toMatchObject({ + legacyCode: 'team_limit_reached', + }); + } finally { + cfg.max_teams_per_user = had; + } + }); + + it('ignores a nonsensical cap rather than locking everyone out', async () => { + const owner = await makeUser(); + const cfg = service.config as { max_teams_per_user?: number }; + const had = cfg.max_teams_per_user; + // A zero or negative cap would otherwise refuse every team. + cfg.max_teams_per_user = 0; + try { + await expect(makeTeam(owner.id)).resolves.toBeTruthy(); + } finally { + cfg.max_teams_per_user = had; + } + }); + + // -- seats per team ------------------------------------------ + + it('provisions up to the seat cap and refuses the next', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + + for (let i = 0; i < SEAT_CAP; i++) { + await provision(team.uid, owner.id); + } + + await expect(provision(team.uid, owner.id)).rejects.toMatchObject({ + statusCode: 409, + legacyCode: 'seat_limit_reached', + }); + }); + + it('holds the seat cap against concurrent provisions', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + + const results = await Promise.allSettled( + Array.from({ length: SEAT_CAP + 3 }, () => + provision(team.uid, owner.id), + ), + ); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength( + SEAT_CAP, + ); + }); + + it('does not count the team owner against the seat cap', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + + // The owner is `org_owned = 0`; it pays, it does not occupy a seat. + for (let i = 0; i < SEAT_CAP; i++) { + await expect(provision(team.uid, owner.id)).resolves.toBeTruthy(); + } + }); + + it('refuses on the cap before creating any account state', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + for (let i = 0; i < SEAT_CAP; i++) await provision(team.uid, owner.id); + + const username = `st_${Math.random().toString(36).slice(2, 10)}`; + await expect( + service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }), + ).rejects.toMatchObject({ legacyCode: 'seat_limit_reached' }); + + // The refused name must still be free, or a capped team would + // burn global usernames on every rejected attempt. + expect(await server.stores.user.getByUsername(username)).toBeFalsy(); + }); + + it('lowering the cap blocks provisioning without disabling anyone', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + const seats = []; + for (let i = 0; i < SEAT_CAP; i++) { + seats.push(await provision(team.uid, owner.id)); + } + + const cfg = service.config as { max_seats_per_team?: number }; + cfg.max_seats_per_team = 1; + try { + await expect(provision(team.uid, owner.id)).rejects.toMatchObject({ + legacyCode: 'seat_limit_reached', + }); + + // Over the limit is not a reason to suspend people. + for (const seat of seats) { + const user = await server.stores.user.getByProperty( + 'id', + seat.userId, + { force: true }, + ); + expect(user?.suspended).toBeFalsy(); + } + } finally { + cfg.max_seats_per_team = SEAT_CAP; + } + }); + + it('does not let a deleted seat be replaced beyond the cap', async () => { + const owner = await makeUser(); + const team = await makeTeam(owner.id); + const seats = []; + for (let i = 0; i < SEAT_CAP; i++) { + seats.push(await provision(team.uid, owner.id)); + } + + // Deleting the account removes the membership row, so the seat frees. + await server.services.userAccount.cascadeDelete(seats[0].userId); + await expect(provision(team.uid, owner.id)).resolves.toBeTruthy(); + await expect(provision(team.uid, owner.id)).rejects.toMatchObject({ + legacyCode: 'seat_limit_reached', + }); + }); +}); diff --git a/src/backend/services/team/TeamEvents.test.ts b/src/backend/services/team/TeamEvents.test.ts new file mode 100644 index 000000000..1748a4c02 --- /dev/null +++ b/src/backend/services/team/TeamEvents.test.ts @@ -0,0 +1,251 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('team billing events', () => { + let server: PuterServer; + let service: PuterServer['services']['team']; + let owner: { id: number }; + + /** Every billing event emitted since the last reset, in order. */ + const seen: Array<{ key: string; data: Record }> = []; + const of = (key: string) => seen.filter((e) => e.key === key); + + const makeUser = async (): Promise<{ id: number; username: string }> => { + const username = `bil_${Math.random().toString(36).slice(2, 10)}`; + const created = (await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + })) as unknown as { id: number }; + return { id: created.id, username }; + }; + + const freeHandle = () => `bw-${Math.random().toString(36).slice(2, 10)}`; + + const makeTeam = async () => + service.createTeam(owner.id, { + name: 'Billing Co', + handle: freeHandle(), + }); + + /** A real provisioned seat, as every chargeable account is. */ + const provision = async (team: { uid: string }) => { + const username = `seat_${Math.random().toString(36).slice(2, 10)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + return created; + }; + + /** Same, for a team whose owner is not the shared one. */ + const provision2 = async (team: { uid: string }, ownerId: number) => { + const username = `seat_${Math.random().toString(36).slice(2, 10)}`; + return service.provisionAccount(team.uid, ownerId, { + username, + email: `${username}@test.local`, + }); + }; + + /** Bytes the report has to find; `size` is what `SUM(size)` reads. */ + const giveFile = async (userId: number, size: number) => { + await server.clients.db.write( + 'INSERT INTO fsentries (uuid, parent_uid, user_id, name, path, is_dir, size, created, accessed, modified) ' + + 'VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + uuidv4(), + userId, + `f_${Math.random().toString(36).slice(2, 8)}`, + `/f_${Math.random().toString(36).slice(2, 8)}`, + server.clients.db.booleanValue(false), + size, + 0, + 0, + 0, + ], + ); + }; + + beforeAll(async () => { + // The cap has its own suite; these tests need many teams. + server = await setupTestServer({ + teams_enabled: true, + max_teams_per_user: 100, + } as never); + service = server.services.team; + owner = await makeUser(); + + for (const key of [ + 'team.account.created', + 'team.account.disabled', + 'team.account.enabled', + 'team.account.deleted', + 'team.deleted', + 'team.held-bytes.report', + ]) { + server.clients.event.on( + key as never, + ((k: string, data: Record) => { + seen.push({ key: k, data }); + }) as never, + ); + } + }); + + beforeEach(() => { + seen.length = 0; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + // -- on-demand lifecycle events ----------------------------------- + + it('emits account-created once, naming the team and the owner', async () => { + const team = await makeTeam(); + const seat = await provision(team); + + const events = of('team.account.created'); + expect(events).toHaveLength(1); + expect(events[0].data).toMatchObject({ + team_uid: team.uid, + owner_user_id: owner.id, + user_id: seat.userId, + username: seat.username, + }); + // The payment side keys the charge off this, so it must be present. + expect(typeof events[0].data.user_uuid).toBe('string'); + }); + + it('carries the held bytes on disable, so billing need not query back', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await giveFile(seat.userId, 4096); + seen.length = 0; + + await service.disableMember(team.uid, owner.id, seat.userId); + + const events = of('team.account.disabled'); + expect(events).toHaveLength(1); + expect(events[0].data.held_bytes).toBe(4096); + expect(events[0].data.user_id).toBe(seat.userId); + }); + + it('emits account-enabled with the bytes that stop being charged', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await giveFile(seat.userId, 1024); + await service.disableMember(team.uid, owner.id, seat.userId); + seen.length = 0; + + await service.enableMember(team.uid, owner.id, seat.userId); + + const events = of('team.account.enabled'); + expect(events).toHaveLength(1); + expect(events[0].data.held_bytes).toBe(1024); + }); + + it('emits one disabled event per seat plus a team event on delete', async () => { + const team = await makeTeam(); + const a = await provision(team); + const b = await provision(team); + seen.length = 0; + + await service.deleteTeam(team.uid, owner.id); + + // Per seat, because the byte charge that follows is per account. + const disabled = of('team.account.disabled'); + expect(disabled).toHaveLength(2); + expect(disabled.map((e) => e.data.user_id).sort()).toEqual( + [a.userId, b.userId].sort(), + ); + + const deleted = of('team.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0].data).toMatchObject({ + team_uid: team.uid, + account_count: 2, + }); + }); + + it('emits account-deleted, which a post-delete listener could not', async () => { + const team = await makeTeam(); + const seat = await provision(team); + seen.length = 0; + + await server.services.userAccount.cascadeDelete(seat.userId); + + const events = of('team.account.deleted'); + expect(events).toHaveLength(1); + expect(events[0].data).toMatchObject({ + team_uid: team.uid, + user_id: seat.userId, + username: seat.username, + }); + }); + + it('leaves the membership unreadable after the delete it was captured for', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await server.services.userAccount.cascadeDelete(seat.userId); + + // `jct_user_group.user_id` is ON DELETE CASCADE -- this is why the + // identity has to be captured before the row goes. + expect(await server.stores.team.getOrgSeat(seat.userId)).toBeNull(); + }); + + it('says nothing about an account no team pays for', async () => { + const outsider = await makeUser(); + seen.length = 0; + + await server.services.userAccount.cascadeDelete(outsider.id); + + expect(of('team.account.deleted')).toHaveLength(0); + }); + + it('does not open a second byte charge when a seat is disabled twice', async () => { + const team = await makeTeam(); + const seat = await provision(team); + await service.disableMember(team.uid, owner.id, seat.userId); + seen.length = 0; + + await service.disableMember(team.uid, owner.id, seat.userId); + + // `held_bytes` opens a charge one `enabled` closes; two opens and one + // close leaves the payer billed for storage nobody holds. + expect(of('team.account.disabled')).toHaveLength(0); + }); + + it('does not close a charge that was never opened', async () => { + const team = await makeTeam(); + const seat = await provision(team); + seen.length = 0; + + await service.enableMember(team.uid, owner.id, seat.userId); + + expect(of('team.account.enabled')).toHaveLength(0); + }); +}); diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index c0065ac74..9b16984f0 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -68,7 +68,11 @@ describe('TeamService', () => { beforeAll(async () => { // The policy and resolver are gated on the same flag as the routes. - server = await setupTestServer({ teams_enabled: true } as never); + // The cap has its own suite; these tests need many teams. + server = await setupTestServer({ + teams_enabled: true, + max_teams_per_user: 100, + } as never); service = server.services.team; owner = await makeUser(); ownerUsername = (await server.stores.user.getById(owner.id))!.username; diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 131bff8f1..6f8045407 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -26,6 +26,11 @@ import { USERNAME_MAX_LENGTH, USERNAME_REGEX, } from '../../controllers/auth/AuthController.js'; +import type { + EventMap, + TeamBillingContext, + TeamBillingEvent, +} from '../../clients/event/types'; import { HttpError } from '../../core/http/HttpError.js'; import { checkHandle } from '../../stores/team/TeamStore.js'; import type { @@ -55,7 +60,72 @@ export const generateTemporaryPassword = (length = 16): string => { /** Why an account was disabled. Free text in `0063`; this is the team one. */ export const DISABLED_BY_TEAM = 'disabled_by_team'; +/** + * Cap-lock bounds. The lock is held for a count plus an insert -- single-digit + * milliseconds -- so 200ms of waiting is already far past the contended case, + * and past it the request proceeds unserialized rather than holding a + * connection or refusing. + */ +const CAP_LOCK_ATTEMPTS = 8; +const CAP_LOCK_RETRY_MS = 25; +const CAP_LOCK_TTL_SECONDS = 10; + export class TeamService extends PuterService { + // -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ---- + + /** The team owner pays, so the charge is keyed to its customer id. */ + async #billingContext(team: TeamRow): Promise { + return { + team_uid: team.uid, + owner_user_id: team.owner_user_id, + }; + } + + /** Bytes the account holds right now, for prod to price if it wants to. */ + async #heldBytes(userId: number): Promise { + try { + return await this.stores.fsEntry.getHeldBytes(userId); + } catch (e) { + // A missing figure must not fail the operation that reported it. + console.warn('[team-billing] held-bytes read failed:', e); + return 0; + } + } + + /** Captured pre-delete: the membership row cascades away with the user. */ + async captureSeatForBilling( + userId: number, + ): Promise { + if (this.config.teams_enabled !== true) return null; + try { + const seat = await this.stores.team.getOrgSeat(userId); + if (!seat) return null; + return { + team_uid: seat.team_uid, + owner_user_id: seat.owner_user_id, + user_id: seat.user_id, + user_uuid: seat.uuid, + username: seat.username, + }; + } catch (e) { + console.warn('[team-billing] seat capture failed:', e); + return null; + } + } + + /** Paired with `captureSeatForBilling`, once the account is really gone. */ + emitSeatDeleted(seat: TeamBillingEvent | null): void { + if (seat) this.#emitBilling('team.account.deleted', seat); + } + + /** Fire-and-forget, as `user.delete` is: the charge is not our business. */ + #emitBilling(name: K, payload: EventMap[K]) { + try { + this.clients.event?.emit(name, payload, {}); + } catch (e) { + console.warn('[team-billing] emit failed:', name, e); + } + } // -- Authority ---- the whole authorization model ------------------ /** 404 to a non-member so the endpoint is not an existence oracle. */ @@ -103,6 +173,20 @@ export class TeamService extends PuterService { // -- Team lifecycle ------------------------------------------ + // -- Caps ---- bounds, not billing; the charge is out of repo --------- + + /** Live teams one user may own. */ + #workspaceCap(): number { + const n = Number(this.config.max_teams_per_user); + return Number.isFinite(n) && n > 0 ? n : 1; + } + + /** Seats one team may provision. Adjustable without a code change. */ + #seatCap(): number { + const n = Number(this.config.max_seats_per_team); + return Number.isFinite(n) && n > 0 ? n : 50; + } + /** A rejected handle is 400, a taken one 409, never an unhandled 500. */ async assertHandleUsable(handle: string): Promise { const rejection = checkHandle(handle); @@ -139,6 +223,50 @@ export class TeamService extends PuterService { } } + /** Serializes a count-then-insert; same shape as `ACLService.#withNodeLock`. */ + async #withCapLock(suffix: string, run: () => Promise): Promise { + const key = `team:cap:${suffix}`; + const token = `${process.pid}:${Date.now()}:${Math.random()}`; + let held = false; + + try { + for (let attempt = 0; attempt < CAP_LOCK_ATTEMPTS; attempt++) { + const claimed = await this.clients.redis.set( + key, + token, + 'EX', + CAP_LOCK_TTL_SECONDS, + 'NX', + ); + if (claimed === 'OK') { + held = true; + break; + } + await new Promise((resolve) => + setTimeout(resolve, CAP_LOCK_RETRY_MS), + ); + } + // Waiting out the budget is not a refusal: the cap is a bound, and + // a spurious 409 on a lone create is worse than a rare overshoot. + if (!held) return run(); + } catch { + // Redis unreachable — same reasoning, proceed unserialized. + return run(); + } + + try { + return await run(); + } finally { + try { + // Only clear our own claim — a lapsed TTL may have reassigned it. + const current = await this.clients.redis.get(key); + if (current === token) await this.clients.redis.del(key); + } catch { + /* the TTL clears it */ + } + } + } + /** Renames or re-handles a team, refusing an unusable handle. */ async updateTeam( teamUid: string, @@ -164,6 +292,28 @@ export class TeamService extends PuterService { ownerUserId: number, input: { name: string; handle?: string | null }, ): Promise { + return this.#withCapLock(`owner:${ownerUserId}`, () => + this.#createTeamLocked(ownerUserId, input), + ); + } + + async #createTeamLocked( + ownerUserId: number, + input: { name: string; handle?: string | null }, + ): Promise { + // First: a capped user should hear that, not that the name was taken. + const cap = this.#workspaceCap(); + if ((await this.stores.team.countOwned(ownerUserId)) >= cap) { + throw new HttpError( + 409, + `You may own ${cap} team${cap === 1 ? '' : 's'}`, + { + legacyCode: 'team_limit_reached', + fields: { limit: cap }, + }, + ); + } + const handle = input.handle ?? null; if (handle !== null) await this.assertHandleUsable(handle); @@ -203,24 +353,20 @@ export class TeamService extends PuterService { ); if (!owner || Number(owner.org_owned) !== 0) return false; - const rows = (await this.clients.db.read( - 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + - 'WHERE `group_id` = ? AND `org_owned` = 0', - [team.id], - )) as { n: number }[]; - return Number(rows[0]?.n) === 1; + return (await this.stores.team.countPayers(team.id)) === 1; } /** Soft delete disables the accounts it created; recovery is via support. */ async deleteTeam(teamUid: string, actorUserId: number): Promise { const team = await this.requireOwner(teamUid, actorUserId); + const billing = await this.#billingContext(team); + let disabled = 0; // Otherwise they keep working, unreachable through a deleted team. let page = await this.stores.team.listMembers(teamUid, { limit: 200 }); for (;;) { for (const member of page.items) { if (Number(member.org_owned) !== 1) continue; - await this.#suspend(member.user_id); await this.stores.team.appendAudit({ teamId: team.id, userId: member.user_id, @@ -228,6 +374,18 @@ export class TeamService extends PuterService { action: 'disable', reason: 'team_deleted', }); + const held = await this.#heldBytes(member.user_id); + await this.#suspend(member.user_id); + disabled++; + + // Per seat, not one bulk event: the byte charge is per account. + this.#emitBilling('team.account.disabled', { + ...billing, + user_id: member.user_id, + user_uuid: member.uuid, + username: member.username, + held_bytes: held, + }); } if (!page.cursor) break; page = await this.stores.team.listMembers(teamUid, { @@ -243,6 +401,11 @@ export class TeamService extends PuterService { action: 'delete_team', }); await this.stores.team.softDelete(teamUid); + + this.#emitBilling('team.deleted', { + ...billing, + account_count: disabled, + }); } /** Team owner only. Readable after deletion -- that is the point of it. */ @@ -353,8 +516,32 @@ export class TeamService extends PuterService { userId: number; username: string; temporaryPassword: string; + }> { + return this.#withCapLock(`team:${teamUid}`, () => + this.#provisionAccountLocked(teamUid, actorUserId, input), + ); + } + + async #provisionAccountLocked( + teamUid: string, + actorUserId: number, + input: { username: string; email: string }, + ): Promise<{ + userId: number; + username: string; + temporaryPassword: string; }> { const team = await this.requireOwner(teamUid, actorUserId); + + // Counted, never derived from a stored total: seats come and go. + const cap = this.#seatCap(); + if ((await this.stores.team.countSeats(team.id)) >= cap) { + throw new HttpError(409, `This team is limited to ${cap} seats`, { + legacyCode: 'seat_limit_reached', + fields: { limit: cap }, + }); + } + this.#assertUsableUsername(input.username); if (!validator.isEmail(input.email)) { throw new HttpError(400, 'Invalid email', { @@ -417,6 +604,14 @@ export class TeamService extends PuterService { }); await this.#notifyAccountCreated(user, team); + // Last: the seat is only chargeable once it exists and can be used. + this.#emitBilling('team.account.created', { + ...(await this.#billingContext(team)), + user_id: user.id, + user_uuid: user.uuid, + username: user.username, + }); + return { userId: user.id, username: user.username, @@ -487,7 +682,18 @@ export class TeamService extends PuterService { targetUserId: number, ): Promise { const team = await this.requireOwner(teamUid, actorUserId); - await this.requireOrgAccount(teamUid, targetUserId); + const membership = await this.requireOrgAccount(teamUid, targetUserId); + + // Already off: emitting again would open a second byte charge that + // only one `enabled` will ever close. + const current = await this.stores.user.getByProperty( + 'id', + targetUserId, + { + force: true, + }, + ); + if (current?.suspended) return; // Recorded first: a failed append must not leave an unlogged suspension. await this.stores.team.appendAudit({ @@ -496,7 +702,17 @@ export class TeamService extends PuterService { actorUserId, action: 'disable', }); + // Read before suspending, though a disabled account cannot change it. + const held = await this.#heldBytes(targetUserId); await this.#suspend(targetUserId); + + this.#emitBilling('team.account.disabled', { + ...(await this.#billingContext(team)), + user_id: targetUserId, + user_uuid: membership.uuid, + username: membership.username, + held_bytes: held, + }); } /** Nothing was destroyed, so the account returns as it was. */ @@ -513,14 +729,13 @@ export class TeamService extends PuterService { force: true, }); // Only the team's own suspension; a platform one must not lift. - if ( - user?.suspended && - user.suspended_reason !== DISABLED_BY_TEAM - ) { + if (user?.suspended && user.suspended_reason !== DISABLED_BY_TEAM) { throw new HttpError(409, 'That account was suspended by Puter', { legacyCode: 'conflict', }); } + // Never disabled, so there is no charge to close. + if (!user?.suspended) return; await this.stores.team.appendAudit({ teamId: team.id, @@ -534,6 +749,15 @@ export class TeamService extends PuterService { suspended_reason: null, }); await this.stores.user.invalidateById(targetUserId); + + // Closes the byte charge the disable opened, at the same figure. + this.#emitBilling('team.account.enabled', { + ...(await this.#billingContext(team)), + user_id: targetUserId, + user_uuid: user.uuid, + username: user.username, + held_bytes: await this.#heldBytes(targetUserId), + }); } /** The three columns together; `suspended` is the one that gates requests. */ diff --git a/src/backend/services/user/UserAccountService.ts b/src/backend/services/user/UserAccountService.ts index dc886bd94..539b02104 100644 --- a/src/backend/services/user/UserAccountService.ts +++ b/src/backend/services/user/UserAccountService.ts @@ -60,6 +60,9 @@ export class UserAccountService extends PuterService { console.warn('[cascade-delete-user] identifier lookup failed:', e); } + // Same reason, one table over: the membership row cascades on delete. + const seat = await this.services.team.captureSeatForBilling(userId); + try { await this.services.fs.removeAllForUser(userId); } catch (e) { @@ -95,6 +98,8 @@ export class UserAccountService extends PuterService { } catch { // ignore — event emission shouldn't block deletion } + // Tells prod to stop charging the owner for this seat. + this.services.team.emitSeatDeleted(seat); } /** diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 563e665e6..d9420297a 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -2489,8 +2489,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; @@ -2676,8 +2675,7 @@ export class FSEntryStore extends PuterStore { const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; const payload = decodeCursor(options.cursor) as - | { p: string } - | undefined; + { p: string } | undefined; const seek = payload ? 'AND path > ?' : ''; const params: unknown[] = payload ? [userId, likePattern, maxSlashes, payload.p, limit + 1] @@ -3048,6 +3046,15 @@ export class FSEntryStore extends PuterStore { return Number.isFinite(affected) ? affected : 0; } + /** Bytes held, with no allowance or quota-bonus reckoning attached. */ + async getHeldBytes(userId: number): Promise { + const rows = (await this.clients.db.read( + `SELECT COALESCE(SUM(size), 0) AS ${this.clients.db.quoteIdentifier('totalUsage')} FROM fsentries WHERE user_id = ?`, + [userId], + )) as { totalUsage: number }[]; + return Number(rows[0]?.totalUsage ?? 0); + } + async getUserStorageAllowance( userId: number, ): Promise<{ curr: number; max: number }> { diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 14810fa01..bd969e768 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -77,7 +77,7 @@ export const MEMBER_PAGE_CAP = 200; export const AUDIT_PAGE_SIZE = 50; export const AUDIT_PAGE_CAP = 200; -/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */ +/** Longest handle mysql can store — `varchar(64)` in mysql_mig_28. */ export const HANDLE_MAX_LENGTH = 64; export const HANDLE_MIN_LENGTH = 3; @@ -379,6 +379,26 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } + /** Seats the team has provisioned. The owner is not one of them. */ + async countSeats(teamId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + + 'WHERE `group_id` = ? AND `org_owned` = 1', + [teamId], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + + /** Live teams this user owns. Soft-deleted ones do not count. */ + async countOwned(ownerUserId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `group` ' + + `WHERE \`owner_user_id\` = ? AND ${this.#live()}`, + [ownerUserId, TEAM_KIND], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + /** How many members pay for themselves; the owner should be the only one. */ async countPayers(teamId: number): Promise { const rows = (await this.clients.db.read( @@ -389,6 +409,22 @@ export class TeamStore extends PuterStore { return Number(rows[0]?.n ?? 0); } + + + /** Soft-deleted teams count: their seats still hold billable bytes. */ + async getOrgSeat(userId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' + + 'g.`uid` AS `team_uid`, g.`owner_user_id` ' + + 'FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + 'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' + + 'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1', + [userId, TEAM_KIND], + )) as unknown as OrgSeatRow[]; + return rows[0] ?? null; + } // -- Audit ---- insert-only; no update or delete path exists -------- /** Records something the team did to an account. */ @@ -476,18 +512,4 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } - /** The team seat this user is, if any. Soft-deleted teams count. */ - async getOrgSeat(userId: number): Promise { - const rows = (await this.clients.db.read( - 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' + - 'g.`uid` AS `team_uid`, g.`owner_user_id` ' + - 'FROM `jct_user_group` ug ' + - 'JOIN `user` u ON u.`id` = ug.`user_id` ' + - 'JOIN `group` g ON g.`id` = ug.`group_id` ' + - 'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' + - 'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1', - [userId, TEAM_KIND], - )) as unknown as OrgSeatRow[]; - return rows[0] ?? null; - } } diff --git a/src/backend/types.ts b/src/backend/types.ts index a58a7772b..edd88502d 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -689,6 +689,10 @@ interface IConfigOptional { * touching data. */ teams_enabled: boolean; + /** Live teams one user may own. Default 1. */ + max_teams_per_user?: number; + /** Seats one team may provision. Default 50. */ + max_seats_per_team?: number; /** * Fully-qualified externally-visible URL (protocol + domain + port). * Computed from `protocol`/`domain`/`pub_port` if unset. diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 9c02c5173..03a6f8a23 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -170,6 +170,26 @@ Recipients are emailed by default and opt out with the unsubscribe link the mail Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**. +### Teams and teams + +Available only where a deployment has turned teams on. Every team route is bounded on calls, and the team itself is bounded on how much it can create. + +| Limit | All accounts | +| ---------------------------------------- | ------------ | +| Team mutations per minute | 60 | +| Team mutations per day | 500 | +| Team reads per minute | 600 | +| Teams one account may own | 1 | +| Seats one team may provision | 50 | + +A seat is a real Puter account on the ordinary tier, created by the team and paid for by its owner, so the seat limit is what bounds a team's size. Over it, provisioning fails with `seat_limit_reached`; over the team limit, creation fails with `team_limit_reached`. Both carry the limit in `fields.limit`. + +Deleting a team frees the owner's slot, but it does **not** free the seats: the accounts it created still exist, still hold their files, and keep their usernames. They are disabled, not removed — deleting a team is not a way to stop paying for the accounts in it. + +Lowering the seat limit never disables anyone. A team already above a reduced limit keeps every account it has and is simply refused new ones until it is back under. + +Both limits are per deployment (`max_teams_per_user`, `max_seats_per_team`) rather than per team, so raising them moves every team at once. + ### Events One write can reach many subscriptions, so events are bounded on both halves: how much you may register, and how much any one event may turn into.