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.
This commit is contained in:
Juan Castro
2026-09-03 15:20:07 -04:00
parent ad70e8763b
commit 37cb11df8c
3 changed files with 537 additions and 0 deletions
+3
View File
@@ -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,
+295
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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');
});
});
+239
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<TeamRow | null> {
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<TeamRow | null> {
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<boolean> {
return (await this.getByHandle(handle)) === null;
}
/** Workspaces this user owns, oldest first. */
async listByOwner(ownerUserId: number): Promise<TeamRow[]> {
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<TeamRow> {
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<TeamRow | null> {
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<boolean> {
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;
}
}