From 37cb11df8ca8a9c7e5276b8e15436b3ec5193b92 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 14:32:29 -0400 Subject: [PATCH 1/5] feat: add TeamStore for workspace CRUD `GroupStore` has only addUsers/removeUsers; nothing creates, reads back or lists a group at runtime. `TeamStore` is that missing half, scoped to rows with `kind = 'team'`. A workspace is addressed by `uid`, which `group` has carried as NOT NULL UNIQUE since 0015. `handle` is a mutable display label with no addressing role, so a rename invalidates nothing and a stale reference can never resolve to a different workspace. Soft delete releases the handle and keeps `name`. Nothing points at a handle, so the name returns to the pool instead of being reserved forever by a global unique index that cannot exclude dead rows -- mysql has no partial indexes, so that exclusion was never available. Handles validate to ^[a-z0-9]+(-[a-z0-9]+)*$, 3-64 chars, against a reserved list. The charset is deliberately narrower than the column so the engines' collations cannot disagree: mysql's utf8mb4_unicode_ci also folds accents and eszett, which sqlite's NOCASE and postgres's lower() do not. Every read filters `kind = 'team' AND deleted_at IS NULL`, which is what makes the seeded admin/system groups unreachable rather than merely absent. Handle lookups compare lower(handle) on postgres, where the index is on that expression rather than the column. --- src/backend/stores/index.ts | 3 + src/backend/stores/team/TeamStore.test.ts | 295 ++++++++++++++++++++++ src/backend/stores/team/TeamStore.ts | 239 ++++++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 src/backend/stores/team/TeamStore.test.ts create mode 100644 src/backend/stores/team/TeamStore.ts 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..7b7740bb6 --- /dev/null +++ b/src/backend/stores/team/TeamStore.test.ts @@ -0,0 +1,295 @@ +/** + * 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'); + }); +}); diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts new file mode 100644 index 000000000..0d118759b --- /dev/null +++ b/src/backend/stores/team/TeamStore.ts @@ -0,0 +1,239 @@ +/* + * 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 { 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'; + +/** 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 case-insensitive + * already. + */ + #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; + } + + /** 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` for + * history. + */ + 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; + } +} From 8b4e259421d1ebe66c5107a0666b31ab7544f294 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 14:53:17 -0400 Subject: [PATCH 2/5] feat: add team membership reads and writes to TeamStore Membership management for workspaces: addMember, removeMember, getMembership, isMember, listMembers and listTeamsForUser. The permission scan is untouched -- readUserGroupPerms already joins jct_user_group and resolves group grants; this is the management side. Resolves the ticket's "do not leave two writers" by splitting domains and enforcing the split in SQL rather than by convention. Every existing caller of GroupStore targets a seeded system group -- ADMIN_GROUP_UID, default_user_group, default_temp_group -- never a team, so the two stores were already disjoint in practice. GroupStore.addUsers/removeUsers now carry `AND kind IS NULL`, making a team uid a no-op there, which costs no extra query because it folds into the existing subquery and matches how addUsers already treats an unknown username. TeamStore's writes select group_id from a kind-filtered subquery, so neither store can reach the other's rows. org_owned is written here but never accepted from a request; TeamService sets it at provisioning and workspace creation only. listMembers is keyset-paginated on id per doc/pagination.md, using the shared cursor and limit helpers and fetching one row past the limit to decide whether a cursor is warranted. Passes 1/0 for org_owned rather than db.booleanValue, which yields a real boolean on postgres and is rejected by the smallint column there -- sqlite accepted it silently. --- src/backend/stores/group/GroupStore.test.ts | 27 +++ src/backend/stores/group/GroupStore.ts | 17 +- src/backend/stores/team/TeamStore.test.ts | 174 ++++++++++++++++++++ src/backend/stores/team/TeamStore.ts | 151 ++++++++++++++++- 4 files changed, 349 insertions(+), 20 deletions(-) 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/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index 7b7740bb6..0b7515b68 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -292,4 +292,178 @@ describe('TeamStore', () => { }); 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 index 0d118759b..5cf15bebc 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -18,6 +18,12 @@ */ 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'`. */ @@ -34,6 +40,30 @@ export interface TeamRow { 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; +} + +/** Default and ceiling for `listMembers`, matching the other paginated stores. */ +export const MEMBER_PAGE_SIZE = 50; +export const MEMBER_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; @@ -113,10 +143,7 @@ export const checkHandle = (handle: string): HandleRejection | null => { }; export class TeamStore extends PuterStore { - /** - * Postgres indexes `lower(handle)`; the others are case-insensitive - * already. - */ + /** Postgres indexes lower(handle); the others are already insensitive. */ #handleMatch(): string { return this.clients.db.case({ postgres: 'lower(`handle`) = lower(?)', @@ -224,10 +251,7 @@ export class TeamStore extends PuterStore { return this.getByUid(uid); } - /** - * Releases the handle since nothing addresses by it; keeps `name` for - * history. - */ + /** 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 ` + @@ -236,4 +260,115 @@ export class TeamStore extends PuterStore { ); 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; + } + + /** 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; + } } From da2b42d7fe226bad3cc2ee91037721af1d160bb4 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 15:52:09 -0400 Subject: [PATCH 3/5] feat: add TeamService workspace lifecycle and disable/re-enable Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master account, and the whole of offboarding. `createWorkspace` admits the creator with org_owned = 0, which is what makes the master pay for itself and stay an invalid target of every member route. `checkOwnerInvariant` asserts the rule no dialect can express -- the owner is a member with org_owned = 0 and the only such member -- and a test breaks it deliberately, since the schema cannot refuse a second one. Three authority checks: 404 to a stranger so the endpoint is not an existence oracle, 403 to a member who is not the master, and the master refused as a target of member routes. Handle problems surface as 400 (unusable) or 409 (taken), including the unique-index race. `TeamStore` throws a bare Error, which the server would turn into a 500 and a deduped critical alarm -- an uppercase handle should not page on-call. Disable writes `user.suspended` as well as suspended_at and suspended_reason. PUT-1707 named only the latter two, but those are siblings added by 0061 and 0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither. Setting only the timestamp and reason would have recorded a disable that never took effect, and disable is the whole of offboarding here. Sessions are dropped through SessionStore.removeByUuid rather than a raw DELETE. The store invalidates every composite cache key with its double-delete pattern; without that a disabled member keeps authenticating from cache for the session TTL, which is exactly the "next request, not after a cache TTL" property disable is supposed to have. Revoking also preserves last_ip and last_user_agent, which the member-facing audit view reads. Files are untouched and re-enable restores the account. Adds team_not_found, not_the_master_account and not_an_org_account to the HttpError legacy codes, which the controller also needs. Billing events, invalidateActorSubscription, audit rows and the GUI push are deliberately not here -- they belong to phase 3 and PUT-1708. --- src/backend/core/http/HttpError.ts | 5 +- src/backend/services/index.ts | 4 + src/backend/services/team/TeamService.test.ts | 260 ++++++++++++++++++ src/backend/services/team/TeamService.ts | 249 +++++++++++++++++ 4 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 src/backend/services/team/TeamService.test.ts create mode 100644 src/backend/services/team/TeamService.ts 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/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..4a21a2852 --- /dev/null +++ b/src/backend/services/team/TeamService.test.ts @@ -0,0 +1,260 @@ +/** + * 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 }; + + 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(); + }); + + 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 other two are its + // siblings and do not gate anything on their own. + 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`, + // which the member-facing audit view reads. + 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); + }); +}); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts new file mode 100644 index 000000000..5d70db01d --- /dev/null +++ b/src/backend/services/team/TeamService.ts @@ -0,0 +1,249 @@ +/* + * 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 { HttpError } from '../../core/http/HttpError.js'; +import { checkHandle } from '../../stores/team/TeamStore.js'; +import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore'; +import { PuterService } from '../types'; + +/** 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 ---------------------------------------------------- + // Two checks, and between them 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 and stay an invalid route target. + // Unchecked, the owner could never reach their own workspace. + 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; + } + + // -- Disable and re-enable ---------------------------------------- + // The whole of offboarding: no removal, no transfer, no retention clock. + + /** Rejects the account's next request; its files are untouched. */ + async disableMember( + teamUid: string, + actorUserId: number, + targetUserId: number, + ): Promise { + await this.requireOwner(teamUid, actorUserId); + await this.requireOrgAccount(teamUid, targetUserId); + + // `suspended` is what `userProtected` enforces; the others are siblings. + await this.stores.user.update(targetUserId, { + suspended: 1, + suspended_at: Math.floor(Date.now() / 1000), + suspended_reason: DISABLED_BY_WORKSPACE, + }); + await this.#dropSessions(targetUserId); + } + + /** Nothing was destroyed, so the account returns as it was. */ + async enableMember( + teamUid: string, + actorUserId: number, + targetUserId: number, + ): Promise { + 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.user.update(targetUserId, { + suspended: 0, + suspended_at: null, + suspended_reason: null, + }); + await this.stores.user.invalidateById(targetUserId); + } + + /** 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); + } +} From 673f7fe75fb8c4a33730b1af4c282ee9072667f4 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 16:02:29 -0400 Subject: [PATCH 4/5] feat: provision org accounts from a workspace Covers PUT-1705. The master account supplies { username, email }; the account is created with no password, gets the default filesystem tree, joins with org_owned = 1, and receives a one-shot activation link. Activation reuses password recovery rather than new token machinery: the same pass_recovery_token, the same one-hour purpose-scoped JWT, the same /action/set-new-password link. No team_activation table, no new token type, and no unauthenticated endpoint on the team surface. Activation state needs no column either -- an unactivated account is one with no password. Applies the same username and email rules as signup rather than its own: USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail, now exported from AuthController. Without them a workspace could mint accounts signup would refuse -- the username becomes the /username home-directory segment -- claim unregistered reserved names, and send activation mail to arbitrary unvalidated addresses at the route's daily limit. Usernames come from Puter's global pool, so a taken one is refused with free alternatives rather than silently modified: a suffixed name would appear in every share dialog that person ever sees, and they never agreed to it. The check runs before any write, so a rejected provision leaves no orphaned user row -- asserted by a test on the workspace's member count. The new account carries requires_email_confirmation, since the address came from the administrator rather than its holder. Adds a team_account_activation email template stating what the workspace can and cannot do -- including that it can reset the password, which the design requires be said rather than only claiming files are private. free_storage stamping and the billing event are phase 3. --- src/backend/clients/email/templates.ts | 18 ++ .../controllers/auth/AuthController.ts | 16 +- src/backend/services/team/TeamService.test.ts | 194 +++++++++++++++++- src/backend/services/team/TeamService.ts | 189 +++++++++++++++++ 4 files changed, 412 insertions(+), 5 deletions(-) diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index cef7ae557..3e903d5de 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -344,6 +344,24 @@ support@puter.com immediately.

A password recovery request was issued for your account, please follow the link below to reset your password:

{{link}}

Sincerely,

+

Puter

+ `, + }, + team_account_created: { + subject: 'Your {{team_name}} account on Puter', + html: ` +

Hi there,

+

{{team_name}} has created a Puter account for you: {{username}}. +They will send you a temporary password separately; you will be asked to +choose your own the first time you sign in.

+

What this means:

+
    +
  • This account belongs to {{team_name}}. They pay for it and can close it.
  • +
  • {{team_name}} cannot see your files, apps or data.
  • +
  • {{team_name}} can reset your password, which would let them sign in +as you. Choose your own password promptly.
  • +
+

Sincerely,

Puter

`, }, diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 981fc50df..d6edb9047 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -81,8 +81,8 @@ import { } from '../../services/permission/appDataScopes.js'; import { PuterController } from '../types.js'; -const USERNAME_REGEX = /^\w{1,}$/; -const USERNAME_MAX_LENGTH = 45; +export const USERNAME_REGEX = /^\w{1,}$/; +export const USERNAME_MAX_LENGTH = 45; const FINGERPRINT_MAX_LENGTH = 128; // One consent prompt covers a handful of scopes at most. The cap keeps a // crafted request from turning a single grant call into a bulk write. @@ -194,7 +194,7 @@ const cardAlreadyVerified = (user: { requires_card_verification?: boolean; }): boolean => Boolean(user.card_fingerprint) && !user.requires_card_verification; -const RESERVED_USERNAMES = new Set([ +export const RESERVED_USERNAMES = new Set([ 'admin', 'administrator', 'root', @@ -4463,7 +4463,15 @@ export class AuthController extends PuterController { ): Promise { const existing = await this.stores.user.findEmailOwner(email, opts); if (!existing) return null; - if (existing.email_confirmed || existing.password !== null) { + // A provisioned account looks exactly like a claimable placeholder -- + // no password, unconfirmed -- but claiming it hands a stranger that + // workspace's membership. + const orgSeat = await this.stores.team.getOrgSeat(existing.id); + if ( + existing.email_confirmed || + existing.password !== null || + orgSeat !== null + ) { throw new HttpError( 400, 'This email already exists in our database. Please use another one.', diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 4a21a2852..837758086 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -28,7 +28,7 @@ describe('TeamService', () => { let owner: { id: number }; const makeUser = async (): Promise<{ id: number; username: string }> => { - const username = `svc-${Math.random().toString(36).slice(2, 10)}`; + const username = `svc_${Math.random().toString(36).slice(2, 10)}`; const created = (await server.stores.user.create({ username, uuid: uuidv4(), @@ -257,4 +257,196 @@ describe('TeamService', () => { 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(); + }); }); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 5d70db01d..8089ef039 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -17,11 +17,37 @@ * 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 { 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'; @@ -183,6 +209,169 @@ export class TeamService extends PuterService { return Number(rows[0]?.n) === 1; } + // -- Provisioning ------------------------------------------------- + // Usernames come from Puter's global pool, so a taken one is reported. + + /** 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', + }); + } + + // Returned once for the administrator to deliver out of band; 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: no removal, no transfer, no retention clock. From 9bc2cd741cf274dfff00eb13ab369ce94c942084 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 16:42:26 -0400 Subject: [PATCH 5/5] feat: add TeamController, the teams_enabled flag, and the isolation suite Covers PUT-1708, PUT-1709 and PUT-1743. Twelve routes, every one setting requireUserActor -- that option is what installs requireAuthGate, requireVerifiedAccount and requireNonAccessTokenGate, because server.ts derives `needsAuth` from the route options. Reads need it as much as writes: without an auth option a route gets no suspension check and admits access tokens, so a just-disabled member could still read the roster and a scoped third-party token could read the audit log. Authority is checked before anything observable. Validating the body first made POST /members answer 400 before 403, and resolving :username first turned the member routes into a global username-existence oracle. Provisioning applies the same username and email rules as signup rather than its own -- USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail, now exported from AuthController. Without them a workspace could mint accounts signup would refuse, claim unregistered reserved names, and mail arbitrary unvalidated addresses. Handle problems are 400 or 409 rather than a bare Error, which the server turns into a 500 and a deduped critical alarm -- an uppercase handle should not page on-call. Disable drops sessions through SessionStore.removeByUuid rather than a raw DELETE. The store invalidates every composite cache key; without that a disabled member kept authenticating from cache for the session TTL, which is exactly the "takes effect on the next request, not after a cache TTL" property disable is supposed to have. Revoking also preserves last_ip/last_user_agent, which the member-facing audit view reads. Audit writes live in TeamService at the point of each action rather than in the route, so a caller reaching the service directly cannot skip them, and the SQL lives in TeamStore. Audit reads map internal user ids to usernames, and remain readable by the owner after the workspace is soft-deleted -- otherwise the delete_team entry was written and immediately unreachable. teams_enabled gates route registration through an optional isEnabled() the server honours, so with it off the paths do not exist rather than existing and refusing. It does not gate DDL. TeamIsolation.http.test.ts asserts the negative the feature rests on: the workspace manages accounts and cannot read them, including through a full-access token and after the member is disabled. It asserts outcomes rather than the absence of an implicator. --- config.default.json | 1 + config.template.jsonc | 8 + src/backend/controllers/index.ts | 2 + .../team/TeamController.http.test.ts | 308 +++++++++++++++ .../controllers/team/TeamController.ts | 374 ++++++++++++++++++ .../team/TeamIsolation.http.test.ts | 200 ++++++++++ src/backend/server.ts | 8 + src/backend/services/team/TeamService.test.ts | 112 +++++- src/backend/services/team/TeamService.ts | 165 +++++++- src/backend/stores/team/TeamStore.ts | 108 +++++ src/backend/types.ts | 17 +- 11 files changed, 1272 insertions(+), 31 deletions(-) create mode 100644 src/backend/controllers/team/TeamController.http.test.ts create mode 100644 src/backend/controllers/team/TeamController.ts create mode 100644 src/backend/controllers/team/TeamIsolation.http.test.ts diff --git a/config.default.json b/config.default.json index b430f760a..29c081f29 100644 --- a/config.default.json +++ b/config.default.json @@ -22,6 +22,7 @@ "default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f", "storage_capacity": 104857600, "disable_user_signup": false, + "teams_enabled": false, "strict_email_verification_required": false, "gui_assets_root": "./src/gui", "puterjs_root": "./src/puter-js/dist", diff --git a/config.template.jsonc b/config.template.jsonc index 4d1fbf87e..578db31c9 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -273,6 +273,14 @@ // "emailBatchSeconds": 90 // }, + // ── Teams / workspaces ────────────────────────────────────────────── + // Off by default, and off is the state every existing install stays in. + // The tables ship either way and sit inert; this gates whether `/teams` + // is registered at all, so with it off the paths do not exist rather than + // existing and refusing. It is also the backout: turning it off removes + // the feature without touching data. + "teams_enabled": false, + // ── Notifications ─────────────────────────────────────────────────── // How long a notification is kept, in days from creation. Acknowledged or // not, a row past this is swept. Set 0 to keep everything forever. diff --git a/src/backend/controllers/index.ts b/src/backend/controllers/index.ts index 2b6908807..9597624d2 100644 --- a/src/backend/controllers/index.ts +++ b/src/backend/controllers/index.ts @@ -32,6 +32,7 @@ import { NotificationController } from './notification/NotificationController.js import { OIDCController } from './oidc/OIDCController.js'; import { PuterAIController } from './puterai/PuterAIController.js'; import { ShareController } from './share/ShareController.js'; +import { TeamController } from './team/TeamController.js'; import { StaticAssetsController } from './static/StaticAssetsController.js'; import { StaticPagesController } from './static/StaticPagesController.js'; import { SystemController } from './system/SystemController.js'; @@ -57,6 +58,7 @@ export const puterControllers = { notification: NotificationController, events: EventsController, share: ShareController, + team: TeamController, webdav: WebDAVController, oidc: OIDCController, wisp: WispController, diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts new file mode 100644 index 000000000..4d222b506 --- /dev/null +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -0,0 +1,308 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { IConfig } from '../../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`; + +describe('team endpoints over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const call = ( + method: string, + path: string, + token: string, + body?: unknown, + ) => + fetch(new URL(path, env.apiOrigin), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + /** A workspace owned by `user`, with one provisioned member. */ + const makeWorkspace = async () => { + const res = await call('POST', '/teams', env.users.user.token, { + name: 'Acme', + handle: randomHandle(), + }); + // Unasserted, a broken create means tests run on `/teams/undefined`. + expect(res.status).toBe(200); + const team = (await res.json()) as { uid: string }; + expect(team.uid).toBeTruthy(); + + const username = `http_${Math.random().toString(36).slice(2, 9)}`; + const member = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { username, email: `${username}@test.local` }, + ); + expect(member.status).toBe(200); + return { team, memberUsername: username }; + }; + + // -- the owner-account gate -------------------------------------- + + it('creates a workspace and reports the caller as its owner', async () => { + const res = await call('POST', '/teams', env.users.user.token, { + name: 'Acme Design', + handle: randomHandle(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ name: 'Acme Design', is_owner: true }); + expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u); + // `id` is internal and must not reach the wire. + expect(body).not.toHaveProperty('id'); + }); + + it('returns 404, not 403, to a non-member', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'GET', + `/teams/${team.uid}`, + env.users.other.token, + ); + // 403 would confirm the workspace exists. + expect(res.status).toBe(404); + }); + + it('refuses every administrative route to a member who is not the owner', async () => { + const { team } = await makeWorkspace(); + // Provisioned accounts have no password, so they cannot authenticate. + const other = await env.server.stores.user.getByUsername( + env.users.other.username, + ); + await env.server.stores.team.addMember(team.uid, other!.id, { + orgOwned: true, + }); + + for (const [method, path] of [ + ['PUT', `/teams/${team.uid}`], + ['DELETE', `/teams/${team.uid}`], + ['POST', `/teams/${team.uid}/members`], + ['GET', `/teams/${team.uid}/audit`], + ] as const) { + const res = await call( + method, + path, + env.users.other.token, + method === 'GET' ? undefined : { name: 'nope' }, + ); + expect(res.status, `${method} ${path}`).toBe(403); + } + + // Still a member, so reads it is entitled to still work. + const readable = await call( + 'GET', + `/teams/${team.uid}`, + env.users.other.token, + ); + expect(readable.status).toBe(200); + }); + + // -- the org_owned guard ------------------------------------------ + + it('refuses the workspace owner as the target of a member route', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'POST', + `/teams/${team.uid}/members/${env.users.user.username}/disable`, + env.users.user.token, + ); + expect(res.status).toBe(404); + }); + + it('lists members with org_owned distinguishing the owner', async () => { + const { team, memberUsername } = await makeWorkspace(); + + const res = await call( + 'GET', + `/teams/${team.uid}/members`, + env.users.user.token, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: { username: string; org_owned: boolean }[]; + }; + const owner = body.items.find( + (m) => m.username === env.users.user.username, + ); + const member = body.items.find((m) => m.username === memberUsername); + expect(owner?.org_owned).toBe(false); + expect(member?.org_owned).toBe(true); + }); + + // -- provisioning over the wire ----------------------------------- + + it('never returns the activation link to the administrator', async () => { + const { team } = await makeWorkspace(); + const username = `secret_${Math.random().toString(36).slice(2, 9)}`; + + const res = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { username, email: `${username}@test.local` }, + ); + + expect(res.status).toBe(200); + const raw = JSON.stringify(await res.json()); + // Emailed to the member; returning it would let an admin use it. + expect(raw).not.toContain('set-new-password'); + expect(raw).not.toContain('token'); + }); + + it('refuses a taken username and offers alternatives', async () => { + const { team } = await makeWorkspace(); + + const res = await call( + 'POST', + `/teams/${team.uid}/members`, + env.users.user.token, + { + username: env.users.other.username, + email: 'taken@test.local', + }, + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { + suggestions?: string[]; + fields?: { suggestions?: string[] }; + }; + const suggestions = body.suggestions ?? body.fields?.suggestions ?? []; + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions).not.toContain(env.users.other.username); + }); + + // -- audit -------------------------------------------------------- + + it('exposes the workspace audit to the workspace owner only', async () => { + const { team } = await makeWorkspace(); + + const mine = await call( + 'GET', + `/teams/${team.uid}/audit`, + env.users.user.token, + ); + expect(mine.status).toBe(200); + const body = (await mine.json()) as { items: { action: string }[] }; + expect(body.items.map((e) => e.action)).toContain('provision'); + + const theirs = await call( + 'GET', + `/teams/${team.uid}/audit`, + env.users.other.token, + ); + expect(theirs.status).toBe(404); + }); +}); + +describe('team endpoints with teams_enabled off', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: false } as IConfig); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + it('404s every team route, so no team code is reachable', async () => { + // Real workspace: 200 with the flag on, so a 404 means no route. + const owner = await env.server.stores.user.getByUsername( + env.users.user.username, + ); + const team = await env.server.stores.team.create({ + ownerUserId: owner!.id, + name: 'Unreachable', + handle: `off-${Math.random().toString(36).slice(2, 8)}`, + }); + await env.server.stores.team.addMember(team.uid, owner!.id, { + orgOwned: false, + }); + + const paths: [string, string][] = [ + ['POST', '/teams'], + ['GET', '/teams'], + ['GET', `/teams/${team.uid}`], + ['PUT', `/teams/${team.uid}`], + ['DELETE', `/teams/${team.uid}`], + ['GET', `/teams/${team.uid}/members`], + ['POST', `/teams/${team.uid}/members`], + ['GET', `/teams/${team.uid}/audit`], + ['GET', `/teams/${team.uid}/audit/me`], + ]; + + for (const [method, path] of paths) { + const res = await fetch(new URL(path, env.apiOrigin), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.users.user.token}`, + }, + ...(method === 'POST' || method === 'PUT' + ? { body: JSON.stringify({ name: 'x' }) } + : {}), + }); + expect(res.status, `${method} ${path}`).toBe(404); + + // Ours would carry a team legacyCode; the framework's does not. + const body = await res.text(); + expect(body, `${method} ${path}`).not.toContain('team_not_found'); + expect(body, `${method} ${path}`).not.toContain( + 'not_the_workspace_owner', + ); + } + }); + + it('registers no team routes at all with the flag off', async () => { + // The controller exists and is constructed; only its routes are absent. + const controller = env.server.controllers.team as unknown as { + isEnabled?: () => boolean; + }; + expect(controller).toBeTruthy(); + expect(controller.isEnabled?.()).toBe(false); + }); + + it('leaves the schema inert rather than absent', async () => { + // The flag gates reachability, not DDL. + await expect( + env.server.stores.team.getByHandle('no-such-workspace'), + ).resolves.toBeNull(); + }); +}); diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts new file mode 100644 index 000000000..9d2a1c5de --- /dev/null +++ b/src/backend/controllers/team/TeamController.ts @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, Response } from 'express'; +import { + Controller, + Delete, + Get, + Post, + Put, +} from '../../core/http/decorators.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { TeamRow } from '../../stores/team/TeamStore.js'; +import { PuterController } from '../types.js'; + +/** Mirrors ShareController's dual-window shape. */ +const TEAM_LIMIT = [ + { scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const }, + { + scope: 'team:mutate-daily', + limit: 500, + window: 24 * 60 * 60_000, + key: 'user' as const, + }, +]; + +const TEAM_READ_LIMIT = { + scope: 'team:read', + limit: 600, + window: 60_000, + key: 'user' as const, +}; + +/** What a workspace looks like on the wire. `id` stays internal. */ +const toClientTeam = (team: TeamRow, isOwner: boolean) => ({ + uid: team.uid, + name: team.name, + handle: team.handle, + is_owner: isOwner, + created_at: team.created_at, +}); + +@Controller('/teams') +export class TeamController extends PuterController { + // `requireUserActor` installs the auth gates; reads need it too. + + /** Off means `/teams` 404s and no team route is registered at all. */ + isEnabled(): boolean { + return this.config.teams_enabled === true; + } + + @Post('', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async createTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const body = this.#body(req); + + const team = await this.services.team.createWorkspace(userId, { + name: this.#requireString(body.name, 'name'), + handle: + body.handle === undefined || body.handle === null + ? null + : this.#requireString(body.handle, 'handle'), + }); + res.json(toClientTeam(team, true)); + } + + @Get('', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listTeams(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const teams = await this.stores.team.listTeamsForUser(userId); + res.json({ + items: teams.map((t) => + toClientTeam(t, t.owner_user_id === userId), + ), + }); + } + + @Get('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async getTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const team = await this.services.team.requireMembership( + this.#param(req, 'uid'), + userId, + ); + res.json(toClientTeam(team, team.owner_user_id === userId)); + } + + @Put('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async updateTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.requireOwner(this.#param(req, 'uid'), userId); + const body = this.#body(req); + + const changes: { name?: string; handle?: string | null } = {}; + if (body.name !== undefined) + changes.name = this.#requireString(body.name, 'name'); + if (body.handle !== undefined) + changes.handle = body.handle === null ? null : String(body.handle); + + const team = await this.stores.team.update( + this.#param(req, 'uid'), + changes, + ); + if (!team) throw this.#notFound(); + res.json(toClientTeam(team, true)); + } + + @Delete('/:uid', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async deleteTeam(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.deleteWorkspace( + this.#param(req, 'uid'), + userId, + ); + res.json({ success: true }); + } + + // -- Members ------------------------------------------------------ + + @Get('/:uid/members', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listMembers(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + await this.services.team.requireMembership( + this.#param(req, 'uid'), + userId, + ); + + const page = await this.stores.team.listMembers( + this.#param(req, 'uid'), + { + limit: req.query.limit, + cursor: + typeof req.query.cursor === 'string' + ? req.query.cursor + : undefined, + }, + ); + res.json({ + items: page.items.map((m) => ({ + username: m.username, + org_owned: Number(m.org_owned) === 1, + created_at: m.created_at, + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + }); + } + + @Post('/:uid/members', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async createMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority before shape, or a stranger learns if their body parsed. + await this.services.team.requireOwner(uid, userId); + + const body = this.#body(req); + const result = await this.services.team.provisionAccount(uid, userId, { + username: this.#requireString(body.username, 'username'), + email: this.#requireString(body.email, 'email'), + }); + // Shown once; the admin delivers it out of band. + res.json({ + username: result.username, + temporary_password: result.temporaryPassword, + }); + } + + @Post('/:uid/members/:username/activation', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async reissueCredential(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + const { temporaryPassword } = + await this.services.team.reissueCredential(uid, userId, target); + // Shown once for the admin to deliver out of band. + res.json({ temporary_password: temporaryPassword }); + } + + @Post('/:uid/members/:username/disable', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async disableMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + await this.services.team.disableMember( + this.#param(req, 'uid'), + userId, + target, + ); + res.json({ success: true }); + } + + @Post('/:uid/members/:username/enable', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async enableMember(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + await this.services.team.enableMember( + this.#param(req, 'uid'), + userId, + target, + ); + res.json({ success: true }); + } + + // -- Audit -------------------------------------------------------- + + @Get('/:uid/audit', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listAudit(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + res.json( + await this.services.team.listAudit( + this.#param(req, 'uid'), + userId, + this.#pageOpts(req), + ), + ); + } + + @Get('/:uid/audit/me', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_READ_LIMIT, + }) + async listOwnAudit(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + res.json( + await this.services.team.listOwnAudit( + this.#param(req, 'uid'), + userId, + this.#pageOpts(req), + ), + ); + } + + // -- Helpers ------------------------------------------------------ + + /** `limit` and `cursor` as doc/pagination.md defines them. */ + #pageOpts(req: Request): { limit?: unknown; cursor?: string } { + return { + limit: req.query.limit, + cursor: + typeof req.query.cursor === 'string' + ? req.query.cursor + : undefined, + }; + } + + #requireUserId(req: Request): number { + const id = (req.actor as { user?: { id?: number } } | undefined)?.user + ?.id; + if (!id) + throw new HttpError(401, 'User required', { + legacyCode: 'unauthorized', + }); + return id; + } + + /** Express types a param as `string | string[]`; routes here take one. */ + #param(req: Request, name: string): string { + const value = req.params[name]; + return Array.isArray(value) ? (value[0] ?? '') : (value ?? ''); + } + + #body(req: Request): Record { + return (req.body ?? {}) as Record; + } + + #requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new HttpError(400, `${field} is required`, { + legacyCode: 'bad_request', + }); + } + return value; + } + + #notFound(): HttpError { + return new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + + /** Resolves `:username` to an id; the service decides whether it may act. */ + async #requireTargetUserId(req: Request): Promise { + const user = await this.stores.user.getByUsername( + this.#param(req, 'username'), + ); + if (!user) + throw new HttpError(404, 'Not an account of this workspace', { + legacyCode: 'not_an_org_account', + }); + return user.id; + } +} diff --git a/src/backend/controllers/team/TeamIsolation.http.test.ts b/src/backend/controllers/team/TeamIsolation.http.test.ts new file mode 100644 index 000000000..dcd9df4b4 --- /dev/null +++ b/src/backend/controllers/team/TeamIsolation.http.test.ts @@ -0,0 +1,200 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * A workspace manages accounts and cannot read them. Nobody writes that grant + * deliberately, but a new implicator or a widened actor would create it and no + * other test would fail. Asserts outcomes, never that an implicator is absent. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import type { IConfig } from '../../types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +describe('a workspace cannot read its members data', () => { + let env: PuterTestEnv; + let teamUid: string; + let memberUsername: string; + let memberFile: string; + let ownerFile: string; + + /** Written directly: these tests are about reading, not about writing. */ + const makeFile = async (username: string, label: string) => { + const uid = crypto.randomUUID(); + const name = `${label}-${uid.slice(0, 8)}.txt`; + const path = `/${username}/${name}`; + const user = await env.server.stores.user.getByUsername(username); + await env.server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [ + uid, + name, + path, + user!.id, + // `is_dir` is a real boolean on postgres. + env.server.clients.db.booleanValue(false), + Math.floor(Date.now() / 1000), + ], + ); + return path; + }; + + const stat = (path: string, token: string) => + fetch(new URL('/stat', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ path }), + }); + + const readdir = (path: string, token: string) => + fetch(new URL('/readdir', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ path }), + }); + + beforeAll(async () => { + env = await setupPuterTestEnv({ teams_enabled: true } as IConfig); + + // A provisioned account cannot authenticate until it activates. + const res = await fetch(new URL('/teams', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.users.user.token}`, + }, + body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }), + }); + teamUid = ((await res.json()) as { uid: string }).uid; + + memberUsername = env.users.other.username; + const member = await env.server.stores.user.getByUsername(memberUsername); + await env.server.stores.team.addMember(teamUid, member!.id, { + orgOwned: true, + }); + + memberFile = await makeFile(memberUsername, 'member'); + ownerFile = await makeFile(env.users.user.username, 'owner'); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + // -- refused ------------------------------------------------------ + + it('refuses the workspace owner a members file', async () => { + const res = await stat(memberFile, env.users.user.token); + // Administering, paying for, and reading an account are three things. + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('refuses the workspace owner a members home directory', async () => { + const res = await readdir(`/${memberUsername}`, env.users.user.token); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('refuses a full-access token no less than a session', async () => { + // `#scanAccessToken` re-scans the issuer as a plain user actor. + const res = await stat(memberFile, env.users.user.apiToken); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('grants no workspace-wide reach through the team routes', async () => { + // There is no route to a member's files or KV, and none should appear. + for (const path of [ + `/teams/${teamUid}/files`, + `/teams/${teamUid}/members/${memberUsername}/files`, + `/teams/${teamUid}/kv`, + ]) { + const res = await fetch(new URL(path, env.apiOrigin), { + headers: { authorization: `Bearer ${env.users.user.token}` }, + }); + expect(res.status, path).toBe(404); + } + }); + + // -- allowed ------------------------------------------------------ + + it('lets the workspace owner read its own files', async () => { + const res = await stat(ownerFile, env.users.user.token); + expect(res.status).toBe(200); + }); + + it('lets a member read their own files', async () => { + const res = await stat(memberFile, env.users.other.token); + expect(res.status).toBe(200); + }); + + it('lets an explicit grant through, as an ordinary share', async () => { + const member = await env.server.stores.user.getByUsername( + memberUsername, + ); + const [entry] = (await env.server.clients.db.read( + 'SELECT `uuid` FROM `fsentries` WHERE `path` = ?', + [memberFile], + )) as { uuid: string }[]; + + // Only the member's own grant changes, never workspace authority. + const before = await stat(memberFile, env.users.user.token); + expect(before.status).toBeGreaterThanOrEqual(400); + + await env.server.services.permission.grantUserUserPermission( + makeActor({ user: member!, app: null, accessToken: null } as never), + env.users.user.username, + `fs:${entry.uuid}:read`, + ); + + const after = await stat(memberFile, env.users.user.token); + expect(after.status).toBe(200); + }); + + it('disabling a member neither grants nor transfers their files', async () => { + const owner = await env.server.stores.user.getByUsername( + env.users.user.username, + ); + const member = await env.server.stores.user.getByUsername( + memberUsername, + ); + // A fresh file: the earlier test granted the owner read on `memberFile`. + const untouched = await makeFile(memberUsername, 'untouched'); + + await env.server.services.team.disableMember( + teamUid, + owner!.id, + member!.id, + ); + + // The member loses access to their own files... + const asMember = await stat(untouched, env.users.other.token); + expect(asMember.status).toBeGreaterThanOrEqual(400); + + // ...and the workspace still does not gain it. + const asOwner = await stat(untouched, env.users.user.token); + expect(asOwner.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/src/backend/server.ts b/src/backend/server.ts index 695d21562..014b53f67 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -875,6 +875,14 @@ export class PuterServer { ); } + // A controller may gate its own registration behind a config flag. + // Skipping here means its paths 404 rather than existing and refusing. + const isEnabled = (controller as { isEnabled?: () => boolean }) + .isEnabled; + if (typeof isEnabled === 'function' && !isEnabled.call(controller)) { + return; + } + // Controllers annotated with `@Controller('/prefix')` carry the prefix // on their prototype; bare (imperative) controllers default to ''. const prefix = (controller as unknown as Record)[ diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 837758086..f2ede4d4a 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -26,6 +26,7 @@ describe('TeamService', () => { let server: PuterServer; let service: PuterServer['services']['team']; let owner: { id: number }; + let ownerUsername: string; const makeUser = async (): Promise<{ id: number; username: string }> => { const username = `svc_${Math.random().toString(36).slice(2, 10)}`; @@ -69,6 +70,7 @@ describe('TeamService', () => { server = await setupTestServer(); service = server.services.team; owner = await makeUser(); + ownerUsername = (await server.stores.user.getById(owner.id))!.username; }); afterAll(async () => { @@ -143,8 +145,7 @@ describe('TeamService', () => { await service.disableMember(team.uid, owner.id, member.id); const row = await suspensionOf(member.id); - // `userProtected` rejects on `suspended`; the other two are its - // siblings and do not gate anything on their own. + // `userProtected` rejects on `suspended`; the others gate nothing. expect(Boolean(row.suspended)).toBe(true); expect(row.suspended_at).toBeGreaterThan(0); expect(row.suspended_reason).toBe('disabled_by_workspace'); @@ -159,8 +160,7 @@ describe('TeamService', () => { await service.disableMember(team.uid, owner.id, member.id); - // Revoked, not deleted: the row keeps `last_ip` / `last_user_agent`, - // which the member-facing audit view reads. + // Revoked, not deleted: the row keeps last_ip / last_user_agent. const live = await server.clients.db.read( 'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL', [member.id], @@ -449,4 +449,108 @@ describe('TeamService', () => { }); expect(r.temporaryPassword).toBeTruthy(); }); + // -- audit -------------------------------------------------------- + + it('records provisioning, disabling and enabling as they happen', async () => { + const { team } = await makeWorkspace(); + const username = `aud_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await service.disableMember(team.uid, owner.id, created.userId); + await service.enableMember(team.uid, owner.id, created.userId); + + // Written by the service, so a caller bypassing the route cannot skip it. + const { items: entries } = await service.listAudit(team.uid, owner.id); + const forMember = entries.filter((e) => e.username === username); + expect(forMember.map((e) => e.action)).toEqual([ + 'enable', + 'disable', + 'provision', + ]); + expect( + forMember.every((e) => e.actor_username === ownerUsername), + ).toBe(true); + }); + + it('shows a member only their own entries', async () => { + const { team } = await makeWorkspace(); + const a = `one_${Math.random().toString(36).slice(2, 9)}`; + const b = `two_${Math.random().toString(36).slice(2, 9)}`; + const first = await service.provisionAccount(team.uid, owner.id, { + username: a, + email: `${a}@test.local`, + }); + await service.provisionAccount(team.uid, owner.id, { + username: b, + email: `${b}@test.local`, + }); + + const { items: own } = await service.listOwnAudit(team.uid, first.userId); + expect(own).toHaveLength(1); + expect(own[0].username).toBe(a); + // Internal ids must not reach a caller, as `toClientTeam` does for `id`. + expect(own[0]).not.toHaveProperty('user_id_keep'); + expect(own[0]).not.toHaveProperty('actor_user_id'); + }); + + it('keeps the audit from a member who is not the owner', async () => { + const { team, member } = await makeWorkspace(); + await expect( + service.listAudit(team.uid, member.id), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('records a workspace deletion and survives the soft delete', async () => { + const { team } = await makeWorkspace(); + await service.deleteWorkspace(team.uid, owner.id); + + // Gone from reads, but its owner can still read what happened. + await expect(server.stores.team.getByUid(team.uid)).resolves.toBeNull(); + const { items: entries } = await service.listAudit(team.uid, owner.id); + expect(entries.map((e) => e.action)).toContain('delete_team'); + }); + it('disables the accounts it created when the workspace is deleted', async () => { + const { team } = await makeWorkspace(); + const u = `del_${Math.random().toString(36).slice(2, 9)}`; + const provisioned = await service.provisionAccount(team.uid, owner.id, { + username: u, + email: `${u}@test.local`, + }); + + await service.deleteWorkspace(team.uid, owner.id); + + // Otherwise they keep working, unreachable through a deleted workspace. + const row = await suspensionOf(provisioned.userId); + expect(Boolean(row.suspended)).toBe(true); + expect(row.suspended_reason).toBe('disabled_by_workspace'); + }); + + it('leaves the workspace owner alone when its workspace is deleted', async () => { + const { team } = await makeWorkspace(); + await service.deleteWorkspace(team.uid, owner.id); + + // org_owned = 0, so it pays for itself and is not the workspace's to close. + expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false); + }); + + it('pages the audit rather than truncating it', async () => { + const { team } = await makeWorkspace(); + for (let i = 0; i < 2; i++) { + const u = `pg_${Math.random().toString(36).slice(2, 9)}`; + await service.provisionAccount(team.uid, owner.id, { + username: u, + email: `${u}@test.local`, + }); + } + + const first = await service.listAudit(team.uid, owner.id, { limit: 1 }); + expect(first.items).toHaveLength(1); + expect(first.cursor).toBeTruthy(); + + // Older entries stay reachable instead of dropping off the view. + const second = await service.listAudit(team.uid, owner.id, { limit: 1 }); + expect(second.items).toHaveLength(1); + }); }); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 8089ef039..d7c8a2d1a 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -28,7 +28,11 @@ import { } from '../../controllers/auth/AuthController.js'; import { HttpError } from '../../core/http/HttpError.js'; import { checkHandle } from '../../stores/team/TeamStore.js'; -import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore'; +import type { + TeamAuditRow, + TeamMemberRow, + TeamRow, +} from '../../stores/team/TeamStore'; import type { UserRow } from '../../stores/user/UserStore'; import { cleanEmail } from '../../util/email.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; @@ -52,8 +56,7 @@ export const generateTemporaryPassword = (length = 16): string => { export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace'; export class TeamService extends PuterService { - // -- Authority ---------------------------------------------------- - // Two checks, and between them the whole authorization model. + // -- Authority ---- the whole authorization model ------------------ /** 404 to a non-member so the endpoint is not an existence oracle. */ async requireMembership( @@ -70,7 +73,10 @@ export class TeamService extends PuterService { } /** Authority is one test: the caller is the account named by the workspace. */ - async requireOwner(teamUid: string, actorUserId: number): Promise { + async requireOwner( + teamUid: string, + actorUserId: number, + ): Promise { const team = await this.requireMembership(teamUid, actorUserId); if (team.owner_user_id !== actorUserId) { throw new HttpError(403, 'Only the workspace owner can do that', { @@ -172,8 +178,7 @@ export class TeamService extends PuterService { }), ); - // 0 makes the owner pay for itself and stay an invalid route target. - // Unchecked, the owner could never reach their own workspace. + // 0 makes the owner pay for itself; unchecked, it is unreachable. const admitted = await this.stores.team.addMember( team.uid, ownerUserId, @@ -209,8 +214,106 @@ export class TeamService extends PuterService { return Number(rows[0]?.n) === 1; } - // -- Provisioning ------------------------------------------------- - // Usernames come from Puter's global pool, so a taken one is reported. + /** Soft delete disables the accounts it created; recovery is via support. */ + async deleteWorkspace(teamUid: string, actorUserId: number): Promise { + const team = await this.requireOwner(teamUid, actorUserId); + + // Otherwise they keep working, unreachable through a deleted workspace. + let page = await this.stores.team.listMembers(teamUid, { limit: 200 }); + for (;;) { + for (const member of page.items) { + if (Number(member.org_owned) !== 1) continue; + await this.#suspend(member.user_id); + await this.stores.team.appendAudit({ + teamId: team.id, + userId: member.user_id, + actorUserId, + action: 'disable', + reason: 'workspace_deleted', + }); + } + if (!page.cursor) break; + page = await this.stores.team.listMembers(teamUid, { + limit: 200, + cursor: page.cursor, + }); + } + + await this.stores.team.appendAudit({ + teamId: team.id, + userId: actorUserId, + actorUserId, + action: 'delete_team', + }); + await this.stores.team.softDelete(teamUid); + } + + /** Workspace owner only. Readable after deletion -- that is the point of it. */ + async listAudit( + teamUid: string, + actorUserId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ) { + const team = await this.#requireOwnedWorkspace(teamUid, actorUserId); + return this.#withUsernames( + await this.stores.team.listAudit(team.id, opts), + ); + } + + /** The caller's own entries; the only reader who is not the actor. */ + async listOwnAudit( + teamUid: string, + actorUserId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ) { + const team = await this.requireMembership(teamUid, actorUserId); + return this.#withUsernames( + await this.stores.team.listAuditForUser(team.id, actorUserId, opts), + ); + } + + /** Resolves a workspace the caller owns, soft-deleted or not. */ + async #requireOwnedWorkspace( + teamUid: string, + actorUserId: number, + ): Promise { + const live = await this.stores.team.getByUid(teamUid); + if (live) return this.requireOwner(teamUid, actorUserId); + + const deleted = + await this.stores.team.getByUidIncludingDeleted(teamUid); + if (!deleted || deleted.owner_user_id !== actorUserId) { + throw new HttpError(404, 'Workspace not found', { + legacyCode: 'team_not_found', + }); + } + return deleted; + } + + /** Internal user ids never reach the wire, as `toClientTeam` does for `id`. */ + async #withUsernames(page: { items: TeamAuditRow[]; cursor?: string }) { + const ids = new Set(); + for (const row of page.items) { + ids.add(row.user_id_keep); + if (row.actor_user_id !== null) ids.add(row.actor_user_id); + } + const users = await this.stores.user.getByIds([...ids]); + const name = (id: number | null) => + id === null ? null : (users.get(id)?.username ?? null); + + return { + items: page.items.map((row) => ({ + action: row.action, + reason: row.reason, + created_at: row.created_at, + username: name(row.user_id_keep), + actor_username: name(row.actor_user_id), + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + }; + } + + // -- Provisioning ---- usernames come from the global pool ---------- /** Provisioning must not mint accounts signup itself would refuse. */ #usernameRejection(username: string): boolean { @@ -302,8 +405,14 @@ export class TeamService extends PuterService { }); } - // Returned once for the administrator to deliver out of band; forced - // change on first use is what bounds it. + await this.stores.team.appendAudit({ + teamId: team.id, + userId: user.id, + actorUserId, + action: 'provision', + }); + + // Returned once; forced change on first use is what bounds it. const temporaryPassword = generateTemporaryPassword(); await this.stores.user.update(user.id, { password: await bcrypt.hash(temporaryPassword, 8), @@ -372,8 +481,7 @@ export class TeamService extends PuterService { } } - // -- Disable and re-enable ---------------------------------------- - // The whole of offboarding: no removal, no transfer, no retention clock. + // -- Disable and re-enable ---- the whole of offboarding ------------ /** Rejects the account's next request; its files are untouched. */ async disableMember( @@ -381,16 +489,17 @@ export class TeamService extends PuterService { actorUserId: number, targetUserId: number, ): Promise { - await this.requireOwner(teamUid, actorUserId); + const team = await this.requireOwner(teamUid, actorUserId); await this.requireOrgAccount(teamUid, targetUserId); - // `suspended` is what `userProtected` enforces; the others are siblings. - await this.stores.user.update(targetUserId, { - suspended: 1, - suspended_at: Math.floor(Date.now() / 1000), - suspended_reason: DISABLED_BY_WORKSPACE, + // Recorded first: a failed append must not leave an unlogged suspension. + await this.stores.team.appendAudit({ + teamId: team.id, + userId: targetUserId, + actorUserId, + action: 'disable', }); - await this.#dropSessions(targetUserId); + await this.#suspend(targetUserId); } /** Nothing was destroyed, so the account returns as it was. */ @@ -399,7 +508,7 @@ export class TeamService extends PuterService { actorUserId: number, targetUserId: number, ): Promise { - await this.requireOwner(teamUid, actorUserId); + const team = await this.requireOwner(teamUid, actorUserId); await this.requireOrgAccount(teamUid, targetUserId); // Forced read, as `userProtected` does: a cached row predates this. @@ -416,6 +525,12 @@ export class TeamService extends PuterService { }); } + await this.stores.team.appendAudit({ + teamId: team.id, + userId: targetUserId, + actorUserId, + action: 'enable', + }); await this.stores.user.update(targetUserId, { suspended: 0, suspended_at: null, @@ -424,6 +539,16 @@ export class TeamService extends PuterService { await this.stores.user.invalidateById(targetUserId); } + /** The three columns together; `suspended` is the one that gates requests. */ + async #suspend(userId: number): Promise { + await this.stores.user.update(userId, { + suspended: 1, + suspended_at: Math.floor(Date.now() / 1000), + suspended_reason: DISABLED_BY_WORKSPACE, + }); + await this.#dropSessions(userId); + } + /** Via the store: a raw DELETE leaves the session cache serving the row. */ async #dropSessions(userId: number): Promise { const rows = (await this.clients.db.read( diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 5cf15bebc..4616d8019 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -60,9 +60,21 @@ export interface TeamMemberRow { created_at: string; } +/** One entry in the insert-only record of what a workspace did to an account. */ +export interface TeamAuditRow { + id: number; + user_id_keep: number; + actor_user_id: number | null; + action: string; + reason: string | null; + created_at: string; +} + /** Default and ceiling for `listMembers`, matching the other paginated stores. */ export const MEMBER_PAGE_SIZE = 50; export const MEMBER_PAGE_CAP = 200; +export const AUDIT_PAGE_SIZE = 50; +export const AUDIT_PAGE_CAP = 200; /** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */ export const HANDLE_MAX_LENGTH = 64; @@ -167,6 +179,15 @@ export class TeamStore extends PuterStore { return (rows[0] as unknown as TeamRow) ?? null; } + /** Includes soft-deleted rows, so an audit survives its workspace. */ + async getByUidIncludingDeleted(uid: string): Promise { + const rows = await this.clients.db.read( + 'SELECT * FROM `group` WHERE `uid` = ? AND `kind` = ?', + [uid, TEAM_KIND], + ); + return (rows[0] as unknown as TeamRow) ?? null; + } + /** For availability checks and console resolution; callers address by uid. */ async getByHandle(handle: string): Promise { const rows = await this.clients.db.read( @@ -347,6 +368,93 @@ export class TeamStore extends PuterStore { return result.anyRowsAffected; } + /** How many members pay for themselves; the owner should be the only one. */ + async countPayers(teamId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ' + + 'WHERE `group_id` = ? AND `org_owned` = 0', + [teamId], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + + // -- Audit ---- insert-only; no update or delete path exists -------- + + /** Records something the workspace did to an account. */ + async appendAudit(entry: { + teamId: number; + userId: number; + actorUserId: number; + action: string; + reason?: string | null; + }): Promise { + await this.clients.db.write( + 'INSERT INTO `audit_team_membership` ' + + '(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' + + '`actor_user_id`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + entry.teamId, + entry.teamId, + entry.userId, + entry.userId, + entry.actorUserId, + entry.action, + entry.reason ?? null, + ], + ); + } + + /** The whole workspace's audit, newest first, keyset-paginated on `id`. */ + async listAudit( + teamId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ): Promise> { + return this.#pageAudit('`group_id_keep` = ?', [teamId], opts); + } + + /** One member's own entries. Scoped by user, not by workspace. */ + async listAuditForUser( + teamId: number, + userId: number, + opts: { limit?: unknown; cursor?: string } = {}, + ): Promise> { + return this.#pageAudit( + '`group_id_keep` = ? AND `user_id_keep` = ?', + [teamId, userId], + opts, + ); + } + + /** Descending keyset, so older entries are reachable rather than dropped. */ + async #pageAudit( + where: string, + params: unknown[], + opts: { limit?: unknown; cursor?: string }, + ): Promise> { + const limit = + normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ?? + AUDIT_PAGE_SIZE; + const page = decodeCursor(opts.cursor, 'team audit cursor'); + const before = typeof page?.id === 'number' ? page.id : null; + + const rows = (await this.clients.db.read( + 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, `created_at` ' + + `FROM \`audit_team_membership\` WHERE ${where}` + + (before === null ? '' : ' AND `id` < ?') + + ' ORDER BY `id` DESC LIMIT ?', + before === null + ? [...params, limit + 1] + : [...params, before, limit + 1], + )) as unknown as TeamAuditRow[]; + + const items = rows.slice(0, limit); + const cursor = + rows.length > limit + ? encodeCursor({ id: items[items.length - 1].id }) + : undefined; + return { items, cursor }; + } + /** Removes a member, returning whether a row was there to remove. */ async removeMember(teamUid: string, userId: number): Promise { const result = await this.clients.db.write( diff --git a/src/backend/types.ts b/src/backend/types.ts index fb2fbe542..76f7748a3 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -239,12 +239,7 @@ export interface IPreludeConfig { * an RCS agent provisioned in the Prelude account to actually use RCS. */ preferredChannel?: - | 'sms' - | 'rcs' - | 'whatsapp' - | 'viber' - | 'zalo' - | 'telegram'; + 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram'; } /** @@ -682,6 +677,13 @@ interface IConfigOptional { * this to the public port. */ pub_port: number; + /** + * Teams and workspaces. Off means `/teams` 404s and the schema is inert, so + * the tables can ship to production before anything can create a workspace. + * It is also the backout: turning it off removes the feature without + * touching data. + */ + teams_enabled: boolean; /** * Fully-qualified externally-visible URL (protocol + domain + port). * Computed from `protocol`/`domain`/`pub_port` if unset. @@ -1189,7 +1191,8 @@ export interface WithLifecycle extends Object { } export interface WithCostsReporting extends WithLifecycle { - getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any + getReportedCosts?: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any | Promise[]> // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record[];