/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import type { Request, Response } from 'express';
import {
Controller,
Delete,
Get,
Post,
Put,
} from '../../core/http/decorators.js';
import { isAccountContext } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { TeamRow } from '../../stores/team/TeamStore.js';
import { PuterController } from '../types.js';
/** Mirrors ShareController's dual-window shape. */
const TEAM_LIMIT = [
{ scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const },
{
scope: 'team:mutate-daily',
limit: 500,
window: 24 * 60 * 60_000,
key: 'user' as const,
},
];
/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
const TEAM_RESET_LIMIT = [
...TEAM_LIMIT,
{
scope: 'team:reset-password',
limit: 20,
window: 24 * 60 * 60_000,
key: 'user' as const,
},
];
const TEAM_READ_LIMIT = {
scope: 'team:read',
limit: 600,
window: 60_000,
key: 'user' as const,
};
/** What a team looks like on the wire. `id` stays internal. */
const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
uid: team.uid,
name: team.name,
handle: team.handle,
is_owner: isOwner,
created_at: team.created_at,
directory_enabled: Number(team.directory_enabled) === 1,
require_2fa: Number(team.require_2fa) === 1,
});
@Controller('/teams')
export class TeamController extends PuterController {
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
// use `requireAuth` so an app can read a team whose owner opted into the
// directory (see `directory_enabled`).
/** Off means `/teams` 404s and no team route is registered at all. */
isEnabled(): boolean {
return this.config.teams_enabled === true;
}
@Post('', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async createTeam(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
const body = this.#body(req);
const team = await this.services.team.createTeam(userId, {
name: this.#requireString(body.name, 'name'),
handle:
body.handle === undefined || body.handle === null
? null
: this.#requireString(body.handle, 'handle'),
});
res.json(toClientTeam(team, true));
}
@Get('', {
subdomain: 'api',
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listTeams(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
// In SQL, or cursor and total describe teams the app cannot see.
const page = await this.stores.team.listTeamsForUser(userId, {
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
includeTotal: req.query.includeTotal === 'true',
openOnly: !isAccountContext(req.actor),
});
res.json({
items: page.items.map((t) =>
toClientTeam(t, t.owner_user_id === userId),
),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total === undefined ? {} : { total: page.total }),
});
}
@Get('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async getTeam(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const team = await this.services.team.requireMembership(
this.#param(req, 'uid'),
userId,
);
res.json(toClientTeam(team, team.owner_user_id === userId));
}
@Put('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async updateTeam(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
const body = this.#body(req);
const changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
require2fa?: boolean;
} = {};
if (body.name !== undefined)
changes.name = this.#requireString(body.name, 'name');
if (body.handle !== undefined)
changes.handle = body.handle === null ? null : String(body.handle);
if (body.directory_enabled !== undefined)
changes.directoryEnabled = body.directory_enabled === true;
if (body.require_2fa !== undefined)
changes.require2fa = body.require_2fa === true;
// Through the service: turning the directory on writes an audit row.
const team = await this.services.team.updateTeam(
this.#param(req, 'uid'),
userId,
changes,
);
res.json(toClientTeam(team, true));
}
@Delete('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async deleteTeam(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
res.json({ success: true });
}
// -- Members ------------------------------------------------------
@Get('/:uid/members', {
subdomain: 'api',
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listMembers(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const team = await this.services.team.requireMembership(
this.#param(req, 'uid'),
userId,
);
const accountContext = isAccountContext(req.actor);
// Same consent as the directory: an app only reads a team that opted in.
if (!accountContext) this.services.team.assertDirectoryOpen(team);
// uuid only when the team owner calls with their own session or API
// token; an app needing a stable id uses `listDirectory()` instead.
const showUuid = accountContext && team.owner_user_id === userId;
const page = await this.stores.team.listMembers(
this.#param(req, 'uid'),
{
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
// An app's view matches the directory's: active seats only.
activeOnly: !accountContext,
},
);
// An app only ever gets a username; org_owned/created_at are account-only.
res.json({
items: page.items.map((m) =>
accountContext
? {
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(showUuid ? { uuid: m.uuid } : {}),
}
: { username: m.username },
),
...(page.cursor ? { cursor: page.cursor } : {}),
});
}
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
@Get('/:uid/directory', {
subdomain: 'api',
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listDirectory(req: Request, res: Response): Promise {
// The membership is the person's, never the app's.
const userId = this.#requireUserId(req);
// A scoped token holds only what it was minted for; a roster is not that.
const token = req.actor?.accessToken;
if (token && token.fullAccess !== true) {
throw new HttpError(
403,
'This endpoint is not available to scoped access tokens',
{ legacyCode: 'forbidden' },
);
}
const page = await this.services.team.listDirectory(
this.#param(req, 'uid'),
userId,
{
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
},
);
res.json(page);
}
@Post('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async createMember(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority before shape, or a stranger learns if their body parsed.
await this.services.team.requireOwner(uid, userId);
const body = this.#body(req);
const result = await this.services.team.provisionAccount(uid, userId, {
username: this.#requireString(body.username, 'username'),
email: this.#optionalString(body.email, 'email'),
});
// Shown once; the admin delivers it out of band.
res.json({
username: result.username,
temporary_password: result.temporaryPassword,
});
}
// Same budget as a reset: both hand the administrator a working credential,
// and a reset re-arms the flag this route needs.
@Post('/:uid/members/:username/activation', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async reissueCredential(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
const { temporaryPassword } =
await this.services.team.reissueCredential(uid, userId, target);
// Shown once for the admin to deliver out of band.
res.json({ temporary_password: temporaryPassword });
}
@Post('/:uid/members/:username/password-reset', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async resetMemberPassword(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
const { temporaryPassword } =
await this.services.team.resetMemberPassword(uid, userId, target);
// Shown once; it is not retrievable afterwards.
res.json({ temporary_password: temporaryPassword });
}
// The gate closes the seat's own route to fix it, so the owner must.
@Post('/:uid/members/:username/email', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async updateSeatEmail(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.updateSeatEmail(
uid,
userId,
target,
(req.body as { email?: unknown } | undefined)?.email ?? null,
);
res.json({ success: true });
}
// Same budget as a password reset: both are a step toward the account.
@Post('/:uid/members/:username/2fa-reset', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async resetMemberTwoFactor(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.resetMemberTwoFactor(uid, userId, target);
res.json({ success: true });
}
@Post('/:uid/members/:username/disable', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async disableMember(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.disableMember(
this.#param(req, 'uid'),
userId,
target,
);
res.json({ success: true });
}
@Post('/:uid/members/:username/enable', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async enableMember(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.enableMember(
this.#param(req, 'uid'),
userId,
target,
);
res.json({ success: true });
}
@Delete('/:uid/members/:username', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async deleteMember(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first (anti-oracle); deleted team included, see the service.
await this.services.team.requireOwnedTeam(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.deleteMember(uid, userId, target);
res.json({ success: true });
}
// -- Audit --------------------------------------------------------
@Get('/:uid/audit', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listAudit(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
res.json(
await this.services.team.listAudit(
this.#param(req, 'uid'),
userId,
this.#pageOpts(req),
),
);
}
@Get('/:uid/audit/me', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listOwnAudit(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
res.json(
await this.services.team.listOwnAudit(
this.#param(req, 'uid'),
userId,
this.#pageOpts(req),
),
);
}
// -- Helpers ------------------------------------------------------
/** `limit` and `cursor` as doc/pagination.md defines them. */
#pageOpts(req: Request): { limit?: unknown; cursor?: string } {
return {
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
};
}
/**
* The domain-allowlist gate, on the two routes that enter the feature. Same
* 404 as a teams-off deployment; other routes bound by membership.
*/
async #requireTeamsAvailable(req: Request, userId: number): Promise {
const email = (
req.actor as { user?: { email?: string | null } } | undefined
)?.user?.email;
if (await this.services.team.teamsAvailableTo(userId, email)) return;
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
}
#requireUserId(req: Request): number {
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
?.id;
if (!id)
throw new HttpError(401, 'User required', {
legacyCode: 'unauthorized',
});
return id;
}
/** Express types a param as `string | string[]`; routes here take one. */
#param(req: Request, name: string): string {
const value = req.params[name];
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
}
#body(req: Request): Record {
return (req.body ?? {}) as Record;
}
/** Absent or empty means "not given"; a wrong type is still a 400. */
#optionalString(value: unknown, field: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== 'string') {
throw new HttpError(400, `${field} must be a string`, {
legacyCode: 'bad_request',
});
}
return value.trim() === '' ? null : value;
}
#requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new HttpError(400, `${field} is required`, {
legacyCode: 'bad_request',
});
}
return value;
}
#notFound(): HttpError {
return new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
/** Resolves `:username` to an id; the service decides whether it may act. */
async #requireTargetUserId(req: Request): Promise {
const user = await this.stores.user.getByUsername(
this.#param(req, 'username'),
);
if (!user)
throw new HttpError(404, 'Not an account of this team', {
legacyCode: 'not_an_org_account',
});
return user.id;
}
}