mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-26 07:06:08 +00:00
Merge pull request #3830 from HeyPuter/juancastro/phase3-land-on-main
Teams phase 3: land the remaining three PRs on main (#3733, #3724, #3725)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,24 @@ type GuiEvent<R = Record<string, unknown>> = {
|
||||
// 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 string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, string>;
|
||||
}
|
||||
).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']);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, unknown> }> = [];
|
||||
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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TeamBillingContext> {
|
||||
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<number> {
|
||||
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<TeamBillingEvent | null> {
|
||||
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<K extends keyof EventMap>(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<void> {
|
||||
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<T>(suffix: string, run: () => Promise<T>): Promise<T> {
|
||||
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<TeamRow> {
|
||||
return this.#withCapLock(`owner:${ownerUserId}`, () =>
|
||||
this.#createTeamLocked(ownerUserId, input),
|
||||
);
|
||||
}
|
||||
|
||||
async #createTeamLocked(
|
||||
ownerUserId: number,
|
||||
input: { name: string; handle?: string | null },
|
||||
): Promise<TeamRow> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<number> {
|
||||
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 }> {
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<OrgSeatRow | null> {
|
||||
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<OrgSeatRow | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user