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/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/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/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts
index 837758086..f2ede4d4a 100644
--- a/src/backend/services/team/TeamService.test.ts
+++ b/src/backend/services/team/TeamService.test.ts
@@ -26,6 +26,7 @@ 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)}`;
@@ -69,6 +70,7 @@ describe('TeamService', () => {
server = await setupTestServer();
service = server.services.team;
owner = await makeUser();
+ ownerUsername = (await server.stores.user.getById(owner.id))!.username;
});
afterAll(async () => {
@@ -143,8 +145,7 @@ describe('TeamService', () => {
await service.disableMember(team.uid, owner.id, member.id);
const row = await suspensionOf(member.id);
- // `userProtected` rejects on `suspended`; the other two are its
- // siblings and do not gate anything on their own.
+ // `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');
@@ -159,8 +160,7 @@ describe('TeamService', () => {
await service.disableMember(team.uid, owner.id, member.id);
- // Revoked, not deleted: the row keeps `last_ip` / `last_user_agent`,
- // which the member-facing audit view reads.
+ // 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],
@@ -449,4 +449,108 @@ describe('TeamService', () => {
});
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
index 8089ef039..d7c8a2d1a 100644
--- a/src/backend/services/team/TeamService.ts
+++ b/src/backend/services/team/TeamService.ts
@@ -28,7 +28,11 @@ import {
} from '../../controllers/auth/AuthController.js';
import { HttpError } from '../../core/http/HttpError.js';
import { checkHandle } from '../../stores/team/TeamStore.js';
-import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore';
+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';
@@ -52,8 +56,7 @@ export const generateTemporaryPassword = (length = 16): string => {
export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace';
export class TeamService extends PuterService {
- // -- Authority ----------------------------------------------------
- // Two checks, and between them the whole authorization model.
+ // -- Authority ---- the whole authorization model ------------------
/** 404 to a non-member so the endpoint is not an existence oracle. */
async requireMembership(
@@ -70,7 +73,10 @@ export class TeamService extends PuterService {
}
/** Authority is one test: the caller is the account named by the workspace. */
- async requireOwner(teamUid: string, actorUserId: number): Promise {
+ 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', {
@@ -172,8 +178,7 @@ export class TeamService extends PuterService {
}),
);
- // 0 makes the owner pay for itself and stay an invalid route target.
- // Unchecked, the owner could never reach their own workspace.
+ // 0 makes the owner pay for itself; unchecked, it is unreachable.
const admitted = await this.stores.team.addMember(
team.uid,
ownerUserId,
@@ -209,8 +214,106 @@ export class TeamService extends PuterService {
return Number(rows[0]?.n) === 1;
}
- // -- Provisioning -------------------------------------------------
- // Usernames come from Puter's global pool, so a taken one is reported.
+ /** 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 {
@@ -302,8 +405,14 @@ export class TeamService extends PuterService {
});
}
- // Returned once for the administrator to deliver out of band; forced
- // change on first use is what bounds it.
+ 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),
@@ -372,8 +481,7 @@ export class TeamService extends PuterService {
}
}
- // -- Disable and re-enable ----------------------------------------
- // The whole of offboarding: no removal, no transfer, no retention clock.
+ // -- Disable and re-enable ---- the whole of offboarding ------------
/** Rejects the account's next request; its files are untouched. */
async disableMember(
@@ -381,16 +489,17 @@ export class TeamService extends PuterService {
actorUserId: number,
targetUserId: number,
): Promise {
- await this.requireOwner(teamUid, actorUserId);
+ const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
- // `suspended` is what `userProtected` enforces; the others are siblings.
- await this.stores.user.update(targetUserId, {
- suspended: 1,
- suspended_at: Math.floor(Date.now() / 1000),
- suspended_reason: DISABLED_BY_WORKSPACE,
+ // 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.#dropSessions(targetUserId);
+ await this.#suspend(targetUserId);
}
/** Nothing was destroyed, so the account returns as it was. */
@@ -399,7 +508,7 @@ export class TeamService extends PuterService {
actorUserId: number,
targetUserId: number,
): Promise {
- await this.requireOwner(teamUid, actorUserId);
+ const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// Forced read, as `userProtected` does: a cached row predates this.
@@ -416,6 +525,12 @@ export class TeamService extends PuterService {
});
}
+ await this.stores.team.appendAudit({
+ teamId: team.id,
+ userId: targetUserId,
+ actorUserId,
+ action: 'enable',
+ });
await this.stores.user.update(targetUserId, {
suspended: 0,
suspended_at: null,
@@ -424,6 +539,16 @@ export class TeamService extends PuterService {
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(
diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts
index 5cf15bebc..4616d8019 100644
--- a/src/backend/stores/team/TeamStore.ts
+++ b/src/backend/stores/team/TeamStore.ts
@@ -60,9 +60,21 @@ export interface TeamMemberRow {
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;
@@ -167,6 +179,15 @@ export class TeamStore extends PuterStore {
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(
@@ -347,6 +368,93 @@ export class TeamStore extends PuterStore {
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(
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[];