feat: add TeamService workspace lifecycle and disable/re-enable

Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master
account, and the whole of offboarding.

`createWorkspace` admits the creator with org_owned = 0, which is what makes
the master pay for itself and stay an invalid target of every member route.
`checkOwnerInvariant` asserts the rule no dialect can express -- the owner is
a member with org_owned = 0 and the only such member -- and a test breaks it
deliberately, since the schema cannot refuse a second one.

Three authority checks: 404 to a stranger so the endpoint is not an existence
oracle, 403 to a member who is not the master, and the master refused as a
target of member routes.

Handle problems surface as 400 (unusable) or 409 (taken), including the
unique-index race. `TeamStore` throws a bare Error, which the server would turn
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable writes `user.suspended` as well as suspended_at and suspended_reason.
PUT-1707 named only the latter two, but those are siblings added by 0061 and
0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither.
Setting only the timestamp and reason would have recorded a disable that never
took effect, and disable is the whole of offboarding here.

Sessions are dropped through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key with its double-delete
pattern; without that a disabled member keeps authenticating from cache for the
session TTL, which is exactly the "next request, not after a cache TTL"
property disable is supposed to have. Revoking also preserves last_ip and
last_user_agent, which the member-facing audit view reads.

Files are untouched and re-enable restores the account.

Adds team_not_found, not_the_master_account and not_an_org_account to the
HttpError legacy codes, which the controller also needs.

Billing events, invalidateActorSubscription, audit rows and the GUI push are
deliberately not here -- they belong to phase 3 and PUT-1708.
This commit is contained in:
Juan Castro
2026-09-03 15:57:23 -04:00
parent 8b4e259421
commit da2b42d7fe
4 changed files with 517 additions and 1 deletions
+4 -1
View File
@@ -54,7 +54,10 @@ export type LegacyErrorCodes =
| 'password_mismatch'
| 'field_not_allowed_for_create'
| 'account_is_not_verified'
| 'app_or_api_token_required';
| 'app_or_api_token_required'
| 'team_not_found'
| 'not_the_workspace_owner'
| 'not_an_org_account';
/**
* Copyright (C) 2024-present Puter Technologies Inc.
+4
View File
@@ -40,6 +40,7 @@ import { PermissionService } from './permission/PermissionService';
import { DefaultUserService } from './selfhosted/DefaultUserService';
import { ShareNotificationService } from './share/ShareNotificationService';
import { ShareService } from './share/ShareService';
import { TeamService } from './team/TeamService';
import { SocketService } from './socket/SocketService';
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
import type { IPuterServiceRegistry } from './types';
@@ -79,6 +80,7 @@ declare module './types' {
homepage: PuterHomepageService;
health: ServerHealthService;
userAccount: UserAccountService;
team: TeamService;
}
}
@@ -110,6 +112,8 @@ export const puterServices = {
// Declared after `fs` — account teardown tears the user's filesystem down
// first.
userAccount: UserAccountService,
// Leaf: team + user stores only.
team: TeamService,
// AppPermissionService + SubdomainPermissionService register permission
// rewriters/implicators only; no runtime state. Placed after fsEntry so
// the FS rewriter runs first for `fs:/path` → `fs:<uuid>` before any
@@ -0,0 +1,260 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <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 };
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `svc-${Math.random().toString(36).slice(2, 10)}`;
const created = (await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
})) as unknown as { id: number };
return { id: created.id, username };
};
const freeHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
/** A workspace with the owner admitted and one provisioned member. */
const makeWorkspace = async () => {
const team = await service.createWorkspace(owner.id, {
name: 'Acme',
handle: freeHandle(),
});
const member = await makeUser();
await server.stores.team.addMember(team.uid, member.id, {
orgOwned: true,
});
return { team, member };
};
const suspensionOf = async (userId: number) => {
const [row] = (await server.clients.db.read(
'SELECT `suspended`, `suspended_at`, `suspended_reason` FROM `user` WHERE `id` = ?',
[userId],
)) as {
suspended: number | null;
suspended_at: number | null;
suspended_reason: string | null;
}[];
return row;
};
beforeAll(async () => {
server = await setupTestServer();
service = server.services.team;
owner = await makeUser();
});
afterAll(async () => {
await server?.shutdown();
});
// -- creating a workspace -----------------------------------------
it('creates a workspace and admits its creator as the workspace owner', async () => {
const team = await service.createWorkspace(owner.id, {
name: 'Acme Design',
handle: freeHandle(),
});
expect(team.owner_user_id).toBe(owner.id);
const membership = await server.stores.team.getMembership(
team.uid,
owner.id,
);
// 0 is what makes the owner pay for itself.
expect(Number(membership?.org_owned)).toBe(0);
});
it('holds the owner invariant that no dialect can express', async () => {
const { team } = await makeWorkspace();
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(true);
});
it('breaks the invariant if a second account is admitted as the payer', async () => {
const { team } = await makeWorkspace();
const other = await makeUser();
await server.stores.team.addMember(team.uid, other.id, {
orgOwned: false,
});
// The check exists precisely because the schema cannot refuse this.
await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false);
});
// -- authority ----------------------------------------------------
it('refuses a member who is not the workspace owner', async () => {
const { team, member } = await makeWorkspace();
await expect(
service.requireOwner(team.uid, member.id),
).rejects.toMatchObject({ statusCode: 403 });
});
it('gives a stranger 404 rather than 403, so it is not an oracle', async () => {
const { team } = await makeWorkspace();
const stranger = await makeUser();
await expect(
service.requireMembership(team.uid, stranger.id),
).rejects.toMatchObject({ statusCode: 404 });
await expect(
service.requireOwner(team.uid, stranger.id),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses the workspace owner as the target of a member route', async () => {
const { team } = await makeWorkspace();
await expect(
service.requireOrgAccount(team.uid, owner.id),
).rejects.toMatchObject({ statusCode: 404 });
});
// -- disable and re-enable ----------------------------------------
it('sets the column the request gate actually reads', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
const row = await suspensionOf(member.id);
// `userProtected` rejects on `suspended`; the other two are its
// siblings and do not gate anything on their own.
expect(Boolean(row.suspended)).toBe(true);
expect(row.suspended_at).toBeGreaterThan(0);
expect(row.suspended_reason).toBe('disabled_by_workspace');
});
it('drops the disabled account\'s sessions', async () => {
const { team, member } = await makeWorkspace();
await server.clients.db.write(
'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)',
[uuidv4(), member.id],
);
await service.disableMember(team.uid, owner.id, member.id);
// Revoked, not deleted: the row keeps `last_ip` / `last_user_agent`,
// which the member-facing audit view reads.
const live = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL',
[member.id],
);
expect(Number(live[0].n)).toBe(0);
const kept = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `sessions` WHERE `user_id` = ?',
[member.id],
);
expect(Number(kept[0].n)).toBe(1);
});
it('restores the account exactly as it was on re-enable', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
await service.enableMember(team.uid, owner.id, member.id);
const row = await suspensionOf(member.id);
expect(Boolean(row.suspended)).toBe(false);
expect(row.suspended_at).toBeNull();
expect(row.suspended_reason).toBeNull();
});
it('leaves the disabled account\'s files alone', async () => {
const { team, member } = await makeWorkspace();
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)',
[uuidv4(), 'kept.txt', member.id, 0],
);
await service.disableMember(team.uid, owner.id, member.id);
const rows = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `fsentries` WHERE `user_id` = ?',
[member.id],
);
expect(Number(rows[0].n)).toBe(1);
});
it('refuses to disable the workspace owner', async () => {
const { team } = await makeWorkspace();
await expect(
service.disableMember(team.uid, owner.id, owner.id),
).rejects.toMatchObject({ statusCode: 404 });
expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
});
it('refuses a disable ordered by someone who is not the owner', async () => {
const { team, member } = await makeWorkspace();
const other = await makeUser();
await server.stores.team.addMember(team.uid, other.id, {
orgOwned: true,
});
await expect(
service.disableMember(team.uid, other.id, member.id),
).rejects.toMatchObject({ statusCode: 403 });
expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
});
it('refuses to disable a member of another workspace', async () => {
const a = await makeWorkspace();
const b = await makeWorkspace();
await expect(
service.disableMember(a.team.uid, owner.id, b.member.id),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses to lift a suspension the workspace did not impose', async () => {
const { team, member } = await makeWorkspace();
// What a platform abuse suspension looks like.
await server.stores.user.update(member.id, {
suspended: 1,
suspended_at: Math.floor(Date.now() / 1000),
suspended_reason: 'abuse_detected',
});
await expect(
service.enableMember(team.uid, owner.id, member.id),
).rejects.toMatchObject({ statusCode: 409 });
const row = await suspensionOf(member.id);
expect(Boolean(row.suspended)).toBe(true);
expect(row.suspended_reason).toBe('abuse_detected');
});
it('lifts its own suspension normally', async () => {
const { team, member } = await makeWorkspace();
await service.disableMember(team.uid, owner.id, member.id);
await service.enableMember(team.uid, owner.id, member.id);
expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false);
});
});
+249
View File
@@ -0,0 +1,249 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { checkHandle } from '../../stores/team/TeamStore.js';
import type { TeamMemberRow, TeamRow } from '../../stores/team/TeamStore';
import { PuterService } from '../types';
/** Why an account was disabled. Free text in `0063`; this is the team one. */
export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace';
export class TeamService extends PuterService {
// -- Authority ----------------------------------------------------
// Two checks, and between them the whole authorization model.
/** 404 to a non-member so the endpoint is not an existence oracle. */
async requireMembership(
teamUid: string,
actorUserId: number,
): Promise<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 and stay an invalid route target.
// Unchecked, the owner could never reach their own workspace.
const admitted = await this.stores.team.addMember(
team.uid,
ownerUserId,
{
orgOwned: false,
},
);
if (!admitted) {
await this.stores.team.softDelete(team.uid);
throw new HttpError(500, 'Could not create the workspace', {
legacyCode: 'internal_error',
});
}
return team;
}
/** The owner is a member with `org_owned = 0`, and the only such member. */
async checkOwnerInvariant(teamUid: string): Promise<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;
}
// -- Disable and re-enable ----------------------------------------
// The whole of offboarding: no removal, no transfer, no retention clock.
/** Rejects the account's next request; its files are untouched. */
async disableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// `suspended` is what `userProtected` enforces; the others are siblings.
await this.stores.user.update(targetUserId, {
suspended: 1,
suspended_at: Math.floor(Date.now() / 1000),
suspended_reason: DISABLED_BY_WORKSPACE,
});
await this.#dropSessions(targetUserId);
}
/** Nothing was destroyed, so the account returns as it was. */
async enableMember(
teamUid: string,
actorUserId: number,
targetUserId: number,
): Promise<void> {
await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
// Forced read, as `userProtected` does: a cached row predates this.
const user = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
// Only the workspace's own suspension; a platform one must not lift.
if (
user?.suspended &&
user.suspended_reason !== DISABLED_BY_WORKSPACE
) {
throw new HttpError(409, 'That account was suspended by Puter', {
legacyCode: 'conflict',
});
}
await this.stores.user.update(targetUserId, {
suspended: 0,
suspended_at: null,
suspended_reason: null,
});
await this.stores.user.invalidateById(targetUserId);
}
/** Via the store: a raw DELETE leaves the session cache serving the row. */
async #dropSessions(userId: number): Promise<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);
}
}