/*
* 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 { 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,
},
];
const TEAM_READ_LIMIT = {
scope: 'team:read',
limit: 600,
window: 60_000,
key: 'user' as const,
};
/** What a workspace 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,
});
@Controller('/teams')
export class TeamController extends PuterController {
// `requireUserActor` installs the auth gates; reads need it too.
/** 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);
const body = this.#body(req);
const team = await this.services.team.createWorkspace(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',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listTeams(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
const teams = await this.stores.team.listTeamsForUser(userId);
res.json({
items: teams.map((t) =>
toClientTeam(t, t.owner_user_id === userId),
),
});
}
@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 } = {};
if (body.name !== undefined)
changes.name = this.#requireString(body.name, 'name');
if (body.handle !== undefined)
changes.handle = body.handle === null ? null : String(body.handle);
const team = await this.stores.team.update(
this.#param(req, 'uid'),
changes,
);
if (!team) throw this.#notFound();
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.deleteWorkspace(
this.#param(req, 'uid'),
userId,
);
res.json({ success: true });
}
// -- Members ------------------------------------------------------
@Get('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listMembers(req: Request, res: Response): Promise {
const userId = this.#requireUserId(req);
await this.services.team.requireMembership(
this.#param(req, 'uid'),
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,
},
);
res.json({
items: page.items.map((m) => ({
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
});
}
@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.#requireString(body.email, 'email'),
});
// Shown once; the admin delivers it out of band.
res.json({
username: result.username,
temporary_password: result.temporaryPassword,
});
}
@Post('/:uid/members/:username/activation', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_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/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 });
}
// -- 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,
};
}
#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;
}
#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, 'Workspace 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 workspace', {
legacyCode: 'not_an_org_account',
});
return user.id;
}
}