Files
puter/src/backend/services/team/TeamService.ts
T
Juan Castro 73698be964 fix: one gate for an address we are about to store
`validator.isEmail` accepts a quoted local part, so an address whose local part
is an arbitrary string passes it. Five call sites relied on that check alone
before writing the address to `user.email`, and the invite path's own shape
regex was equally permissive.

`isStorableEmail` is now the single gate: the library check, plus a refusal of
`<`, `>` and `"`, which are legal in an address only inside the quoted form.
Nothing deliverable uses that form, so the rule costs no real address --
apostrophes and ampersands in a local part still pass, and the suite covers
them.

One function rather than a check each caller has to remember, because the next
place that stores an address will not remember.
2026-10-06 16:47:15 -04:00

1337 lines
49 KiB
TypeScript

/*
* 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 bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import {
USERNAME_MAX_LENGTH,
USERNAME_REGEX,
} from '../../controllers/auth/AuthController.js';
import { isReservedUsername } from '../../util/reservedUsernames.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,
TeamBillingEvent,
} from '../../clients/event/types';
import { HttpError } from '../../core/http/HttpError.js';
import {
AUDIT_PAGE_CAP,
AUDIT_PAGE_SIZE,
checkHandle,
} from '../../stores/team/TeamStore.js';
import type {
TeamAuditRow,
TeamMemberRow,
TeamRow,
} from '../../stores/team/TeamStore';
import {
decodeCursor,
encodeCursor,
normalizeLimit,
type PageResult,
} from '../../util/pagination.js';
import type { UserRow } from '../../stores/user/UserStore';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import {
generateTemporaryPassword,
temporaryPasswordExpiry,
} from '../../util/temporaryPassword.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { PuterService } from '../types';
/** 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, so 200ms of
* waiting is already far past the contended case; past it the request is
* refused, because running unserialized would leave the cap unenforced.
*/
const CAP_LOCK_ATTEMPTS = 8;
const CAP_LOCK_RETRY_MS = 25;
const CAP_LOCK_TTL_SECONDS = 10;
/**
* Audit vocabulary. `reset_member_password` covers both a reissue before first
* use and a reset of a live account: from the member's side both mean the
* administrator now holds a working credential for their account.
*/
export const AUDIT_RESET_PASSWORD = 'reset_member_password';
export const AUDIT_ACTIVATE = 'activate';
/** Written before the row it names goes; `_keep` is what preserves it. */
export const AUDIT_DELETE_ACCOUNT = 'delete_account';
/** A disclosure change, so it is recorded like anything else the team does. */
export const AUDIT_DIRECTORY_ON = 'directory_enabled';
export const AUDIT_DIRECTORY_OFF = 'directory_disabled';
/** Changes what every seat must hold to sign in, so it is recorded too. */
export const AUDIT_2FA_ON = 'require_2fa_enabled';
export const AUDIT_2FA_OFF = 'require_2fa_disabled';
/** Its own action, never a side effect of a password reset. */
export const AUDIT_RESET_2FA = 'reset_member_2fa';
/** Not an audit row: synthesised from `sessions` for the member's own view. */
export const SIGN_IN_ACTION = 'sign_in';
/** One line of a member's activity, whether recorded or a sign-in. */
export interface MemberActivityEntry {
action: string;
reason: string | null;
/** Unix seconds, so the two sources sort on one axis on every dialect. */
created_at: number;
username: string | null;
actor_username: string | null;
ip: string | null;
user_agent: string | null;
}
/** `sessions` stores unix seconds; audit rows a timestamp the driver shapes. */
const epochSeconds = (value: unknown): number => {
if (value instanceof Date) return Math.floor(value.getTime() / 1000);
if (typeof value === 'number') return Math.floor(value);
const text = String(value ?? '');
// sqlite returns UTC 'YYYY-MM-DD HH:MM:SS', which Date.parse reads as local.
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u.test(text)
? `${text.replace(' ', 'T')}Z`
: text;
const parsed = Date.parse(iso);
return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
};
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. */
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;
}
}
/** The membership cascaded away, so the cached reads over it must go too. */
async forgetSeat(seat: TeamBillingEvent): Promise<void> {
const team = await this.stores.team.getByUid(seat.team_uid);
await this.stores.team.bustMembership(team?.id ?? -1, seat.user_id);
await this.stores.team.bustMember(seat.team_uid, seat.user_id);
}
/** 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 ------------------
/**
* Whether this account may enter the teams surface. Membership in any team
* always passes: an allowed owner brought them in.
*/
async teamsAvailableTo(
userId: number,
email: string | null | undefined,
): Promise<boolean> {
const domains = this.config.teams_allowed_email_domains;
if (!Array.isArray(domains) || domains.length === 0) return true;
const at = String(email ?? '').lastIndexOf('@');
const domain =
at === -1
? ''
: String(email)
.slice(at + 1)
.toLowerCase();
if (
domain !== '' &&
domains.some((d) => String(d).toLowerCase() === domain)
) {
return true;
}
return (await this.stores.team.listGroupIdsForUser(userId)).length > 0;
}
/**
* An account a team provisioned does not take on accounts of its own; that
* is the console's job, not a seat's (PUT-1890). Keyed on `org_owned = 1` —
* the owner is `org_owned = 0` in their own team, and a joined member is an
* ordinary self-paying account.
*/
async #refuseIfOrgSeat(userId: number): Promise<void> {
const seat = await this.stores.team.getOrgSeat(userId);
if (!seat) return;
throw new HttpError(
403,
seat.team_name
? `${seat.team_name} owns this account, so it cannot create a team of its own.`
: 'A team owns this account, so it cannot create a team of its own.',
{ legacyCode: 'forbidden' },
);
}
/** 404 to a non-member so the endpoint is not an existence oracle. */
async requireMembership(
teamUid: string,
actorUserId: number,
): Promise<TeamRow> {
const team = await this.stores.team.getByUid(teamUid);
if (!team || !(await this.stores.team.isMember(teamUid, actorUserId))) {
throw new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
return team;
}
/** Authority is one test: the caller is the account named by the team. */
async requireOwner(teamUid: string, actorUserId: number): Promise<TeamRow> {
const team = await this.requireMembership(teamUid, actorUserId);
if (team.owner_user_id !== actorUserId) {
throw new HttpError(403, 'Only the team owner can do that', {
legacyCode: 'not_the_team_owner',
});
}
return team;
}
/** The team owner is never a valid target of a member route. */
async requireOrgAccount(
teamUid: string,
targetUserId: number,
opts: { includeDeleted?: boolean } = {},
): Promise<TeamMemberRow> {
const membership = opts.includeDeleted
? await this.stores.team.getMembershipIncludingDeleted(
teamUid,
targetUserId,
)
: await this.stores.team.getMembership(teamUid, targetUserId);
// Tested explicitly, never inferred from NULL.
if (!membership || Number(membership.org_owned) !== 1) {
throw new HttpError(404, 'Not an account of this team', {
legacyCode: 'not_an_org_account',
});
}
return membership;
}
// -- Team lifecycle ------------------------------------------
// -- Caps ---- bounds, not billing; the charge is out of repo ---------
/** Live teams one user may own. */
#teamCap(): number {
const n = Number(this.config.max_teams_per_user);
return Number.isFinite(n) && n > 0 ? n : 1;
}
/** 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;
// The whole row, not an id/uuid stub: a resolver may key on any
// field, and one that misses makes the cap depend on whether
// something else cached this user's plan first.
const policy = await this.services.metering.getActorSubscription({
user: owner,
} 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. */
async assertHandleUsable(handle: string): Promise<void> {
const rejection = checkHandle(handle);
if (rejection) {
throw new HttpError(400, `Unusable handle: ${rejection}`, {
legacyCode: 'bad_request',
});
}
if (!(await this.stores.team.isHandleAvailable(handle))) {
throw new HttpError(409, 'That handle is taken', {
legacyCode: 'conflict',
});
}
}
/** The unique index is the arbiter, so a race still lands as a 409. */
async #asHttpErrors<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (e) {
if (e instanceof HttpError) throw e;
const message = String((e as Error)?.message ?? '');
if (/unusable team handle/iu.test(message)) {
throw new HttpError(400, message, {
legacyCode: 'bad_request',
});
}
if (/unique|duplicate/iu.test(message)) {
throw new HttpError(409, 'That handle is taken', {
legacyCode: 'conflict',
});
}
throw e;
}
}
/**
* Serializes a count-then-insert; same shape as `ACLService.#withNodeLock`.
* Refuses rather than running unserialized, or the cap is unenforced under
* concurrency. A lone create never contends.
*/
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),
);
}
} catch (e) {
// Same answer as contention below — the caller retries either way.
// Logged, because unlike contention the cause is ours.
console.warn('[team] cap lock unavailable:', e);
held = false;
}
if (!held) {
throw new HttpError(409, 'Busy — try that again in a moment', {
legacyCode: 'conflict',
});
}
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,
actorUserId: number,
changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
require2fa?: boolean;
},
): Promise<TeamRow> {
const before = await this.requireOwner(teamUid, actorUserId);
if (changes.handle) await this.assertHandleUsable(changes.handle);
if (changes.require2fa === true) {
await this.#assertOwnerHas2fa(actorUserId);
}
const team = await this.#asHttpErrors(() =>
this.stores.team.update(teamUid, changes),
);
if (!team) {
throw new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
// Recorded because it changes who can read the member list, which is
// not something a team should be able to alter silently.
const was = Number(before.directory_enabled) === 1;
if (
changes.directoryEnabled !== undefined &&
changes.directoryEnabled !== was
) {
await this.stores.team.appendAudit({
teamId: team.id,
userId: actorUserId,
actorUserId,
action: changes.directoryEnabled
? AUDIT_DIRECTORY_ON
: AUDIT_DIRECTORY_OFF,
});
}
const required = Number(before.require_2fa) === 1;
if (
changes.require2fa !== undefined &&
changes.require2fa !== required
) {
await this.stores.team.appendAudit({
teamId: team.id,
userId: actorUserId,
actorUserId,
action: changes.require2fa ? AUDIT_2FA_ON : AUDIT_2FA_OFF,
});
// The seat rows carry this flag, so a stale one would keep a
// member locked out after the owner turned the rule off.
await this.#bustSeatCaches(team);
}
return team;
}
/** Turning the rule on without it would lock out the account doing so. */
async #assertOwnerHas2fa(actorUserId: number): Promise<void> {
const owner = await this.stores.user.getByProperty('id', actorUserId, {
force: true,
});
if (owner?.otp_enabled) return;
throw new HttpError(
409,
'Set up two-factor authentication on your own account before requiring it of the team.',
{ legacyCode: 'conflict' },
);
}
/** Bounded by the seat cap, so no paging cap is needed. */
async #bustSeatCaches(team: TeamRow): Promise<void> {
let cursor: string | undefined;
for (let page = 0; page < 50; page++) {
const res = await this.stores.team.listMembers(team.uid, {
limit: 100,
...(cursor ? { cursor } : {}),
});
for (const member of res.items) {
await this.stores.team.bustMembership(
team.id,
Number(member.user_id),
);
}
if (!res.cursor) return;
cursor = res.cursor;
}
}
/** Whether the owner opened the team to the apps its members use. */
isDirectoryOpen(team: TeamRow): boolean {
return Number(team.directory_enabled) === 1;
}
/**
* 404 rather than 403: whether a team opened its directory is itself
* something an app should not be able to probe for.
*/
assertDirectoryOpen(team: TeamRow): void {
if (!this.isDirectoryOpen(team)) {
throw new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
}
/**
* The member list as an app may read it, once the team has opted in. The
* page carries only what a colleague already sees through `/members`.
*/
async listDirectory(
teamUid: string,
actorUserId: number,
opts: { limit?: unknown; cursor?: string } = {},
): Promise<PageResult<{ username: string; uuid: string }>> {
const team = await this.requireMembership(teamUid, actorUserId);
this.assertDirectoryOpen(team);
const page = await this.stores.team.listDirectory(teamUid, opts);
return {
items: page.items.map((m) => ({
username: m.username,
uuid: m.uuid,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
}
/** Creates a team and admits its creator as the team owner. */
async createTeam(
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> {
await this.#refuseIfOrgSeat(ownerUserId);
// First: a capped user should hear that, not that the name was taken.
const cap = this.#teamCap();
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);
const team = await this.#asHttpErrors(() =>
this.stores.team.create({
ownerUserId,
name: input.name,
handle,
}),
);
// 0 makes the owner pay for itself; unchecked, it is unreachable.
const admitted = await this.stores.team.addMember(
team.uid,
ownerUserId,
{
orgOwned: false,
},
);
if (!admitted) {
await this.stores.team.softDelete(team.uid);
throw new HttpError(500, 'Could not create the team', {
legacyCode: 'internal_error',
});
}
return team;
}
/** The owner is a member with `org_owned = 0`, and the only such member. */
async checkOwnerInvariant(teamUid: string): Promise<boolean> {
const team = await this.stores.team.getByUid(teamUid);
if (!team) return false;
const owner = await this.stores.team.getMembership(
teamUid,
team.owner_user_id,
);
if (!owner || Number(owner.org_owned) !== 0) return false;
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.stores.team.appendAudit({
teamId: team.id,
userId: member.user_id,
actorUserId,
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,
});
// The team notice covers the disabling, so a member is
// told once rather than twice about the same event.
await this.#notifyMember(member.user_id, 'team_closed', team);
}
if (!page.cursor) break;
page = await this.stores.team.listMembers(teamUid, {
limit: 200,
cursor: page.cursor,
});
}
await this.stores.team.appendAudit({
teamId: team.id,
userId: actorUserId,
actorUserId,
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. */
async listAudit(
teamUid: string,
actorUserId: number,
opts: { limit?: unknown; cursor?: string } = {},
) {
const team = await this.requireOwnedTeam(teamUid, actorUserId);
return this.#withUsernames(
await this.stores.team.listAudit(team.id, opts),
);
}
/**
* The caller's own entries, interleaved with sign-ins to their account. The
* only reader who is not the actor, and the only place a sign-in between an
* administrator's reset and the member's own password change becomes
* visible to the person it concerns.
*/
async listOwnAudit(
teamUid: string,
actorUserId: number,
opts: { limit?: unknown; cursor?: string } = {},
): Promise<PageResult<MemberActivityEntry>> {
const team = await this.requireMembership(teamUid, actorUserId);
const limit =
normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ??
AUDIT_PAGE_SIZE;
const cursor =
decodeCursor(opts.cursor, 'member activity cursor') ?? {};
const fromAudit = typeof cursor.a === 'number' ? cursor.a : undefined;
const fromSignIn = typeof cursor.s === 'number' ? cursor.s : null;
const audit = await this.stores.team.listAuditForUser(
team.id,
actorUserId,
{
limit,
cursor:
fromAudit === undefined
? undefined
: encodeCursor({ id: fromAudit }),
},
);
// One past the limit, so a page that consumes no sign-in still knows
// whether any remain.
const signIns = await this.stores.session.listSignIns(actorUserId, {
limit: limit + 1,
beforeId: fromSignIn,
});
const named = await this.#withUsernames(audit);
const self =
(await this.stores.user.getById(actorUserId))?.username ?? null;
const merged = [
...named.items.map((entry, i) => ({
entry,
stream: 'a' as const,
id: audit.items[i].id,
})),
...signIns.slice(0, limit).map((row) => ({
entry: {
action: SIGN_IN_ACTION,
reason: null,
created_at: epochSeconds(row.created_at),
username: self,
actor_username: null,
ip: row.last_ip,
user_agent: row.last_user_agent,
} as MemberActivityEntry,
stream: 's' as const,
id: row.id,
})),
].sort(
(x, y) =>
y.entry.created_at - x.entry.created_at ||
x.stream.localeCompare(y.stream) ||
y.id - x.id,
);
const page = merged.slice(0, limit);
const more =
merged.length > limit || !!audit.cursor || signIns.length > limit;
// A stream that contributed nothing keeps its old position: everything
// it still holds is older than this page, so it resumes where it was.
const next = {
...(page.some((row) => row.stream === 'a')
? { a: page.findLast((row) => row.stream === 'a')!.id }
: fromAudit === undefined
? {}
: { a: fromAudit }),
...(page.some((row) => row.stream === 's')
? { s: page.findLast((row) => row.stream === 's')!.id }
: fromSignIn === null
? {}
: { s: fromSignIn }),
};
return {
items: page.map((row) => row.entry),
...(more ? { cursor: encodeCursor(next) } : {}),
};
}
/** Resolves a team the caller owns, soft-deleted or not. */
async requireOwnedTeam(
teamUid: string,
actorUserId: number,
): Promise<TeamRow> {
const live = await this.stores.team.getByUid(teamUid);
if (live) return this.requireOwner(teamUid, actorUserId);
const deleted =
await this.stores.team.getByUidIncludingDeleted(teamUid);
if (!deleted || deleted.owner_user_id !== actorUserId) {
throw new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
return deleted;
}
/** Internal user ids never reach the wire, as `toClientTeam` does for `id`. */
async #withUsernames(page: {
items: TeamAuditRow[];
cursor?: string;
}): Promise<PageResult<MemberActivityEntry>> {
const ids = new Set<number>();
for (const row of page.items) {
ids.add(row.user_id_keep);
if (row.actor_user_id !== null) ids.add(row.actor_user_id);
}
const users = await this.stores.user.getByIds([...ids]);
const name = (id: number | null) =>
id === null ? null : (users.get(id)?.username ?? null);
return {
items: page.items.map((row): MemberActivityEntry => ({
action: row.action,
reason: row.reason,
created_at: epochSeconds(row.created_at),
username: name(row.user_id_keep),
actor_username: name(row.actor_user_id),
// Only a sign-in carries these; the shape stays uniform.
ip: null,
user_agent: null,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
}
// -- Provisioning ---- usernames come from the global pool ----------
/** Provisioning must not mint accounts signup itself would refuse. */
#usernameRejection(username: string): boolean {
return (
!USERNAME_REGEX.test(username) ||
username.length > USERNAME_MAX_LENGTH ||
isReservedUsername(username)
);
}
#assertUsableUsername(username: string): void {
if (this.#usernameRejection(username)) {
throw new HttpError(400, 'Invalid username', {
legacyCode: 'bad_request',
});
}
}
/** Free names near `username`, checked for availability. Never auto-applied. */
async suggestUsernames(username: string, count = 3): Promise<string[]> {
// Room for the suffix, or every suggestion breaks the length rule.
const base = username.slice(0, USERNAME_MAX_LENGTH - 3);
const found: string[] = [];
for (let n = 1; n <= 40 && found.length < count; n++) {
const candidate = `${base}${n}`;
if (this.#usernameRejection(candidate)) continue;
if (await this.stores.user.getByUsername(candidate)) continue;
if (
await this.stores.fsEntry.findHomePathConflict(
candidate,
undefined,
{ includeDescendants: true },
)
)
continue;
found.push(candidate);
}
return found;
}
/** Returns the activation link; the account has no password until used. */
async provisionAccount(
teamUid: string,
actorUserId: number,
input: { username: string; email?: string | null },
): Promise<{
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 | null },
): Promise<{
userId: number;
username: string;
temporaryPassword: string;
}> {
const team = await this.requireOwner(teamUid, actorUserId);
// Unreachable while `createTeam` refuses a seat, since provisioning
// needs ownership; kept so the rule does not rest on that alone.
await this.#refuseIfOrgSeat(actorUserId);
// Counted, never derived from a stored total: seats come and go.
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',
fields: { limit: cap },
});
}
this.#assertUsableUsername(input.username);
const email = typeof input.email === 'string' ? input.email.trim() : '';
if (email && !isStorableEmail(email)) {
throw new HttpError(400, 'Invalid email', {
legacyCode: 'bad_request',
});
}
// Before any write, so a taken name fails cleanly. Also taken if free
// in the users table, but another account's rows still sit at or
// under its home path.
if (
(await this.stores.user.getByUsername(input.username)) ||
(await this.stores.fsEntry.findHomePathConflict(
input.username,
undefined,
{ includeDescendants: true },
))
) {
throw new HttpError(409, 'That username is taken', {
legacyCode: 'username_already_in_use',
fields: {
suggestions: await this.suggestUsernames(input.username),
},
});
}
// `idx_user_owned_email` is partial and skips password-null rows.
if (email && (await this.stores.user.findEmailOwner(email))) {
throw new HttpError(409, 'That email is already in use', {
legacyCode: 'email_already_in_use',
});
}
const user = await this.stores.user.create({
username: input.username,
uuid: uuidv4(),
password: null,
email: email || null,
clean_email: email ? cleanEmail(email) : null,
// Never demanded: the team creating the account is the trust anchor.
requires_email_confirmation: false,
});
await generateDefaultFsentries(this.clients.db, this.stores.user, user);
// Otherwise a vanished team leaves a real account nobody owns.
const admitted = await this.stores.team.addMember(teamUid, user.id, {
orgOwned: true,
});
if (!admitted) {
await this.services.userAccount.cascadeDelete(user.id);
throw new HttpError(409, 'That team is no longer available', {
legacyCode: 'conflict',
});
}
await this.stores.team.appendAudit({
teamId: team.id,
userId: user.id,
actorUserId,
action: 'provision',
});
// Returned once; forced change on first use is what bounds it.
const temporaryPassword = await this.#issueTemporaryPassword(user.id);
await this.#notifyUser(user, 'team_account_created', team, {
temporary_password: temporaryPassword,
});
// 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,
temporaryPassword,
};
}
/** Issues a fresh credential, invalidating the previous one. */
async reissueCredential(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<{ temporaryPassword: string }> {
const team = await this.requireOwner(teamUid, actorUserId);
const user = await this.#requireTargetAccount(teamUid, targetUserId);
// Only before first use; changing a live account's password is reset.
if (!user.requires_password_change) {
throw new HttpError(409, 'That account is already activated', {
legacyCode: 'conflict',
});
}
// Recorded first, so a failed append cannot leave an unlogged credential.
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: AUDIT_RESET_PASSWORD,
reason: 'reissue',
});
const temporaryPassword =
await this.#issueTemporaryPassword(targetUserId);
await this.#notifyUser(user, 'team_account_created', team, {
temporary_password: temporaryPassword,
});
return { temporaryPassword };
}
/**
* Takes a live account back with a fresh temporary password. The one route
* from a team to member data, and the answer to a locked-out employee.
*/
async resetMemberPassword(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<{ temporaryPassword: string }> {
const team = await this.requireOwner(teamUid, actorUserId);
const user = await this.#requireTargetAccount(teamUid, targetUserId);
// Recorded first, so a failed append cannot leave an unlogged reset.
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: AUDIT_RESET_PASSWORD,
});
const temporaryPassword =
await this.#issueTemporaryPassword(targetUserId);
// 2FA is deliberately untouched: a reset alone is not takeover.
await this.#dropSessions(targetUserId);
await this.#notifyUser(user, 'team_password_reset', team);
return { temporaryPassword };
}
/**
* Clears a seat's second factor so it can enrol again, for the member who
* lost both their authenticator and their recovery codes.
*
* Deliberately not part of `resetMemberPassword`, which leaves 2FA alone
* precisely so a password reset is not takeover. Together the two are, and
* for an account the team created and pays for that is the team's to do —
* but it has to be chosen, and recorded, rather than arrived at sideways.
*/
async resetMemberTwoFactor(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
const user = await this.#requireTargetAccount(teamUid, targetUserId);
if (!user.otp_enabled) {
throw new HttpError(409, 'That account has no 2FA to reset', {
legacyCode: 'conflict',
});
}
// Recorded first, so a failed append cannot leave an unlogged reset.
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: AUDIT_RESET_2FA,
});
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = ?, `otp_secret` = NULL, ' +
'`otp_recovery_codes` = NULL WHERE `id` = ?',
[this.clients.db.booleanValue(false), targetUserId],
);
await this.stores.user.invalidateById(targetUserId);
await this.#dropSessions(targetUserId);
await this.#notifyUser(user, 'team_2fa_reset', team);
}
/**
* Records that a member replaced the credential their administrator issued.
* A no-op for everyone who is not a seat, which is almost every account.
*/
async recordPasswordSelfChange(userId: number): Promise<void> {
const seat = await this.stores.team.getOrgSeat(userId);
if (!seat) return;
const team = await this.stores.team.getByUidIncludingDeleted(
seat.team_uid,
);
if (!team) return;
await this.stores.team.appendAudit({
teamId: team.id,
userId,
actorUserId: userId,
action: AUDIT_ACTIVATE,
});
}
/** The target of a member route, read past the cache the caller just wrote. */
async #requireTargetAccount(
teamUid: string,
targetUserId: number,
): Promise<UserRow> {
await this.requireOrgAccount(teamUid, targetUserId);
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
if (!user) {
throw new HttpError(404, 'Account not found', {
legacyCode: 'not_found',
});
}
return user as UserRow;
}
/** Never logged and never stored in plaintext; the caller shows it once. */
async #issueTemporaryPassword(userId: number): Promise<string> {
const temporaryPassword = generateTemporaryPassword();
await this.stores.user.update(userId, {
password: await bcrypt.hash(temporaryPassword, 8),
requires_password_change: 1,
temp_password_expires_at: temporaryPasswordExpiry(),
});
await this.stores.user.invalidateById(userId);
return temporaryPassword;
}
/** Best effort: the admin also gets the credential in the API response. */
async #notifyUser(
user: UserRow | null | undefined,
template: EmailTemplateName,
team: TeamRow,
vars: Record<string, string> = {},
): Promise<void> {
if (!this.clients.email || !user?.email) return;
try {
const sent = await this.clients.email.send(user.email, template, {
username: user.username,
team_name: team.name ?? 'Your team',
...vars,
});
// `sendRaw` returns null with no transport rather than throwing.
if (sent === null) {
console.warn(`[team] no email transport for ${template}`);
}
} catch (e) {
console.warn(`[team] ${template} notice failed:`, e);
}
}
/** The same notice, for a caller holding only the member's id. */
async #notifyMember(
userId: number,
template: EmailTemplateName,
team: TeamRow,
): Promise<void> {
await this.#notifyUser(
await this.stores.user.getById(userId),
template,
team,
);
}
// -- Disable and re-enable ---- the whole of offboarding ------------
/** Rejects the account's next request; its files are untouched. */
async disableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
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({
teamId: team.id,
userId: targetUserId,
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,
});
// Their sessions are gone, so email is the only channel left.
await this.#notifyMember(targetUserId, 'team_account_disabled', team);
}
/** Nothing was destroyed, so the account returns as it was. */
async enableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// Forced read, as `userProtected` does: a cached row predates this.
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
// Only the team's own suspension; a platform one must not lift.
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,
userId: targetUserId,
actorUserId,
action: 'enable',
});
await this.stores.user.update(targetUserId, {
suspended: 0,
suspended_at: null,
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),
});
}
/** Disable is the reversible step in front of the irreversible one. */
async deleteMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
// Deleted team included: the only self-serve way to retire its seats.
const team = await this.requireOwnedTeam(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId, {
includeDeleted: true,
});
// Forced, as `enableMember` is: a cached row predates the disable.
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
if (!user?.suspended) {
throw new HttpError(409, 'Disable the account before deleting it', {
legacyCode: 'account_must_be_disabled_first',
});
}
// Only the team's own suspension may be deleted, as `enableMember` holds.
if (user.suspended_reason !== DISABLED_BY_TEAM) {
throw new HttpError(409, 'That account was suspended by Puter', {
legacyCode: 'conflict',
});
}
// Written first; `_keep` is what makes it outlive the account.
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: AUDIT_DELETE_ACCOUNT,
});
// `cascadeDelete` emits `team.account.deleted` itself; a second emit
// here would close the storage charge twice.
await this.services.userAccount.cascadeDelete(targetUserId);
}
/** The three columns together; `suspended` is the one that gates requests. */
async #suspend(userId: number): Promise<void> {
await this.stores.user.update(userId, {
suspended: 1,
suspended_at: Math.floor(Date.now() / 1000),
suspended_reason: DISABLED_BY_TEAM,
});
await this.#dropSessions(userId);
}
/** Via the store: a raw DELETE leaves the session cache serving the row. */
async #dropSessions(userId: number): Promise<void> {
const rows = (await this.clients.db.read(
'SELECT `uuid` FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL',
[userId],
)) as { uuid: string }[];
for (const row of rows) {
await this.stores.session.removeByUuid(row.uuid);
}
await this.stores.user.invalidateById(userId);
}
}