From 37cb11df8ca8a9c7e5276b8e15436b3ec5193b92 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 1 Sep 2026 14:32:29 -0400 Subject: [PATCH] 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; + } +}