Run computers with resources and health checks, add settings for bots, workspaces, computers and users

- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and
  editable; new resources stop, remove and recreate the container, its home
  dir kept. Every computer container has a docker health check and stays
  "creating" until it passes; only healthy computers are reached, by
  hostname, else by their IP on the network (dev outside docker).
- Docker is required: the app creates the cobble network on startup and
  exits without docker.
- Production container: deploy/run-app-container.sh mounts .env, the data
  dir and the docker socket; the database (DB_DIR), media, computers' homes
  and bun-sqlite's live schema now live on the data volume.
- Settings pages for bots, workspaces, computers and users, sharing list
  components; bots can be deleted; users are created with a password and
  confirmation, edited and deleted through RBAC-gated APIs.
- User types trimmed to super_admin, admin, member, read_only and revoked;
  admins manage members. UserAvatar shows profile photos from the user row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
+51
View File
@@ -0,0 +1,51 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteBot from "@/src/functions/backend/db/bots/delete-bot";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Deletes one of the session user's worker bots and everything it had
* (conversations, tasks, models). The coordinator stays.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
const bot = await deleteBot({ user_id: user.id, bot_id });
await logEvent({
title: "Bot deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the bot ${bot.name}.`,
metadata: { bot_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${bot.name || "The bot"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -42,13 +42,19 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus
throw new AppError(`Only a super admin can do that.`);
}
const { name, description, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
const { name, description, cpus, memory_mb, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
throw new AppError(`Workspace not found.`);
}
const { computer_id } = await createComputer({ user_id: user.id, name, description });
const { computer_id } = await createComputer({
user_id: user.id,
name,
description,
cpus,
memory_mb,
});
if (workspace_id) {
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
@@ -0,0 +1,67 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import updateComputer from "@/src/functions/backend/db/computers/update-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { ComputerWithStatus } from "@/src/types";
import { UpdateComputerRequestSchema } from "@/src/schemas/computer-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Updates a computer of the session user (super admins only: each one is
* a container on this host). New resources (`cpus`, `memory_mb`) recreate
* its container in the background, its files kept; its state arrives as
* `server:computer-updated`. Returns the computer.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
if (!isPassThroughUser({ user_types })) {
throw new AppError(`Only a super admin can do that.`);
}
const { computer_id, ...data } = parseWithSchema({ schema: UpdateComputerRequestSchema, data: body });
const { recreating } = await updateComputer({ user_id: user.id, computer_id, data });
const computer = (await grabUserComputers({ computer_id }))[0];
const name = computer?.name || "The computer";
await logEvent({
title: "Computer updated",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} updated the computer ${name}.`,
metadata: { computer_id, ...data, recreating },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: recreating ? `${name} is restarting with its new settings.` : `${name} saved.`,
singleRes: recreating ? { ...computer, status: "creating", error: null } : computer,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+50
View File
@@ -0,0 +1,50 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deleteUser from "@/src/functions/backend/db/users/delete-user";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { UserIdRequestSchema } from "@/src/schemas/user-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Deletes a user and everything that's theirs, when the actor could
* revoke them (`canRevokeUser`), never themselves.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { user_id } = parseWithSchema({ schema: UserIdRequestSchema, data: body });
const deleted = await deleteUser({ actor_user_id: user.id, actor_user_types: user_types || [], user_id });
await logEvent({
title: "User deleted",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} deleted user #${user_id} (${deleted.email}).`,
metadata: { user_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: `${deleted.first_name || "The user"} is deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+96
View File
@@ -0,0 +1,96 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import saveUser from "@/src/functions/backend/db/users/save-user";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import canCreateUser from "@/src/utils/rbac/can-create-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import parseUserTypes from "@/src/utils/parse-user-types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
* name, email, username, password and user types, each type checked
* against the assignment matrix. A new user gets their coordinator and main computer
* straight away, as at signup. Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = parseWithSchema({ schema: SaveUserRequestSchema, data: body });
const target = data.user_id ? (await grabUsers({ user_id: data.user_id })).singleRes : null;
const allowed = data.user_id
? canUpdateUser({
actor_user_types: user_types,
actor_user_id: user.id,
target_user_id: data.user_id,
target_user_types: parseUserTypes({ user_types: target?.user_types }).map((user_type) => ({ user_type })),
update_fields: target && target.email != data.email.trim().toLowerCase() ? ["email"] : [],
})
: canCreateUser({ user_types });
if (!allowed) {
throw new AppError(`You can't do that.`);
}
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
if (created) {
/**
* The account exists now; `/admin/bots` creates both on their
* first visit if this fails.
*/
await ensureCoordinatorBot({ user_id })
.then((coordinator_id) => ensureMainComputer({ user_id, coordinator_id }))
.catch((error) =>
logError({
title: "Coordinator or main computer not created with the user",
error,
user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
}
await logEvent({
title: created ? "User created" : "User updated",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} ${created ? "created" : "updated"} user #${user_id}.`,
metadata: { user_id, user_types: data.user_types, password_changed: Boolean(data.password) },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: created ? `${data.first_name} can sign in now.` : `Saved.`,
singleRes: (await grabUsers({ user_id })).singleRes,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};