assafvayner HF Staff commited on
Commit
1cf4d4c
·
1 Parent(s): ef84ace

feat(users): add UsersRepo with admin bootstrap and password management

Browse files
src/lib/server/users/repository.ts ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { USERNAME_RE, UsersFileSchema } from '../../domain/schemas';
2
+ import type { User } from '../../domain/types';
3
+ import { ulid } from '../../domain/ulid';
4
+ import { hashPassword, MIN_PASSWORD_LENGTH, verifyPassword } from '../auth/password';
5
+ import { decodeText, encodeText, type BucketStore } from '../storage/store';
6
+
7
+ export const USERS_PATH = 'users.json';
8
+ export const BOOTSTRAP_ADMIN_PASSWORD = '1234';
9
+
10
+ export class UserValidationError extends Error {
11
+ constructor(
12
+ message: string,
13
+ public readonly field: string
14
+ ) {
15
+ super(message);
16
+ this.name = 'UserValidationError';
17
+ }
18
+ }
19
+
20
+ export interface CreateUserInput {
21
+ username: string;
22
+ displayName: string;
23
+ password: string;
24
+ }
25
+
26
+ export interface UsersRepoOptions {
27
+ clock?: () => Date;
28
+ newId?: () => string;
29
+ }
30
+
31
+ /** Owns users.json. Every mutation rewrites the whole file, then swaps memory. */
32
+ export class UsersRepo {
33
+ private queue: Promise<unknown> = Promise.resolve();
34
+
35
+ private constructor(
36
+ private readonly store: BucketStore,
37
+ private users: User[],
38
+ private readonly clock: () => Date,
39
+ private readonly newId: () => string
40
+ ) {}
41
+
42
+ static async load(store: BucketStore, opts: UsersRepoOptions = {}): Promise<UsersRepo> {
43
+ const clock = opts.clock ?? (() => new Date());
44
+ const newId = opts.newId ?? ulid;
45
+ const bytes = await store.get(USERS_PATH);
46
+ if (bytes) {
47
+ const file = UsersFileSchema.parse(JSON.parse(decodeText(bytes)));
48
+ return new UsersRepo(store, file.users, clock, newId);
49
+ }
50
+ const now = clock().toISOString();
51
+ const admin: User = {
52
+ id: 'u_admin',
53
+ username: 'admin',
54
+ displayName: 'Admin',
55
+ passwordHash: await hashPassword(BOOTSTRAP_ADMIN_PASSWORD),
56
+ role: 'admin',
57
+ active: true,
58
+ mustChangePassword: true,
59
+ sessionVersion: 1,
60
+ createdAt: now,
61
+ updatedAt: now
62
+ };
63
+ const repo = new UsersRepo(store, [], clock, newId);
64
+ await repo.persist([admin]);
65
+ return repo;
66
+ }
67
+
68
+ // ---- reads -------------------------------------------------------------
69
+
70
+ all(): User[] {
71
+ return [...this.users];
72
+ }
73
+ byId(id: string): User | undefined {
74
+ return this.users.find((u) => u.id === id);
75
+ }
76
+ byUsername(username: string): User | undefined {
77
+ const key = username.trim().toLowerCase();
78
+ return this.users.find((u) => u.username === key);
79
+ }
80
+ /** Active non-admin users, sorted by display name. */
81
+ activeMembers(): User[] {
82
+ return this.members().filter((u) => u.active);
83
+ }
84
+ /** All non-admin users, sorted by display name. */
85
+ members(): User[] {
86
+ return this.users.filter((u) => u.role === 'member').sort((a, b) => a.displayName.localeCompare(b.displayName));
87
+ }
88
+ isActiveMember(id: string): boolean {
89
+ const u = this.byId(id);
90
+ return !!u && u.role === 'member' && u.active;
91
+ }
92
+
93
+ async authenticate(username: string, password: string): Promise<User | null> {
94
+ const user = this.byUsername(username);
95
+ // Always run a hash comparison so timing does not reveal whether the username exists.
96
+ const ok = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
97
+ return ok && user && user.active ? user : null;
98
+ }
99
+
100
+ // ---- writes ------------------------------------------------------------
101
+
102
+ create(input: CreateUserInput): Promise<User> {
103
+ return this.run(async () => {
104
+ const username = input.username.trim().toLowerCase();
105
+ const displayName = input.displayName.trim();
106
+ if (!USERNAME_RE.test(username)) throw new UserValidationError('Username must be 3–32 characters: a–z, 0–9, _ . -', 'username');
107
+ if (username === 'admin' || this.byUsername(username)) throw new UserValidationError('Username is taken', 'username');
108
+ if (displayName.length === 0 || displayName.length > 120) throw new UserValidationError('Display name is required', 'displayName');
109
+ validatePassword(input.password);
110
+ const now = this.clock().toISOString();
111
+ const user: User = {
112
+ id: `u_${this.newId()}`,
113
+ username,
114
+ displayName,
115
+ passwordHash: await hashPassword(input.password),
116
+ role: 'member',
117
+ active: true,
118
+ mustChangePassword: true,
119
+ sessionVersion: 1,
120
+ createdAt: now,
121
+ updatedAt: now
122
+ };
123
+ await this.persist([...this.users, user]);
124
+ return user;
125
+ });
126
+ }
127
+
128
+ setPassword(id: string, password: string, opts: { mustChange: boolean }): Promise<User> {
129
+ return this.run(async () => {
130
+ validatePassword(password);
131
+ const passwordHash = await hashPassword(password);
132
+ return this.patch(id, (u) => ({ ...u, passwordHash, mustChangePassword: opts.mustChange, sessionVersion: u.sessionVersion + 1 }));
133
+ });
134
+ }
135
+
136
+ setActive(id: string, active: boolean): Promise<User> {
137
+ return this.run(async () => {
138
+ const u = this.require(id);
139
+ if (u.role === 'admin') throw new UserValidationError('The admin account cannot be deactivated', 'active');
140
+ return this.patch(id, (cur) => ({ ...cur, active, sessionVersion: active ? cur.sessionVersion : cur.sessionVersion + 1 }));
141
+ });
142
+ }
143
+
144
+ rename(id: string, displayName: string): Promise<User> {
145
+ return this.run(async () => {
146
+ const name = displayName.trim();
147
+ if (name.length === 0 || name.length > 120) throw new UserValidationError('Display name is required', 'displayName');
148
+ return this.patch(id, (u) => ({ ...u, displayName: name }));
149
+ });
150
+ }
151
+
152
+ // ---- internals ---------------------------------------------------------
153
+
154
+ private run<T>(fn: () => Promise<T>): Promise<T> {
155
+ const next = this.queue.then(fn, fn);
156
+ this.queue = next.catch(() => undefined);
157
+ return next;
158
+ }
159
+
160
+ private require(id: string): User {
161
+ const u = this.byId(id);
162
+ if (!u) throw new UserValidationError('User not found', 'id');
163
+ return u;
164
+ }
165
+
166
+ private async patch(id: string, fn: (u: User) => User): Promise<User> {
167
+ const current = this.require(id);
168
+ const updated = { ...fn(current), updatedAt: this.clock().toISOString() };
169
+ await this.persist(this.users.map((u) => (u.id === id ? updated : u)));
170
+ return updated;
171
+ }
172
+
173
+ private async persist(users: User[]): Promise<void> {
174
+ const file = UsersFileSchema.parse({ version: 1, users });
175
+ await this.store.put(USERS_PATH, encodeText(JSON.stringify(file, null, 2)), 'application/json');
176
+ this.users = users;
177
+ }
178
+ }
179
+
180
+ export function validatePassword(password: string): void {
181
+ if (password.length < MIN_PASSWORD_LENGTH) throw new UserValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`, 'password');
182
+ if (password.length > 256) throw new UserValidationError('Password is too long', 'password');
183
+ }
tests/unit/users/repository.test.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { verifyPassword } from '../../../src/lib/server/auth/password';
6
+ import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
7
+ import { UsersRepo, UserValidationError } from '../../../src/lib/server/users/repository';
8
+
9
+ describe('UsersRepo', () => {
10
+ let dir: string;
11
+ let store: LocalFsStore;
12
+ const clock = () => new Date('2026-08-29T12:00:00.000Z');
13
+ beforeEach(async () => {
14
+ dir = await mkdtemp(join(tmpdir(), 'splitwise-users-'));
15
+ store = new LocalFsStore(dir);
16
+ });
17
+ afterEach(() => rm(dir, { recursive: true, force: true }));
18
+
19
+ it('bootstraps admin/1234 with a forced password change when users.json is missing', async () => {
20
+ const repo = await UsersRepo.load(store, { clock });
21
+ const admin = repo.byUsername('admin');
22
+ expect(admin?.role).toBe('admin');
23
+ expect(admin?.mustChangePassword).toBe(true);
24
+ expect(await verifyPassword('1234', admin!.passwordHash)).toBe(true);
25
+ expect(await store.get('users.json')).not.toBeNull();
26
+ const again = await UsersRepo.load(store, { clock });
27
+ expect(again.all()).toHaveLength(1);
28
+ });
29
+
30
+ it('creates members with validation and case-insensitive uniqueness', async () => {
31
+ const repo = await UsersRepo.load(store, { clock });
32
+ const bob = await repo.create({ username: 'Bob', displayName: 'Bob B', password: 'temppass1' });
33
+ expect(bob.username).toBe('bob');
34
+ expect(bob.role).toBe('member');
35
+ expect(bob.mustChangePassword).toBe(true);
36
+ expect(repo.activeMembers().map((u) => u.id)).toEqual([bob.id]);
37
+ await expect(repo.create({ username: 'BOB', displayName: 'x', password: 'temppass1' })).rejects.toBeInstanceOf(UserValidationError);
38
+ await expect(repo.create({ username: 'admin', displayName: 'x', password: 'temppass1' })).rejects.toBeInstanceOf(UserValidationError);
39
+ await expect(repo.create({ username: 'ok', displayName: 'x', password: 'short' })).rejects.toBeInstanceOf(UserValidationError);
40
+ await expect(repo.create({ username: 'bad name', displayName: 'x', password: 'temppass1' })).rejects.toBeInstanceOf(UserValidationError);
41
+ });
42
+
43
+ it('authenticates and persists across reload', async () => {
44
+ const repo = await UsersRepo.load(store, { clock });
45
+ const bob = await repo.create({ username: 'bob', displayName: 'Bob', password: 'temppass1' });
46
+ expect((await repo.authenticate('bob', 'temppass1'))?.id).toBe(bob.id);
47
+ expect(await repo.authenticate('bob', 'nope')).toBeNull();
48
+ expect(await repo.authenticate('nobody', 'temppass1')).toBeNull();
49
+ const reloaded = await UsersRepo.load(store, { clock });
50
+ expect(reloaded.byId(bob.id)?.displayName).toBe('Bob');
51
+ });
52
+
53
+ it('password changes bump sessionVersion and clear mustChangePassword', async () => {
54
+ const repo = await UsersRepo.load(store, { clock });
55
+ const bob = await repo.create({ username: 'bob', displayName: 'Bob', password: 'temppass1' });
56
+ const after = await repo.setPassword(bob.id, 'newpassword', { mustChange: false });
57
+ expect(after.sessionVersion).toBe(bob.sessionVersion + 1);
58
+ expect(after.mustChangePassword).toBe(false);
59
+ expect((await repo.authenticate('bob', 'newpassword'))?.id).toBe(bob.id);
60
+ const reset = await repo.setPassword(bob.id, 'adminreset1', { mustChange: true });
61
+ expect(reset.mustChangePassword).toBe(true);
62
+ });
63
+
64
+ it('deactivation bumps sessionVersion and blocks login; admin cannot be deactivated', async () => {
65
+ const repo = await UsersRepo.load(store, { clock });
66
+ const bob = await repo.create({ username: 'bob', displayName: 'Bob', password: 'temppass1' });
67
+ const off = await repo.setActive(bob.id, false);
68
+ expect(off.active).toBe(false);
69
+ expect(off.sessionVersion).toBe(bob.sessionVersion + 1);
70
+ expect(await repo.authenticate('bob', 'temppass1')).toBeNull();
71
+ expect(repo.activeMembers()).toEqual([]);
72
+ await expect(repo.setActive(repo.byUsername('admin')!.id, false)).rejects.toBeInstanceOf(UserValidationError);
73
+ });
74
+
75
+ it('renames with validation', async () => {
76
+ const repo = await UsersRepo.load(store, { clock });
77
+ const bob = await repo.create({ username: 'bob', displayName: 'Bob', password: 'temppass1' });
78
+ expect((await repo.rename(bob.id, ' Robert ')).displayName).toBe('Robert');
79
+ await expect(repo.rename(bob.id, '')).rejects.toBeInstanceOf(UserValidationError);
80
+ });
81
+
82
+ it('leaves memory untouched when the store write fails', async () => {
83
+ const repo = await UsersRepo.load(store, { clock });
84
+ store.put = async () => {
85
+ throw new Error('down');
86
+ };
87
+ await expect(repo.create({ username: 'bob', displayName: 'Bob', password: 'temppass1' })).rejects.toThrow('down');
88
+ expect(repo.byUsername('bob')).toBeUndefined();
89
+ });
90
+ });