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:

+ +

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[];