diff --git a/src/backend/data/subPolicies/index.ts b/src/backend/data/subPolicies/index.ts
index 5e34f04f8..4f702a2c6 100644
--- a/src/backend/data/subPolicies/index.ts
+++ b/src/backend/data/subPolicies/index.ts
@@ -17,7 +17,12 @@
* along with this program. If not, see .
*/
+import { ORG_SEAT_FREE } from './orgSeatFreePolicy.js';
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
import { TEMP_USER_FREE } from './tempUserFreePolicy.js';
-export const SUB_POLICIES = [TEMP_USER_FREE, REGISTERED_USER_FREE];
+export const SUB_POLICIES = [
+ TEMP_USER_FREE,
+ REGISTERED_USER_FREE,
+ ORG_SEAT_FREE,
+];
diff --git a/src/backend/data/subPolicies/orgSeatFreePolicy.ts b/src/backend/data/subPolicies/orgSeatFreePolicy.ts
new file mode 100644
index 000000000..70f6d1904
--- /dev/null
+++ b/src/backend/data/subPolicies/orgSeatFreePolicy.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { ORG_SEAT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
+import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
+
+/** Half the free plan, so a team cannot mint full free tiers by provisioning. */
+export const ORG_SEAT_FREE = {
+ id: ORG_SEAT_FREE_SUBSCRIPTION,
+ monthUsageAllowance: Math.floor(
+ REGISTERED_USER_FREE.monthUsageAllowance / 2,
+ ),
+ monthlyStorageAllowance: Math.floor(
+ REGISTERED_USER_FREE.monthlyStorageAllowance / 2,
+ ),
+};
diff --git a/src/backend/services/metering/consts.ts b/src/backend/services/metering/consts.ts
index 568ff501e..02905ff9c 100644
--- a/src/backend/services/metering/consts.ts
+++ b/src/backend/services/metering/consts.ts
@@ -32,6 +32,7 @@ export const PERIOD_ESCAPE = '_dot_';
export const MONTHLY_CHARGE_CLAIM = 'monthlyChargesApplied';
export const DEFAULT_FREE_SUBSCRIPTION = 'user_free';
export const DEFAULT_TEMP_SUBSCRIPTION = 'temp_free';
+export const ORG_SEAT_FREE_SUBSCRIPTION = 'org_seat_free';
/**
* The policies an account holds without paying for anything. Everything else —
@@ -44,6 +45,7 @@ export const DEFAULT_TEMP_SUBSCRIPTION = 'temp_free';
export const FREE_SUBSCRIPTION_IDS: ReadonlySet = new Set([
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
+ ORG_SEAT_FREE_SUBSCRIPTION,
]);
// WARNING: DO NOT USE THESE IN PROD
diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts
index 3f8b7b3be..05d8dc51e 100644
--- a/src/backend/services/team/TeamService.test.ts
+++ b/src/backend/services/team/TeamService.test.ts
@@ -18,7 +18,17 @@
*/
import { v4 as uuidv4 } from 'uuid';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { subscriptionSatisfies } from '../metering/enforcement.js';
+import { REGISTERED_USER_FREE } from '../../data/subPolicies/registeredUserFreePolicy.js';
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from 'vitest';
import { PuterServer } from '../../server.ts';
import { setupTestServer } from '../../testUtil.ts';
@@ -27,6 +37,7 @@ describe('TeamService', () => {
let service: PuterServer['services']['team'];
let owner: { id: number };
let ownerUsername: string;
+ let ownerUuid: string;
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `svc_${Math.random().toString(36).slice(2, 10)}`;
@@ -72,10 +83,13 @@ describe('TeamService', () => {
server = await setupTestServer({
teams_enabled: true,
max_teams_per_user: 100,
+ max_seats_per_team: 100,
} as never);
service = server.services.team;
owner = await makeUser();
- ownerUsername = (await server.stores.user.getById(owner.id))!.username;
+ const ownerRow = (await server.stores.user.getById(owner.id))!;
+ ownerUsername = ownerRow.username;
+ ownerUuid = ownerRow.uuid;
});
afterAll(async () => {
@@ -644,6 +658,98 @@ describe('TeamService', () => {
}
});
+ describe('what a seat of a free team gets', () => {
+ const policyFor = async (userId: number, uuid: string) => {
+ server.services.metering.invalidateActorSubscription(uuid);
+ return server.services.metering.getActorSubscription({
+ user: { id: userId, uuid },
+ } as never);
+ };
+
+ it('resolves half the free plan', async () => {
+ const { team } = await makeTeam();
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username: `half_${Math.random().toString(36).slice(2, 9)}`,
+ });
+ const row = (await server.stores.user.getById(created.userId))!;
+ const seat = await policyFor(created.userId, row.uuid);
+
+ expect(seat.id).toBe('org_seat_free');
+ expect(seat.monthUsageAllowance).toBe(
+ Math.floor(REGISTERED_USER_FREE.monthUsageAllowance / 2),
+ );
+ expect(seat.monthlyStorageAllowance).toBe(
+ Math.floor(REGISTERED_USER_FREE.monthlyStorageAllowance / 2),
+ );
+ });
+
+ it('leaves anyone who is not a seat alone', async () => {
+ const plain = await policyFor(owner.id, ownerUuid);
+ expect(plain.id).not.toBe('org_seat_free');
+ });
+
+ it('still counts as free, so plan gates refuse it', async () => {
+ // Nobody paid for it; it must not satisfy `requireSubscription`.
+ expect(subscriptionSatisfies('org_seat_free', true)).toBe(false);
+ });
+ });
+
+ describe('the seat cap', () => {
+ // 0 clears the absolute override, so the plan-derived caps apply.
+ const cfg = () =>
+ (service as unknown as { config: Record }).config;
+ beforeEach(() => {
+ cfg().max_seats_per_team = 0;
+ });
+ afterEach(() => {
+ cfg().max_seats_per_team = 100;
+ });
+
+ const addSeat = (teamUid: string) =>
+ service.provisionAccount(teamUid, owner.id, {
+ username: `cap_${Math.random().toString(36).slice(2, 9)}`,
+ });
+
+ // makeTeam seeds one seat, so three more reaches four.
+ const fillToFour = async (teamUid: string) => {
+ for (let i = 0; i < 3; i++) await addSeat(teamUid);
+ };
+
+ it('stops a free owner at four seats', async () => {
+ const { team } = await makeTeam();
+ await fillToFour(team.uid);
+ await expect(addSeat(team.uid)).rejects.toMatchObject({
+ statusCode: 409,
+ fields: { limit: 4 },
+ });
+ });
+
+ it('lets a paid owner past four', async () => {
+ const { team } = await makeTeam();
+ const metering = server.services.metering as unknown as {
+ registerPolicy: (p: Record) => void;
+ registerSubscriptionResolver: (fn: unknown) => void;
+ invalidateActorSubscription: (uuid: string) => void;
+ };
+ // A resolver naming an unregistered policy falls back to free.
+ metering.registerPolicy({
+ id: 'business',
+ monthUsageAllowance: 4_500_000_000,
+ monthlyStorageAllowance: 2_147_483_648_000,
+ });
+ metering.registerSubscriptionResolver(
+ (actor: { user?: { id?: number } }) =>
+ actor?.user?.id === owner.id ? 'business' : null,
+ );
+ metering.invalidateActorSubscription(ownerUuid);
+
+ await fillToFour(team.uid);
+ await expect(addSeat(team.uid)).resolves.toMatchObject({
+ username: expect.any(String),
+ });
+ });
+ });
+
it('refuses an invalid email', async () => {
const { team } = await makeTeam();
await expect(
diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts
index 7afe3c51b..efa2db3c0 100644
--- a/src/backend/services/team/TeamService.ts
+++ b/src/backend/services/team/TeamService.ts
@@ -26,6 +26,13 @@ import {
USERNAME_REGEX,
} from '../../controllers/auth/AuthController.js';
import type { EmailTemplateName } from '../../clients/email/templates.js';
+import { subscriptionSatisfies } from '../metering/enforcement.js';
+import { ORG_SEAT_FREE_SUBSCRIPTION } from '../metering/consts.js';
+
+// A free team is small on purpose; paying widens it. Both overridable in config.
+const FREE_SEAT_CAP = 4;
+const PAID_SEAT_CAP = 40;
+
import type {
EventMap,
TeamBillingContext,
@@ -114,6 +121,20 @@ const epochSeconds = (value: unknown): number => {
};
export class TeamService extends PuterService {
+ /** Half the free plan for a seat, unless a paid tier outranks it. */
+ override async onServerStart(): Promise {
+ if (this.config.teams_enabled !== true) return;
+ this.services.metering.registerDefaultSubscriptionResolver(
+ async (actor) => {
+ const userId = actor?.user?.id;
+ if (typeof userId !== 'number') return null;
+ // Cached, negatives included: almost nothing is a seat.
+ const seat = await this.stores.team.getOrgSeat(userId);
+ return seat ? ORG_SEAT_FREE_SUBSCRIPTION : null;
+ },
+ );
+ }
+
// -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ----
/** The team owner pays, so the charge is keyed to its customer id. */
@@ -231,10 +252,34 @@ export class TeamService extends PuterService {
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;
+ /** Depends on whether the owner pays; `max_seats_per_team` overrides both. */
+ async #seatCap(ownerUserId: number): Promise {
+ const override = Number(this.config.max_seats_per_team);
+ if (Number.isFinite(override) && override > 0) return override;
+
+ const paid = await this.#ownerPays(ownerUserId);
+ const key = paid
+ ? 'max_seats_per_team_paid'
+ : 'max_seats_per_team_free';
+ const n = Number(this.config[key]);
+ if (Number.isFinite(n) && n > 0) return n;
+ return paid ? PAID_SEAT_CAP : FREE_SEAT_CAP;
+ }
+
+ /** A resolved policy outside the free set is a plan someone is paying for. */
+ async #ownerPays(ownerUserId: number): Promise {
+ try {
+ const owner = await this.stores.user.getById(ownerUserId);
+ if (!owner?.uuid) return false;
+ const policy = await this.services.metering.getActorSubscription({
+ user: { id: owner.id, uuid: owner.uuid },
+ } as never);
+ return subscriptionSatisfies(policy.id, true);
+ } catch (e) {
+ // Smaller cap on an unreadable plan: over-provisioning is worse.
+ console.warn('[team] seat cap plan lookup failed:', e);
+ return false;
+ }
}
/** A rejected handle is 400, a taken one 409, never an unhandled 500. */
@@ -723,7 +768,7 @@ export class TeamService extends PuterService {
const team = await this.requireOwner(teamUid, actorUserId);
// Counted, never derived from a stored total: seats come and go.
- const cap = this.#seatCap();
+ const cap = await this.#seatCap(team.owner_user_id);
if ((await this.stores.team.countSeats(team.id)) >= cap) {
throw new HttpError(409, `This team is limited to ${cap} seats`, {
legacyCode: 'seat_limit_reached',