import { USERNAME_RE, UsersFileSchema } from '../../domain/schemas'; import type { User } from '../../domain/types'; import { ulid } from '../../domain/ulid'; import { hashPassword, MIN_PASSWORD_LENGTH, verifyPassword } from '../auth/password'; import { decodeText, encodeText, type BucketStore } from '../storage/store'; export const USERS_PATH = 'users.json'; export const BOOTSTRAP_ADMIN_PASSWORD = '1234'; export class UserValidationError extends Error { constructor( message: string, public readonly field: string ) { super(message); this.name = 'UserValidationError'; } } export interface CreateUserInput { username: string; displayName: string; password: string; } export interface UsersRepoOptions { clock?: () => Date; newId?: () => string; } /** Owns users.json. Every mutation rewrites the whole file, then swaps memory. */ export class UsersRepo { private queue: Promise = Promise.resolve(); private constructor( private readonly store: BucketStore, private users: User[], private readonly clock: () => Date, private readonly newId: () => string ) {} static async load(store: BucketStore, opts: UsersRepoOptions = {}): Promise { const clock = opts.clock ?? (() => new Date()); const newId = opts.newId ?? ulid; const bytes = await store.get(USERS_PATH); const existing = bytes ? UsersFileSchema.parse(JSON.parse(decodeText(bytes))).users : []; if (bytes && existing.some((u) => u.role === 'admin')) { return new UsersRepo(store, existing, clock, newId); } // users.json is missing, or present but has no admin: bootstrap/re-bootstrap one. const now = clock().toISOString(); const admin: User = { id: 'u_admin', username: 'admin', displayName: 'Admin', passwordHash: await hashPassword(BOOTSTRAP_ADMIN_PASSWORD), role: 'admin', active: true, mustChangePassword: true, sessionVersion: 1, createdAt: now, updatedAt: now }; const repo = new UsersRepo(store, existing, clock, newId); await repo.persist([...existing, admin]); return repo; } // ---- reads ------------------------------------------------------------- all(): User[] { return [...this.users]; } byId(id: string): User | undefined { return this.users.find((u) => u.id === id); } byUsername(username: string): User | undefined { const key = username.trim().toLowerCase(); return this.users.find((u) => u.username === key); } /** Active non-admin users, sorted by display name. */ activeMembers(): User[] { return this.members().filter((u) => u.active); } /** All non-admin users, sorted by display name. */ members(): User[] { return this.users .filter((u) => u.role === 'member') .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.id.localeCompare(b.id)); } isActiveMember(id: string): boolean { const u = this.byId(id); return !!u && u.role === 'member' && u.active; } async authenticate(username: string, password: string): Promise { const user = this.byUsername(username); // Always run a hash comparison so timing does not reveal whether the username exists. // A ScryptOverloadError from a login flood propagates: the guarded action turns it into a 503. const ok = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='); return ok && user && user.active ? user : null; } // ---- writes ------------------------------------------------------------ create(input: CreateUserInput): Promise { return this.run(async () => { const username = input.username.trim().toLowerCase(); const displayName = input.displayName.trim(); if (!USERNAME_RE.test(username)) throw new UserValidationError('Username must be 3–32 characters: a–z, 0–9, _ . -', 'username'); if (username === 'admin' || this.byUsername(username)) throw new UserValidationError('Username is taken', 'username'); if (displayName.length === 0 || displayName.length > 120) throw new UserValidationError('Display name is required', 'displayName'); validatePassword(input.password); const now = this.clock().toISOString(); const user: User = { id: `u_${this.newId()}`, username, displayName, passwordHash: await hashPassword(input.password), role: 'member', active: true, mustChangePassword: true, sessionVersion: 1, createdAt: now, updatedAt: now }; await this.persist([...this.users, user]); return user; }); } setPassword(id: string, password: string, opts: { mustChange: boolean }): Promise { return this.run(async () => { validatePassword(password); const passwordHash = await hashPassword(password); return this.patch(id, (u) => ({ ...u, passwordHash, mustChangePassword: opts.mustChange, sessionVersion: u.sessionVersion + 1 })); }); } setActive(id: string, active: boolean): Promise { return this.run(async () => { const u = this.require(id); if (u.role === 'admin') throw new UserValidationError('The admin account cannot be deactivated', 'active'); return this.patch(id, (cur) => ({ ...cur, active, sessionVersion: active ? cur.sessionVersion : cur.sessionVersion + 1 })); }); } rename(id: string, displayName: string): Promise { return this.run(async () => { const name = displayName.trim(); if (name.length === 0 || name.length > 120) throw new UserValidationError('Display name is required', 'displayName'); return this.patch(id, (u) => ({ ...u, displayName: name })); }); } // ---- internals --------------------------------------------------------- private run(fn: () => Promise): Promise { const next = this.queue.then(fn, fn); this.queue = next.catch(() => undefined); return next; } private require(id: string): User { const u = this.byId(id); if (!u) throw new UserValidationError('User not found', 'id'); return u; } private async patch(id: string, fn: (u: User) => User): Promise { const current = this.require(id); const updated = { ...fn(current), updatedAt: this.clock().toISOString() }; await this.persist(this.users.map((u) => (u.id === id ? updated : u))); return updated; } private async persist(users: User[]): Promise { const file = UsersFileSchema.parse({ version: 1, users }); await this.store.put(USERS_PATH, encodeText(JSON.stringify(file, null, 2)), 'application/json'); this.users = users; } } export function validatePassword(password: string): void { if (password.length < MIN_PASSWORD_LENGTH) throw new UserValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`, 'password'); if (password.length > 256) throw new UserValidationError('Password is too long', 'password'); }