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>
167 lines
6.8 KiB
TypeScript
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()));
|
|
}
|