splitwise / src /lib /server /users /repository.ts
assafvayner's picture
assafvayner HF Staff
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
e830ad4
Raw History Blame Contribute Delete
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');
}