@nxgt/janus 0.13.0 → 0.15.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.
Files changed (92) hide show
  1. package/README.md +132 -25
  2. package/dist/auth/config/index.d.ts +1 -1
  3. package/dist/auth/config/index.d.ts.map +1 -1
  4. package/dist/auth/config/janus-config.d.ts +25 -0
  5. package/dist/auth/config/janus-config.d.ts.map +1 -1
  6. package/dist/auth/config/resolve-config.d.ts.map +1 -1
  7. package/dist/auth/config/resolve-sign-in.d.ts +5 -0
  8. package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
  9. package/dist/auth/config/resolved-config.d.ts +6 -0
  10. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  11. package/dist/auth/derived-keys.d.ts +9 -0
  12. package/dist/auth/derived-keys.d.ts.map +1 -0
  13. package/dist/auth/email-sign-in.d.ts +40 -0
  14. package/dist/auth/email-sign-in.d.ts.map +1 -0
  15. package/dist/auth/events.d.ts +22 -6
  16. package/dist/auth/events.d.ts.map +1 -1
  17. package/dist/auth/index.d.ts +2 -2
  18. package/dist/auth/index.d.ts.map +1 -1
  19. package/dist/auth/magic-link.d.ts +22 -0
  20. package/dist/auth/magic-link.d.ts.map +1 -0
  21. package/dist/auth/one-time/index.d.ts +3 -1
  22. package/dist/auth/one-time/index.d.ts.map +1 -1
  23. package/dist/auth/one-time/window.d.ts +50 -0
  24. package/dist/auth/one-time/window.d.ts.map +1 -0
  25. package/dist/auth/password-written.d.ts +7 -0
  26. package/dist/auth/password-written.d.ts.map +1 -1
  27. package/dist/auth/port/types/tokens.d.ts +6 -3
  28. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  29. package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
  30. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
  31. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  32. package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
  33. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  34. package/dist/auth/sign-in-attempts.d.ts +43 -0
  35. package/dist/auth/sign-in-attempts.d.ts.map +1 -0
  36. package/dist/auth/sign-in-code.d.ts +1 -3
  37. package/dist/auth/sign-in-code.d.ts.map +1 -1
  38. package/dist/auth/types/email-flows.d.ts +3 -3
  39. package/dist/auth/types/index.d.ts +1 -0
  40. package/dist/auth/types/index.d.ts.map +1 -1
  41. package/dist/auth/types/janus.d.ts +8 -1
  42. package/dist/auth/types/janus.d.ts.map +1 -1
  43. package/dist/auth/types/magic-link.d.ts +46 -0
  44. package/dist/auth/types/magic-link.d.ts.map +1 -0
  45. package/dist/auth/types/password.d.ts +8 -0
  46. package/dist/auth/types/password.d.ts.map +1 -1
  47. package/dist/auth/users/any-type-api.d.ts +2 -2
  48. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  49. package/dist/auth/users/fields-patch.d.ts +8 -2
  50. package/dist/auth/users/fields-patch.d.ts.map +1 -1
  51. package/dist/auth/users/record-flows.d.ts.map +1 -1
  52. package/dist/auth/users/sign-in.d.ts +2 -2
  53. package/dist/auth/users/sign-in.d.ts.map +1 -1
  54. package/dist/auth/users/type-api.d.ts +2 -1
  55. package/dist/auth/users/type-api.d.ts.map +1 -1
  56. package/dist/auth/users/update.d.ts +12 -0
  57. package/dist/auth/users/update.d.ts.map +1 -0
  58. package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
  59. package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
  60. package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
  61. package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
  62. package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
  63. package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
  64. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  65. package/dist/conformance/index.js +26 -4
  66. package/dist/conformance/index.js.map +3 -3
  67. package/dist/errors/janus-error/base.d.ts +1 -0
  68. package/dist/errors/janus-error/base.d.ts.map +1 -1
  69. package/dist/errors/janus-error/codes.d.ts +3 -1
  70. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  71. package/dist/errors/janus-error/options.d.ts +9 -2
  72. package/dist/errors/janus-error/options.d.ts.map +1 -1
  73. package/dist/index.js +296 -101
  74. package/dist/index.js.map +24 -17
  75. package/dist/permissions/index.js +3 -3
  76. package/docs/README.md +4 -3
  77. package/docs/guide/adapters.md +11 -7
  78. package/docs/guide/email-flows.md +11 -7
  79. package/docs/guide/errors.md +12 -4
  80. package/docs/guide/events.md +75 -14
  81. package/docs/guide/magic-link.md +411 -0
  82. package/docs/guide/passwords.md +116 -17
  83. package/docs/guide/second-factor.md +6 -3
  84. package/docs/guide/sign-in-code.md +14 -6
  85. package/docs/guide/users.md +13 -7
  86. package/docs/guide/vocabulary.md +6 -5
  87. package/docs/roadmap.md +37 -23
  88. package/docs/troubleshooting.md +203 -9
  89. package/package.json +1 -1
  90. /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
  91. /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
  92. /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
@@ -4,15 +4,15 @@ import {
4
4
  UNSTORABLE,
5
5
  MAX_PAGE_SIZE2,
6
6
  pageLimit2
7
- } from "../chunks/index-jp2kka6d.js";
7
+ } from "../chunks/index-kj3st57z.js";
8
8
  import {
9
9
  PermissionDepthError2,
10
10
  isSetOf2,
11
11
  formatEntity2
12
- } from "../chunks/index-j2gd8fry.js";
12
+ } from "../chunks/index-hnwggyxs.js";
13
13
  import {
14
14
  createMemoryRelations2
15
- } from "../chunks/index-km7ffctx.js";
15
+ } from "../chunks/index-6c9fbtv2.js";
16
16
 
17
17
  // src/permissions/resolve/resolved.ts
18
18
  function admits(holders, subject) {
package/docs/README.md CHANGED
@@ -14,11 +14,12 @@ below are defined once, in [Words](guide/vocabulary.md#words).
14
14
  | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
15
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
16
16
  | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
17
+ | [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
17
18
  | [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
18
19
  | [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
19
20
  | [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
20
- | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
21
- | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or rate-limiting password guesses |
21
+ | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
22
+ | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
22
23
 
23
24
  ### Permissions — `@nxgt/janus/permissions`
24
25
 
@@ -32,6 +33,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
32
33
  | --- | --- |
33
34
  | [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
34
35
  | [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
35
- | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens` and the second factor, or running the conformance suites |
36
+ | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens`, the second factor and a new token kind, or running the conformance suites |
36
37
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
37
38
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -160,7 +160,7 @@ update. A read followed by a write lets two requests redeem one reset token.
160
160
  A token is its hash, never its secret, and what it is for:
161
161
 
162
162
  ```ts
163
- type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'stepUp';
163
+ type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp';
164
164
 
165
165
  interface TokenRecord {
166
166
  readonly tokenHash: string;
@@ -178,9 +178,13 @@ interface TokenRecord {
178
178
  A token redeemed for another kind is unknown: every method that takes a
179
179
  `kind` matches on it. A `stepUp` token is a signed-in user's confirmation of
180
180
  a sensitive action; kept apart from `signInCode`, a sign-in code never
181
- confirms an action, nor an action's code signs anyone in. A store that lists
182
- the kinds — a `CHECK`, an enum in a validator — lists `stepUp` too, which
183
- `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
181
+ confirms an action, nor an action's code signs anyone in. A `magicLink`
182
+ token is a sign-in link's, new in 0.15, kept apart from `signInCode` the same
183
+ way: a sign-in code's challenge is handed to whoever asked for it, and a
184
+ store that redeemed it as a link would sign them in with no code —
185
+ `tokens.magicLinkKind` holds that it does not. A store that lists the
186
+ kinds — a `CHECK`, an enum in a validator — lists `stepUp` and `magicLink`
187
+ too, which `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
184
188
  answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
185
189
  a token of the same kind whose secret nobody is given, never redeemed and
186
190
  only counted — and its `address` is `''`: a column or a validator that refuses an
@@ -282,8 +286,8 @@ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
282
286
  Spends every **unspent** token of one user and one `kind` at `at` — but the
283
287
  one whose hash is `except`, when given — and answers how many it spent. The
284
288
  core calls it right after issuing a sign-in code, with that code's hash as
285
- `except`, so only the last code sent works — and so after issuing a step-up
286
- or a reset link; and after writing a password, for the user's
289
+ `except`, so only the last code sent works — and so after issuing a sign-in
290
+ link, a step-up or a reset link; and after writing a password, for the user's
287
291
  `resetPassword` links and `secondFactor` challenges:
288
292
 
289
293
  | The stored token | Written | Counted |
@@ -403,7 +407,7 @@ compile error naming the missing method.
403
407
 
404
408
  | Suite | Cases | Harness opens |
405
409
  | --- | --- | --- |
406
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 54: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
410
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 55: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
407
411
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
408
412
 
409
413
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -34,11 +34,13 @@ verified.emailVerified; // true
34
34
  `sendMail` above is yours to write, or
35
35
  [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)
36
36
  writes it: it takes what `send` and `request` answer, as they answer it, and
37
- sends the verification, reset and sign-in code e-mails — with the notices
37
+ sends the verification, reset, sign-in code and sign-in link e-mails — with the notices
38
38
  of a password, an e-mail or a second factor changed, and a welcome on
39
39
  `user.created` — in English and French, over any `@nxgt/mail` transport.
40
- Every link is required: `getStarted`, since janus-mail 0.4.0, is where the
41
- welcome's **Get started** button leads.
40
+ Every link is required but two: `getStarted`, since janus-mail 0.4.0, is
41
+ where the welcome's **Get started** button leads; `recoveryCodes` and
42
+ `magicLink` — the page a [sign-in link](magic-link.md) opens, since
43
+ janus-mail 0.7.0 — are optional.
42
44
 
43
45
  ```ts
44
46
  import { janusMail } from '@nxgt/janus-mail';
@@ -91,8 +93,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
91
93
  clinic.staff.verifyEmail;
92
94
  ```
93
95
 
94
- A type with an e-mail can also sign in with a code sent to it, with or
95
- without a password — see [sign-in codes](sign-in-code.md).
96
+ A type with an e-mail can also sign in with a code or a link sent to it,
97
+ with or without a password — see [sign-in codes](sign-in-code.md) and
98
+ [sign-in links](magic-link.md).
96
99
 
97
100
  ## Options
98
101
 
@@ -180,8 +183,8 @@ The links are spent **after** the password is written, so a link issued
180
183
  during the write is spent too. If the store fails at that step, the call
181
184
  rejects with `STORE_FAILED` although the password is written — answer 503 as
182
185
  usual — and the older links may still be live until the next `request`,
183
- which spends them. Sign-in codes and step-ups are not spent: the password
184
- proves neither.
186
+ which spends them. Sign-in codes, sign-in links and step-ups are not spent:
187
+ the password proves none of them.
185
188
 
186
189
  `confirm` sets the password, marks the e-mail verified — the link proved it —
187
190
  and **signs the user out everywhere**: their sessions are revoked, and every
@@ -234,6 +237,7 @@ never the token, and no refusal's message contains it.
234
237
 
235
238
  - [Users](users.md) — `email`, `update`, and the other per-type methods
236
239
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
240
+ - [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
237
241
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
238
242
  - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
239
243
  - [Errors](errors.md) — every code, and the status it deserves
@@ -54,11 +54,11 @@ that wired the library, so no handler needs to tell it apart.
54
54
  | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session | `expectedVersion`, `actualVersion` |
55
55
  | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
56
56
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
57
- | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
57
+ | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
58
58
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
59
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
59
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link | `userId` |
60
60
  | `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
61
- | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
61
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
62
62
  | `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — a recovery code not the user's, or already used — see [recovery codes](second-factor.md#signing-in-with-one) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm` and from `recover`: what the challenge has left, `0` once it is spent; from `regenerateRecoveryCodes`, what the user's 15-minute window has left, `0` past the fifth — see [regenerating them](second-factor.md#regenerating-them). None from `activate`. `userId` |
63
63
  | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
64
64
  | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
@@ -81,7 +81,11 @@ async function signIn(email: string, password: string): Promise<Response> {
81
81
  return Response.json({ token });
82
82
  } catch (error) {
83
83
  if (error instanceof CredentialError && error.code === 'CREDENTIALS_INVALID') {
84
- console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword'
84
+ console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword' | 'throttled'
85
+ if (error.retryAfter !== undefined) {
86
+ const headers = { 'retry-after': String(error.retryAfter) };
87
+ return Response.json({ error: 'invalid', retryAfter: error.retryAfter }, { status: 401, headers });
88
+ }
85
89
  return Response.json({ error: 'invalid' }, { status: 401 });
86
90
  }
87
91
  if (error instanceof StoreFailure) return new Response(null, { status: 503 });
@@ -96,6 +100,10 @@ async function signIn(email: string, password: string): Promise<Response> {
96
100
  would read as "no".
97
101
  - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
98
102
  the new `version`.
103
+ - **`CREDENTIALS_INVALID`'s `retryAfter`** belongs in the body and a
104
+ `Retry-After` header: the login is throttled, and the right password is
105
+ refused too until then. It says nothing of whether the login exists — an
106
+ unknown one is throttled alike.
99
107
  - **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
100
108
  how many attempts are left. `0` means the challenge is spent: send the visitor
101
109
  back to the password.
@@ -1,8 +1,8 @@
1
1
  # User events
2
2
 
3
3
  This page is for hearing what happens to a user once it is written: created,
4
- e-mail verified, password reset, second factor turned on or off, recovery
5
- codes regenerated or one used, deleted. Another service can then follow
4
+ e-mail verified, password reset or changed, e-mail changed, second factor
5
+ turned on or off, recovery codes regenerated or one used, deleted. Another service can then follow
6
6
  without polling. `janus` hands each event to one function you give it;
7
7
  **delivering it is yours** — or
8
8
  [`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
@@ -38,13 +38,15 @@ received[0];
38
38
  The words — **user event**, **listener** — are defined in
39
39
  [the vocabulary](vocabulary.md#identities).
40
40
 
41
- ## The eight types
41
+ ## The ten types
42
42
 
43
43
  | `type` | Sent by | Not sent |
44
44
  | --- | --- | --- |
45
45
  | `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
46
- | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
46
+ | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
47
47
  | `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
48
+ | `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
49
+ | `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
48
50
  | `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
49
51
  | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
50
52
  | `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
@@ -52,9 +54,13 @@ The words — **user event**, **listener** — are defined in
52
54
  | `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
53
55
 
54
56
  A reset whose link verifies the e-mail sends both, `user.passwordReset`
55
- first. Switch on `type`: the eight are a closed set, and TypeScript refuses a
56
- ninth. A new type is a compile error in a `switch` that exhausts them — the two
57
- recovery-code types, added in 0.10, broke such a `switch` until it handled them.
57
+ first. **A reset does not also send `user.passwordChanged`**: a listener that
58
+ cares about every new password handles both types, as the `switch` below
59
+ does — one event per write, rather than two a listener would have to tell
60
+ apart. Switch on `type`: the ten are a closed set, and TypeScript refuses an
61
+ eleventh. A new type is a compile error in a `switch` that exhausts them — the
62
+ two recovery-code types, added in 0.10, and the two change types, added in
63
+ 0.14, each broke such a `switch` until it handled them.
58
64
 
59
65
  ```ts
60
66
  function onUserEvent(event: UserEvent): void {
@@ -64,7 +70,10 @@ function onUserEvent(event: UserEvent): void {
64
70
  case 'user.emailVerified':
65
71
  return unlockFeatures(event.userId);
66
72
  case 'user.passwordReset':
67
- return alertSecurityTeam(event.userId);
73
+ case 'user.passwordChanged':
74
+ return noticeTheUser(event); // @nxgt/janus-mail's passwordChanged — see below
75
+ case 'user.emailChanged':
76
+ return noticeTheFormerAddress(event); // to event.formerEmail — see below
68
77
  case 'user.secondFactorEnabled':
69
78
  case 'user.secondFactorDisabled':
70
79
  return noticeTheUser(event); // @nxgt/janus-mail's twoFactorEnabled or twoFactorDisabled
@@ -114,14 +123,59 @@ With several user types, the count is the type's:
114
123
  `auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
115
124
  `event.userType`.
116
125
 
126
+ ### Telling the user their password or e-mail changed
127
+
128
+ A password changed by someone else, or an e-mail moved to their inbox, is how
129
+ an account is taken over — so tell the user each time. For a password, read
130
+ the user and write to their address. For an e-mail, write to the **former**
131
+ address: the new one belongs to whoever changed it. The event carries it,
132
+ since nothing else keeps it once the write landed:
133
+
134
+ ```ts
135
+ // `mail` is janusMail({ … }), and the user schema holds a name and a locale.
136
+ const auth = janus({
137
+ ...config,
138
+ async events(event) {
139
+ if (event.type === 'user.passwordChanged') {
140
+ const user = await auth.get(event.userId);
141
+ await mail.passwordChanged({ name: user.name, locale: user.locale, email: user.email });
142
+ }
143
+ if (event.type === 'user.emailChanged' && event.formerEmail != null) {
144
+ const user = await auth.get(event.userId);
145
+ await mail.emailChanged({
146
+ name: user.name,
147
+ locale: user.locale,
148
+ formerEmail: event.formerEmail, // to the inbox the account just left
149
+ newEmail: user.email,
150
+ });
151
+ }
152
+ },
153
+ });
154
+ ```
155
+
156
+ `formerEmail` is `null` for a user who had no e-mail before the update:
157
+ there is no former inbox to tell. The example assumes a required e-mail; with
158
+ an optional one, an update that removes it sends the event too, and the user
159
+ read back has no `newEmail` to name — check `user.email` before the notice. Pass the same notices on `user.passwordReset`
160
+ too, if a reset should be told as well.
161
+
117
162
  ## What an event carries
118
163
 
119
- **The user named by id, and nothing else.** No login, no e-mail, no field of
120
- the schema, no password or hash, no session or one-time token. An event can
164
+ **The user named by id, and nothing else** — with one exception, below. No
165
+ login, no field of the schema, no password or hash, no session or one-time
166
+ token. An event can
121
167
  land in a queue, a log or another company's endpoint; whoever receives it
122
168
  reads the rest from where it is kept — `auth.get(event.userId)` — if they
123
169
  may. A user deleted since is `NOT_FOUND` there, which is the answer.
124
170
 
171
+ **The exception is `formerEmail`, on `user.emailChanged` only**: the address
172
+ the user had before the update, as it was stored, or `null` when they had
173
+ none. Once the write landed it is kept nowhere else, and a notice to the
174
+ inbox the account just left needs it. It is absent from every other type.
175
+ `@nxgt/janus-webhooks` does not carry it: it strips it before the queue, so
176
+ it reaches no endpoint, no queue and no `onGivingUp` report — send the
177
+ notice from the listener, in the process that wrote.
178
+
125
179
  `id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
126
180
  a queue job id, a primary key, a webhook's `webhook-id` header. Two events
127
181
  never share one.
@@ -148,15 +202,18 @@ Where the listener runs within a flow, and what an outage does to it:
148
202
  | `create` | after the insert — its last step | — |
149
203
  | `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
150
204
  | `verifyEmail.confirm` | after the write — the flow's last step | — |
151
- | `signInCode.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
205
+ | `update` | after the write — the flow's last step | — |
206
+ | `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
207
+ | `signInCode.confirm`, `magicLink.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
152
208
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
153
209
  | `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
154
210
  | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
155
211
  | `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
156
212
 
157
213
  So a listener that takes its time never leaves an old session alive after a
158
- reset, and one that checks permissions on `user.deleted` finds the tuples
159
- gone. A retry could not send a lost event either way: `delete` then finds
214
+ reset, one that hears of a password changed finds the older reset links
215
+ already spent, and one that checks permissions on `user.deleted` finds the
216
+ tuples gone. A retry could not send a lost event either way: `delete` then finds
160
217
  nobody, and the reset link is spent.
161
218
 
162
219
  `occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
@@ -231,6 +288,8 @@ type UserEventType =
231
288
  | 'user.created'
232
289
  | 'user.emailVerified'
233
290
  | 'user.passwordReset'
291
+ | 'user.passwordChanged'
292
+ | 'user.emailChanged'
234
293
  | 'user.secondFactorEnabled'
235
294
  | 'user.secondFactorDisabled'
236
295
  | 'user.recoveryCodesRegenerated'
@@ -243,6 +302,7 @@ interface UserEvent {
243
302
  readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
244
303
  readonly userId: Id;
245
304
  readonly userType: string;
305
+ readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
246
306
  }
247
307
 
248
308
  type UserEventListener = (event: UserEvent) => void | Promise<void>;
@@ -257,5 +317,6 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
257
317
 
258
318
  - [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
259
319
  - [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled`; [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
260
- - [Users](users.md) — `create`, `signUp`, `delete`
320
+ - [Users](users.md) — `create`, `signUp`, `update`, `delete`
321
+ - [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
261
322
  - [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`