mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 05:55:42 +00:00
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:
19 files changed
+3054
-24
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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>)[
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>[];
|
||||
|
||||
Reference in new issue
Block a user