mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-12 00:05:38 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user