import type { Handler } from 'hono'; import { createUser, updateUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb'; import type { UserRole } from 'officerdb'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { provisionUserDirs } from '@@/data-path'; import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user'; import { validatePassword } from '../auth/validate-password'; import { validateUsername } from '../auth/validate-username'; import { toPublicUser } from './manage-users'; // The owner creating a second account. Until this existed, `createUser` had exactly one call site — // `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had // been inserted into Postgres by hand. // // ── Why the owner sets the password ── // // The alternative is an invite: a token emailed to the person, who then sets their own. That is the // better shape and it needs a mail path, a token table and an expiry policy. This is the honest // intermediate: the owner types a password and tells the person, the same way they would hand over a // wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it. // // ── Status is 'Active', deliberately ── // // The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare // UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password, // which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin // UI is verified by definition — the owner is the verification. /** Roles this route may assign. Never 'Super Admin' — see below. */ const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin'); export const createUserHandler: Handler = async function (ctx) { const body = (ctx.get('body') ?? {}) as Record; const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; const name = typeof body.name === 'string' ? body.name.trim() : ''; const password = typeof body.password === 'string' ? body.password : ''; const role = typeof body.role === 'string' ? body.role : 'Member'; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address'); if (!name) throw errors.BAD_REQUEST('Name is required'); // The same validators bootstrap uses. A member's password protects the same surface a member can // reach, so there is no argument for a weaker rule here — and two different rules would mean the // owner could create an account that could not then change its own password to something similar. const username = validateUsername(typeof body.username === 'string' ? body.username : undefined); validatePassword(password); // Refused rather than filtered, so the owner is told instead of quietly getting a Member. // // There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say // "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then // return whichever the query reached first. That answer decides the identity the agent sidecar runs // as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query // plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown. if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) { throw errors.BAD_REQUEST( role === 'Super Admin' ? 'There is one server owner and it cannot be created here.' : `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`, ); } // Checked before the insert purely for the message — both columns are unique, so the database is the // real guard and this is a race it can lose harmlessly (the insert then throws). if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists'); if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken'); const user = await createUser({ email, password: await argon2.hash(password), name, username, status: 'Active', role: role as UserRole, }); // After the row, and not fatal if it fails. A missing directory is repairable from a shell // (`bun scripts/provision-user-dirs.ts `); an account that half-exists because mkdir failed // is not, and the owner would have to go into Postgres to clean it up — the exact thing this route // is here to stop being necessary. try { provisionUserDirs(email); } catch (ex) { console.warn(`[users] created ${email} but could not provision its data directories`, ex); } // The Linux account, when the host is set up for it. Same posture as the directories and for the same // reason: this is a side effect of creating a platform account, and a failed `useradd` must not undo an // account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which every // consumer already has to handle — that is what an account made before this feature looks like. // // Reported back in the response rather than only logged, so the owner sees "created, but no OS account" // at the moment they click rather than discovering it when a terminal opens in the wrong home. let osUser: string | null = null; let osUserError: string | null = null; if (OS_USERS_ENABLED) { const result = await ensureOsUser({ email, username }); if (result.ok) { osUser = result.osUser; await updateUser(user.id, { osUser: result.osUser }); } else { osUserError = result.error; console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`); } } return ctx.json({ user: { ...toPublicUser(user), osUser }, osUserError }, 201); };