@meith/accounts 0.21.1 → 0.22.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.1",
3
+ "version": "0.22.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.1"
23
+ "@meith/i18n": "0.22.0"
24
24
  }
25
25
  }
@@ -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',
@@ -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
package/src/ports.ts CHANGED
@@ -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
  }
package/src/service.ts CHANGED
@@ -25,11 +25,6 @@ export interface IdentityDeps {
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 =
@@ -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
@@ -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