Merge pull request #3714 from HeyPuter/juancastro/put-1708-26-teamcontroller-and-audit-writes

🏗️ PUT-1708 + PUT-1709 + PUT-1743: TeamController, teams_enabled, and the isolation suite
This commit is contained in:
Juan Fernando Castro authored and GitHub committed 2026-09-03 16:21:02 -04:00
commit d855315db8
19 files changed
+3054 -24

No files matched your search

+1
View File
@@ -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",
+8
View File
@@ -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.
+18
View File
@@ -344,6 +344,24 @@ support@puter.com immediately.
<p>A password recovery request was issued for your account, please follow the link below to reset your password:</p>
<p><a href="{{link}}">{{link}}</a></p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_account_created: {
subject: 'Your {{team_name}} account on Puter',
html: `
<p>Hi there,</p>
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
They will send you a temporary password separately; you will be asked to
choose your own the first time you sign in.</p>
<p>What this means:</p>
<ul>
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
<li>{{team_name}} <b>cannot</b> see your files, apps or data.</li>
<li>{{team_name}} <b>can</b> reset your password, which would let them sign in
as you. Choose your own password promptly.</li>
</ul>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
+12 -4
View File
@@ -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<UserRow | null> {
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.',
+2
View File
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, unknown>;
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();
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> {
return (req.body ?? {}) as Record<string, unknown>;
}
#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<number> {
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;
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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);
});
});
+4 -1
View File
@@ -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.
+8
View File
@@ -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<string, unknown>)[
+4
View File
@@ -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:<uuid>` before any
@@ -0,0 +1,556 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <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';
describe('TeamService', () => {
let server: PuterServer;
let service: PuterServer['services']['team'];
let owner: { id: number };
let ownerUsername: string;
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `svc_${Math.random().toString(36).slice(2, 10)}`;
const created = (await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
})) as unknown as { id: number };
return { id: created.id, username };
};
const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
/** A workspace with the owner admitted and one provisioned member. */
const makeWorkspace = async () => {
const team = await service.createWorkspace(owner.id, {
name: 'Acme',
handle: freeHandle(),
});
const member = await makeUser();
await server.stores.team.addMember(team.uid, member.id, {
orgOwned: true,
});
return { team, member };
};
const suspensionOf = async (userId: number) => {
const [row] = (await server.clients.db.read(
'SELECT `suspended`, `suspended_at`, `suspended_reason` FROM `user` WHERE `id` = ?',
[userId],
)) as {
suspended: number | null;
suspended_at: number | null;
suspended_reason: string | null;
}[];
return row;
};
beforeAll(async () => {
server = await setupTestServer();
service = server.services.team;
owner = await makeUser();
ownerUsername = (await server.stores.user.getById(owner.id))!.username;
});
afterAll(async () => {
await server?.shutdown();
});
// -- creating a workspace -----------------------------------------
it('creates a workspace and admits its creator as the workspace owner', async () => {
const team = await service.createWorkspace(owner.id, {
name: 'Acme Design',
handle: freeHandle(),
});
expect(team.owner_user_id).toBe(owner.id);
const membership = await server.stores.team.getMembership(
team.uid,
owner.id,
);
// 0 is what makes the owner pay for itself.
expect(Number(membership?.org_owned)).toBe(0);
});
it('holds the owner invariant that no dialect can express', async () => {
const { team } = await makeWorkspace();
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(true);
});
it('breaks the invariant if a second account is admitted as the payer', async () => {
const { team } = await makeWorkspace();
const other = await makeUser();
await server.stores.team.addMember(team.uid, other.id, {
orgOwned: false,
});
// The check exists precisely because the schema cannot refuse this.
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false);
});
// -- authority ----------------------------------------------------
it('refuses a member who is not the workspace owner', async () => {
const { team, member } = await makeWorkspace();
await expect(
service.requireOwner(team.uid, member.id),
).rejects.toMatchObject({ statusCode: 403 });
});
it('gives a stranger 404 rather than 403, so it is not an oracle', async () => {
const { team } = await makeWorkspace();
const stranger = await makeUser();
await expect(
service.requireMembership(team.uid, stranger.id),
).rejects.toMatchObject({ statusCode: 404 });
await expect(
service.requireOwner(team.uid, stranger.id),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses the workspace owner as the target of a member route', async () => {
const { team } = await makeWorkspace();
await expect(
service.requireOrgAccount(team.uid, owner.id),
).rejects.toMatchObject({ statusCode: 404 });
});
// -- disable and re-enable ----------------------------------------
it('sets the column the request gate actually reads', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
const row = await suspensionOf(member.id);
// `userProtected` rejects on `suspended`; the others gate nothing.
expect(Boolean(row.suspended)).toBe(true);
expect(row.suspended_at).toBeGreaterThan(0);
expect(row.suspended_reason).toBe('disabled_by_workspace');
});
it('drops the disabled account\'s sessions', async () => {
const { team, member } = await makeWorkspace();
await server.clients.db.write(
'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)',
[uuidv4(), member.id],
);
await service.disableMember(team.uid, owner.id, member.id);
// Revoked, not deleted: the row keeps last_ip / last_user_agent.
const live = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL',
[member.id],
);
expect(Number(live[0].n)).toBe(0);
const kept = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ?',
[member.id],
);
expect(Number(kept[0].n)).toBe(1);
});
it('restores the account exactly as it was on re-enable', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
await service.enableMember(team.uid, owner.id, member.id);
const row = await suspensionOf(member.id);
expect(Boolean(row.suspended)).toBe(false);
expect(row.suspended_at).toBeNull();
expect(row.suspended_reason).toBeNull();
});
it('leaves the disabled account\'s files alone', async () => {
const { team, member } = await makeWorkspace();
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)',
[uuidv4(), 'kept.txt', member.id, 0],
);
await service.disableMember(team.uid, owner.id, member.id);
const rows = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `fsentries` WHERE `user_id` = ?',
[member.id],
);
expect(Number(rows[0].n)).toBe(1);
});
it('refuses to disable the workspace owner', async () => {
const { team } = await makeWorkspace();
await expect(
service.disableMember(team.uid, owner.id, owner.id),
).rejects.toMatchObject({ statusCode: 404 });
expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
});
it('refuses a disable ordered by someone who is not the owner', async () => {
const { team, member } = await makeWorkspace();
const other = await makeUser();
await server.stores.team.addMember(team.uid, other.id, {
orgOwned: true,
});
await expect(
service.disableMember(team.uid, other.id, member.id),
).rejects.toMatchObject({ statusCode: 403 });
expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
});
it('refuses to disable a member of another workspace', async () => {
const a = await makeWorkspace();
const b = await makeWorkspace();
await expect(
service.disableMember(a.team.uid, owner.id, b.member.id),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses to lift a suspension the workspace did not impose', async () => {
const { team, member } = await makeWorkspace();
// What a platform abuse suspension looks like.
await server.stores.user.update(member.id, {
suspended: 1,
suspended_at: Math.floor(Date.now() / 1000),
suspended_reason: 'abuse_detected',
});
await expect(
service.enableMember(team.uid, owner.id, member.id),
).rejects.toMatchObject({ statusCode: 409 });
const row = await suspensionOf(member.id);
expect(Boolean(row.suspended)).toBe(true);
expect(row.suspended_reason).toBe('abuse_detected');
});
it('lifts its own suspension normally', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
await service.enableMember(team.uid, owner.id, member.id);
expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
});
// -- provisioning -------------------------------------------------
it('creates an account the workspace owns, pending a password change', async () => {
const { team } = await makeWorkspace();
const username = `prov_${Math.random().toString(36).slice(2, 9)}`;
const result = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
expect(result.username).toBe(username);
const user = await server.stores.user.getById(result.userId);
expect(Boolean(user?.requires_password_change)).toBe(true);
const membership = await server.stores.team.getMembership(
team.uid,
result.userId,
);
expect(Number(membership?.org_owned)).toBe(1);
});
it('gives the new account its default filesystem tree', async () => {
const { team } = await makeWorkspace();
const username = `tree_${Math.random().toString(36).slice(2, 9)}`;
const result = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
const user = await server.stores.user.getById(result.userId);
expect(user?.trash_uuid).toBeTruthy();
});
it('returns a temporary password the admin delivers out of band', async () => {
const { team } = await makeWorkspace();
const username = `link_${Math.random().toString(36).slice(2, 9)}`;
const result = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
expect(result.temporaryPassword).toHaveLength(16);
const user = await server.stores.user.getById(result.userId);
// Hashed, and the account cannot do anything until it is changed.
expect(user?.password).not.toBe(result.temporaryPassword);
expect(Boolean(user?.requires_password_change)).toBe(true);
});
it('writes nothing when the username is taken', async () => {
const { team } = await makeWorkspace();
const taken = await makeUser();
const before = await server.stores.team.listMembers(team.uid);
await expect(
service.provisionAccount(team.uid, owner.id, {
username: taken.username,
email: 'someone@test.local',
}),
).rejects.toMatchObject({ statusCode: 409 });
// Checked before any row is written, so the workspace is untouched.
const after = await server.stores.team.listMembers(team.uid);
expect(after.items).toHaveLength(before.items.length);
});
it('offers free alternatives instead of modifying the name', async () => {
const taken = await makeUser();
const suggestions = await service.suggestUsernames(taken.username);
expect(suggestions.length).toBeGreaterThan(0);
for (const suggestion of suggestions) {
expect(suggestion).not.toBe(taken.username);
await expect(
server.stores.user.getByUsername(suggestion),
).resolves.toBeFalsy();
}
});
it('refuses provisioning by anyone but the workspace owner', async () => {
const { team, member } = await makeWorkspace();
await expect(
service.provisionAccount(team.uid, member.id, {
username: `nope_${Math.random().toString(36).slice(2, 9)}`,
email: 'nope@test.local',
}),
).rejects.toMatchObject({ statusCode: 403 });
});
it('re-issues a different credential each time', async () => {
const { team } = await makeWorkspace();
const username = `again_${Math.random().toString(36).slice(2, 9)}`;
const result = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
const again = await service.reissueCredential(
team.uid,
owner.id,
result.userId,
);
expect(again.temporaryPassword).not.toBe(result.temporaryPassword);
});
it('refuses to re-issue once the member has chosen a password', async () => {
const { team } = await makeWorkspace();
const username = `done_${Math.random().toString(36).slice(2, 9)}`;
const result = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
// Simulate the member choosing their own password.
await server.stores.user.update(result.userId, {
requires_password_change: 0,
});
await expect(
service.reissueCredential(team.uid, owner.id, result.userId),
).rejects.toMatchObject({ statusCode: 409 });
});
it('refuses an email that already belongs to an account', async () => {
const { team } = await makeWorkspace();
const existing = await makeUser();
const row = await server.stores.user.getById(existing.id);
await expect(
service.provisionAccount(team.uid, owner.id, {
username: `dup_${Math.random().toString(36).slice(2, 9)}`,
email: row!.email!,
}),
).rejects.toMatchObject({ statusCode: 409 });
});
it('refuses a username signup itself would reject', async () => {
const { team } = await makeWorkspace();
for (const username of ['has-hyphen', 'x'.repeat(46), 'admin']) {
await expect(
service.provisionAccount(team.uid, owner.id, {
username,
email: 'x@test.local',
}),
).rejects.toMatchObject({ statusCode: 400 });
}
});
it('refuses an invalid email', async () => {
const { team } = await makeWorkspace();
await expect(
service.provisionAccount(team.uid, owner.id, {
username: `bad_${Math.random().toString(36).slice(2, 9)}`,
email: 'not-an-email',
}),
).rejects.toMatchObject({ statusCode: 400 });
});
it('only suggests usernames the next call would accept', async () => {
const taken = await makeUser();
for (const s of await service.suggestUsernames(taken.username)) {
expect(s.length).toBeLessThanOrEqual(45);
expect(s).toMatch(/^\w+$/u);
}
});
it('generates a different credential for every account', async () => {
const { team } = await makeWorkspace();
const seen = new Set<string>();
for (let i = 0; i < 3; i++) {
const u = `gen_${Math.random().toString(36).slice(2, 9)}`;
const r = await service.provisionAccount(team.uid, owner.id, {
username: u,
email: `${u}@test.local`,
});
expect(r.temporaryPassword).toMatch(/^[A-Za-z2-9]{16}$/u);
seen.add(r.temporaryPassword);
}
expect(seen.size).toBe(3);
});
it('survives having no email transport, since the notice carries nothing', async () => {
const { team } = await makeWorkspace();
const u = `noem_${Math.random().toString(36).slice(2, 9)}`;
// The credential is returned, so delivery failing loses nothing.
const r = await service.provisionAccount(team.uid, owner.id, {
username: u,
email: `${u}@test.local`,
});
expect(r.temporaryPassword).toBeTruthy();
});
// -- audit --------------------------------------------------------
it('records provisioning, disabling and enabling as they happen', async () => {
const { team } = await makeWorkspace();
const username = `aud_${Math.random().toString(36).slice(2, 9)}`;
const created = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
await service.disableMember(team.uid, owner.id, created.userId);
await service.enableMember(team.uid, owner.id, created.userId);
// Written by the service, so a caller bypassing the route cannot skip it.
const { items: entries } = await service.listAudit(team.uid, owner.id);
const forMember = entries.filter((e) => e.username === username);
expect(forMember.map((e) => e.action)).toEqual([
'enable',
'disable',
'provision',
]);
expect(
forMember.every((e) => e.actor_username === ownerUsername),
).toBe(true);
});
it('shows a member only their own entries', async () => {
const { team } = await makeWorkspace();
const a = `one_${Math.random().toString(36).slice(2, 9)}`;
const b = `two_${Math.random().toString(36).slice(2, 9)}`;
const first = await service.provisionAccount(team.uid, owner.id, {
username: a,
email: `${a}@test.local`,
});
await service.provisionAccount(team.uid, owner.id, {
username: b,
email: `${b}@test.local`,
});
const { items: own } = await service.listOwnAudit(team.uid, first.userId);
expect(own).toHaveLength(1);
expect(own[0].username).toBe(a);
// Internal ids must not reach a caller, as `toClientTeam` does for `id`.
expect(own[0]).not.toHaveProperty('user_id_keep');
expect(own[0]).not.toHaveProperty('actor_user_id');
});
it('keeps the audit from a member who is not the owner', async () => {
const { team, member } = await makeWorkspace();
await expect(
service.listAudit(team.uid, member.id),
).rejects.toMatchObject({ statusCode: 403 });
});
it('records a workspace deletion and survives the soft delete', async () => {
const { team } = await makeWorkspace();
await service.deleteWorkspace(team.uid, owner.id);
// Gone from reads, but its owner can still read what happened.
await expect(server.stores.team.getByUid(team.uid)).resolves.toBeNull();
const { items: entries } = await service.listAudit(team.uid, owner.id);
expect(entries.map((e) => e.action)).toContain('delete_team');
});
it('disables the accounts it created when the workspace is deleted', async () => {
const { team } = await makeWorkspace();
const u = `del_${Math.random().toString(36).slice(2, 9)}`;
const provisioned = await service.provisionAccount(team.uid, owner.id, {
username: u,
email: `${u}@test.local`,
});
await service.deleteWorkspace(team.uid, owner.id);
// Otherwise they keep working, unreachable through a deleted workspace.
const row = await suspensionOf(provisioned.userId);
expect(Boolean(row.suspended)).toBe(true);
expect(row.suspended_reason).toBe('disabled_by_workspace');
});
it('leaves the workspace owner alone when its workspace is deleted', async () => {
const { team } = await makeWorkspace();
await service.deleteWorkspace(team.uid, owner.id);
// org_owned = 0, so it pays for itself and is not the workspace's to close.
expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
});
it('pages the audit rather than truncating it', async () => {
const { team } = await makeWorkspace();
for (let i = 0; i < 2; i++) {
const u = `pg_${Math.random().toString(36).slice(2, 9)}`;
await service.provisionAccount(team.uid, owner.id, {
username: u,
email: `${u}@test.local`,
});
}
const first = await service.listAudit(team.uid, owner.id, { limit: 1 });
expect(first.items).toHaveLength(1);
expect(first.cursor).toBeTruthy();
// Older entries stay reachable instead of dropping off the view.
const second = await service.listAudit(team.uid, owner.id, { limit: 1 });
expect(second.items).toHaveLength(1);
});
});
+563
View File
@@ -0,0 +1,563 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto';
import validator from 'validator';
import { v4 as uuidv4 } from 'uuid';
import {
RESERVED_USERNAMES,
USERNAME_MAX_LENGTH,
USERNAME_REGEX,
} from '../../controllers/auth/AuthController.js';
import { HttpError } from '../../core/http/HttpError.js';
import { checkHandle } from '../../stores/team/TeamStore.js';
import type {
TeamAuditRow,
TeamMemberRow,
TeamRow,
} from '../../stores/team/TeamStore';
import type { UserRow } from '../../stores/user/UserStore';
import { cleanEmail } from '../../util/email.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { PuterService } from '../types';
/** Unambiguous alphabet -- no 0/O or 1/l, since a human retypes this. */
const TEMP_PASSWORD_ALPHABET =
'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
/** ~95 bits, generated rather than chosen so it is never a reused pattern. */
export const generateTemporaryPassword = (length = 16): string => {
const bytes = randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += TEMP_PASSWORD_ALPHABET[bytes[i] % TEMP_PASSWORD_ALPHABET.length];
}
return out;
};
/** Why an account was disabled. Free text in `0063`; this is the team one. */
export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace';
export class TeamService extends PuterService {
// -- Authority ---- the whole authorization model ------------------
/** 404 to a non-member so the endpoint is not an existence oracle. */
async requireMembership(
teamUid: string,
actorUserId: number,
): Promise<TeamRow> {
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<TeamRow> {
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<TeamMemberRow> {
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<void> {
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<T>(run: () => Promise<T>): Promise<T> {
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<TeamRow> {
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<TeamRow> {
const handle = input.handle ?? null;
if (handle !== null) await this.assertHandleUsable(handle);
const team = await this.#asHttpErrors(() =>
this.stores.team.create({
ownerUserId,
name: input.name,
handle,
}),
);
// 0 makes the owner pay for itself; unchecked, it is unreachable.
const admitted = await this.stores.team.addMember(
team.uid,
ownerUserId,
{
orgOwned: false,
},
);
if (!admitted) {
await this.stores.team.softDelete(team.uid);
throw new HttpError(500, 'Could not create the workspace', {
legacyCode: 'internal_error',
});
}
return team;
}
/** The owner is a member with `org_owned = 0`, and the only such member. */
async checkOwnerInvariant(teamUid: string): Promise<boolean> {
const team = await this.stores.team.getByUid(teamUid);
if (!team) return false;
const owner = await this.stores.team.getMembership(
teamUid,
team.owner_user_id,
);
if (!owner || Number(owner.org_owned) !== 0) return false;
const rows = (await this.clients.db.read(
'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
'WHERE `group_id` = ? AND `org_owned` = 0',
[team.id],
)) as { n: number }[];
return Number(rows[0]?.n) === 1;
}
/** Soft delete disables the accounts it created; recovery is via support. */
async deleteWorkspace(teamUid: string, actorUserId: number): Promise<void> {
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<TeamRow> {
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<number>();
for (const row of page.items) {
ids.add(row.user_id_keep);
if (row.actor_user_id !== null) ids.add(row.actor_user_id);
}
const users = await this.stores.user.getByIds([...ids]);
const name = (id: number | null) =>
id === null ? null : (users.get(id)?.username ?? null);
return {
items: page.items.map((row) => ({
action: row.action,
reason: row.reason,
created_at: row.created_at,
username: name(row.user_id_keep),
actor_username: name(row.actor_user_id),
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
}
// -- Provisioning ---- usernames come from the global pool ----------
/** Provisioning must not mint accounts signup itself would refuse. */
#usernameRejection(username: string): boolean {
return (
!USERNAME_REGEX.test(username) ||
username.length > USERNAME_MAX_LENGTH ||
RESERVED_USERNAMES.has(username.toLowerCase())
);
}
#assertUsableUsername(username: string): void {
if (this.#usernameRejection(username)) {
throw new HttpError(400, 'Invalid username', {
legacyCode: 'bad_request',
});
}
}
/** Free names near `username`, checked for availability. Never auto-applied. */
async suggestUsernames(username: string, count = 3): Promise<string[]> {
// Room for the suffix, or every suggestion breaks the length rule.
const base = username.slice(0, USERNAME_MAX_LENGTH - 3);
const found: string[] = [];
for (let n = 1; n <= 40 && found.length < count; n++) {
const candidate = `${base}${n}`;
if (this.#usernameRejection(candidate)) continue;
if (!(await this.stores.user.getByUsername(candidate))) {
found.push(candidate);
}
}
return found;
}
/** Returns the activation link; the account has no password until used. */
async provisionAccount(
teamUid: string,
actorUserId: number,
input: { username: string; email: string },
): Promise<{
userId: number;
username: string;
temporaryPassword: string;
}> {
const team = await this.requireOwner(teamUid, actorUserId);
this.#assertUsableUsername(input.username);
if (!validator.isEmail(input.email)) {
throw new HttpError(400, 'Invalid email', {
legacyCode: 'bad_request',
});
}
// Before any write, so a taken name fails cleanly.
if (await this.stores.user.getByUsername(input.username)) {
throw new HttpError(409, 'That username is taken', {
legacyCode: 'username_already_in_use',
fields: {
suggestions: await this.suggestUsernames(input.username),
},
});
}
// `idx_user_owned_email` is partial and skips password-null rows.
if (await this.stores.user.findEmailOwner(input.email)) {
throw new HttpError(409, 'That email is already in use', {
legacyCode: 'email_already_in_use',
});
}
const user = await this.stores.user.create({
username: input.username,
uuid: uuidv4(),
password: null,
email: input.email,
clean_email: cleanEmail(input.email),
// The address came from the administrator, not its holder.
requires_email_confirmation: true,
});
await generateDefaultFsentries(this.clients.db, this.stores.user, user);
// Otherwise a vanished workspace leaves a real account nobody owns.
const admitted = await this.stores.team.addMember(teamUid, user.id, {
orgOwned: true,
});
if (!admitted) {
await this.services.userAccount.cascadeDelete(user.id);
throw new HttpError(409, 'That workspace is no longer available', {
legacyCode: 'conflict',
});
}
await this.stores.team.appendAudit({
teamId: team.id,
userId: user.id,
actorUserId,
action: 'provision',
});
// Returned once; forced change on first use is what bounds it.
const temporaryPassword = generateTemporaryPassword();
await this.stores.user.update(user.id, {
password: await bcrypt.hash(temporaryPassword, 8),
requires_password_change: 1,
});
await this.#notifyAccountCreated(user, team);
return {
userId: user.id,
username: user.username,
temporaryPassword,
};
}
/** Issues a fresh credential, invalidating the previous one. */
async reissueCredential(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<{ temporaryPassword: string }> {
const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
if (!user) {
throw new HttpError(404, 'Account not found', {
legacyCode: 'not_found',
});
}
// Only before first use; changing a live account's password is reset.
if (!user.requires_password_change) {
throw new HttpError(409, 'That account is already activated', {
legacyCode: 'conflict',
});
}
const temporaryPassword = generateTemporaryPassword();
await this.stores.user.update(targetUserId, {
password: await bcrypt.hash(temporaryPassword, 8),
requires_password_change: 1,
});
await this.#notifyAccountCreated(user, team);
return { temporaryPassword };
}
/** A notice only -- it carries no credential, so delivery is best effort. */
async #notifyAccountCreated(user: UserRow, team: TeamRow): Promise<void> {
if (!this.clients.email || !user.email) return;
try {
const sent = await this.clients.email.send(
user.email,
'team_account_created',
{
username: user.username,
team_name: team.name ?? 'Your workspace',
},
);
// `sendRaw` returns null with no transport rather than throwing.
if (sent === null) {
console.warn('[team-provision] no email transport configured');
}
} catch (e) {
console.warn('[team-provision] notice failed:', e);
}
}
// -- Disable and re-enable ---- the whole of offboarding ------------
/** Rejects the account's next request; its files are untouched. */
async disableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// Recorded first: a failed append must not leave an unlogged suspension.
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: 'disable',
});
await this.#suspend(targetUserId);
}
/** Nothing was destroyed, so the account returns as it was. */
async enableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// Forced read, as `userProtected` does: a cached row predates this.
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
// Only the workspace's own suspension; a platform one must not lift.
if (
user?.suspended &&
user.suspended_reason !== DISABLED_BY_WORKSPACE
) {
throw new HttpError(409, 'That account was suspended by Puter', {
legacyCode: 'conflict',
});
}
await this.stores.team.appendAudit({
teamId: team.id,
userId: targetUserId,
actorUserId,
action: 'enable',
});
await this.stores.user.update(targetUserId, {
suspended: 0,
suspended_at: null,
suspended_reason: null,
});
await this.stores.user.invalidateById(targetUserId);
}
/** The three columns together; `suspended` is the one that gates requests. */
async #suspend(userId: number): Promise<void> {
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<void> {
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);
}
}
@@ -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]);
});
});
+5 -12
View File
@@ -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<void> {
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<void> {
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})`,
+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,
+469
View File
@@ -0,0 +1,469 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <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');
});
// -- 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();
});
});
+482
View File
@@ -0,0 +1,482 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import {
encodeCursor,
decodeCursor,
normalizeLimit,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types';
/** A workspace: a `group` row with `kind = 'team'`. */
export interface TeamRow {
id: number;
uid: string;
owner_user_id: number;
kind: string;
name: string | null;
handle: string | null;
deleted_at: string | null;
created_at: string;
}
export const TEAM_KIND = 'team';
/** A membership row, joined to the member's username. */
/** A seat, joined to the workspace that pays for it. */
export interface OrgSeatRow {
id: number;
user_id: number;
uuid: string;
username: string;
team_uid: string;
owner_user_id: number;
}
export interface TeamMemberRow {
id: number;
user_id: number;
group_id: number;
username: string;
org_owned: number;
created_at: string;
}
/** One entry in the insert-only record of what a workspace did to an account. */
export interface TeamAuditRow {
id: number;
user_id_keep: number;
actor_user_id: number | null;
action: string;
reason: string | null;
created_at: string;
}
/** Default and ceiling for `listMembers`, matching the other paginated stores. */
export const MEMBER_PAGE_SIZE = 50;
export const MEMBER_PAGE_CAP = 200;
export const AUDIT_PAGE_SIZE = 50;
export const AUDIT_PAGE_CAP = 200;
/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */
export const HANDLE_MAX_LENGTH = 64;
export const HANDLE_MIN_LENGTH = 3;
/** Mysql declares `name varchar(255)`; sqlite and postgres use TEXT. */
export const NAME_MAX_LENGTH = 255;
/** Narrower than the column, so the engines' collations cannot disagree. */
const HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
/** A handle reaching users in email makes an impersonation more convincing. */
const RESERVED_HANDLES = new Set([
'about',
'account',
'admin',
'administrator',
'api',
'app',
'apps',
'billing',
'blog',
'ceo',
'contact',
'dashboard',
'dev',
'developer',
'docs',
'help',
'internal',
'legal',
'login',
'mail',
'moderator',
'official',
'owner',
'payment',
'payments',
'puter',
'puter-support',
'puterteam',
'root',
'security',
'settings',
'signup',
'staff',
'status',
'support',
'system',
'team',
'teams',
'trust',
'trust-safety',
'verify',
'workspace',
'workspaces',
]);
export type HandleRejection =
'too_short' | 'too_long' | 'malformed' | 'reserved';
/** Trimmed and capped, so the same name is accepted on every engine. */
export const normalizeTeamName = (name: string): string => {
const trimmed = name.trim();
if (trimmed === '') throw new Error('team name is required');
if (trimmed.length > NAME_MAX_LENGTH)
throw new Error('team name is too long');
return trimmed;
};
/** Why `handle` is unusable, or null when it is fine. */
export const checkHandle = (handle: string): HandleRejection | null => {
if (handle.length < HANDLE_MIN_LENGTH) return 'too_short';
if (handle.length > HANDLE_MAX_LENGTH) return 'too_long';
if (!HANDLE_PATTERN.test(handle)) return 'malformed';
if (RESERVED_HANDLES.has(handle)) return 'reserved';
return null;
};
export class TeamStore extends PuterStore {
/** Postgres indexes lower(handle); the others are already insensitive. */
#handleMatch(): string {
return this.clients.db.case({
postgres: 'lower(`handle`) = lower(?)',
otherwise: '`handle` = ?',
});
}
/** Makes the seeded `kind IS NULL` groups unreachable, not merely absent. */
#live(): string {
return '`kind` = ? AND `deleted_at` IS NULL';
}
// -- Reads --------------------------------------------------------
/** The workspace with this uid, or null. Soft-deleted ones are excluded. */
async getByUid(uid: string): Promise<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;
}
/** Includes soft-deleted rows, so an audit survives its workspace. */
async getByUidIncludingDeleted(uid: string): Promise<TeamRow | null> {
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<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`. */
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;
}
// -- Membership ---- sole writer of team rows -----------------------
/** The membership row for this user in this workspace, or null. */
async getMembership(
teamUid: string,
userId: number,
): Promise<TeamMemberRow | null> {
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<boolean> {
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<PageResult<TeamMemberRow>> {
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<TeamRow[]> {
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<boolean> {
// Kind-filtered subquery, so a non-team uid inserts nothing.
const result = await this.clients.db.write(
`${this.clients.db.insertIgnoreInto('jct_user_group')} ` +
'(`user_id`, `group_id`, `org_owned`) ' +
'SELECT ?, g.`id`, ? FROM `group` g ' +
`WHERE g.\`uid\` = ? AND g.${this.#live()}` +
this.clients.db.insertIgnoreSuffix(),
// Not `booleanValue`: it yields a real boolean on postgres.
[userId, opts.orgOwned ? 1 : 0, teamUid, TEAM_KIND],
);
return result.anyRowsAffected;
}
/** How many members pay for themselves; the owner should be the only one. */
async countPayers(teamId: number): Promise<number> {
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<void> {
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<PageResult<TeamAuditRow>> {
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<PageResult<TeamAuditRow>> {
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<PageResult<TeamAuditRow>> {
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<boolean> {
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<OrgSeatRow | null> {
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;
}
}
+10 -7
View File
@@ -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<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];