diff --git a/config.default.json b/config.default.json
index b430f760a..29c081f29 100644
--- a/config.default.json
+++ b/config.default.json
@@ -22,6 +22,7 @@
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
"storage_capacity": 104857600,
"disable_user_signup": false,
+ "teams_enabled": false,
"strict_email_verification_required": false,
"gui_assets_root": "./src/gui",
"puterjs_root": "./src/puter-js/dist",
diff --git a/config.template.jsonc b/config.template.jsonc
index 4d1fbf87e..578db31c9 100644
--- a/config.template.jsonc
+++ b/config.template.jsonc
@@ -273,6 +273,14 @@
// "emailBatchSeconds": 90
// },
+ // ── Teams / workspaces ──────────────────────────────────────────────
+ // Off by default, and off is the state every existing install stays in.
+ // The tables ship either way and sit inert; this gates whether `/teams`
+ // is registered at all, so with it off the paths do not exist rather than
+ // existing and refusing. It is also the backout: turning it off removes
+ // the feature without touching data.
+ "teams_enabled": false,
+
// ── Notifications ───────────────────────────────────────────────────
// How long a notification is kept, in days from creation. Acknowledged or
// not, a row past this is swept. Set 0 to keep everything forever.
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index cef7ae557..3e903d5de 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -344,6 +344,24 @@ support@puter.com immediately.
A password recovery request was issued for your account, please follow the link below to reset your password:
{{link}}
Sincerely,
+Puter
+ `,
+ },
+ team_account_created: {
+ subject: 'Your {{team_name}} account on Puter',
+ html: `
+Hi there,
+{{team_name}} has created a Puter account for you: {{username}}.
+They will send you a temporary password separately; you will be asked to
+choose your own the first time you sign in.
+What this means:
+
+- This account belongs to {{team_name}}. They pay for it and can close it.
+- {{team_name}} cannot see your files, apps or data.
+- {{team_name}} can reset your password, which would let them sign in
+as you. Choose your own password promptly.
+
+Sincerely,
Puter
`,
},
diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts
index 981fc50df..d6edb9047 100644
--- a/src/backend/controllers/auth/AuthController.ts
+++ b/src/backend/controllers/auth/AuthController.ts
@@ -81,8 +81,8 @@ import {
} from '../../services/permission/appDataScopes.js';
import { PuterController } from '../types.js';
-const USERNAME_REGEX = /^\w{1,}$/;
-const USERNAME_MAX_LENGTH = 45;
+export const USERNAME_REGEX = /^\w{1,}$/;
+export const USERNAME_MAX_LENGTH = 45;
const FINGERPRINT_MAX_LENGTH = 128;
// One consent prompt covers a handful of scopes at most. The cap keeps a
// crafted request from turning a single grant call into a bulk write.
@@ -194,7 +194,7 @@ const cardAlreadyVerified = (user: {
requires_card_verification?: boolean;
}): boolean =>
Boolean(user.card_fingerprint) && !user.requires_card_verification;
-const RESERVED_USERNAMES = new Set([
+export const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
'root',
@@ -4463,7 +4463,15 @@ export class AuthController extends PuterController {
): Promise {
const existing = await this.stores.user.findEmailOwner(email, opts);
if (!existing) return null;
- if (existing.email_confirmed || existing.password !== null) {
+ // A provisioned account looks exactly like a claimable placeholder --
+ // no password, unconfirmed -- but claiming it hands a stranger that
+ // workspace's membership.
+ const orgSeat = await this.stores.team.getOrgSeat(existing.id);
+ if (
+ existing.email_confirmed ||
+ existing.password !== null ||
+ orgSeat !== null
+ ) {
throw new HttpError(
400,
'This email already exists in our database. Please use another one.',
diff --git a/src/backend/controllers/index.ts b/src/backend/controllers/index.ts
index 2b6908807..9597624d2 100644
--- a/src/backend/controllers/index.ts
+++ b/src/backend/controllers/index.ts
@@ -32,6 +32,7 @@ import { NotificationController } from './notification/NotificationController.js
import { OIDCController } from './oidc/OIDCController.js';
import { PuterAIController } from './puterai/PuterAIController.js';
import { ShareController } from './share/ShareController.js';
+import { TeamController } from './team/TeamController.js';
import { StaticAssetsController } from './static/StaticAssetsController.js';
import { StaticPagesController } from './static/StaticPagesController.js';
import { SystemController } from './system/SystemController.js';
@@ -57,6 +58,7 @@ export const puterControllers = {
notification: NotificationController,
events: EventsController,
share: ShareController,
+ team: TeamController,
webdav: WebDAVController,
oidc: OIDCController,
wisp: WispController,
diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts
new file mode 100644
index 000000000..4d222b506
--- /dev/null
+++ b/src/backend/controllers/team/TeamController.http.test.ts
@@ -0,0 +1,308 @@
+/**
+ * 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import type { IConfig } from '../../types';
+import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
+
+const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
+
+describe('team endpoints over HTTP', () => {
+ let env: PuterTestEnv;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ const call = (
+ method: string,
+ path: string,
+ token: string,
+ body?: unknown,
+ ) =>
+ fetch(new URL(path, env.apiOrigin), {
+ method,
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+
+ /** A workspace owned by `user`, with one provisioned member. */
+ const makeWorkspace = async () => {
+ const res = await call('POST', '/teams', env.users.user.token, {
+ name: 'Acme',
+ handle: randomHandle(),
+ });
+ // Unasserted, a broken create means tests run on `/teams/undefined`.
+ expect(res.status).toBe(200);
+ const team = (await res.json()) as { uid: string };
+ expect(team.uid).toBeTruthy();
+
+ const username = `http_${Math.random().toString(36).slice(2, 9)}`;
+ const member = await call(
+ 'POST',
+ `/teams/${team.uid}/members`,
+ env.users.user.token,
+ { username, email: `${username}@test.local` },
+ );
+ expect(member.status).toBe(200);
+ return { team, memberUsername: username };
+ };
+
+ // -- the owner-account gate --------------------------------------
+
+ it('creates a workspace and reports the caller as its owner', async () => {
+ const res = await call('POST', '/teams', env.users.user.token, {
+ name: 'Acme Design',
+ handle: randomHandle(),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Record;
+ expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
+ expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
+ // `id` is internal and must not reach the wire.
+ expect(body).not.toHaveProperty('id');
+ });
+
+ it('returns 404, not 403, to a non-member', async () => {
+ const { team } = await makeWorkspace();
+
+ const res = await call(
+ 'GET',
+ `/teams/${team.uid}`,
+ env.users.other.token,
+ );
+ // 403 would confirm the workspace exists.
+ expect(res.status).toBe(404);
+ });
+
+ it('refuses every administrative route to a member who is not the owner', async () => {
+ const { team } = await makeWorkspace();
+ // Provisioned accounts have no password, so they cannot authenticate.
+ const other = await env.server.stores.user.getByUsername(
+ env.users.other.username,
+ );
+ await env.server.stores.team.addMember(team.uid, other!.id, {
+ orgOwned: true,
+ });
+
+ for (const [method, path] of [
+ ['PUT', `/teams/${team.uid}`],
+ ['DELETE', `/teams/${team.uid}`],
+ ['POST', `/teams/${team.uid}/members`],
+ ['GET', `/teams/${team.uid}/audit`],
+ ] as const) {
+ const res = await call(
+ method,
+ path,
+ env.users.other.token,
+ method === 'GET' ? undefined : { name: 'nope' },
+ );
+ expect(res.status, `${method} ${path}`).toBe(403);
+ }
+
+ // Still a member, so reads it is entitled to still work.
+ const readable = await call(
+ 'GET',
+ `/teams/${team.uid}`,
+ env.users.other.token,
+ );
+ expect(readable.status).toBe(200);
+ });
+
+ // -- the org_owned guard ------------------------------------------
+
+ it('refuses the workspace owner as the target of a member route', async () => {
+ const { team } = await makeWorkspace();
+
+ const res = await call(
+ 'POST',
+ `/teams/${team.uid}/members/${env.users.user.username}/disable`,
+ env.users.user.token,
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('lists members with org_owned distinguishing the owner', async () => {
+ const { team, memberUsername } = await makeWorkspace();
+
+ const res = await call(
+ 'GET',
+ `/teams/${team.uid}/members`,
+ env.users.user.token,
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ items: { username: string; org_owned: boolean }[];
+ };
+ const owner = body.items.find(
+ (m) => m.username === env.users.user.username,
+ );
+ const member = body.items.find((m) => m.username === memberUsername);
+ expect(owner?.org_owned).toBe(false);
+ expect(member?.org_owned).toBe(true);
+ });
+
+ // -- provisioning over the wire -----------------------------------
+
+ it('never returns the activation link to the administrator', async () => {
+ const { team } = await makeWorkspace();
+ const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
+
+ const res = await call(
+ 'POST',
+ `/teams/${team.uid}/members`,
+ env.users.user.token,
+ { username, email: `${username}@test.local` },
+ );
+
+ expect(res.status).toBe(200);
+ const raw = JSON.stringify(await res.json());
+ // Emailed to the member; returning it would let an admin use it.
+ expect(raw).not.toContain('set-new-password');
+ expect(raw).not.toContain('token');
+ });
+
+ it('refuses a taken username and offers alternatives', async () => {
+ const { team } = await makeWorkspace();
+
+ const res = await call(
+ 'POST',
+ `/teams/${team.uid}/members`,
+ env.users.user.token,
+ {
+ username: env.users.other.username,
+ email: 'taken@test.local',
+ },
+ );
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as {
+ suggestions?: string[];
+ fields?: { suggestions?: string[] };
+ };
+ const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
+ expect(suggestions.length).toBeGreaterThan(0);
+ expect(suggestions).not.toContain(env.users.other.username);
+ });
+
+ // -- audit --------------------------------------------------------
+
+ it('exposes the workspace audit to the workspace owner only', async () => {
+ const { team } = await makeWorkspace();
+
+ const mine = await call(
+ 'GET',
+ `/teams/${team.uid}/audit`,
+ env.users.user.token,
+ );
+ expect(mine.status).toBe(200);
+ const body = (await mine.json()) as { items: { action: string }[] };
+ expect(body.items.map((e) => e.action)).toContain('provision');
+
+ const theirs = await call(
+ 'GET',
+ `/teams/${team.uid}/audit`,
+ env.users.other.token,
+ );
+ expect(theirs.status).toBe(404);
+ });
+});
+
+describe('team endpoints with teams_enabled off', () => {
+ let env: PuterTestEnv;
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ it('404s every team route, so no team code is reachable', async () => {
+ // Real workspace: 200 with the flag on, so a 404 means no route.
+ const owner = await env.server.stores.user.getByUsername(
+ env.users.user.username,
+ );
+ const team = await env.server.stores.team.create({
+ ownerUserId: owner!.id,
+ name: 'Unreachable',
+ handle: `off-${Math.random().toString(36).slice(2, 8)}`,
+ });
+ await env.server.stores.team.addMember(team.uid, owner!.id, {
+ orgOwned: false,
+ });
+
+ const paths: [string, string][] = [
+ ['POST', '/teams'],
+ ['GET', '/teams'],
+ ['GET', `/teams/${team.uid}`],
+ ['PUT', `/teams/${team.uid}`],
+ ['DELETE', `/teams/${team.uid}`],
+ ['GET', `/teams/${team.uid}/members`],
+ ['POST', `/teams/${team.uid}/members`],
+ ['GET', `/teams/${team.uid}/audit`],
+ ['GET', `/teams/${team.uid}/audit/me`],
+ ];
+
+ for (const [method, path] of paths) {
+ const res = await fetch(new URL(path, env.apiOrigin), {
+ method,
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${env.users.user.token}`,
+ },
+ ...(method === 'POST' || method === 'PUT'
+ ? { body: JSON.stringify({ name: 'x' }) }
+ : {}),
+ });
+ expect(res.status, `${method} ${path}`).toBe(404);
+
+ // Ours would carry a team legacyCode; the framework's does not.
+ const body = await res.text();
+ expect(body, `${method} ${path}`).not.toContain('team_not_found');
+ expect(body, `${method} ${path}`).not.toContain(
+ 'not_the_workspace_owner',
+ );
+ }
+ });
+
+ it('registers no team routes at all with the flag off', async () => {
+ // The controller exists and is constructed; only its routes are absent.
+ const controller = env.server.controllers.team as unknown as {
+ isEnabled?: () => boolean;
+ };
+ expect(controller).toBeTruthy();
+ expect(controller.isEnabled?.()).toBe(false);
+ });
+
+ it('leaves the schema inert rather than absent', async () => {
+ // The flag gates reachability, not DDL.
+ await expect(
+ env.server.stores.team.getByHandle('no-such-workspace'),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts
new file mode 100644
index 000000000..9d2a1c5de
--- /dev/null
+++ b/src/backend/controllers/team/TeamController.ts
@@ -0,0 +1,374 @@
+/*
+ * 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;
+ }
+}
diff --git a/src/backend/controllers/team/TeamIsolation.http.test.ts b/src/backend/controllers/team/TeamIsolation.http.test.ts
new file mode 100644
index 000000000..dcd9df4b4
--- /dev/null
+++ b/src/backend/controllers/team/TeamIsolation.http.test.ts
@@ -0,0 +1,200 @@
+/**
+ * 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 .
+ */
+
+/**
+ * A workspace manages accounts and cannot read them. Nobody writes that grant
+ * deliberately, but a new implicator or a widened actor would create it and no
+ * other test would fail. Asserts outcomes, never that an implicator is absent.
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { makeActor } from '../../core/actor.js';
+import type { IConfig } from '../../types';
+import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
+
+describe('a workspace cannot read its members data', () => {
+ let env: PuterTestEnv;
+ let teamUid: string;
+ let memberUsername: string;
+ let memberFile: string;
+ let ownerFile: string;
+
+ /** Written directly: these tests are about reading, not about writing. */
+ const makeFile = async (username: string, label: string) => {
+ const uid = crypto.randomUUID();
+ const name = `${label}-${uid.slice(0, 8)}.txt`;
+ const path = `/${username}/${name}`;
+ const user = await env.server.stores.user.getByUsername(username);
+ await env.server.clients.db.write(
+ 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?)',
+ [
+ uid,
+ name,
+ path,
+ user!.id,
+ // `is_dir` is a real boolean on postgres.
+ env.server.clients.db.booleanValue(false),
+ Math.floor(Date.now() / 1000),
+ ],
+ );
+ return path;
+ };
+
+ const stat = (path: string, token: string) =>
+ fetch(new URL('/stat', env.apiOrigin), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ path }),
+ });
+
+ const readdir = (path: string, token: string) =>
+ fetch(new URL('/readdir', env.apiOrigin), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ path }),
+ });
+
+ beforeAll(async () => {
+ env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
+
+ // A provisioned account cannot authenticate until it activates.
+ const res = await fetch(new URL('/teams', env.apiOrigin), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${env.users.user.token}`,
+ },
+ body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }),
+ });
+ teamUid = ((await res.json()) as { uid: string }).uid;
+
+ memberUsername = env.users.other.username;
+ const member = await env.server.stores.user.getByUsername(memberUsername);
+ await env.server.stores.team.addMember(teamUid, member!.id, {
+ orgOwned: true,
+ });
+
+ memberFile = await makeFile(memberUsername, 'member');
+ ownerFile = await makeFile(env.users.user.username, 'owner');
+ }, 120_000);
+
+ afterAll(async () => {
+ await env?.shutdown();
+ });
+
+ // -- refused ------------------------------------------------------
+
+ it('refuses the workspace owner a members file', async () => {
+ const res = await stat(memberFile, env.users.user.token);
+ // Administering, paying for, and reading an account are three things.
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('refuses the workspace owner a members home directory', async () => {
+ const res = await readdir(`/${memberUsername}`, env.users.user.token);
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('refuses a full-access token no less than a session', async () => {
+ // `#scanAccessToken` re-scans the issuer as a plain user actor.
+ const res = await stat(memberFile, env.users.user.apiToken);
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('grants no workspace-wide reach through the team routes', async () => {
+ // There is no route to a member's files or KV, and none should appear.
+ for (const path of [
+ `/teams/${teamUid}/files`,
+ `/teams/${teamUid}/members/${memberUsername}/files`,
+ `/teams/${teamUid}/kv`,
+ ]) {
+ const res = await fetch(new URL(path, env.apiOrigin), {
+ headers: { authorization: `Bearer ${env.users.user.token}` },
+ });
+ expect(res.status, path).toBe(404);
+ }
+ });
+
+ // -- allowed ------------------------------------------------------
+
+ it('lets the workspace owner read its own files', async () => {
+ const res = await stat(ownerFile, env.users.user.token);
+ expect(res.status).toBe(200);
+ });
+
+ it('lets a member read their own files', async () => {
+ const res = await stat(memberFile, env.users.other.token);
+ expect(res.status).toBe(200);
+ });
+
+ it('lets an explicit grant through, as an ordinary share', async () => {
+ const member = await env.server.stores.user.getByUsername(
+ memberUsername,
+ );
+ const [entry] = (await env.server.clients.db.read(
+ 'SELECT `uuid` FROM `fsentries` WHERE `path` = ?',
+ [memberFile],
+ )) as { uuid: string }[];
+
+ // Only the member's own grant changes, never workspace authority.
+ const before = await stat(memberFile, env.users.user.token);
+ expect(before.status).toBeGreaterThanOrEqual(400);
+
+ await env.server.services.permission.grantUserUserPermission(
+ makeActor({ user: member!, app: null, accessToken: null } as never),
+ env.users.user.username,
+ `fs:${entry.uuid}:read`,
+ );
+
+ const after = await stat(memberFile, env.users.user.token);
+ expect(after.status).toBe(200);
+ });
+
+ it('disabling a member neither grants nor transfers their files', async () => {
+ const owner = await env.server.stores.user.getByUsername(
+ env.users.user.username,
+ );
+ const member = await env.server.stores.user.getByUsername(
+ memberUsername,
+ );
+ // A fresh file: the earlier test granted the owner read on `memberFile`.
+ const untouched = await makeFile(memberUsername, 'untouched');
+
+ await env.server.services.team.disableMember(
+ teamUid,
+ owner!.id,
+ member!.id,
+ );
+
+ // The member loses access to their own files...
+ const asMember = await stat(untouched, env.users.other.token);
+ expect(asMember.status).toBeGreaterThanOrEqual(400);
+
+ // ...and the workspace still does not gain it.
+ const asOwner = await stat(untouched, env.users.user.token);
+ expect(asOwner.status).toBeGreaterThanOrEqual(400);
+ });
+});
diff --git a/src/backend/core/http/HttpError.ts b/src/backend/core/http/HttpError.ts
index 2a7046295..a13580e7c 100644
--- a/src/backend/core/http/HttpError.ts
+++ b/src/backend/core/http/HttpError.ts
@@ -54,7 +54,10 @@ export type LegacyErrorCodes =
| 'password_mismatch'
| 'field_not_allowed_for_create'
| 'account_is_not_verified'
- | 'app_or_api_token_required';
+ | 'app_or_api_token_required'
+ | 'team_not_found'
+ | 'not_the_workspace_owner'
+ | 'not_an_org_account';
/**
* Copyright (C) 2024-present Puter Technologies Inc.
diff --git a/src/backend/server.ts b/src/backend/server.ts
index 695d21562..014b53f67 100644
--- a/src/backend/server.ts
+++ b/src/backend/server.ts
@@ -875,6 +875,14 @@ export class PuterServer {
);
}
+ // A controller may gate its own registration behind a config flag.
+ // Skipping here means its paths 404 rather than existing and refusing.
+ const isEnabled = (controller as { isEnabled?: () => boolean })
+ .isEnabled;
+ if (typeof isEnabled === 'function' && !isEnabled.call(controller)) {
+ return;
+ }
+
// Controllers annotated with `@Controller('/prefix')` carry the prefix
// on their prototype; bare (imperative) controllers default to ''.
const prefix = (controller as unknown as Record)[
diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts
index dfb5c41c6..e769bce03 100644
--- a/src/backend/services/index.ts
+++ b/src/backend/services/index.ts
@@ -40,6 +40,7 @@ import { PermissionService } from './permission/PermissionService';
import { DefaultUserService } from './selfhosted/DefaultUserService';
import { ShareNotificationService } from './share/ShareNotificationService';
import { ShareService } from './share/ShareService';
+import { TeamService } from './team/TeamService';
import { SocketService } from './socket/SocketService';
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
import type { IPuterServiceRegistry } from './types';
@@ -79,6 +80,7 @@ declare module './types' {
homepage: PuterHomepageService;
health: ServerHealthService;
userAccount: UserAccountService;
+ team: TeamService;
}
}
@@ -110,6 +112,8 @@ export const puterServices = {
// Declared after `fs` — account teardown tears the user's filesystem down
// first.
userAccount: UserAccountService,
+ // Leaf: team + user stores only.
+ team: TeamService,
// AppPermissionService + SubdomainPermissionService register permission
// rewriters/implicators only; no runtime state. Placed after fsEntry so
// the FS rewriter runs first for `fs:/path` → `fs:` before any
diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts
new file mode 100644
index 000000000..f2ede4d4a
--- /dev/null
+++ b/src/backend/services/team/TeamService.test.ts
@@ -0,0 +1,556 @@
+/**
+ * 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 { v4 as uuidv4 } from 'uuid';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { PuterServer } from '../../server.ts';
+import { setupTestServer } from '../../testUtil.ts';
+
+describe('TeamService', () => {
+ let server: PuterServer;
+ let service: PuterServer['services']['team'];
+ let owner: { id: number };
+ let ownerUsername: string;
+
+ const makeUser = async (): Promise<{ id: number; username: string }> => {
+ const username = `svc_${Math.random().toString(36).slice(2, 10)}`;
+ const created = (await server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: null,
+ email: `${username}@test.local`,
+ })) as unknown as { id: number };
+ return { id: created.id, username };
+ };
+
+ const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
+
+ /** A workspace with the owner admitted and one provisioned member. */
+ const makeWorkspace = async () => {
+ const team = await service.createWorkspace(owner.id, {
+ name: 'Acme',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+ await server.stores.team.addMember(team.uid, member.id, {
+ orgOwned: true,
+ });
+ return { team, member };
+ };
+
+ const suspensionOf = async (userId: number) => {
+ const [row] = (await server.clients.db.read(
+ 'SELECT `suspended`, `suspended_at`, `suspended_reason` FROM `user` WHERE `id` = ?',
+ [userId],
+ )) as {
+ suspended: number | null;
+ suspended_at: number | null;
+ suspended_reason: string | null;
+ }[];
+ return row;
+ };
+
+ beforeAll(async () => {
+ server = await setupTestServer();
+ service = server.services.team;
+ owner = await makeUser();
+ ownerUsername = (await server.stores.user.getById(owner.id))!.username;
+ });
+
+ afterAll(async () => {
+ await server?.shutdown();
+ });
+
+ // -- creating a workspace -----------------------------------------
+
+ it('creates a workspace and admits its creator as the workspace owner', async () => {
+ const team = await service.createWorkspace(owner.id, {
+ name: 'Acme Design',
+ handle: freeHandle(),
+ });
+
+ expect(team.owner_user_id).toBe(owner.id);
+ const membership = await server.stores.team.getMembership(
+ team.uid,
+ owner.id,
+ );
+ // 0 is what makes the owner pay for itself.
+ expect(Number(membership?.org_owned)).toBe(0);
+ });
+
+ it('holds the owner invariant that no dialect can express', async () => {
+ const { team } = await makeWorkspace();
+ await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(true);
+ });
+
+ it('breaks the invariant if a second account is admitted as the payer', async () => {
+ const { team } = await makeWorkspace();
+ const other = await makeUser();
+ await server.stores.team.addMember(team.uid, other.id, {
+ orgOwned: false,
+ });
+
+ // The check exists precisely because the schema cannot refuse this.
+ await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false);
+ });
+
+ // -- authority ----------------------------------------------------
+
+ it('refuses a member who is not the workspace owner', async () => {
+ const { team, member } = await makeWorkspace();
+ await expect(
+ service.requireOwner(team.uid, member.id),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ });
+
+ it('gives a stranger 404 rather than 403, so it is not an oracle', async () => {
+ const { team } = await makeWorkspace();
+ const stranger = await makeUser();
+ await expect(
+ service.requireMembership(team.uid, stranger.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ await expect(
+ service.requireOwner(team.uid, stranger.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('refuses the workspace owner as the target of a member route', async () => {
+ const { team } = await makeWorkspace();
+ await expect(
+ service.requireOrgAccount(team.uid, owner.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ // -- disable and re-enable ----------------------------------------
+
+ it('sets the column the request gate actually reads', async () => {
+ const { team, member } = await makeWorkspace();
+
+ await service.disableMember(team.uid, owner.id, member.id);
+
+ const row = await suspensionOf(member.id);
+ // `userProtected` rejects on `suspended`; the others gate nothing.
+ expect(Boolean(row.suspended)).toBe(true);
+ expect(row.suspended_at).toBeGreaterThan(0);
+ expect(row.suspended_reason).toBe('disabled_by_workspace');
+ });
+
+ it('drops the disabled account\'s sessions', async () => {
+ const { team, member } = await makeWorkspace();
+ await server.clients.db.write(
+ 'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)',
+ [uuidv4(), member.id],
+ );
+
+ await service.disableMember(team.uid, owner.id, member.id);
+
+ // Revoked, not deleted: the row keeps last_ip / last_user_agent.
+ const live = await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL',
+ [member.id],
+ );
+ expect(Number(live[0].n)).toBe(0);
+
+ const kept = await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ?',
+ [member.id],
+ );
+ expect(Number(kept[0].n)).toBe(1);
+ });
+
+ it('restores the account exactly as it was on re-enable', async () => {
+ const { team, member } = await makeWorkspace();
+ await service.disableMember(team.uid, owner.id, member.id);
+
+ await service.enableMember(team.uid, owner.id, member.id);
+
+ const row = await suspensionOf(member.id);
+ expect(Boolean(row.suspended)).toBe(false);
+ expect(row.suspended_at).toBeNull();
+ expect(row.suspended_reason).toBeNull();
+ });
+
+ it('leaves the disabled account\'s files alone', async () => {
+ const { team, member } = await makeWorkspace();
+ await server.clients.db.write(
+ 'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)',
+ [uuidv4(), 'kept.txt', member.id, 0],
+ );
+
+ await service.disableMember(team.uid, owner.id, member.id);
+
+ const rows = await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `fsentries` WHERE `user_id` = ?',
+ [member.id],
+ );
+ expect(Number(rows[0].n)).toBe(1);
+ });
+
+ it('refuses to disable the workspace owner', async () => {
+ const { team } = await makeWorkspace();
+ await expect(
+ service.disableMember(team.uid, owner.id, owner.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+
+ expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
+ });
+
+ it('refuses a disable ordered by someone who is not the owner', async () => {
+ const { team, member } = await makeWorkspace();
+ const other = await makeUser();
+ await server.stores.team.addMember(team.uid, other.id, {
+ orgOwned: true,
+ });
+
+ await expect(
+ service.disableMember(team.uid, other.id, member.id),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
+ });
+
+ it('refuses to disable a member of another workspace', async () => {
+ const a = await makeWorkspace();
+ const b = await makeWorkspace();
+
+ await expect(
+ service.disableMember(a.team.uid, owner.id, b.member.id),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+ it('refuses to lift a suspension the workspace did not impose', async () => {
+ const { team, member } = await makeWorkspace();
+ // What a platform abuse suspension looks like.
+ await server.stores.user.update(member.id, {
+ suspended: 1,
+ suspended_at: Math.floor(Date.now() / 1000),
+ suspended_reason: 'abuse_detected',
+ });
+
+ await expect(
+ service.enableMember(team.uid, owner.id, member.id),
+ ).rejects.toMatchObject({ statusCode: 409 });
+
+ const row = await suspensionOf(member.id);
+ expect(Boolean(row.suspended)).toBe(true);
+ expect(row.suspended_reason).toBe('abuse_detected');
+ });
+
+ it('lifts its own suspension normally', async () => {
+ const { team, member } = await makeWorkspace();
+ await service.disableMember(team.uid, owner.id, member.id);
+
+ await service.enableMember(team.uid, owner.id, member.id);
+ expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
+ });
+
+
+ // -- provisioning -------------------------------------------------
+
+ it('creates an account the workspace owns, pending a password change', async () => {
+ const { team } = await makeWorkspace();
+ const username = `prov_${Math.random().toString(36).slice(2, 9)}`;
+
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ expect(result.username).toBe(username);
+ const user = await server.stores.user.getById(result.userId);
+ expect(Boolean(user?.requires_password_change)).toBe(true);
+
+ const membership = await server.stores.team.getMembership(
+ team.uid,
+ result.userId,
+ );
+ expect(Number(membership?.org_owned)).toBe(1);
+ });
+
+ it('gives the new account its default filesystem tree', async () => {
+ const { team } = await makeWorkspace();
+ const username = `tree_${Math.random().toString(36).slice(2, 9)}`;
+
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ const user = await server.stores.user.getById(result.userId);
+ expect(user?.trash_uuid).toBeTruthy();
+ });
+
+ it('returns a temporary password the admin delivers out of band', async () => {
+ const { team } = await makeWorkspace();
+ const username = `link_${Math.random().toString(36).slice(2, 9)}`;
+
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ expect(result.temporaryPassword).toHaveLength(16);
+ const user = await server.stores.user.getById(result.userId);
+ // Hashed, and the account cannot do anything until it is changed.
+ expect(user?.password).not.toBe(result.temporaryPassword);
+ expect(Boolean(user?.requires_password_change)).toBe(true);
+ });
+
+ it('writes nothing when the username is taken', async () => {
+ const { team } = await makeWorkspace();
+ const taken = await makeUser();
+ const before = await server.stores.team.listMembers(team.uid);
+
+ await expect(
+ service.provisionAccount(team.uid, owner.id, {
+ username: taken.username,
+ email: 'someone@test.local',
+ }),
+ ).rejects.toMatchObject({ statusCode: 409 });
+
+ // Checked before any row is written, so the workspace is untouched.
+ const after = await server.stores.team.listMembers(team.uid);
+ expect(after.items).toHaveLength(before.items.length);
+ });
+
+ it('offers free alternatives instead of modifying the name', async () => {
+ const taken = await makeUser();
+ const suggestions = await service.suggestUsernames(taken.username);
+
+ expect(suggestions.length).toBeGreaterThan(0);
+ for (const suggestion of suggestions) {
+ expect(suggestion).not.toBe(taken.username);
+ await expect(
+ server.stores.user.getByUsername(suggestion),
+ ).resolves.toBeFalsy();
+ }
+ });
+
+ it('refuses provisioning by anyone but the workspace owner', async () => {
+ const { team, member } = await makeWorkspace();
+ await expect(
+ service.provisionAccount(team.uid, member.id, {
+ username: `nope_${Math.random().toString(36).slice(2, 9)}`,
+ email: 'nope@test.local',
+ }),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ });
+
+ it('re-issues a different credential each time', async () => {
+ const { team } = await makeWorkspace();
+ const username = `again_${Math.random().toString(36).slice(2, 9)}`;
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ const again = await service.reissueCredential(
+ team.uid,
+ owner.id,
+ result.userId,
+ );
+ expect(again.temporaryPassword).not.toBe(result.temporaryPassword);
+ });
+
+ it('refuses to re-issue once the member has chosen a password', async () => {
+ const { team } = await makeWorkspace();
+ const username = `done_${Math.random().toString(36).slice(2, 9)}`;
+ const result = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ // Simulate the member choosing their own password.
+ await server.stores.user.update(result.userId, {
+ requires_password_change: 0,
+ });
+
+ await expect(
+ service.reissueCredential(team.uid, owner.id, result.userId),
+ ).rejects.toMatchObject({ statusCode: 409 });
+ });
+ it('refuses an email that already belongs to an account', async () => {
+ const { team } = await makeWorkspace();
+ const existing = await makeUser();
+ const row = await server.stores.user.getById(existing.id);
+
+ await expect(
+ service.provisionAccount(team.uid, owner.id, {
+ username: `dup_${Math.random().toString(36).slice(2, 9)}`,
+ email: row!.email!,
+ }),
+ ).rejects.toMatchObject({ statusCode: 409 });
+ });
+
+ it('refuses a username signup itself would reject', async () => {
+ const { team } = await makeWorkspace();
+ for (const username of ['has-hyphen', 'x'.repeat(46), 'admin']) {
+ await expect(
+ service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: 'x@test.local',
+ }),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ }
+ });
+
+ it('refuses an invalid email', async () => {
+ const { team } = await makeWorkspace();
+ await expect(
+ service.provisionAccount(team.uid, owner.id, {
+ username: `bad_${Math.random().toString(36).slice(2, 9)}`,
+ email: 'not-an-email',
+ }),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('only suggests usernames the next call would accept', async () => {
+ const taken = await makeUser();
+ for (const s of await service.suggestUsernames(taken.username)) {
+ expect(s.length).toBeLessThanOrEqual(45);
+ expect(s).toMatch(/^\w+$/u);
+ }
+ });
+ it('generates a different credential for every account', async () => {
+ const { team } = await makeWorkspace();
+ const seen = new Set();
+ for (let i = 0; i < 3; i++) {
+ const u = `gen_${Math.random().toString(36).slice(2, 9)}`;
+ const r = await service.provisionAccount(team.uid, owner.id, {
+ username: u,
+ email: `${u}@test.local`,
+ });
+ expect(r.temporaryPassword).toMatch(/^[A-Za-z2-9]{16}$/u);
+ seen.add(r.temporaryPassword);
+ }
+ expect(seen.size).toBe(3);
+ });
+
+ it('survives having no email transport, since the notice carries nothing', async () => {
+ const { team } = await makeWorkspace();
+ const u = `noem_${Math.random().toString(36).slice(2, 9)}`;
+
+ // The credential is returned, so delivery failing loses nothing.
+ const r = await service.provisionAccount(team.uid, owner.id, {
+ username: u,
+ email: `${u}@test.local`,
+ });
+ expect(r.temporaryPassword).toBeTruthy();
+ });
+ // -- audit --------------------------------------------------------
+
+ it('records provisioning, disabling and enabling as they happen', async () => {
+ const { team } = await makeWorkspace();
+ const username = `aud_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await service.disableMember(team.uid, owner.id, created.userId);
+ await service.enableMember(team.uid, owner.id, created.userId);
+
+ // Written by the service, so a caller bypassing the route cannot skip it.
+ const { items: entries } = await service.listAudit(team.uid, owner.id);
+ const forMember = entries.filter((e) => e.username === username);
+ expect(forMember.map((e) => e.action)).toEqual([
+ 'enable',
+ 'disable',
+ 'provision',
+ ]);
+ expect(
+ forMember.every((e) => e.actor_username === ownerUsername),
+ ).toBe(true);
+ });
+
+ it('shows a member only their own entries', async () => {
+ const { team } = await makeWorkspace();
+ const a = `one_${Math.random().toString(36).slice(2, 9)}`;
+ const b = `two_${Math.random().toString(36).slice(2, 9)}`;
+ const first = await service.provisionAccount(team.uid, owner.id, {
+ username: a,
+ email: `${a}@test.local`,
+ });
+ await service.provisionAccount(team.uid, owner.id, {
+ username: b,
+ email: `${b}@test.local`,
+ });
+
+ const { items: own } = await service.listOwnAudit(team.uid, first.userId);
+ expect(own).toHaveLength(1);
+ expect(own[0].username).toBe(a);
+ // Internal ids must not reach a caller, as `toClientTeam` does for `id`.
+ expect(own[0]).not.toHaveProperty('user_id_keep');
+ expect(own[0]).not.toHaveProperty('actor_user_id');
+ });
+
+ it('keeps the audit from a member who is not the owner', async () => {
+ const { team, member } = await makeWorkspace();
+ await expect(
+ service.listAudit(team.uid, member.id),
+ ).rejects.toMatchObject({ statusCode: 403 });
+ });
+
+ it('records a workspace deletion and survives the soft delete', async () => {
+ const { team } = await makeWorkspace();
+ await service.deleteWorkspace(team.uid, owner.id);
+
+ // Gone from reads, but its owner can still read what happened.
+ await expect(server.stores.team.getByUid(team.uid)).resolves.toBeNull();
+ const { items: entries } = await service.listAudit(team.uid, owner.id);
+ expect(entries.map((e) => e.action)).toContain('delete_team');
+ });
+ it('disables the accounts it created when the workspace is deleted', async () => {
+ const { team } = await makeWorkspace();
+ const u = `del_${Math.random().toString(36).slice(2, 9)}`;
+ const provisioned = await service.provisionAccount(team.uid, owner.id, {
+ username: u,
+ email: `${u}@test.local`,
+ });
+
+ await service.deleteWorkspace(team.uid, owner.id);
+
+ // Otherwise they keep working, unreachable through a deleted workspace.
+ const row = await suspensionOf(provisioned.userId);
+ expect(Boolean(row.suspended)).toBe(true);
+ expect(row.suspended_reason).toBe('disabled_by_workspace');
+ });
+
+ it('leaves the workspace owner alone when its workspace is deleted', async () => {
+ const { team } = await makeWorkspace();
+ await service.deleteWorkspace(team.uid, owner.id);
+
+ // org_owned = 0, so it pays for itself and is not the workspace's to close.
+ expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
+ });
+
+ it('pages the audit rather than truncating it', async () => {
+ const { team } = await makeWorkspace();
+ for (let i = 0; i < 2; i++) {
+ const u = `pg_${Math.random().toString(36).slice(2, 9)}`;
+ await service.provisionAccount(team.uid, owner.id, {
+ username: u,
+ email: `${u}@test.local`,
+ });
+ }
+
+ const first = await service.listAudit(team.uid, owner.id, { limit: 1 });
+ expect(first.items).toHaveLength(1);
+ expect(first.cursor).toBeTruthy();
+
+ // Older entries stay reachable instead of dropping off the view.
+ const second = await service.listAudit(team.uid, owner.id, { limit: 1 });
+ expect(second.items).toHaveLength(1);
+ });
+});
diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts
new file mode 100644
index 000000000..d7c8a2d1a
--- /dev/null
+++ b/src/backend/services/team/TeamService.ts
@@ -0,0 +1,563 @@
+/*
+ * 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 bcrypt from 'bcrypt';
+import { randomBytes } from 'node:crypto';
+import validator from 'validator';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ RESERVED_USERNAMES,
+ USERNAME_MAX_LENGTH,
+ USERNAME_REGEX,
+} from '../../controllers/auth/AuthController.js';
+import { HttpError } from '../../core/http/HttpError.js';
+import { checkHandle } from '../../stores/team/TeamStore.js';
+import type {
+ TeamAuditRow,
+ TeamMemberRow,
+ TeamRow,
+} from '../../stores/team/TeamStore';
+import type { UserRow } from '../../stores/user/UserStore';
+import { cleanEmail } from '../../util/email.js';
+import { generateDefaultFsentries } from '../../util/userProvisioning.js';
+import { PuterService } from '../types';
+
+/** Unambiguous alphabet -- no 0/O or 1/l, since a human retypes this. */
+const TEMP_PASSWORD_ALPHABET =
+ 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
+
+/** ~95 bits, generated rather than chosen so it is never a reused pattern. */
+export const generateTemporaryPassword = (length = 16): string => {
+ const bytes = randomBytes(length);
+ let out = '';
+ for (let i = 0; i < length; i++) {
+ out += TEMP_PASSWORD_ALPHABET[bytes[i] % TEMP_PASSWORD_ALPHABET.length];
+ }
+ return out;
+};
+
+/** Why an account was disabled. Free text in `0063`; this is the team one. */
+export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace';
+
+export class TeamService extends PuterService {
+ // -- Authority ---- the whole authorization model ------------------
+
+ /** 404 to a non-member so the endpoint is not an existence oracle. */
+ async requireMembership(
+ teamUid: string,
+ actorUserId: number,
+ ): Promise {
+ const team = await this.stores.team.getByUid(teamUid);
+ if (!team || !(await this.stores.team.isMember(teamUid, actorUserId))) {
+ throw new HttpError(404, 'Workspace not found', {
+ legacyCode: 'team_not_found',
+ });
+ }
+ return team;
+ }
+
+ /** Authority is one test: the caller is the account named by the workspace. */
+ async requireOwner(
+ teamUid: string,
+ actorUserId: number,
+ ): Promise {
+ const team = await this.requireMembership(teamUid, actorUserId);
+ if (team.owner_user_id !== actorUserId) {
+ throw new HttpError(403, 'Only the workspace owner can do that', {
+ legacyCode: 'not_the_workspace_owner',
+ });
+ }
+ return team;
+ }
+
+ /** The workspace owner is never a valid target of a member route. */
+ async requireOrgAccount(
+ teamUid: string,
+ targetUserId: number,
+ ): Promise {
+ const membership = 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 workspace', {
+ legacyCode: 'not_an_org_account',
+ });
+ }
+ return membership;
+ }
+
+ // -- Workspace lifecycle ------------------------------------------
+
+ /** A rejected handle is 400, a taken one 409, never an unhandled 500. */
+ async assertHandleUsable(handle: string): Promise {
+ 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(run: () => Promise): Promise {
+ 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;
+ }
+ }
+
+ /** Renames or re-handles a workspace, refusing an unusable handle. */
+ async updateWorkspace(
+ teamUid: string,
+ actorUserId: number,
+ changes: { name?: string; handle?: string | null },
+ ): Promise {
+ await this.requireOwner(teamUid, actorUserId);
+ if (changes.handle) await this.assertHandleUsable(changes.handle);
+
+ const team = await this.#asHttpErrors(() =>
+ this.stores.team.update(teamUid, changes),
+ );
+ if (!team) {
+ throw new HttpError(404, 'Workspace not found', {
+ legacyCode: 'team_not_found',
+ });
+ }
+ return team;
+ }
+
+ /** Creates a workspace and admits its creator as the workspace owner. */
+ async createWorkspace(
+ ownerUserId: number,
+ input: { name: string; handle?: string | null },
+ ): Promise {
+ 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 workspace', {
+ legacyCode: 'internal_error',
+ });
+ }
+ return team;
+ }
+
+ /** The owner is a member with `org_owned = 0`, and the only such member. */
+ async checkOwnerInvariant(teamUid: string): Promise {
+ 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;
+
+ const rows = (await this.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
+ 'WHERE `group_id` = ? AND `org_owned` = 0',
+ [team.id],
+ )) as { n: number }[];
+ return Number(rows[0]?.n) === 1;
+ }
+
+ /** Soft delete disables the accounts it created; recovery is via support. */
+ async deleteWorkspace(teamUid: string, actorUserId: number): Promise {
+ const team = await this.requireOwner(teamUid, actorUserId);
+
+ // Otherwise they keep working, unreachable through a deleted workspace.
+ 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.#suspend(member.user_id);
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId: member.user_id,
+ actorUserId,
+ action: 'disable',
+ reason: 'workspace_deleted',
+ });
+ }
+ 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);
+ }
+
+ /** Workspace 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.#requireOwnedWorkspace(teamUid, actorUserId);
+ return this.#withUsernames(
+ await this.stores.team.listAudit(team.id, opts),
+ );
+ }
+
+ /** The caller's own entries; the only reader who is not the actor. */
+ async listOwnAudit(
+ teamUid: string,
+ actorUserId: number,
+ opts: { limit?: unknown; cursor?: string } = {},
+ ) {
+ const team = await this.requireMembership(teamUid, actorUserId);
+ return this.#withUsernames(
+ await this.stores.team.listAuditForUser(team.id, actorUserId, opts),
+ );
+ }
+
+ /** Resolves a workspace the caller owns, soft-deleted or not. */
+ async #requireOwnedWorkspace(
+ teamUid: string,
+ actorUserId: number,
+ ): Promise {
+ 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, 'Workspace 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 }) {
+ const ids = new Set();
+ 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) => ({
+ action: row.action,
+ reason: row.reason,
+ created_at: row.created_at,
+ username: name(row.user_id_keep),
+ actor_username: name(row.actor_user_id),
+ })),
+ ...(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 ||
+ RESERVED_USERNAMES.has(username.toLowerCase())
+ );
+ }
+
+ #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 {
+ // 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))) {
+ 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 },
+ ): Promise<{
+ userId: number;
+ username: string;
+ temporaryPassword: string;
+ }> {
+ const team = await this.requireOwner(teamUid, actorUserId);
+ this.#assertUsableUsername(input.username);
+ if (!validator.isEmail(input.email)) {
+ throw new HttpError(400, 'Invalid email', {
+ legacyCode: 'bad_request',
+ });
+ }
+
+ // Before any write, so a taken name fails cleanly.
+ if (await this.stores.user.getByUsername(input.username)) {
+ 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 (await this.stores.user.findEmailOwner(input.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: input.email,
+ clean_email: cleanEmail(input.email),
+ // The address came from the administrator, not its holder.
+ requires_email_confirmation: true,
+ });
+
+ await generateDefaultFsentries(this.clients.db, this.stores.user, user);
+
+ // Otherwise a vanished workspace 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 workspace 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 = generateTemporaryPassword();
+ await this.stores.user.update(user.id, {
+ password: await bcrypt.hash(temporaryPassword, 8),
+ requires_password_change: 1,
+ });
+ await this.#notifyAccountCreated(user, team);
+
+ 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);
+ 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',
+ });
+ }
+ // 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',
+ });
+ }
+
+ const temporaryPassword = generateTemporaryPassword();
+ await this.stores.user.update(targetUserId, {
+ password: await bcrypt.hash(temporaryPassword, 8),
+ requires_password_change: 1,
+ });
+ await this.#notifyAccountCreated(user, team);
+ return { temporaryPassword };
+ }
+
+ /** A notice only -- it carries no credential, so delivery is best effort. */
+ async #notifyAccountCreated(user: UserRow, team: TeamRow): Promise {
+ if (!this.clients.email || !user.email) return;
+ try {
+ const sent = await this.clients.email.send(
+ user.email,
+ 'team_account_created',
+ {
+ username: user.username,
+ team_name: team.name ?? 'Your workspace',
+ },
+ );
+ // `sendRaw` returns null with no transport rather than throwing.
+ if (sent === null) {
+ console.warn('[team-provision] no email transport configured');
+ }
+ } catch (e) {
+ console.warn('[team-provision] notice failed:', e);
+ }
+ }
+
+ // -- 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 {
+ const team = await this.requireOwner(teamUid, actorUserId);
+ await this.requireOrgAccount(teamUid, targetUserId);
+
+ // Recorded first: a failed append must not leave an unlogged suspension.
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId: targetUserId,
+ actorUserId,
+ action: 'disable',
+ });
+ await this.#suspend(targetUserId);
+ }
+
+ /** Nothing was destroyed, so the account returns as it was. */
+ async enableMember(
+ teamUid: string,
+ actorUserId: number,
+ targetUserId: number,
+ ): Promise {
+ 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 workspace's own suspension; a platform one must not lift.
+ if (
+ user?.suspended &&
+ user.suspended_reason !== DISABLED_BY_WORKSPACE
+ ) {
+ throw new HttpError(409, 'That account was suspended by Puter', {
+ legacyCode: 'conflict',
+ });
+ }
+
+ 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);
+ }
+
+ /** The three columns together; `suspended` is the one that gates requests. */
+ async #suspend(userId: number): Promise {
+ await this.stores.user.update(userId, {
+ suspended: 1,
+ suspended_at: Math.floor(Date.now() / 1000),
+ suspended_reason: DISABLED_BY_WORKSPACE,
+ });
+ await this.#dropSessions(userId);
+ }
+
+ /** Via the store: a raw DELETE leaves the session cache serving the row. */
+ async #dropSessions(userId: number): Promise {
+ 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);
+ }
+}
diff --git a/src/backend/stores/group/GroupStore.test.ts b/src/backend/stores/group/GroupStore.test.ts
index e6b1e6ce4..ec57cb9b6 100644
--- a/src/backend/stores/group/GroupStore.test.ts
+++ b/src/backend/stores/group/GroupStore.test.ts
@@ -158,4 +158,31 @@ describe('GroupStore', () => {
expect(await memberUsernames(a)).toEqual([]);
expect(await memberUsernames(b)).toEqual([member.username]);
});
+ // -- the boundary with TeamStore ----------------------------------
+
+ it('refuses to add a member to a team, leaving that to TeamStore', async () => {
+ const uid = uuidv4();
+ await server.clients.db.write(
+ 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?)',
+ [uid, owner.id, 'team', 'Not Yours', '{}', '{}'],
+ );
+
+ // Two writers produced the duplicates; the split is enforced in SQL.
+ await store.addUsers(uid, [member.username]);
+ expect(await memberUsernames(uid)).toEqual([]);
+ });
+
+ it('refuses to remove a member from a team', async () => {
+ const uid = uuidv4();
+ await server.clients.db.write(
+ 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `extra`, `metadata`) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?)',
+ [uid, owner.id, 'team', 'Also Not Yours', '{}', '{}'],
+ );
+ await server.stores.team.addMember(uid, member.id, { orgOwned: true });
+
+ await store.removeUsers(uid, [member.username]);
+ expect(await memberUsernames(uid)).toEqual([member.username]);
+ });
});
diff --git a/src/backend/stores/group/GroupStore.ts b/src/backend/stores/group/GroupStore.ts
index 5b8c9426c..5cf030beb 100644
--- a/src/backend/stores/group/GroupStore.ts
+++ b/src/backend/stores/group/GroupStore.ts
@@ -33,36 +33,29 @@ import { PuterStore } from '../types';
* which joins the junction table itself.
*/
export class GroupStore extends PuterStore {
- /**
- * Adds users (by username) to the group identified by `uid`. No-op if
- * `usernames` is empty, and for a user who is already a member.
- */
+ /** Adds users to a seeded system group. No-op for a team or a member. */
async addUsers(uid: string, usernames: string[]): Promise {
if (usernames.length === 0) return;
const placeholders = `(${usernames.map(() => '?').join(', ')})`;
- // Ignore conflicts on the unique pair index from 0072; re-adding a member
- // was a duplicate row before it, and would raise without this.
+ // Ignore conflicts on the unique pair index from 0072.
await this.clients.db.write(
`${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
'(`user_id`, `group_id`) ' +
'SELECT u.id, g.id FROM `user` u ' +
- 'JOIN (SELECT id FROM `group` WHERE uid = ?) g ON 1 = 1 ' +
+ 'JOIN (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) g ON 1 = 1 ' +
`WHERE u.username IN ${placeholders}` +
this.clients.db.insertIgnoreSuffix(),
[uid, ...usernames],
);
}
- /**
- * Removes users (by username) from the group identified by `uid`. No-op if
- * `usernames` is empty.
- */
+ /** Removes users from a seeded system group. No-op for a team. */
async removeUsers(uid: string, usernames: string[]): Promise {
if (usernames.length === 0) return;
const placeholders = `(${usernames.map(() => '?').join(', ')})`;
await this.clients.db.write(
'DELETE FROM `jct_user_group` ' +
- 'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ?) ' +
+ 'WHERE `group_id` = (SELECT id FROM `group` WHERE uid = ? AND kind IS NULL) ' +
'AND `user_id` IN (' +
'SELECT u.id FROM `user` u ' +
`WHERE u.username IN ${placeholders})`,
diff --git a/src/backend/stores/index.ts b/src/backend/stores/index.ts
index ce0149e47..44afc272a 100644
--- a/src/backend/stores/index.ts
+++ b/src/backend/stores/index.ts
@@ -35,6 +35,7 @@ import { SessionStore } from './session/SessionStore.js';
import { ShareStore } from './share/ShareStore.js';
import { SubdomainStore } from './subdomain/SubdomainStore.js';
import { SystemKVStore } from './systemKv/SystemKVStore.js';
+import { TeamStore } from './team/TeamStore.js';
import { UserBlockStore } from './userBlock/UserBlockStore.js';
import { UserStore } from './user/UserStore.js';
import type { IPuterStoreRegistry } from './types.js';
@@ -60,6 +61,7 @@ declare module './types.js' {
notification: NotificationStore;
share: ShareStore;
group: GroupStore;
+ team: TeamStore;
permission: PermissionStore;
session: SessionStore;
oidc: OIDCStore;
@@ -93,6 +95,7 @@ export const puterStores = {
notification: NotificationStore,
share: ShareStore,
group: GroupStore,
+ team: TeamStore,
permission: PermissionStore,
session: SessionStore,
oidc: OIDCStore,
diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts
new file mode 100644
index 000000000..0b7515b68
--- /dev/null
+++ b/src/backend/stores/team/TeamStore.test.ts
@@ -0,0 +1,469 @@
+/**
+ * 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 { v4 as uuidv4 } from 'uuid';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { PuterServer } from '../../server.ts';
+import { setupTestServer } from '../../testUtil.ts';
+import { checkHandle } from './TeamStore.ts';
+
+describe('TeamStore', () => {
+ let server: PuterServer;
+ let store: PuterServer['stores']['team'];
+ let owner: { id: number };
+
+ const makeUser = async (): Promise<{ id: number }> => {
+ const username = `team-${Math.random().toString(36).slice(2, 10)}`;
+ const created = (await server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: null,
+ email: `${username}@test.local`,
+ })) as unknown as { id: number };
+ return { id: created.id };
+ };
+
+ // Random so parallel cases never collide on the unique index.
+ const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
+
+ beforeAll(async () => {
+ server = await setupTestServer();
+ store = server.stores.team;
+ owner = await makeUser();
+ });
+
+ afterAll(async () => {
+ await server?.shutdown();
+ });
+
+ // -- handle validation --------------------------------------------
+
+ it('accepts lowercase handles with single inner hyphens', () => {
+ for (const handle of ['acme', 'acme-design', 'a1-b2-c3', 'x'.repeat(64)]) {
+ expect(checkHandle(handle)).toBeNull();
+ }
+ });
+
+ it('refuses handles that are malformed, mis-sized or reserved', () => {
+ expect(checkHandle('ab')).toBe('too_short');
+ expect(checkHandle('x'.repeat(65))).toBe('too_long');
+ expect(checkHandle('Acme')).toBe('malformed');
+ expect(checkHandle('acme_design')).toBe('malformed');
+ expect(checkHandle('-acme')).toBe('malformed');
+ expect(checkHandle('acme-')).toBe('malformed');
+ expect(checkHandle('acme--design')).toBe('malformed');
+ expect(checkHandle('café')).toBe('malformed');
+ // The point of the list: these read like Puter itself.
+ expect(checkHandle('puter-support')).toBe('reserved');
+ expect(checkHandle('security')).toBe('reserved');
+ expect(checkHandle('admin')).toBe('reserved');
+ });
+
+ it('refuses a reserved handle at the store boundary, not just in the checker', async () => {
+ await expect(
+ store.create({ ownerUserId: owner.id, name: 'Support', handle: 'support' }),
+ ).rejects.toThrow(/reserved/u);
+ });
+
+ // -- create and read ----------------------------------------------
+
+ it('creates a workspace and reads it back by uid', async () => {
+ const handle = freeHandle();
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Acme Design',
+ handle,
+ });
+
+ expect(created).toMatchObject({
+ owner_user_id: owner.id,
+ kind: 'team',
+ name: 'Acme Design',
+ handle,
+ deleted_at: null,
+ });
+ expect(created.uid).toMatch(/^[0-9a-f-]{36}$/u);
+ await expect(store.getByUid(created.uid)).resolves.toMatchObject({
+ uid: created.uid,
+ });
+ });
+
+ it('creates a workspace without a handle', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Unnamed',
+ });
+ expect(created.handle).toBeNull();
+ });
+
+ it('resolves a handle case-insensitively', async () => {
+ const handle = freeHandle();
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Case',
+ handle,
+ });
+ // NOCASE on sqlite, utf8mb4_unicode_ci on mysql, lower() on postgres.
+ await expect(
+ store.getByHandle(handle.toUpperCase()),
+ ).resolves.toMatchObject({ uid: created.uid });
+ });
+
+ it('refuses a duplicate handle, including one differing only in case', async () => {
+ const handle = freeHandle();
+ await store.create({ ownerUserId: owner.id, name: 'First', handle });
+
+ await expect(
+ store.create({ ownerUserId: owner.id, name: 'Second', handle }),
+ ).rejects.toThrow();
+ // `create` rejects uppercase before any SQL, so insert directly.
+ await expect(
+ server.clients.db.write(
+ 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `handle`, `extra`, `metadata`) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [uuidv4(), owner.id, 'team', 'Third', handle.toUpperCase(), '{}', '{}'],
+ ),
+ ).rejects.toThrow(/unique|duplicate/iu);
+ });
+
+ it('returns null for a uid that is not a workspace', async () => {
+ await expect(store.getByUid(uuidv4())).resolves.toBeNull();
+ await expect(store.getByHandle(freeHandle())).resolves.toBeNull();
+ });
+
+ // -- the seeded system groups -------------------------------------
+
+ it('never returns a seeded system group from any method', async () => {
+ const seeded = (await server.clients.db.read(
+ 'SELECT `uid`, `owner_user_id` FROM `group` WHERE `kind` IS NULL',
+ )) as { uid: string; owner_user_id: number }[];
+ expect(seeded.length).toBeGreaterThan(1);
+
+ // Unreachable by predicate: a team admin is not a platform admin.
+ for (const group of seeded) {
+ await expect(store.getByUid(group.uid)).resolves.toBeNull();
+ }
+ const owners = new Set(seeded.map((g) => g.owner_user_id));
+ for (const ownerId of owners) {
+ const listed = await store.listByOwner(ownerId);
+ expect(listed.map((t) => t.uid)).not.toContain(seeded[0].uid);
+ }
+ });
+
+ // -- update -------------------------------------------------------
+
+ it('renames a workspace without changing its uid', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Before',
+ handle: freeHandle(),
+ });
+
+ const updated = await store.update(created.uid, { name: 'After' });
+ expect(updated).toMatchObject({ uid: created.uid, name: 'After' });
+ });
+
+ it('keeps the uid valid after the handle changes', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Movable',
+ handle: freeHandle(),
+ });
+ const next = freeHandle();
+
+ await store.update(created.uid, { handle: next });
+
+ // The whole point of addressing by uid: the reference survives a rename.
+ await expect(store.getByUid(created.uid)).resolves.toMatchObject({
+ handle: next,
+ });
+ });
+
+ it('refuses to update into a reserved handle', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Fine',
+ handle: freeHandle(),
+ });
+ await expect(
+ store.update(created.uid, { handle: 'admin' }),
+ ).rejects.toThrow(/reserved/u);
+ });
+
+ it('returns null when updating a uid that does not exist', async () => {
+ await expect(
+ store.update(uuidv4(), { name: 'Ghost' }),
+ ).resolves.toBeNull();
+ });
+
+ // -- soft delete --------------------------------------------------
+
+ it('excludes a soft-deleted workspace from reads', async () => {
+ const handle = freeHandle();
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Doomed',
+ handle,
+ });
+
+ await expect(store.softDelete(created.uid)).resolves.toBe(true);
+
+ await expect(store.getByUid(created.uid)).resolves.toBeNull();
+ await expect(store.getByHandle(handle)).resolves.toBeNull();
+ const listed = await store.listByOwner(owner.id);
+ expect(listed.map((t) => t.uid)).not.toContain(created.uid);
+ });
+
+ it('releases the handle on soft delete so it can be claimed again', async () => {
+ const handle = freeHandle();
+ const first = await store.create({
+ ownerUserId: owner.id,
+ name: 'First',
+ handle,
+ });
+ await store.softDelete(first.uid);
+
+ // Nothing addresses by handle, so the name returns to the pool.
+ const second = await store.create({
+ ownerUserId: owner.id,
+ name: 'Second',
+ handle,
+ });
+ expect(second.uid).not.toBe(first.uid);
+ await expect(store.getByHandle(handle)).resolves.toMatchObject({
+ uid: second.uid,
+ });
+ });
+
+ it('keeps `name` on a soft-deleted workspace so history still reads', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Remembered',
+ handle: freeHandle(),
+ });
+ await store.softDelete(created.uid);
+
+ const [row] = (await server.clients.db.read(
+ 'SELECT `name`, `handle`, `deleted_at` FROM `group` WHERE `uid` = ?',
+ [created.uid],
+ )) as { name: string; handle: string | null; deleted_at: string }[];
+ expect(row.name).toBe('Remembered');
+ expect(row.handle).toBeNull();
+ expect(row.deleted_at).not.toBeNull();
+ });
+
+ it('reports no rows affected when soft-deleting twice', async () => {
+ const created = await store.create({
+ ownerUserId: owner.id,
+ name: 'Once',
+ handle: freeHandle(),
+ });
+ await expect(store.softDelete(created.uid)).resolves.toBe(true);
+ await expect(store.softDelete(created.uid)).resolves.toBe(false);
+ });
+ it('refuses a name that is empty or longer than mysql allows', async () => {
+ await expect(
+ store.create({ ownerUserId: owner.id, name: ' ' }),
+ ).rejects.toThrow(/required/iu);
+ await expect(
+ store.create({ ownerUserId: owner.id, name: 'x'.repeat(256) }),
+ ).rejects.toThrow(/too long/iu);
+
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: ' Padded ',
+ handle: freeHandle(),
+ });
+ expect(team.name).toBe('Padded');
+ });
+
+ // -- membership ---------------------------------------------------
+
+ it('adds a member and reads the membership back', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Members',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+
+ await expect(
+ store.addMember(team.uid, member.id, { orgOwned: true }),
+ ).resolves.toBe(true);
+
+ const row = await store.getMembership(team.uid, member.id);
+ expect(row).toMatchObject({ user_id: member.id, group_id: team.id });
+ expect(Boolean(row?.org_owned)).toBe(true);
+ await expect(store.isMember(team.uid, member.id)).resolves.toBe(true);
+ });
+
+ it('distinguishes the workspace owner by org_owned', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Payer',
+ handle: freeHandle(),
+ });
+ await store.addMember(team.uid, owner.id, { orgOwned: false });
+
+ const row = await store.getMembership(team.uid, owner.id);
+ // Decides who pays, not who may read.
+ expect(Boolean(row?.org_owned)).toBe(false);
+ });
+
+ it('treats a repeated add as a no-op rather than a duplicate', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Once',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+
+ await store.addMember(team.uid, member.id, { orgOwned: true });
+ await store.addMember(team.uid, member.id, { orgOwned: true });
+
+ const page = await store.listMembers(team.uid);
+ expect(page.items.filter((m) => m.user_id === member.id)).toHaveLength(1);
+ });
+
+ it('removes a member and reports whether there was one', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Leaving',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+ await store.addMember(team.uid, member.id, { orgOwned: true });
+
+ await expect(store.removeMember(team.uid, member.id)).resolves.toBe(true);
+ await expect(store.removeMember(team.uid, member.id)).resolves.toBe(false);
+ await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
+ });
+
+ it('scopes membership to the workspace asked for', async () => {
+ const a = await store.create({
+ ownerUserId: owner.id,
+ name: 'A',
+ handle: freeHandle(),
+ });
+ const b = await store.create({
+ ownerUserId: owner.id,
+ name: 'B',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+ await store.addMember(a.uid, member.id, { orgOwned: true });
+
+ await expect(store.isMember(b.uid, member.id)).resolves.toBe(false);
+ expect((await store.listMembers(b.uid)).items).toEqual([]);
+ });
+
+ it('lists the workspaces a user belongs to', async () => {
+ const member = await makeUser();
+ const a = await store.create({
+ ownerUserId: owner.id,
+ name: 'One',
+ handle: freeHandle(),
+ });
+ const b = await store.create({
+ ownerUserId: owner.id,
+ name: 'Two',
+ handle: freeHandle(),
+ });
+ await store.addMember(a.uid, member.id, { orgOwned: true });
+ await store.addMember(b.uid, member.id, { orgOwned: true });
+
+ const teams = await store.listTeamsForUser(member.id);
+ expect(teams.map((t) => t.uid).sort()).toEqual([a.uid, b.uid].sort());
+ });
+
+ it('drops a soft-deleted workspace from the user\'s list', async () => {
+ const member = await makeUser();
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Gone',
+ handle: freeHandle(),
+ });
+ await store.addMember(team.uid, member.id, { orgOwned: true });
+ await store.softDelete(team.uid);
+
+ expect(await store.listTeamsForUser(member.id)).toEqual([]);
+ await expect(store.isMember(team.uid, member.id)).resolves.toBe(false);
+ });
+
+ it('refuses to add a member to a group that is not a team', async () => {
+ const [seeded] = (await server.clients.db.read(
+ 'SELECT `uid` FROM `group` WHERE `kind` IS NULL LIMIT 1',
+ )) as { uid: string }[];
+ const member = await makeUser();
+
+ await expect(
+ store.addMember(seeded.uid, member.id, { orgOwned: true }),
+ ).resolves.toBe(false);
+ });
+
+ // -- pagination ---------------------------------------------------
+
+ it('pages members on `id` and stops when the set is exhausted', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Paged',
+ handle: freeHandle(),
+ });
+ const ids: number[] = [];
+ for (let i = 0; i < 5; i++) {
+ const m = await makeUser();
+ ids.push(m.id);
+ await store.addMember(team.uid, m.id, { orgOwned: true });
+ }
+
+ const seen: number[] = [];
+ let cursor: string | undefined;
+ let pages = 0;
+ do {
+ const page = await store.listMembers(team.uid, { limit: 2, cursor });
+ seen.push(...page.items.map((m) => m.user_id));
+ cursor = page.cursor;
+ pages++;
+ } while (cursor && pages < 10);
+
+ expect(seen.sort()).toEqual([...ids].sort());
+ expect(pages).toBe(3);
+ });
+
+ it('caps the page size rather than trusting the caller', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Capped',
+ handle: freeHandle(),
+ });
+ // An empty workspace satisfies any cap, so seed enough to page.
+ for (let i = 0; i < 3; i++) {
+ const m = await makeUser();
+ await store.addMember(team.uid, m.id, { orgOwned: true });
+ }
+
+ const capped = await store.listMembers(team.uid, { limit: 100_000 });
+ expect(capped.items).toHaveLength(3);
+ expect(capped.cursor).toBeUndefined();
+
+ const paged = await store.listMembers(team.uid, { limit: 2 });
+ expect(paged.items).toHaveLength(2);
+ expect(paged.cursor).toBeTruthy();
+ });
+});
diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts
new file mode 100644
index 000000000..4616d8019
--- /dev/null
+++ b/src/backend/stores/team/TeamStore.ts
@@ -0,0 +1,482 @@
+/*
+ * 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 { v4 as uuidv4 } from 'uuid';
+import {
+ encodeCursor,
+ decodeCursor,
+ normalizeLimit,
+ type PageResult,
+} from '../../util/pagination.js';
+import { PuterStore } from '../types';
+
+/** A workspace: a `group` row with `kind = 'team'`. */
+export interface TeamRow {
+ id: number;
+ uid: string;
+ owner_user_id: number;
+ kind: string;
+ name: string | null;
+ handle: string | null;
+ deleted_at: string | null;
+ created_at: string;
+}
+
+export const TEAM_KIND = 'team';
+
+/** A membership row, joined to the member's username. */
+/** A seat, joined to the workspace that pays for it. */
+export interface OrgSeatRow {
+ id: number;
+ user_id: number;
+ uuid: string;
+ username: string;
+ team_uid: string;
+ owner_user_id: number;
+}
+
+export interface TeamMemberRow {
+ id: number;
+ user_id: number;
+ group_id: number;
+ username: string;
+ org_owned: number;
+ created_at: string;
+}
+
+/** One entry in the insert-only record of what a workspace did to an account. */
+export interface TeamAuditRow {
+ id: number;
+ user_id_keep: number;
+ actor_user_id: number | null;
+ action: string;
+ reason: string | null;
+ created_at: string;
+}
+
+/** Default and ceiling for `listMembers`, matching the other paginated stores. */
+export const MEMBER_PAGE_SIZE = 50;
+export const MEMBER_PAGE_CAP = 200;
+export const AUDIT_PAGE_SIZE = 50;
+export const AUDIT_PAGE_CAP = 200;
+
+/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */
+export const HANDLE_MAX_LENGTH = 64;
+export const HANDLE_MIN_LENGTH = 3;
+
+/** Mysql declares `name varchar(255)`; sqlite and postgres use TEXT. */
+export const NAME_MAX_LENGTH = 255;
+
+/** Narrower than the column, so the engines' collations cannot disagree. */
+const HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
+
+/** A handle reaching users in email makes an impersonation more convincing. */
+const RESERVED_HANDLES = new Set([
+ 'about',
+ 'account',
+ 'admin',
+ 'administrator',
+ 'api',
+ 'app',
+ 'apps',
+ 'billing',
+ 'blog',
+ 'ceo',
+ 'contact',
+ 'dashboard',
+ 'dev',
+ 'developer',
+ 'docs',
+ 'help',
+ 'internal',
+ 'legal',
+ 'login',
+ 'mail',
+ 'moderator',
+ 'official',
+ 'owner',
+ 'payment',
+ 'payments',
+ 'puter',
+ 'puter-support',
+ 'puterteam',
+ 'root',
+ 'security',
+ 'settings',
+ 'signup',
+ 'staff',
+ 'status',
+ 'support',
+ 'system',
+ 'team',
+ 'teams',
+ 'trust',
+ 'trust-safety',
+ 'verify',
+ 'workspace',
+ 'workspaces',
+]);
+
+export type HandleRejection =
+ 'too_short' | 'too_long' | 'malformed' | 'reserved';
+
+/** Trimmed and capped, so the same name is accepted on every engine. */
+export const normalizeTeamName = (name: string): string => {
+ const trimmed = name.trim();
+ if (trimmed === '') throw new Error('team name is required');
+ if (trimmed.length > NAME_MAX_LENGTH)
+ throw new Error('team name is too long');
+ return trimmed;
+};
+
+/** Why `handle` is unusable, or null when it is fine. */
+export const checkHandle = (handle: string): HandleRejection | null => {
+ if (handle.length < HANDLE_MIN_LENGTH) return 'too_short';
+ if (handle.length > HANDLE_MAX_LENGTH) return 'too_long';
+ if (!HANDLE_PATTERN.test(handle)) return 'malformed';
+ if (RESERVED_HANDLES.has(handle)) return 'reserved';
+ return null;
+};
+
+export class TeamStore extends PuterStore {
+ /** Postgres indexes lower(handle); the others are already insensitive. */
+ #handleMatch(): string {
+ return this.clients.db.case({
+ postgres: 'lower(`handle`) = lower(?)',
+ otherwise: '`handle` = ?',
+ });
+ }
+
+ /** Makes the seeded `kind IS NULL` groups unreachable, not merely absent. */
+ #live(): string {
+ return '`kind` = ? AND `deleted_at` IS NULL';
+ }
+
+ // -- Reads --------------------------------------------------------
+
+ /** The workspace with this uid, or null. Soft-deleted ones are excluded. */
+ async getByUid(uid: string): Promise {
+ const rows = await this.clients.db.read(
+ `SELECT * FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()}`,
+ [uid, TEAM_KIND],
+ );
+ return (rows[0] as unknown as TeamRow) ?? null;
+ }
+
+ /** Includes soft-deleted rows, so an audit survives its workspace. */
+ async getByUidIncludingDeleted(uid: string): Promise {
+ const rows = await this.clients.db.read(
+ 'SELECT * FROM `group` WHERE `uid` = ? AND `kind` = ?',
+ [uid, TEAM_KIND],
+ );
+ return (rows[0] as unknown as TeamRow) ?? null;
+ }
+
+ /** For availability checks and console resolution; callers address by uid. */
+ async getByHandle(handle: string): Promise {
+ const rows = await this.clients.db.read(
+ `SELECT * FROM \`group\` WHERE ${this.#handleMatch()} AND ${this.#live()}`,
+ [handle, TEAM_KIND],
+ );
+ return (rows[0] as unknown as TeamRow) ?? null;
+ }
+
+ /** Whether the handle is free to claim. Does not validate its spelling. */
+ async isHandleAvailable(handle: string): Promise {
+ return (await this.getByHandle(handle)) === null;
+ }
+
+ /** Workspaces this user owns, oldest first. */
+ async listByOwner(ownerUserId: number): Promise {
+ const rows = await this.clients.db.read(
+ `SELECT * FROM \`group\` WHERE \`owner_user_id\` = ? AND ${this.#live()} ORDER BY \`id\``,
+ [ownerUserId, TEAM_KIND],
+ );
+ return rows as unknown as TeamRow[];
+ }
+
+ // -- Writes -------------------------------------------------------
+
+ /** Throws on an unusable or taken handle; the unique index is the arbiter. */
+ async create(input: {
+ ownerUserId: number;
+ name: string;
+ handle?: string | null;
+ }): Promise {
+ const name = normalizeTeamName(input.name);
+ const handle = input.handle ?? null;
+ if (handle !== null) {
+ const rejection = checkHandle(handle);
+ if (rejection) {
+ throw new Error(`unusable team handle: ${rejection}`);
+ }
+ }
+
+ const uid = uuidv4();
+ await this.clients.db.write(
+ 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, `name`, `handle`, `extra`, `metadata`) ' +
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [uid, input.ownerUserId, TEAM_KIND, name, handle, '{}', '{}'],
+ );
+
+ const created = await this.getByUid(uid);
+ if (!created)
+ throw new Error('team disappeared immediately after insert');
+ return created;
+ }
+
+ /** Null when no live workspace has that uid. `handle: null` releases it. */
+ async update(
+ uid: string,
+ changes: { name?: string; handle?: string | null },
+ ): Promise {
+ const sets: string[] = [];
+ const params: unknown[] = [];
+
+ if (changes.name !== undefined) {
+ sets.push('`name` = ?');
+ params.push(normalizeTeamName(changes.name));
+ }
+ if (changes.handle !== undefined) {
+ if (changes.handle !== null) {
+ const rejection = checkHandle(changes.handle);
+ if (rejection) {
+ throw new Error(`unusable team handle: ${rejection}`);
+ }
+ }
+ sets.push('`handle` = ?');
+ params.push(changes.handle);
+ }
+ if (sets.length === 0) return this.getByUid(uid);
+
+ await this.clients.db.write(
+ `UPDATE \`group\` SET ${sets.join(', ')} WHERE \`uid\` = ? AND ${this.#live()}`,
+ [...params, uid, TEAM_KIND],
+ );
+ return this.getByUid(uid);
+ }
+
+ /** Releases the handle, since nothing addresses by it; keeps `name`. */
+ async softDelete(uid: string): Promise {
+ const result = await this.clients.db.write(
+ `UPDATE \`group\` SET \`deleted_at\` = CURRENT_TIMESTAMP, \`handle\` = NULL ` +
+ `WHERE \`uid\` = ? AND ${this.#live()}`,
+ [uid, TEAM_KIND],
+ );
+ return result.anyRowsAffected;
+ }
+
+ // -- Membership ---- sole writer of team rows -----------------------
+
+ /** The membership row for this user in this workspace, or null. */
+ async getMembership(
+ teamUid: string,
+ userId: number,
+ ): Promise {
+ const rows = await this.clients.db.read(
+ 'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
+ 'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' +
+ 'JOIN `user` u ON u.`id` = ug.`user_id` ' +
+ 'JOIN `group` g ON g.`id` = ug.`group_id` ' +
+ `WHERE g.\`uid\` = ? AND ug.\`user_id\` = ? AND g.${this.#live()}`,
+ [teamUid, userId, TEAM_KIND],
+ );
+ return (rows[0] as unknown as TeamMemberRow) ?? null;
+ }
+
+ /** Whether this user belongs to this workspace. */
+ async isMember(teamUid: string, userId: number): Promise {
+ return (await this.getMembership(teamUid, userId)) !== null;
+ }
+
+ /** A workspace's members, keyset-paginated on `id` per doc/pagination.md. */
+ async listMembers(
+ teamUid: string,
+ opts: { limit?: unknown; cursor?: string } = {},
+ ): Promise> {
+ const limit =
+ normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
+ MEMBER_PAGE_SIZE;
+ const page = decodeCursor(opts.cursor, 'team member cursor');
+ const after = typeof page?.id === 'number' ? page.id : null;
+
+ // One row past the limit is how we know a further page exists.
+ const rows = (await this.clients.db.read(
+ 'SELECT ug.`id`, ug.`user_id`, ug.`group_id`, ug.`org_owned`, ' +
+ 'ug.`created_at`, u.`username` FROM `jct_user_group` ug ' +
+ 'JOIN `user` u ON u.`id` = ug.`user_id` ' +
+ 'JOIN `group` g ON g.`id` = ug.`group_id` ' +
+ `WHERE g.\`uid\` = ? AND g.${this.#live()}` +
+ (after === null ? '' : ' AND ug.`id` > ?') +
+ ' ORDER BY ug.`id` LIMIT ?',
+ after === null
+ ? [teamUid, TEAM_KIND, limit + 1]
+ : [teamUid, TEAM_KIND, after, limit + 1],
+ )) as unknown as TeamMemberRow[];
+
+ const items = rows.slice(0, limit);
+ const cursor =
+ rows.length > limit
+ ? encodeCursor({ id: items[items.length - 1].id })
+ : undefined;
+ return { items, cursor };
+ }
+
+ /** Workspaces this user belongs to, oldest first. */
+ async listTeamsForUser(userId: number): Promise {
+ const rows = await this.clients.db.read(
+ 'SELECT g.* FROM `group` g ' +
+ 'JOIN `jct_user_group` ug ON ug.`group_id` = g.`id` ' +
+ `WHERE ug.\`user_id\` = ? AND g.${this.#live()} ORDER BY g.\`id\``,
+ [userId, TEAM_KIND],
+ );
+ return rows as unknown as TeamRow[];
+ }
+
+ /** `orgOwned` decides who pays: 1 workspace-created, 0 the workspace owner. */
+ async addMember(
+ teamUid: string,
+ userId: number,
+ opts: { orgOwned: boolean },
+ ): Promise {
+ // Kind-filtered subquery, so a non-team uid inserts nothing.
+ const result = await this.clients.db.write(
+ `${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
+ '(`user_id`, `group_id`, `org_owned`) ' +
+ 'SELECT ?, g.`id`, ? FROM `group` g ' +
+ `WHERE g.\`uid\` = ? AND g.${this.#live()}` +
+ this.clients.db.insertIgnoreSuffix(),
+ // Not `booleanValue`: it yields a real boolean on postgres.
+ [userId, opts.orgOwned ? 1 : 0, teamUid, TEAM_KIND],
+ );
+ return result.anyRowsAffected;
+ }
+
+ /** How many members pay for themselves; the owner should be the only one. */
+ async countPayers(teamId: number): Promise {
+ const rows = (await this.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
+ 'WHERE `group_id` = ? AND `org_owned` = 0',
+ [teamId],
+ )) as { n: number }[];
+ return Number(rows[0]?.n ?? 0);
+ }
+
+ // -- Audit ---- insert-only; no update or delete path exists --------
+
+ /** Records something the workspace did to an account. */
+ async appendAudit(entry: {
+ teamId: number;
+ userId: number;
+ actorUserId: number;
+ action: string;
+ reason?: string | null;
+ }): Promise {
+ await this.clients.db.write(
+ 'INSERT INTO `audit_team_membership` ' +
+ '(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
+ '`actor_user_id`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [
+ entry.teamId,
+ entry.teamId,
+ entry.userId,
+ entry.userId,
+ entry.actorUserId,
+ entry.action,
+ entry.reason ?? null,
+ ],
+ );
+ }
+
+ /** The whole workspace's audit, newest first, keyset-paginated on `id`. */
+ async listAudit(
+ teamId: number,
+ opts: { limit?: unknown; cursor?: string } = {},
+ ): Promise> {
+ return this.#pageAudit('`group_id_keep` = ?', [teamId], opts);
+ }
+
+ /** One member's own entries. Scoped by user, not by workspace. */
+ async listAuditForUser(
+ teamId: number,
+ userId: number,
+ opts: { limit?: unknown; cursor?: string } = {},
+ ): Promise> {
+ return this.#pageAudit(
+ '`group_id_keep` = ? AND `user_id_keep` = ?',
+ [teamId, userId],
+ opts,
+ );
+ }
+
+ /** Descending keyset, so older entries are reachable rather than dropped. */
+ async #pageAudit(
+ where: string,
+ params: unknown[],
+ opts: { limit?: unknown; cursor?: string },
+ ): Promise> {
+ const limit =
+ normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ??
+ AUDIT_PAGE_SIZE;
+ const page = decodeCursor(opts.cursor, 'team audit cursor');
+ const before = typeof page?.id === 'number' ? page.id : null;
+
+ const rows = (await this.clients.db.read(
+ 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, `created_at` ' +
+ `FROM \`audit_team_membership\` WHERE ${where}` +
+ (before === null ? '' : ' AND `id` < ?') +
+ ' ORDER BY `id` DESC LIMIT ?',
+ before === null
+ ? [...params, limit + 1]
+ : [...params, before, limit + 1],
+ )) as unknown as TeamAuditRow[];
+
+ const items = rows.slice(0, limit);
+ const cursor =
+ rows.length > limit
+ ? encodeCursor({ id: items[items.length - 1].id })
+ : undefined;
+ return { items, cursor };
+ }
+
+ /** Removes a member, returning whether a row was there to remove. */
+ async removeMember(teamUid: string, userId: number): Promise {
+ const result = await this.clients.db.write(
+ 'DELETE FROM `jct_user_group` WHERE `user_id` = ? AND `group_id` = ' +
+ `(SELECT \`id\` FROM \`group\` WHERE \`uid\` = ? AND ${this.#live()})`,
+ [userId, teamUid, TEAM_KIND],
+ );
+ return result.anyRowsAffected;
+ }
+
+ /** The workspace seat this user is, if any. Soft-deleted workspaces count. */
+ async getOrgSeat(userId: number): Promise {
+ const rows = (await this.clients.db.read(
+ 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
+ 'g.`uid` AS `team_uid`, g.`owner_user_id` ' +
+ 'FROM `jct_user_group` ug ' +
+ 'JOIN `user` u ON u.`id` = ug.`user_id` ' +
+ 'JOIN `group` g ON g.`id` = ug.`group_id` ' +
+ 'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' +
+ 'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1',
+ [userId, TEAM_KIND],
+ )) as unknown as OrgSeatRow[];
+ return rows[0] ?? null;
+ }
+}
diff --git a/src/backend/types.ts b/src/backend/types.ts
index fb2fbe542..76f7748a3 100644
--- a/src/backend/types.ts
+++ b/src/backend/types.ts
@@ -239,12 +239,7 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
- | 'sms'
- | 'rcs'
- | 'whatsapp'
- | 'viber'
- | 'zalo'
- | 'telegram';
+ 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
}
/**
@@ -682,6 +677,13 @@ interface IConfigOptional {
* this to the public port.
*/
pub_port: number;
+ /**
+ * Teams and workspaces. Off means `/teams` 404s and the schema is inert, so
+ * the tables can ship to production before anything can create a workspace.
+ * It is also the backout: turning it off removes the feature without
+ * touching data.
+ */
+ teams_enabled: boolean;
/**
* Fully-qualified externally-visible URL (protocol + domain + port).
* Computed from `protocol`/`domain`/`pub_port` if unset.
@@ -1189,7 +1191,8 @@ export interface WithLifecycle extends Object {
}
export interface WithCostsReporting extends WithLifecycle {
- getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getReportedCosts?: () =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record[];