Files
platform/src/databases/officer_db/src/queries/auth.ts
T
pastilhasandClaude Opus 5 69a31051ac 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>
2026-08-11 15:15:09 +00:00

167 lines
6.8 KiB
TypeScript

import { eq, and, lt, sql } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from '../schema';
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
// ── Users ──
export async function getUsers(): Promise<UserSelect[]> {
return db.select().from(users);
}
export async function getUserById(id: number): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user;
}
export async function getUserByEmail(email: string): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.email, email));
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.
//
// This was `order by id limit 1` — the first account created. That was the same claim the role column
// 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')).orderBy(users.id).limit(1);
return user;
}
export async function getUserCount(): Promise<number> {
const [result] = await db.select({ count: sql<number>`count(*)::int` }).from(users);
return result?.count ?? 0;
}
export async function createUser(data: UserInsert): Promise<UserSelect> {
const [user] = await db.insert(users).values(data).returning();
return user!;
}
export async function updateUser(id: number, data: Partial<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
const [user] = await db
.update(users)
.set({ ...data, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return user;
}
// The owner cannot be deleted. ck_users_owner_is_super_admin pins user 1's role, but a row-level CHECK
// cannot stop a DELETE, and deleting the owner is the same outcome by another route: a platform with
// nobody who can reach the vault, no identity for the agent sidecar to run as, and a web origin
// restricted to a Super Admin that no longer exists. Cascades would take the passkeys with it, so there
// is no signing back in either.
//
// Throws rather than returning false. This has no callers today; the one it will get is a
// manage-users flow, and a silent `false` there reads as "already gone".
export async function deleteUser(id: number): Promise<boolean> {
if (id === OWNER_USER_ID) {
throw new Error(`Refusing to delete user ${OWNER_USER_ID}: the platform owner cannot be removed.`);
}
const result = await db.delete(users).where(eq(users.id, id)).returning({ id: users.id });
return result.length > 0;
}
// ── Passkeys ──
export async function getPasskeysByUserId(userId: number): Promise<PasskeySelect[]> {
return db.select().from(passkeys).where(eq(passkeys.userId, userId));
}
export async function getPasskeysByUserIdAndOrigin(userId: number, origin: string): Promise<PasskeySelect[]> {
return db
.select()
.from(passkeys)
.where(and(eq(passkeys.userId, userId), eq(passkeys.origin, origin)));
}
export async function getPasskeyByCredentialId(
userId: number,
credentialId: string,
): Promise<PasskeySelect | undefined> {
const [passkey] = await db
.select()
.from(passkeys)
.where(and(eq(passkeys.userId, userId), eq(passkeys.credentialId, credentialId)));
return passkey;
}
export async function createPasskey(data: PasskeyInsert): Promise<PasskeySelect> {
const [passkey] = await db.insert(passkeys).values(data).returning();
return passkey!;
}
export async function updatePasskey(
id: number,
data: Partial<Omit<PasskeySelect, 'id'>>,
): Promise<PasskeySelect | undefined> {
const [passkey] = await db.update(passkeys).set(data).where(eq(passkeys.id, id)).returning();
return passkey;
}
// ── Passkey Challenges ──
export async function storeChallenge(userId: number, origin: string, challenge: string, ttlMs: number) {
const expiresAt = new Date(Date.now() + ttlMs);
// Upsert: delete existing challenge for this user+origin, then insert
await db
.delete(passkeyChallenges)
.where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)));
await db.insert(passkeyChallenges).values({ userId, origin, challenge, expiresAt });
}
export async function consumeChallenge(userId: number, origin: string): Promise<string | null> {
const now = new Date();
// Clean up expired challenges
await db.delete(passkeyChallenges).where(lt(passkeyChallenges.expiresAt, now));
// Find and delete the matching challenge
const [entry] = await db
.delete(passkeyChallenges)
.where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)))
.returning();
if (!entry) return null;
if (entry.expiresAt < now) return null;
return entry.challenge;
}
// ── Token Blacklist ──
export async function blacklistToken(jti: string, expiresAt: number) {
// expiresAt comes as Unix seconds from JWT exp claim
const expiresDate = new Date(expiresAt * 1000);
await db.insert(tokenBlacklist).values({ jti, expiresAt: expiresDate }).onConflictDoNothing();
}
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
const [entry] = await db.select({ jti: tokenBlacklist.jti }).from(tokenBlacklist).where(eq(tokenBlacklist.jti, jti));
return !!entry;
}
export async function cleanupExpiredTokens() {
await db.delete(tokenBlacklist).where(lt(tokenBlacklist.expiresAt, new Date()));
}