@meith/accounts 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,390 @@
1
+ import { ValidationError } from '@meith/core'
2
+ import { msg, normaliseLocale } from '@meith/i18n'
3
+
4
+ import { foldIdentifier } from './case-fold'
5
+ import { hashPassword, needsRehash, verifyPassword } from './crypto/password'
6
+ import { generateToken, hashToken } from './crypto/tokens'
7
+ import type {
8
+ AccountRepository,
9
+ CredentialTokenRepository,
10
+ RememberTokenRepository,
11
+ SessionRepository,
12
+ } from './ports'
13
+
14
+ export const LOCATION_MAX = 100
15
+ export const WEBSITE_MAX = 200
16
+ export const BIO_MAX = 1000
17
+
18
+ export const PAGE_SIZE_MIN = 5
19
+ export const PAGE_SIZE_MAX = 100
20
+
21
+ export const EMAIL_CHANGE_TTL_MINUTES = 60
22
+
23
+ export interface MemberSettings {
24
+ readonly userId: number
25
+ readonly email: string
26
+ readonly timezone: string
27
+ readonly locale: string
28
+ readonly postsPerPage: number | null
29
+ readonly threadsPerPage: number | null
30
+ readonly invisible: boolean
31
+ readonly location: string | null
32
+ readonly website: string | null
33
+ readonly bio: string | null
34
+ readonly displayGroupId: number | null
35
+ }
36
+
37
+ export interface MemberGroupChoice {
38
+ readonly groupId: number
39
+ readonly title: string
40
+ readonly isPrimary: boolean
41
+ readonly isStaff: boolean
42
+ }
43
+
44
+ export interface MemberSettingsRepository {
45
+ read(userId: number): Promise<MemberSettings | null>
46
+
47
+ groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]>
48
+
49
+ saveDisplayGroup(input: {
50
+ readonly userId: number
51
+ readonly displayGroupId: number | null
52
+ }): Promise<void>
53
+
54
+ saveProfile(input: {
55
+ readonly userId: number
56
+ readonly location: string | null
57
+ readonly website: string | null
58
+ readonly bio: string | null
59
+ }): Promise<void>
60
+
61
+ saveOptions(input: {
62
+ readonly userId: number
63
+ readonly timezone: string
64
+ readonly locale: string
65
+ readonly postsPerPage: number | null
66
+ readonly threadsPerPage: number | null
67
+ readonly invisible: boolean
68
+ }): Promise<void>
69
+
70
+ adoptEmail(input: {
71
+ readonly userId: number
72
+ readonly email: string
73
+ readonly emailLower: string
74
+ }): Promise<boolean>
75
+ }
76
+
77
+ export const AUTOMATIC_TIMEZONE = 'auto'
78
+
79
+ export function isKnownTimezone(value: string): boolean {
80
+ if (value === 'UTC') return true
81
+
82
+ if (!/^[A-Za-z][A-Za-z0-9_+-]*\/[A-Za-z0-9_+/-]+$/.test(value)) return false
83
+
84
+ try {
85
+ new Intl.DateTimeFormat('en-GB', { timeZone: value })
86
+ return true
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ export function isTimezonePreference(value: string): boolean {
93
+ return value === AUTOMATIC_TIMEZONE || isKnownTimezone(value)
94
+ }
95
+
96
+ export const AUTOMATIC_LOCALE = 'auto'
97
+
98
+ export function isLocalePreference(value: string): boolean {
99
+ return value === AUTOMATIC_LOCALE || normaliseLocale(value) !== null
100
+ }
101
+
102
+ export class MemberSettingsService {
103
+ private readonly settings: MemberSettingsRepository
104
+ private readonly accounts: AccountRepository
105
+ private readonly sessions: SessionRepository
106
+ private readonly remember: RememberTokenRepository
107
+ private readonly tokens: CredentialTokenRepository
108
+ private readonly now: () => Date
109
+
110
+ constructor(deps: {
111
+ settings: MemberSettingsRepository
112
+ accounts: AccountRepository
113
+ sessions: SessionRepository
114
+ remember: RememberTokenRepository
115
+ tokens: CredentialTokenRepository
116
+ now?: () => Date
117
+ }) {
118
+ this.settings = deps.settings
119
+ this.accounts = deps.accounts
120
+ this.sessions = deps.sessions
121
+ this.remember = deps.remember
122
+ this.tokens = deps.tokens
123
+ this.now = deps.now ?? (() => new Date())
124
+ }
125
+
126
+ async read(userId: number): Promise<MemberSettings | null> {
127
+ return this.settings.read(userId)
128
+ }
129
+
130
+ async groupsHeldBy(userId: number): Promise<readonly MemberGroupChoice[]> {
131
+ return this.settings.groupsHeldBy(userId)
132
+ }
133
+
134
+ async saveDisplayGroup(input: {
135
+ readonly userId: number
136
+ readonly displayGroupId: string
137
+ }): Promise<void> {
138
+ const held = await this.settings.groupsHeldBy(input.userId)
139
+ const primary = held.find((choice) => choice.isPrimary)
140
+
141
+ if (primary?.isStaff === true) {
142
+ throw new ValidationError(msg('error.accounts.group-one-were-appointed-shown'))
143
+ }
144
+
145
+ const raw = input.displayGroupId.trim()
146
+ if (raw === '' || (primary !== undefined && raw === String(primary.groupId))) {
147
+ await this.settings.saveDisplayGroup({ userId: input.userId, displayGroupId: null })
148
+ return
149
+ }
150
+
151
+ if (!/^[1-9]\d*$/.test(raw)) {
152
+ throw new ValidationError(msg('error.accounts.group-board'))
153
+ }
154
+
155
+ const chosen = Number(raw)
156
+ if (!held.some((choice) => choice.groupId === chosen)) {
157
+ throw new ValidationError(msg('error.accounts.only-shown-as-group-if'))
158
+ }
159
+
160
+ await this.settings.saveDisplayGroup({
161
+ userId: input.userId,
162
+ displayGroupId: chosen,
163
+ })
164
+ }
165
+
166
+ async saveProfile(input: {
167
+ readonly userId: number
168
+ readonly location: string
169
+ readonly website: string
170
+ readonly bio: string
171
+ }): Promise<void> {
172
+ const location = input.location.trim()
173
+ const bio = input.bio.trim()
174
+ const website = input.website.trim()
175
+
176
+ if (location.length > LOCATION_MAX) {
177
+ throw new ValidationError(msg('error.accounts.location-length', { max: LOCATION_MAX }))
178
+ }
179
+ if (bio.length > BIO_MAX) {
180
+ throw new ValidationError(msg('error.accounts.bio-length', { max: BIO_MAX }))
181
+ }
182
+ if (website.length > WEBSITE_MAX) {
183
+ throw new ValidationError(msg('error.accounts.website-length', { max: WEBSITE_MAX }))
184
+ }
185
+
186
+ await this.settings.saveProfile({
187
+ userId: input.userId,
188
+ location: location === '' ? null : location,
189
+ website: website === '' ? null : normaliseWebsite(website),
190
+ bio: bio === '' ? null : bio,
191
+ })
192
+ }
193
+
194
+ async saveOptions(input: {
195
+ readonly userId: number
196
+ readonly timezone: string
197
+ readonly locale: string
198
+ readonly postsPerPage: string
199
+ readonly threadsPerPage: string
200
+ readonly invisible: boolean
201
+ }): Promise<void> {
202
+ const timezone = input.timezone.trim()
203
+ if (!isTimezonePreference(timezone)) {
204
+ throw new ValidationError(msg('error.accounts.timezone-board-recognises'))
205
+ }
206
+
207
+ const locale = input.locale.trim()
208
+ if (!isLocalePreference(locale)) {
209
+ throw new ValidationError(msg('error.accounts.language-board-recognises'))
210
+ }
211
+
212
+ await this.settings.saveOptions({
213
+ userId: input.userId,
214
+ timezone,
215
+ locale,
216
+ postsPerPage: parsePageSize(input.postsPerPage, 'Posts per page'),
217
+ threadsPerPage: parsePageSize(input.threadsPerPage, 'Threads per page'),
218
+ invisible: input.invisible,
219
+ })
220
+ }
221
+
222
+ async changePassword(input: {
223
+ readonly userId: number
224
+ readonly currentPassword: string
225
+ readonly newPassword: string
226
+ readonly minLength: number
227
+ }): Promise<void> {
228
+ const account = await this.requireVerified(input.userId, input.currentPassword)
229
+
230
+ const next = input.newPassword
231
+ if (next.length < input.minLength) {
232
+ throw new ValidationError(msg('error.accounts.password-min', { min: input.minLength }))
233
+ }
234
+ if (next === input.currentPassword) {
235
+ throw new ValidationError(msg('error.accounts.password-already-using'))
236
+ }
237
+
238
+ const hash = await hashPassword(next)
239
+ const at = this.now()
240
+ await this.accounts.updatePassword(account.id, hash, 'argon2id')
241
+ await this.sessions.revokeAllForUser(account.id)
242
+ await this.remember.revokeAllForUser(account.id, 'password_change', at)
243
+ await this.tokens.revokeAllForUser(account.id, 'email_change')
244
+ }
245
+
246
+ async requestEmailChange(input: {
247
+ readonly userId: number
248
+ readonly currentPassword: string
249
+ readonly newEmail: string
250
+ }): Promise<{ token: string; email: string; previousEmail: string; expiresAt: Date }> {
251
+ const account = await this.requireVerified(input.userId, input.currentPassword)
252
+
253
+ const email = input.newEmail.trim()
254
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
255
+ throw new ValidationError(msg('error.accounts.look-like-e-mail-address'))
256
+ }
257
+
258
+ const emailLower = foldIdentifier(email)
259
+ if (emailLower === account.emailLower) {
260
+ throw new ValidationError(msg('error.accounts.address-already-using'))
261
+ }
262
+
263
+ const taken = await this.accounts.findByEmailLower(emailLower)
264
+ if (taken !== null) {
265
+ throw new ValidationError(msg('error.accounts.address-already-use-board'))
266
+ }
267
+
268
+ const token = generateToken()
269
+ const at = this.now()
270
+ const expiresAt = new Date(at.getTime() + EMAIL_CHANGE_TTL_MINUTES * 60_000)
271
+
272
+ await this.tokens.revokeAllForUser(account.id, 'email_change')
273
+ await this.tokens.issue({
274
+ tokenHash: await hashToken(token),
275
+ userId: account.id,
276
+ purpose: 'email_change',
277
+ payload: JSON.stringify({ version: 1, email, previousEmailLower: account.emailLower }),
278
+ expiresAt,
279
+ })
280
+
281
+ return { token, email, previousEmail: account.email, expiresAt }
282
+ }
283
+
284
+ async confirmEmailChange(
285
+ token: string,
286
+ actorUserId: number,
287
+ ): Promise<{ userId: number; email: string; previousEmail: string } | null> {
288
+ const tokenHash = await hashToken(token)
289
+ const pending = await this.tokens.peek(tokenHash, 'email_change', this.now())
290
+ if (pending === null || pending.userId !== actorUserId) return null
291
+
292
+ const payload = parseEmailChangePayload(pending.payload)
293
+ if (payload === null) return null
294
+
295
+ const account = await this.accounts.findById(pending.userId)
296
+ if (account === null || account.emailLower !== payload.previousEmailLower) return null
297
+
298
+ const consumed = await this.tokens.consume(tokenHash, 'email_change', this.now())
299
+ if (
300
+ consumed === null ||
301
+ consumed.userId !== pending.userId ||
302
+ consumed.payload !== pending.payload
303
+ ) {
304
+ return null
305
+ }
306
+
307
+ const adopted = await this.settings.adoptEmail({
308
+ userId: pending.userId,
309
+ email: payload.email,
310
+ emailLower: foldIdentifier(payload.email),
311
+ })
312
+
313
+ return adopted
314
+ ? { userId: pending.userId, email: payload.email, previousEmail: account.email }
315
+ : null
316
+ }
317
+
318
+ private async requireVerified(userId: number, currentPassword: string) {
319
+ const account = await this.accounts.findById(userId)
320
+ if (account === null) throw new ValidationError(msg('error.accounts.account-exist'))
321
+
322
+ if (account.passwordHash === null) {
323
+ throw new ValidationError(msg('error.accounts.account-password-set-so-changed'))
324
+ }
325
+
326
+ const ok = await verifyPassword(currentPassword, account.passwordHash)
327
+ if (!ok) throw new ValidationError(msg('error.accounts.current-password'))
328
+
329
+ void needsRehash
330
+
331
+ return account
332
+ }
333
+ }
334
+
335
+ function parseEmailChangePayload(
336
+ payload: string | null,
337
+ ): { email: string; previousEmailLower: string } | null {
338
+ if (payload === null) return null
339
+
340
+ try {
341
+ const parsed: unknown = JSON.parse(payload)
342
+ if (
343
+ typeof parsed !== 'object' ||
344
+ parsed === null ||
345
+ !('version' in parsed) ||
346
+ parsed.version !== 1 ||
347
+ !('email' in parsed) ||
348
+ typeof parsed.email !== 'string' ||
349
+ !('previousEmailLower' in parsed) ||
350
+ typeof parsed.previousEmailLower !== 'string'
351
+ ) {
352
+ return null
353
+ }
354
+ return { email: parsed.email, previousEmailLower: parsed.previousEmailLower }
355
+ } catch {
356
+ return null
357
+ }
358
+ }
359
+
360
+ function parsePageSize(raw: string, label: string): number | null {
361
+ const value = raw.trim()
362
+ if (value === '') return null
363
+
364
+ if (!/^[1-9]\d*$/.test(value)) {
365
+ throw new ValidationError(msg('error.accounts.page-size-number', { label }))
366
+ }
367
+ const size = Number(value)
368
+ if (!Number.isSafeInteger(size) || size < PAGE_SIZE_MIN || size > PAGE_SIZE_MAX) {
369
+ throw new ValidationError(
370
+ msg('error.accounts.page-size-range', { label, min: PAGE_SIZE_MIN, max: PAGE_SIZE_MAX }),
371
+ )
372
+ }
373
+ return size
374
+ }
375
+
376
+ function normaliseWebsite(value: string): string {
377
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`
378
+
379
+ let url: URL
380
+ try {
381
+ url = new URL(candidate)
382
+ } catch {
383
+ throw new ValidationError(msg('error.accounts.look-like-web-address'))
384
+ }
385
+
386
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
387
+ throw new ValidationError(msg('error.accounts.website-address-must-start-with'))
388
+ }
389
+ return url.toString()
390
+ }
@@ -0,0 +1,88 @@
1
+ import type { BanFilter } from './ban-filter'
2
+ import type {
3
+ AccountRepository,
4
+ BanFilterRepository,
5
+ BanRecord,
6
+ BanRepository,
7
+ CreateBanInput,
8
+ } from './ports'
9
+
10
+ export interface MemoryBanDeps {
11
+ readonly accounts: AccountRepository & {
12
+ getPrimaryGroupId?(userId: number): Promise<number | null>
13
+ setPrimaryGroupId?(userId: number, groupId: number | null): Promise<void>
14
+ revokeAllSessions?(userId: number): Promise<void>
15
+ }
16
+ }
17
+
18
+ export class MemoryBans implements BanRepository {
19
+ private readonly rows: (BanRecord & { bannedByUserId: number | null })[] = []
20
+ private nextId = 1
21
+
22
+ private readonly primaryGroup = new Map<number, number | null>()
23
+ readonly revoked: number[] = []
24
+
25
+ setPrimaryGroup(userId: number, groupId: number | null): void {
26
+ this.primaryGroup.set(userId, groupId)
27
+ }
28
+
29
+ primaryGroupOf(userId: number): number | null {
30
+ return this.primaryGroup.get(userId) ?? null
31
+ }
32
+
33
+ async findActive(userId: number): Promise<BanRecord | null> {
34
+ return this.rows.find((r) => r.userId === userId && r.liftedAt === null) ?? null
35
+ }
36
+
37
+ async create(input: CreateBanInput): Promise<BanRecord> {
38
+ const previousPrimaryGroupId = this.primaryGroup.get(input.userId) ?? null
39
+
40
+ const row = {
41
+ id: this.nextId++,
42
+ userId: input.userId,
43
+ bannedByUserId: input.bannedByUserId,
44
+ reason: input.reason,
45
+ publicReason: input.publicReason,
46
+ previousPrimaryGroupId,
47
+ expiresAt: input.expiresAt,
48
+ liftedAt: null,
49
+ }
50
+ this.rows.push(row)
51
+
52
+ this.primaryGroup.set(input.userId, input.bannedGroupId)
53
+ this.revoked.push(input.userId)
54
+ return row
55
+ }
56
+
57
+ async lift(banId: number, now: Date): Promise<void> {
58
+ const row = this.rows.find((r) => r.id === banId && r.liftedAt === null)
59
+ if (!row) return
60
+
61
+ ;(row as { liftedAt: Date | null }).liftedAt = now
62
+ this.primaryGroup.set(row.userId, row.previousPrimaryGroupId)
63
+ }
64
+
65
+ async expireDue(now: Date, limit: number): Promise<number> {
66
+ const due = this.rows
67
+ .filter((r) => r.liftedAt === null && r.expiresAt !== null && r.expiresAt <= now)
68
+ .slice(0, limit)
69
+
70
+ for (const row of due) await this.lift(row.id, now)
71
+ return due.length
72
+ }
73
+ }
74
+
75
+ export class MemoryBanFilters implements BanFilterRepository {
76
+ private readonly rows: BanFilter[] = []
77
+ private nextId = 1
78
+
79
+ add(type: BanFilter['type'], pattern: string): BanFilter {
80
+ const row = { id: this.nextId++, type, pattern }
81
+ this.rows.push(row)
82
+ return row
83
+ }
84
+
85
+ async listAll(): Promise<readonly BanFilter[]> {
86
+ return [...this.rows]
87
+ }
88
+ }