/* * 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; } }