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.