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 { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres'; import { lookupOsUser } from '@@/os-user'; 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'); // ── The database role follows the platform role ── // // Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at // creation and then never look again, so demoting one would leave their Postgres role, their databases // and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than // not having gated it, because the UI then says something untrue. // // REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so // the account still says Developer and the whole thing can be retried. Doing it the other way round would // leave a Member holding database access with nothing in the row to indicate it. const had = rolePermitsDevTools(existing.role); const wants = rolePermitsDevTools(role as UserRole); if (had && !wants && existing.osUser) { const dropped = await dropPostgresRole(existing.osUser); if (!dropped.ok) { console.error(`[users] could not revoke database access for ${existing.email}: ${dropped.error}`); throw errors.INTERNAL_SERVER_ERROR( `Could not revoke ${existing.email}'s database access: ${dropped.error} The role was NOT changed.`, ); } if (dropped.reassigned.length) { // Their data is kept, exactly as on deletion — but it is now owned by the platform and they cannot // reach it, so say where it went rather than letting it look deleted. console.info( `[users] revoked ${existing.osUser}'s database role — ${dropped.reassigned.length} database(s) ` + `kept and reassigned to the platform: ${dropped.reassigned.join(', ')}`, ); } } const updated = await updateUser(id, { role: role as UserRole }); if (!updated) throw errors.NOT_FOUND('User not found'); // Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has // already happened and is the thing the owner asked for. Retryable via POST /users/:id/provision-linux, // which reads the role from the row. let databaseError: string | null = null; if (!had && wants && existing.osUser) { const ids = await lookupOsUser(existing.osUser); if (!ids) { databaseError = `${existing.osUser} is not a Linux account on this machine, so it got no database role.`; } else { const granted = await provisionPostgresRole({ email: existing.email, osUser: existing.osUser, uid: ids.uid, gid: ids.gid, }); if (!granted.ok) databaseError = granted.error; } if (databaseError) console.warn(`[users] ${existing.email}: ${databaseError}`); } return ctx.json({ user: toPublicUser(updated), databaseError }); }; 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`, ); } } // ── The Postgres side, after the Linux side and before the row ── // // The same trap as the uid, in a different id space: `provisionPostgresRole` ADOPTS a role that already // exists, so a role left behind here is inherited whole — with its databases — by the next member who // gets the same username. The owner hands out usernames, so reuse is likelier than a reissued uid, not // less. // // Ordered after the Linux teardown deliberately. That step can fail and abort, and it must not abort // AFTER something irreversible has happened to their databases. Ordered before `deleteUser` for the // reason stated above: the row is what remembers there is anything left to clean up. // // Their data is kept — the databases are reassigned to the platform role, not dropped, the same way // `severMemberTree` chowns their files rather than deleting them. if (existing.osUser) { const pg = await dropPostgresRole(existing.osUser); if (!pg.ok) { console.error(`[users] POSTGRES DEPROVISION FAILED for ${existing.email}: ${pg.error}`); throw errors.INTERNAL_SERVER_ERROR( `Could not remove ${existing.email}'s Postgres role: ${pg.error} ` + `The platform account was NOT deleted, so this can be retried.`, ); } if (pg.removed) { console.info( `[users] dropped Postgres role ${existing.osUser}` + (pg.reassigned.length ? ` — ${pg.reassigned.length} database(s) kept and reassigned: ${pg.reassigned.join(', ')}` : ' — it owned no databases'), ); } } // 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 }); };