mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-24 22:26:42 +00:00
feat: size a team by whether its owner pays, and halve a free seat's allowance
Three related limits. Seats per team now depend on the owner's plan: 4 free, 40 paid, decided by `subscriptionSatisfies(id, true)` -- which is `!FREE_SUBSCRIPTION_IDS.has(id)`, so a plan an extension adds counts as paid without core knowing its name. The existing `max_seats_per_team` still overrides both, so a deployment that already set it keeps what it asked for, and `max_seats_per_team_free` / `_paid` tune each. An unreadable plan takes the smaller cap: over-provisioning a free team is the worse failure. A seat of a team that pays for nothing resolves `org_seat_free`, half the registered free plan. Without it a team is a way to mint free tiers -- provision four seats and each arrives with a full free allowance nobody paid for. The figures are derived from `REGISTERED_USER_FREE` rather than restated, so the two cannot drift, and the id joins `FREE_SUBSCRIPTION_IDS` because nobody paid for it either and it must not satisfy a plan gate. It is a *default* resolver, so a paid team tier -- which only prod knows about, through `registerSubscriptionResolver` -- still outranks it. The lookup is `getOrgSeat`, already cached with its negatives, because almost nothing is a seat. Found while testing: the suite was reading `max_seats_per_team` out of the developer's own config.json, so the cap under test was whatever that file said. It would have passed here and failed in CI, which has no such file. The suite now pins the value and the cap tests set their own. Falsified three ways, each breaking only its own test: equal caps fails "lets a paid owner past four"; a resolver returning null, and an unhalved allowance, both fail "resolves half the free plan". 186 team/whoami tests, 159 metering tests, typecheck clean.
This commit is contained in:
@@ -17,7 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
@@ -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<string> = new Set([
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
]);
|
||||
|
||||
// WARNING: DO NOT USE THESE IN PROD
|
||||
|
||||
@@ -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<string, unknown> }).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<string, unknown>) => 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(
|
||||
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user