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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user