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:
@@ -2,6 +2,7 @@ export {
|
||||
getUsers,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
getOwnerUser,
|
||||
getUserCount,
|
||||
createUser,
|
||||
|
||||
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
||||
return user;
|
||||
}
|
||||
|
||||
// `username` is unique in the schema, so this exists to turn a would-be constraint violation into a
|
||||
// sentence. Creating an account is a form someone fills in, and "duplicate key value violates unique
|
||||
// constraint users_username_unique" is not an answer to give them.
|
||||
export async function getUserByUsername(username: string): Promise<UserSelect | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||
return user;
|
||||
}
|
||||
|
||||
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
|
||||
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
||||
// the main server.
|
||||
@@ -27,8 +35,15 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
||||
// now makes, asserted a second way, and the two would part company the moment the owner was not user
|
||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||
// sidecar refusing to start beats it silently running as the wrong person.
|
||||
//
|
||||
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
||||
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
||||
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
||||
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
||||
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
||||
// account the CHECK constraint already pins.
|
||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).limit(1);
|
||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user