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/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/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/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 92741c6ed..6f8045407 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -60,6 +60,16 @@ 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) ---- @@ -163,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); @@ -199,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, @@ -224,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); @@ -426,9 +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', { @@ -573,9 +686,13 @@ export class TeamService extends PuterService { // 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, - }); + 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. @@ -612,10 +729,7 @@ 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', }); 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.