the owner can create accounts

POST /api/users plus an Add-account form in Settings > User management. Until now
createUser had one call site — bootstrap, gated on an empty user table — so every
non-owner account anywhere had been inserted into Postgres by hand.

Created accounts are Active. The column defaults to Unverified and signin refuses
anything else with a bare UNAUTHORIZED, which is exactly what made the hand-INSERT
route look like a wrong password.

Also closes a hole found while reading the write path: a second Super Admin was
storable. The CHECK constraint pins user 1's role but cannot see other rows, and
getOwnerUser() was LIMIT 1 with no ORDER BY, so two holders would have made "who owns
this server" a question the query plan answered — and that answer feeds the agent
sidecar's identity, vault access and origin scoping. Both write paths now refuse the
role and getOwnerUser() orders by id.

USER_DIRS and provisionUserDirs move into data-path.ts so the create handler and
scripts/provision-user-dirs.ts cannot disagree about what an account's skeleton is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:15:09 +00:00
co-authored by Claude Opus 5
parent b7184283e0
commit 69a31051ac
10 changed files with 408 additions and 29 deletions
+14 -1
View File
@@ -21,7 +21,8 @@ type PublicUser = {
isOwner: boolean;
};
const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
/** Shared with create-user.ts, so a created account and a listed one are described the same way. */
export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
id: u.id,
email: u.email,
name: u.name,
@@ -37,7 +38,10 @@ 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,
});
};
@@ -58,6 +62,15 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
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');