import type { Handler } from 'hono'; import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb'; import type { UserRole } from 'officerdb'; import * as errors from '@@/custom-errors'; import { deprovisionOsAccount } from '@@/os-user-deprovision'; import { DATA_PATH } from '@@/data-path'; // Owner-only management of the other accounts. Everything here is gated by ownerGate in // users-router.ts; these handlers assume the caller is the Super Admin. // // The password hash never leaves this file — listing users is not a reason to hand them out. type PublicUser = { id: number; email: string; name: string | null; username: string | null; avatar: string | null; status: string; role: string; createdAt: Date; /** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */ isOwner: boolean; /** The Linux account this runs as, or null where per-user OS accounts are off. */ osUser: string | null; /** * The public half of their generated SSH key. Listed because it has an errand attached — it must be * added to their Gitea account — and the create form promises it is retrievable here afterwards. Public * by definition, so no reason to withhold it from the owner-only endpoint that already returns emails. */ osSshPublicKey: string | null; }; /** Shared with create-user.ts, so a created account and a listed one are described the same way. */ export const toPublicUser = (u: Awaited>[number]): PublicUser => ({ id: u.id, email: u.email, name: u.name, username: u.username, avatar: u.avatar, status: u.status, role: u.role, createdAt: u.createdAt, isOwner: u.id === OWNER_USER_ID, osUser: u.osUser, osSshPublicKey: u.osSshPublicKey, }); export const listUsersHandler: Handler = async function (ctx) { const users = await getUsers(); return ctx.json({ users: users.sort((a, b) => a.id - b.id).map(toPublicUser), // Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a // picker — both write paths refuse it — which is what `assignableRoles` is for. roles: USER_ROLES, assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'), ownerId: OWNER_USER_ID, }); }; export const updateUserRoleHandler: Handler = async function (ctx) { const id = Number(ctx.req.param('id')); if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id'); const { role } = ctx.get('body') as { role?: unknown }; if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) { throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`); } // ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the // message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person // clicking the dropdown nothing about why. if (id === OWNER_USER_ID && role !== 'Super Admin') { throw errors.FORBIDDEN('The server owner cannot be demoted.'); } // And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a // second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the // owner by that role, so two holders make "who owns this server" a question the query plan answers. // It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the // server over is a deliberate act, not a dropdown. if (id !== OWNER_USER_ID && role === 'Super Admin') { throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.'); } const existing = await getUserById(id); if (!existing) throw errors.NOT_FOUND('User not found'); const updated = await updateUser(id, { role: role as UserRole }); if (!updated) throw errors.NOT_FOUND('User not found'); return ctx.json({ user: toPublicUser(updated) }); }; export const deleteUserHandler: Handler = async function (ctx) { const id = Number(ctx.req.param('id')); if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id'); // deleteUser() throws for the owner regardless; this turns it into a 403 with a sentence rather than // an unhandled error. if (id === OWNER_USER_ID) throw errors.FORBIDDEN('The server owner cannot be removed.'); const existing = await getUserById(id); if (!existing) throw errors.NOT_FOUND('User not found'); // ── The Linux side goes FIRST, and its failure stops the delete ── // // This used to be the whole handler: remove the row, cascade the database, done. Measured on the // production host on 2026-08-12, immediately after deleting a member through this route: their Linux // account was still alive with a working login shell, their rootless Docker daemon was still running a // healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten // they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to // the next member along with everything still owned by it. // // Ordered this way round because the row is what remembers there is anything to clean up. Delete it // first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry // against. Keeping the account on failure is also the safer half of the trade — an account that still // exists is inert, whereas a freed uid whose files still carry it is the hazard itself. if (existing.osUser) { const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser }); if (!deprovisioned.ok) { // Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage // and the freed range — which after a failed release is the only surviving record of it. console.error( `[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` + (deprovisioned.freed ? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` + ` ${deprovisioned.freed.subUid?.count ?? ''}` : ''), ); throw errors.INTERNAL_SERVER_ERROR( `Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` + `The platform account was NOT deleted, so this can be retried.`, ); } for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`); if (deprovisioned.freed) { // The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after // `userdel` this log is the only place the freed subuid range still exists. // // DATA_PATH is spelled out rather than left to the operator, because this line exists to be COPIED // and sudo's env_reset drops it — the version without it fell back to a hardcoded default and made // the checker report CLEAN without reading a single member tree. const { osUser, uid, subUid } = deprovisioned.freed; const range = subUid ? `${subUid.start} ${subUid.count}` : null; console.info( range ? `[users] deprovisioned ${osUser} — verify with: sudo DATA_PATH=${DATA_PATH} ` + `./scripts/assert-uid-free.sh --check ${osUser} ${uid} ${range}` : `[users] deprovisioned ${osUser} (uid ${uid}) — it had no /etc/subuid range, so only the uid ` + `half is verifiable: sudo DATA_PATH=${DATA_PATH} ./scripts/assert-uid-free.sh --check ` + `${osUser} ${uid} will refuse without real numbers`, ); } } // Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed // to them. There is no undo, which is why the UI asks first. await deleteUser(id); return ctx.json({ ok: true }); };