import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' import type { Pool } from 'pg' export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null } export interface UserRepository { registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }> authenticateUser(handle: string, password: string): Promise getUser(id: string): Promise } const HANDLE = /^[a-z0-9_.-]{3,32}$/ // scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency. function hashPassword(password: string): string { const salt = randomBytes(16) return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}` } function verifyPassword(password: string, stored: string): boolean { const [saltHex, hashHex] = stored.split(':') if (!saltHex || !hashHex) return false const expected = Buffer.from(hashHex, 'hex') const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64) return expected.length === actual.length && timingSafeEqual(expected, actual) } export function createUserRepository(pool: Pool): UserRepository { const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto => ({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url }) return { async registerUser({ handle: rawHandle, password, displayName: rawName }) { const handle = rawHandle.trim().toLowerCase() const displayName = rawName.trim().slice(0, 60) if (!HANDLE.test(handle)) return { error: 'Handle must be 3–32 chars: a–z, 0–9, . _ -' } if (password.length < 6) return { error: 'Password must be at least 6 characters' } if (!displayName) return { error: 'A display name is required' } const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle]) if (existing.rowCount) return { error: 'That handle is taken' } const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>( 'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url', [handle, hashPassword(password), displayName])).rows[0] return { user: toDto(row) } }, async authenticateUser(rawHandle, password) { const handle = rawHandle.trim().toLowerCase() const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>( 'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0] if (!row || !verifyPassword(password, row.password_hash)) return null return toDto(row) }, async getUser(id) { const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>( 'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0] return row ? toDto(row) : null }, } }