File size: 6,764 Bytes
1cf4d4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
 
 
1cf4d4c
1c7b998
1cf4d4c
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
 
1cf4d4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
 
 
1cf4d4c
 
 
 
 
 
 
 
 
e830ad4
1cf4d4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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');
}