mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-17 17:19:52 +00:00
* fix: settle which default plan an org seat resolves to, and what counts as paid Default subscription resolvers ran in registration order, which is boot order: whichever layer registered first answered first. A seat is provisioned by a team and its address is never confirmed, so the seat resolver now states a priority and wins over anything a deployment infers from an address. The seat cap asked whether the owner's plan was outside the free set, which a default plan can be without anyone having bought it. Metering now reports whether an explicit subscription answered, and the cap rests on that. * fix: the review's findings on plan resolution `fromDefault` was read off which resolver answered, not off which policy applied. An explicit id naming a policy nobody registered falls through to the default one, and that read as bought. It follows the applied policy now. A deployment with unlimited metering has no explicit resolver, so every owner on it became unpaid and its teams dropped from the paid seat cap to the free one. Unlimited answers for what a paid plan would, so it counts as one. Also: the new constants had been placed between `FREE_SUBSCRIPTION_IDS` and its docblock, the register docstring still described first-wins, and prettier had reflowed an untouched licence header. * feat: a seat confirms the address its team typed in An address on a seat is the team's claim about someone else's inbox, and nothing asked the inbox. It is now demanded and sent a code, so the address counts for nothing until the seat hands it back. Resolvers that read a domain are the reason this matters. No address is still fine and demands nothing: the team is the trust anchor for the account, not for a mailbox it does not hold. `/confirm-email` allows an unconfirmed actor, so a seat can sign in with its temporary credential and clear the demand; nothing locks it out. * fix: clear the confirmation demand with the address it names `clearUnconfirmedEmail` releases a seat's address so the inbox's owner can sign up with it. The demand to confirm that address has to go at the same write: the gate reads `requires_email_confirmation && !email_confirmed`, and once the address is gone there is nothing left to send a code to. * fix: keep a seat's address correctable, and only ask what can be answered Review of the confirmation demand turned up two ways a seat could be left holding one it had no way to meet: - With no mail transport configured, the code is never sent and a resend cannot reach the seat either, so the demand stood with nothing able to lift it. The seat is now gated only when a code can actually travel. - An address the owner mistyped gated the seat on an inbox nobody could read, and the gate itself closes `/user-protected/change-email`, the route the seat would have fixed it by. `updateSeatEmail` lets the owner who typed the address retarget it, and refuses once it is confirmed, when it belongs to whoever proved it. Also resolves the owner's plan sequentially, so the second read takes the cache the first wrote and the pair cannot come from two resolutions, and restores the licence header a jsdoc reflow had rewrapped. * fix: let the confirm-email window open before the profile has loaded Booted from a stored token, the window renders before `window.user` is populated, and reading `.email` off null threw inside the window itself -- so the one screen that could have asked for the code never drew, leaving a blank page. Reproducible without a team: sign up, then load the GUI again before confirming. The address is now encoded rather than interpolated raw, and both forms of the line are i18n keys; it was hardcoded English before.
560 lines
19 KiB
TypeScript
560 lines
19 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 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown> {
|
|
return (req.body ?? {}) as Record<string, unknown>;
|
|
}
|
|
|
|
/** 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<number> {
|
|
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;
|
|
}
|
|
}
|