Spaces:
Sleeping
Sleeping
assafvayner HF Staff
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
e830ad4 Download src/lib/server/users/repository.ts from assafvayner/splitwise: direct link, hf CLI and curl.
- Browser
- Download file 6.76 kB
-
https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/src/lib/server/users/repository.ts
- Command line
-
hf download hf://spaces/assafvayner/splitwise/src/lib/server/users/repository.ts
-
curl -L -o repository.ts https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/src/lib/server/users/repository.ts
6.76 kB
| 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<unknown> = 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<UsersRepo> { | |
| 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<User | null> { | |
| 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<User> { | |
| 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<User> { | |
| 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<User> { | |
| 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<User> { | |
| 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<T>(fn: () => Promise<T>): Promise<T> { | |
| 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<User> { | |
| 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<void> { | |
| 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'); | |
| } | |