@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.
package/src/policy.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { AuthConfig } from './ports'
2
+
3
+ export type AuthPolicy = Omit<AuthConfig, 'activationMethod' | 'defaultMemberGroupId'>
4
+
5
+ export const DEFAULT_AUTH_POLICY: AuthPolicy = {
6
+ registrationEnabled: true,
7
+ minPasswordLength: 8,
8
+ usernameMin: 3,
9
+ usernameMax: 30,
10
+ maxLoginAttempts: 5,
11
+ maxAccountLoginAttempts: 50,
12
+ lockoutMinutes: 15,
13
+ sessionLifetimeDays: 14,
14
+ resetTokenTtlMinutes: 60,
15
+ reservedUsernames: [
16
+ 'admin',
17
+ 'administrator',
18
+ 'root',
19
+ 'moderator',
20
+ 'mod',
21
+ 'staff',
22
+ 'system',
23
+ 'guest',
24
+ 'anonymous',
25
+ 'me',
26
+ 'you',
27
+ ],
28
+ }
29
+
30
+ export const AUTH_SETTING_KEYS = {
31
+ registrationEnabled: 'registration.enabled',
32
+ activationMethod: 'registration.method',
33
+ minPasswordLength: 'registration.min_password_length',
34
+ usernameMin: 'registration.username_min',
35
+ usernameMax: 'registration.username_max',
36
+ maxLoginAttempts: 'security.max_login_attempts',
37
+ maxAccountLoginAttempts: 'security.max_account_login_attempts',
38
+ lockoutMinutes: 'security.lockout_minutes',
39
+ sessionLifetimeDays: 'security.session_idle_days',
40
+ } as const
41
+
42
+ export type SettingReader = (key: string) => unknown
43
+
44
+ export interface ResolvedAuthSettings {
45
+ readonly registrationEnabled: boolean
46
+ readonly activationMethod: AuthConfig['activationMethod']
47
+ readonly minPasswordLength: number
48
+ readonly usernameMin: number
49
+ readonly usernameMax: number
50
+ readonly maxLoginAttempts: number
51
+ readonly maxAccountLoginAttempts: number
52
+ readonly lockoutMinutes: number
53
+ readonly sessionLifetimeDays: number
54
+ }
55
+
56
+ const ACTIVATION_METHODS: readonly AuthConfig['activationMethod'][] = [
57
+ 'none',
58
+ 'email',
59
+ 'admin',
60
+ 'both',
61
+ ]
62
+
63
+ export function resolveAuthPolicy(
64
+ read: SettingReader,
65
+ base: AuthPolicy & { readonly activationMethod: AuthConfig['activationMethod'] },
66
+ ): ResolvedAuthSettings {
67
+ const method = read(AUTH_SETTING_KEYS.activationMethod)
68
+ const min = read(AUTH_SETTING_KEYS.usernameMin)
69
+ const max = read(AUTH_SETTING_KEYS.usernameMax)
70
+
71
+ const usernameMin = positiveInteger(min) ?? base.usernameMin
72
+ const usernameMax = positiveInteger(max) ?? base.usernameMax
73
+ const impossible = usernameMin > usernameMax
74
+
75
+ const enabled = read(AUTH_SETTING_KEYS.registrationEnabled)
76
+
77
+ return {
78
+ registrationEnabled: typeof enabled === 'boolean' ? enabled : base.registrationEnabled,
79
+ activationMethod:
80
+ typeof method === 'string' && (ACTIVATION_METHODS as readonly string[]).includes(method)
81
+ ? (method as AuthConfig['activationMethod'])
82
+ : base.activationMethod,
83
+ minPasswordLength:
84
+ positiveInteger(read(AUTH_SETTING_KEYS.minPasswordLength)) ?? base.minPasswordLength,
85
+ usernameMin: impossible ? base.usernameMin : usernameMin,
86
+ usernameMax: impossible ? base.usernameMax : usernameMax,
87
+ maxLoginAttempts:
88
+ countingInteger(read(AUTH_SETTING_KEYS.maxLoginAttempts)) ?? base.maxLoginAttempts,
89
+ maxAccountLoginAttempts:
90
+ countingInteger(read(AUTH_SETTING_KEYS.maxAccountLoginAttempts)) ??
91
+ base.maxAccountLoginAttempts,
92
+ lockoutMinutes: positiveInteger(read(AUTH_SETTING_KEYS.lockoutMinutes)) ?? base.lockoutMinutes,
93
+ sessionLifetimeDays:
94
+ positiveInteger(read(AUTH_SETTING_KEYS.sessionLifetimeDays)) ?? base.sessionLifetimeDays,
95
+ }
96
+ }
97
+
98
+ function positiveInteger(value: unknown): number | null {
99
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null
100
+ }
101
+
102
+ function countingInteger(value: unknown): number | null {
103
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
104
+ }
package/src/ports.ts ADDED
@@ -0,0 +1,415 @@
1
+ import type { BanFilter } from './ban-filter'
2
+
3
+ export type AccountState = 'active' | 'awaiting_activation' | 'banned'
4
+
5
+ export interface AccountRecord {
6
+ readonly id: number
7
+ readonly username: string
8
+ readonly usernameLower: string
9
+ readonly email: string
10
+ readonly emailLower: string
11
+ readonly passwordHash: string | null
12
+ readonly passwordAlgo: string | null
13
+ readonly state: AccountState
14
+ readonly emailVerifiedAt: Date | null
15
+ readonly primaryGroupId: number | null
16
+ }
17
+
18
+ export interface NewAccount {
19
+ readonly username: string
20
+ readonly usernameLower: string
21
+ readonly email: string
22
+ readonly emailLower: string
23
+ readonly passwordHash: string | null
24
+ readonly passwordAlgo: string | null
25
+ readonly state: AccountState
26
+ readonly primaryGroupId: number
27
+ readonly registrationIpPrefix?: string | null
28
+ }
29
+
30
+ export interface AccountRepository {
31
+ findById(id: number): Promise<AccountRecord | null>
32
+ findByUsernameLower(usernameLower: string): Promise<AccountRecord | null>
33
+ findByEmailLower(emailLower: string): Promise<AccountRecord | null>
34
+ create(input: NewAccount): Promise<AccountRecord>
35
+ updatePassword(userId: number, passwordHash: string, passwordAlgo: string): Promise<void>
36
+ setState(userId: number, state: AccountState): Promise<void>
37
+ markEmailVerified(userId: number, at: Date, activate: boolean): Promise<AccountState | null>
38
+ touchLastActive(userId: number, now: Date, windowSeconds: number): Promise<boolean>
39
+ recordLastIpPrefix(userId: number, prefix: string): Promise<void>
40
+ }
41
+
42
+ export interface MemberProfileRecord {
43
+ readonly id: number
44
+ readonly username: string
45
+ readonly title: string | null
46
+ readonly postCount: number
47
+ readonly createdAt: Date
48
+ readonly lastActiveAt: Date | null
49
+ readonly location: string | null
50
+ readonly website: string | null
51
+ readonly bio: string | null
52
+ }
53
+
54
+ export interface MemberSuggestion {
55
+ readonly id: number
56
+ readonly username: string
57
+ }
58
+
59
+ export interface MemberProfileRepository {
60
+ findPublicById(id: number): Promise<MemberProfileRecord | null>
61
+ searchByUsernamePrefix(prefix: string, limit: number): Promise<readonly MemberSuggestion[]>
62
+ }
63
+
64
+ export interface SessionRecord {
65
+ readonly id: number
66
+ readonly userId: number
67
+ readonly expiresAt: Date
68
+ readonly revokedAt: Date | null
69
+ readonly supersededBySessionId: number | null
70
+ readonly credentialProvedAt: Date | null
71
+ readonly lastSeenAt: Date
72
+ }
73
+
74
+ export interface SessionLocation {
75
+ readonly path: string | null
76
+ readonly forumId: number | null
77
+ readonly threadId: number | null
78
+ }
79
+
80
+ export interface ActiveSessionRecord {
81
+ readonly id: number
82
+ readonly createdAt: Date
83
+ readonly lastSeenAt: Date
84
+ readonly expiresAt: Date
85
+ readonly ipPrefix: string | null
86
+ readonly userAgent: string | null
87
+ }
88
+
89
+ export interface SessionRepository {
90
+ create(input: {
91
+ tokenHash: string
92
+ userId: number
93
+ expiresAt: Date
94
+ ipPrefix?: string | null
95
+ userAgent?: string | null
96
+ }): Promise<SessionRecord>
97
+ findByTokenHash(tokenHash: string): Promise<SessionRecord | null>
98
+ markCredentialProved(sessionId: number, userId: number, at: Date): Promise<boolean>
99
+ listActiveForUser(userId: number, now: Date): Promise<readonly ActiveSessionRecord[]>
100
+ revoke(sessionId: number): Promise<void>
101
+ revokeOwned(userId: number, sessionId: number, now: Date): Promise<boolean>
102
+ revokeAllForUserExcept(userId: number, sessionId: number | null): Promise<number>
103
+ revokeAllForUser(userId: number): Promise<void>
104
+ supersede(oldSessionId: number, newSessionId: number, now: Date): Promise<void>
105
+ touchLocation(
106
+ sessionId: number,
107
+ location: SessionLocation,
108
+ now: Date,
109
+ windowSeconds: number,
110
+ ): Promise<boolean>
111
+ }
112
+
113
+ export type RememberRotation =
114
+ | { readonly status: 'rotated'; readonly userId: number; readonly familyId: string }
115
+ | { readonly status: 'reuse'; readonly userId: number; readonly familyId: string }
116
+ | { readonly status: 'invalid' }
117
+
118
+ /**
119
+ * How long after a remember token is rotated its old value is still honoured.
120
+ *
121
+ * Rotation is single-use, and two requests carrying the same cookie arrive
122
+ * whenever a browser restores several tabs at once, a link is double-clicked, or
123
+ * a prefetch races the navigation that prompted it. Exactly one wins the claim;
124
+ * without this window the others are indistinguishable from a stolen token and
125
+ * cost the member every session they hold. Real theft reuses a token long after
126
+ * the fact, not inside the same breath.
127
+ */
128
+ export const REMEMBER_ROTATION_GRACE_SECONDS = 30
129
+
130
+ /**
131
+ * Whether a spent remember token is a concurrent request rather than a theft.
132
+ *
133
+ * A revoked family is always theft: the board has already decided about it.
134
+ * Elapsed time is not required to be positive — requests racing each other
135
+ * stamp `now` on the way in, so the one that loses the claim can carry a
136
+ * timestamp from just before the winner wrote `usedAt`, which is the very case
137
+ * this window exists to forgive.
138
+ */
139
+ export function withinRotationGrace(
140
+ row: { readonly usedAt: Date | null; readonly revokedAt: Date | null },
141
+ now: Date,
142
+ ): boolean {
143
+ if (row.revokedAt !== null || row.usedAt === null) return false
144
+
145
+ return now.getTime() - row.usedAt.getTime() <= REMEMBER_ROTATION_GRACE_SECONDS * 1_000
146
+ }
147
+
148
+ export interface RememberTokenRepository {
149
+ issue(input: {
150
+ tokenHash: string
151
+ familyId: string
152
+ userId: number
153
+ expiresAt: Date
154
+ }): Promise<void>
155
+ rotate(input: {
156
+ presentedHash: string
157
+ nextHash: string
158
+ now: Date
159
+ nextExpiresAt: Date
160
+ }): Promise<RememberRotation>
161
+ revokeFamily(familyId: string, reason: string, now: Date): Promise<void>
162
+ revokeAllForUser(userId: number, reason: string, now: Date): Promise<void>
163
+ findByTokenHash(tokenHash: string): Promise<{
164
+ familyId: string
165
+ userId: number
166
+ usedAt: Date | null
167
+ revokedAt: Date | null
168
+ } | null>
169
+ }
170
+
171
+ export type CredentialPurpose =
172
+ | 'password_reset'
173
+ | 'email_verification'
174
+ | 'email_change'
175
+ | 'second_factor'
176
+
177
+ export interface CredentialTokenRepository {
178
+ issue(input: {
179
+ tokenHash: string
180
+ userId: number
181
+ purpose: CredentialPurpose
182
+ payload?: string | null
183
+ expiresAt: Date
184
+ }): Promise<void>
185
+ consume(
186
+ tokenHash: string,
187
+ purpose: CredentialPurpose,
188
+ now: Date,
189
+ ): Promise<{ userId: number; payload: string | null } | null>
190
+ /**
191
+ * The same lookup without spending the token. A half-finished sign-in has to
192
+ * survive a mistyped code, so the token that carries it is only consumed once
193
+ * the second factor is actually satisfied.
194
+ */
195
+ peek(
196
+ tokenHash: string,
197
+ purpose: CredentialPurpose,
198
+ now: Date,
199
+ ): Promise<{ userId: number; payload: string | null } | null>
200
+ revokeAllForUser(userId: number, purpose: CredentialPurpose): Promise<void>
201
+ }
202
+
203
+ export interface UserIdentityRecord {
204
+ readonly id: number
205
+ readonly userId: number
206
+ readonly provider: string
207
+ readonly subject: string
208
+ readonly label: string | null
209
+ readonly linkedAt: Date
210
+ readonly lastUsedAt: Date | null
211
+ }
212
+
213
+ export interface LinkIdentityInput {
214
+ readonly userId: number
215
+ readonly provider: string
216
+ readonly subject: string
217
+ readonly label: string | null
218
+ readonly now: Date
219
+ }
220
+
221
+ export interface UserIdentityRepository {
222
+ findBySubject(provider: string, subject: string): Promise<UserIdentityRecord | null>
223
+ listForUser(userId: number): Promise<readonly UserIdentityRecord[]>
224
+ link(input: LinkIdentityInput): Promise<UserIdentityRecord>
225
+ unlink(userId: number, identityId: number): Promise<boolean>
226
+ markUsed(identityId: number, now: Date): Promise<void>
227
+ }
228
+
229
+ export interface PasskeyRecord {
230
+ readonly id: number
231
+ readonly userId: number
232
+ readonly credentialId: string
233
+ readonly publicKey: string
234
+ readonly signCount: number
235
+ readonly label: string
236
+ readonly transports: string | null
237
+ readonly createdAt: Date
238
+ readonly lastUsedAt: Date | null
239
+ }
240
+
241
+ export interface NewPasskey {
242
+ readonly userId: number
243
+ readonly credentialId: string
244
+ readonly publicKey: string
245
+ readonly signCount: number
246
+ readonly label: string
247
+ readonly transports: string | null
248
+ readonly now: Date
249
+ }
250
+
251
+ export interface PasskeyRepository {
252
+ findByCredentialId(credentialId: string): Promise<PasskeyRecord | null>
253
+ listForUser(userId: number): Promise<readonly PasskeyRecord[]>
254
+ create(input: NewPasskey): Promise<PasskeyRecord>
255
+ remove(userId: number, passkeyId: number): Promise<boolean>
256
+ markUsed(passkeyId: number, signCount: number, now: Date): Promise<void>
257
+ }
258
+
259
+ export interface TwoFactorRecord {
260
+ readonly userId: number
261
+ readonly sealedSecret: string
262
+ readonly confirmedAt: Date | null
263
+ readonly lastStep: number | null
264
+ readonly createdAt: Date
265
+ }
266
+
267
+ export interface TwoFactorRepository {
268
+ find(userId: number): Promise<TwoFactorRecord | null>
269
+ startEnrolment(input: {
270
+ userId: number
271
+ sealedSecret: string
272
+ now: Date
273
+ }): Promise<TwoFactorRecord>
274
+ confirm(userId: number, step: number, now: Date): Promise<boolean>
275
+ /** False when the step has already been spent, which is a replayed code. */
276
+ spendStep(userId: number, step: number): Promise<boolean>
277
+ remove(userId: number): Promise<boolean>
278
+ }
279
+
280
+ export interface RecoveryCodeRepository {
281
+ replaceAll(userId: number, hashes: readonly string[], now: Date): Promise<void>
282
+ spend(userId: number, hash: string, now: Date): Promise<boolean>
283
+ countUnused(userId: number): Promise<number>
284
+ removeAll(userId: number): Promise<void>
285
+ }
286
+
287
+ export const AUTH_EVENT_KINDS = [
288
+ 'login',
289
+ 'login_failed',
290
+ 'logout',
291
+ 'second_factor_failed',
292
+ 'second_factor_enabled',
293
+ 'second_factor_disabled',
294
+ 'second_factor_cleared',
295
+ 'recovery_code_used',
296
+ 'recovery_codes_replaced',
297
+ 'password_changed',
298
+ 'password_reset',
299
+ 'email_change_requested',
300
+ 'email_changed',
301
+ 'session_revoked',
302
+ 'sessions_revoked',
303
+ 'identity_linked',
304
+ 'identity_unlinked',
305
+ 'passkey_added',
306
+ 'passkey_removed',
307
+ ] as const
308
+
309
+ export type AuthEventKind = (typeof AUTH_EVENT_KINDS)[number]
310
+
311
+ export interface AuthEventRecord {
312
+ readonly id: number
313
+ readonly userId: number | null
314
+ readonly kind: AuthEventKind
315
+ readonly ipPrefix: string | null
316
+ readonly userAgent: string | null
317
+ readonly detail: Readonly<Record<string, unknown>>
318
+ readonly at: Date
319
+ }
320
+
321
+ export interface NewAuthEvent {
322
+ readonly userId: number | null
323
+ readonly kind: AuthEventKind
324
+ readonly ipPrefix?: string | null
325
+ readonly userAgent?: string | null
326
+ readonly detail?: Readonly<Record<string, unknown>>
327
+ readonly at: Date
328
+ }
329
+
330
+ export interface AuthEventRepository {
331
+ record(event: NewAuthEvent): Promise<void>
332
+ listForUser(userId: number, limit: number): Promise<readonly AuthEventRecord[]>
333
+ listRecent(input: {
334
+ limit: number
335
+ before?: number | undefined
336
+ kind?: AuthEventKind | undefined
337
+ }): Promise<readonly AuthEventRecord[]>
338
+ pruneBefore(cutoff: Date, limit?: number): Promise<number>
339
+ }
340
+
341
+ export interface LoginAttemptRepository {
342
+ record(bucket: string, succeeded: boolean, at: Date): Promise<void>
343
+ countFailuresSince(bucket: string, since: Date): Promise<number>
344
+ clear(bucket: string): Promise<void>
345
+ }
346
+
347
+ export interface LoginBucket {
348
+ readonly key: string
349
+ readonly max?: number | undefined
350
+ readonly clearOnSuccess?: boolean | undefined
351
+ }
352
+
353
+ export interface AuthConfig {
354
+ readonly registrationEnabled: boolean
355
+ readonly minPasswordLength: number
356
+ readonly usernameMin: number
357
+ readonly usernameMax: number
358
+ readonly activationMethod: 'none' | 'email' | 'admin' | 'both'
359
+ readonly maxLoginAttempts: number
360
+ readonly maxAccountLoginAttempts: number
361
+ readonly lockoutMinutes: number
362
+ readonly sessionLifetimeDays: number
363
+ readonly resetTokenTtlMinutes: number
364
+ readonly reservedUsernames: readonly string[]
365
+ readonly defaultMemberGroupId: number
366
+ }
367
+
368
+ export type Clock = () => Date
369
+
370
+ export interface AccountStore {
371
+ readonly accounts: AccountRepository
372
+ readonly sessions: SessionRepository
373
+ readonly tokens: CredentialTokenRepository
374
+ readonly loginAttempts: LoginAttemptRepository
375
+ readonly remember: RememberTokenRepository
376
+ readonly identities: UserIdentityRepository
377
+ readonly passkeys: PasskeyRepository
378
+ readonly twoFactor: TwoFactorRepository
379
+ readonly recoveryCodes: RecoveryCodeRepository
380
+ readonly authEvents: AuthEventRepository
381
+ }
382
+
383
+ export interface BanRecord {
384
+ readonly id: number
385
+ readonly userId: number
386
+ readonly reason: string | null
387
+ readonly publicReason: string | null
388
+ readonly previousPrimaryGroupId: number | null
389
+ readonly expiresAt: Date | null
390
+ readonly liftedAt: Date | null
391
+ }
392
+
393
+ export interface CreateBanInput {
394
+ readonly userId: number
395
+ readonly bannedByUserId: number | null
396
+ readonly reason: string | null
397
+ readonly publicReason: string | null
398
+ readonly expiresAt: Date | null
399
+ readonly bannedGroupId: number
400
+ readonly now: Date
401
+ }
402
+
403
+ export interface BanRepository {
404
+ findActive(userId: number): Promise<BanRecord | null>
405
+
406
+ create(input: CreateBanInput): Promise<BanRecord>
407
+
408
+ lift(banId: number, now: Date): Promise<void>
409
+
410
+ expireDue(now: Date, limit: number): Promise<number>
411
+ }
412
+
413
+ export interface BanFilterRepository {
414
+ listAll(): Promise<readonly BanFilter[]>
415
+ }
@@ -0,0 +1,17 @@
1
+ import { isAppError } from '@meith/core'
2
+
3
+ export const REGISTER_FIELD = {
4
+ username: 'username',
5
+ email: 'email',
6
+ password: 'password',
7
+ } as const
8
+
9
+ export type RegisterField = (typeof REGISTER_FIELD)[keyof typeof REGISTER_FIELD]
10
+
11
+ const FIELDS: readonly string[] = Object.values(REGISTER_FIELD)
12
+
13
+ export function rejectedField(error: unknown): RegisterField | null {
14
+ if (!isAppError(error)) return null
15
+ const field = error.meta.field
16
+ return typeof field === 'string' && FIELDS.includes(field) ? (field as RegisterField) : null
17
+ }