@meith/accounts 0.21.2 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/accounts",
3
- "version": "0.21.2",
3
+ "version": "0.23.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,6 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "hash-wasm": "^4.12.0",
23
- "@meith/i18n": "0.21.2"
23
+ "@meith/i18n": "0.23.0"
24
24
  }
25
25
  }
@@ -0,0 +1,5 @@
1
+ import type { BanFilterRepository } from './ports'
2
+
3
+ export const BAN_FILTERS_NOT_CONSULTED: BanFilterRepository = {
4
+ listAll: async () => [],
5
+ }
@@ -11,11 +11,6 @@ import type {
11
11
 
12
12
  export const PROVIDER_KINDS: readonly ProviderKind[] = ['github', 'google', 'oidc']
13
13
 
14
- /**
15
- * What a link is called when the provider behind it is switched off: the
16
- * identity stays on the account, so the row still has to say something a
17
- * member recognises rather than the key it is stored under.
18
- */
19
14
  const PROVIDER_LABELS: Readonly<Record<ProviderKind, string>> = {
20
15
  github: 'GitHub',
21
16
  google: 'Google',
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  type BanFilterType,
9
9
  matchBanFilter,
10
10
  } from './ban-filter'
11
+ export { BAN_FILTERS_NOT_CONSULTED } from './ban-filters-not-consulted'
11
12
  export { type BanInput, BanService, type BanServiceDeps } from './ban-service'
12
13
  export { foldIdentifier } from './case-fold'
13
14
  export {
@@ -114,10 +115,13 @@ export type {
114
115
  AuthEventKind,
115
116
  AuthEventRecord,
116
117
  AuthEventRepository,
118
+ BanFilterAdminRepository,
119
+ BanFilterRecord,
117
120
  BanFilterRepository,
118
121
  BanRecord,
119
122
  BanRepository,
120
123
  Clock,
124
+ CreateBanFilterInput,
121
125
  CreateBanInput,
122
126
  CredentialPurpose,
123
127
  CredentialTokenRepository,
@@ -32,6 +32,7 @@ export interface MemberSettings {
32
32
  readonly website: string | null
33
33
  readonly bio: string | null
34
34
  readonly displayGroupId: number | null
35
+ readonly massMailOptInAt: Date | null
35
36
  }
36
37
 
37
38
  export interface MemberGroupChoice {
@@ -67,6 +68,8 @@ export interface MemberSettingsRepository {
67
68
  readonly invisible: boolean
68
69
  }): Promise<void>
69
70
 
71
+ saveMassMailOptIn(input: { readonly userId: number; readonly optIn: boolean }): Promise<void>
72
+
70
73
  adoptEmail(input: {
71
74
  readonly userId: number
72
75
  readonly email: string
@@ -1,9 +1,14 @@
1
- import type { BanFilter } from './ban-filter'
1
+ import { ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { assertUsableFilter, type BanFilter } from './ban-filter'
2
5
  import type {
3
6
  AccountRepository,
4
- BanFilterRepository,
7
+ BanFilterAdminRepository,
8
+ BanFilterRecord,
5
9
  BanRecord,
6
10
  BanRepository,
11
+ CreateBanFilterInput,
7
12
  CreateBanInput,
8
13
  } from './ports'
9
14
 
@@ -72,17 +77,60 @@ export class MemoryBans implements BanRepository {
72
77
  }
73
78
  }
74
79
 
75
- export class MemoryBanFilters implements BanFilterRepository {
76
- private readonly rows: BanFilter[] = []
80
+ export class MemoryBanFilters implements BanFilterAdminRepository {
81
+ private readonly rows: BanFilterRecord[] = []
77
82
  private nextId = 1
78
83
 
79
- add(type: BanFilter['type'], pattern: string): BanFilter {
80
- const row = { id: this.nextId++, type, pattern }
81
- this.rows.push(row)
82
- return row
84
+ add(type: BanFilter['type'], pattern: string, note: string | null = null): BanFilter {
85
+ return this.insert({ type, pattern, note, createdByUserId: null })
83
86
  }
84
87
 
85
88
  async listAll(): Promise<readonly BanFilter[]> {
86
- return [...this.rows]
89
+ return this.rows.map((row) => ({ id: row.id, type: row.type, pattern: row.pattern }))
90
+ }
91
+
92
+ async listForAdmin(): Promise<readonly BanFilterRecord[]> {
93
+ return [...this.rows].sort((a, b) => b.id - a.id)
94
+ }
95
+
96
+ async create(input: CreateBanFilterInput): Promise<number> {
97
+ assertUsableFilter(input.type, input.pattern)
98
+
99
+ const pattern = input.pattern.trim()
100
+ const note = input.note?.trim()
101
+
102
+ if (this.rows.some((row) => row.type === input.type && row.pattern === pattern)) {
103
+ throw new ValidationError(msg('error.accounts.ban-filter-already-held'))
104
+ }
105
+
106
+ return this.insert({
107
+ type: input.type,
108
+ pattern,
109
+ note: note === undefined || note === '' ? null : note,
110
+ createdByUserId: input.createdByUserId,
111
+ }).id
112
+ }
113
+
114
+ async remove(id: number): Promise<void> {
115
+ const at = this.rows.findIndex((row) => row.id === id)
116
+ if (at !== -1) this.rows.splice(at, 1)
117
+ }
118
+
119
+ private insert(input: {
120
+ type: BanFilter['type']
121
+ pattern: string
122
+ note: string | null
123
+ createdByUserId: number | null
124
+ }): BanFilterRecord {
125
+ const row: BanFilterRecord = {
126
+ id: this.nextId++,
127
+ type: input.type,
128
+ pattern: input.pattern,
129
+ note: input.note,
130
+ createdByUserId: input.createdByUserId,
131
+ createdAt: new Date(),
132
+ }
133
+ this.rows.push(row)
134
+ return row
87
135
  }
88
136
  }
package/src/ports.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BanFilter } from './ban-filter'
1
+ import type { BanFilter, BanFilterType } from './ban-filter'
2
2
 
3
3
  export type AccountState = 'active' | 'awaiting_activation' | 'banned'
4
4
 
@@ -115,27 +115,8 @@ export type RememberRotation =
115
115
  | { readonly status: 'reuse'; readonly userId: number; readonly familyId: string }
116
116
  | { readonly status: 'invalid' }
117
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
118
  export const REMEMBER_ROTATION_GRACE_SECONDS = 30
129
119
 
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
120
  export function withinRotationGrace(
140
121
  row: { readonly usedAt: Date | null; readonly revokedAt: Date | null },
141
122
  now: Date,
@@ -187,11 +168,6 @@ export interface CredentialTokenRepository {
187
168
  purpose: CredentialPurpose,
188
169
  now: Date,
189
170
  ): 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
171
  peek(
196
172
  tokenHash: string,
197
173
  purpose: CredentialPurpose,
@@ -272,7 +248,6 @@ export interface TwoFactorRepository {
272
248
  now: Date
273
249
  }): Promise<TwoFactorRecord>
274
250
  confirm(userId: number, step: number, now: Date): Promise<boolean>
275
- /** False when the step has already been spent, which is a replayed code. */
276
251
  spendStep(userId: number, step: number): Promise<boolean>
277
252
  remove(userId: number): Promise<boolean>
278
253
  }
@@ -413,3 +388,24 @@ export interface BanRepository {
413
388
  export interface BanFilterRepository {
414
389
  listAll(): Promise<readonly BanFilter[]>
415
390
  }
391
+
392
+ export interface BanFilterRecord extends BanFilter {
393
+ readonly note: string | null
394
+ readonly createdByUserId: number | null
395
+ readonly createdAt: Date
396
+ }
397
+
398
+ export interface CreateBanFilterInput {
399
+ readonly type: BanFilterType
400
+ readonly pattern: string
401
+ readonly note?: string | null
402
+ readonly createdByUserId: number | null
403
+ }
404
+
405
+ export interface BanFilterAdminRepository extends BanFilterRepository {
406
+ listForAdmin(): Promise<readonly BanFilterRecord[]>
407
+
408
+ create(input: CreateBanFilterInput): Promise<number>
409
+
410
+ remove(id: number): Promise<void>
411
+ }
package/src/service.ts CHANGED
@@ -20,16 +20,11 @@ export interface IdentityDeps {
20
20
  readonly store: AccountStore
21
21
  readonly config: AuthConfig
22
22
  readonly clock?: Clock
23
- readonly banFilters?: BanFilterRepository
23
+ readonly banFilters: BanFilterRepository
24
24
  readonly bans?: BanLookup
25
25
  readonly secondFactor?: SecondFactorLookup
26
26
  }
27
27
 
28
- /**
29
- * Whether an account asks for something beyond its password. Kept as a port
30
- * rather than the whole two-factor service so that the login path cannot reach
31
- * the secrets it holds — it only ever needs the yes or no.
32
- */
33
28
  export interface SecondFactorLookup {
34
29
  isEnrolled(userId: number): Promise<boolean>
35
30
  }
@@ -61,11 +56,6 @@ export interface LoginResult {
61
56
  readonly expiresAt: Date
62
57
  }
63
58
 
64
- /**
65
- * How long a half-finished sign-in waits for its second factor. Long enough to
66
- * fetch a phone from another room, short enough that a password proven on a
67
- * shared machine does not stay proven.
68
- */
69
59
  export const SECOND_FACTOR_TTL_MINUTES = 10
70
60
 
71
61
  export type LoginOutcome =
@@ -127,7 +117,7 @@ export class IdentityService {
127
117
  private readonly config: AuthConfig
128
118
  private readonly now: Clock
129
119
 
130
- private readonly banFilters: BanFilterRepository | undefined
120
+ private readonly banFilters: BanFilterRepository
131
121
 
132
122
  private readonly bans: BanLookup | undefined
133
123
 
@@ -432,11 +422,6 @@ export class IdentityService {
432
422
  return { token, expiresAt }
433
423
  }
434
424
 
435
- /**
436
- * Who a half-finished sign-in belongs to, without spending it — the code can
437
- * be mistyped, and a member who fumbles it should not have to start from
438
- * their password again.
439
- */
440
425
  async pendingSecondFactor(token: string): Promise<PendingSecondFactor | null> {
441
426
  const held = await this.store.tokens.peek(await hashToken(token), 'second_factor', this.now())
442
427
  if (held === null) return null
@@ -444,7 +429,6 @@ export class IdentityService {
444
429
  return { userId: held.userId, remember: held.payload === 'remember' }
445
430
  }
446
431
 
447
- /** Spends the hold and starts the session it was standing in for. */
448
432
  async redeemSecondFactor(token: string, context: RequestContext = {}): Promise<LoginResult> {
449
433
  const at = this.now()
450
434
  const redeemed = await this.store.tokens.consume(await hashToken(token), 'second_factor', at)
@@ -464,11 +448,6 @@ export class IdentityService {
464
448
  await this.store.tokens.revokeAllForUser(userId, 'second_factor')
465
449
  }
466
450
 
467
- /**
468
- * The second step gets its own counter. The password counters were cleared
469
- * when the password proved out, and a six-digit code is worth a million
470
- * guesses — far fewer than a password, and so worth far less patience.
471
- */
472
451
  async assertSecondFactorAttemptsLeft(userId: number): Promise<void> {
473
452
  const max = this.config.maxLoginAttempts
474
453
  if (max <= 0) return
@@ -527,8 +506,6 @@ export class IdentityService {
527
506
  }
528
507
 
529
508
  private async assertNotFiltered(subject: BanFilterSubject): Promise<void> {
530
- if (!this.banFilters) return
531
-
532
509
  const match = matchBanFilter(await this.banFilters.listAll(), subject)
533
510
  if (match) {
534
511
  throw new ForbiddenError(msg('error.accounts.account-used-board-contact-administrator'))
@@ -8,12 +8,6 @@ const IV_BYTES = 12
8
8
 
9
9
  const INFO = new TextEncoder().encode('meith/two-factor-secret')
10
10
 
11
- /**
12
- * The shared secret an authenticator app holds is a password equivalent: with
13
- * it, anybody can mint that member's codes forever. It is sealed with a key
14
- * derived from AUTH_SECRET so that a leaked backup, or a read of the table by
15
- * anything that never had the environment, is not enough on its own.
16
- */
17
11
  export async function sealSecret(plaintext: string, passphrase: string): Promise<string> {
18
12
  const key = await deriveKey(passphrase)
19
13
  const iv = new Uint8Array(IV_BYTES)
@@ -117,11 +117,6 @@ export class TwoFactorService {
117
117
  }
118
118
  }
119
119
 
120
- /**
121
- * The enrolment already under way, so the setup screen survives a reload
122
- * without minting a second secret — which would strand whatever the member
123
- * had already typed into their authenticator app.
124
- */
125
120
  async pendingEnrolment(userId: number, boardName: string): Promise<Enrolment | null> {
126
121
  const record = await this.twoFactor.find(userId)
127
122
  if (record === null || record.confirmedAt !== null) return null
@@ -223,11 +218,6 @@ export class TwoFactorService {
223
218
  }
224
219
  }
225
220
 
226
- /**
227
- * The yes-or-no the login path needs, straight off the repository. Built here
228
- * rather than from the whole service so that composing the two does not hand
229
- * the login path a key it has no use for.
230
- */
231
221
  export function enrolmentLookup(repository: TwoFactorRepository): {
232
222
  isEnrolled(userId: number): Promise<boolean>
233
223
  } {
package/src/totp/totp.ts CHANGED
@@ -6,11 +6,6 @@ export const TOTP_DIGITS = 6
6
6
 
7
7
  export const TOTP_SECRET_BYTES = 20
8
8
 
9
- /**
10
- * How far either side of now a code is still taken. One step each way covers a
11
- * device whose clock has drifted and a member who started typing at 29 seconds
12
- * past; more than that widens the window an intercepted code stays usable in.
13
- */
14
9
  export const TOTP_SKEW_STEPS = 1
15
10
 
16
11
  export function generateTotpSecret(byteLength = TOTP_SECRET_BYTES): string {
@@ -57,12 +52,6 @@ export interface TotpMatch {
57
52
  readonly step: number
58
53
  }
59
54
 
60
- /**
61
- * The step the code belongs to, or null. The step is returned rather than a
62
- * bare yes so the caller can refuse a code it has already accepted: a code is
63
- * valid for thirty seconds, and anybody who reads it over a shoulder or off a
64
- * proxy log has that long to use it first.
65
- */
66
55
  export async function matchTotp(input: {
67
56
  readonly secret: string
68
57
  readonly code: string
@@ -200,12 +200,6 @@ export class PasskeyService {
200
200
  return { account, login }
201
201
  }
202
202
 
203
- /**
204
- * The same signature check as signing in, against one named account and with
205
- * no session at the end of it. A second factor has to prove the device
206
- * belongs to the member who has just given their password — accepting any
207
- * registered passkey would let anybody past anybody's second step.
208
- */
209
203
  async proveOwnership(input: {
210
204
  readonly userId: number
211
205
  readonly credentialId: string