@nxgt/janus 0.22.0 → 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.
Files changed (54) hide show
  1. package/README.md +97 -17
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/config/warn-throttle-window.d.ts.map +1 -1
  7. package/dist/auth/email-flows/change-email-token.d.ts +42 -0
  8. package/dist/auth/email-flows/change-email-token.d.ts.map +1 -0
  9. package/dist/auth/email-flows/change-email.d.ts +11 -0
  10. package/dist/auth/email-flows/change-email.d.ts.map +1 -0
  11. package/dist/auth/email-flows/flows.d.ts +5 -5
  12. package/dist/auth/email-flows/flows.d.ts.map +1 -1
  13. package/dist/auth/events.d.ts +3 -2
  14. package/dist/auth/events.d.ts.map +1 -1
  15. package/dist/auth/index.d.ts +1 -1
  16. package/dist/auth/index.d.ts.map +1 -1
  17. package/dist/auth/mail-requests.d.ts +6 -3
  18. package/dist/auth/mail-requests.d.ts.map +1 -1
  19. package/dist/auth/one-time/issue.d.ts +7 -1
  20. package/dist/auth/one-time/issue.d.ts.map +1 -1
  21. package/dist/auth/port/types/tokens.d.ts +10 -4
  22. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  23. package/dist/auth/types/change-email.d.ts +51 -0
  24. package/dist/auth/types/change-email.d.ts.map +1 -0
  25. package/dist/auth/types/index.d.ts +1 -0
  26. package/dist/auth/types/index.d.ts.map +1 -1
  27. package/dist/auth/types/janus.d.ts +2 -1
  28. package/dist/auth/types/janus.d.ts.map +1 -1
  29. package/dist/auth/types/user-type.d.ts +3 -1
  30. package/dist/auth/types/user-type.d.ts.map +1 -1
  31. package/dist/auth/users/any-type-api.d.ts +2 -2
  32. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  33. package/dist/auth/users/update.d.ts +2 -1
  34. package/dist/auth/users/update.d.ts.map +1 -1
  35. package/dist/conformance/cases/tokens/change-email.d.ts +8 -0
  36. package/dist/conformance/cases/tokens/change-email.d.ts.map +1 -0
  37. package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
  38. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  39. package/dist/conformance/index.js +92 -57
  40. package/dist/conformance/index.js.map +6 -5
  41. package/dist/errors/janus-error/codes.d.ts +7 -3
  42. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  43. package/dist/index.js +134 -31
  44. package/dist/index.js.map +13 -11
  45. package/docs/README.md +1 -1
  46. package/docs/guide/adapters.md +13 -7
  47. package/docs/guide/email-flows.md +90 -10
  48. package/docs/guide/errors.md +3 -3
  49. package/docs/guide/events.md +5 -4
  50. package/docs/guide/users.md +10 -8
  51. package/docs/guide/vocabulary.md +3 -3
  52. package/docs/roadmap.md +13 -10
  53. package/docs/troubleshooting.md +32 -12
  54. package/package.json +3 -2
package/docs/README.md CHANGED
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
13
13
  | --- | --- |
14
14
  | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` — or finding a user by e-mail on a type with no password (`findByEmail`) |
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 — or sign in a user your application proved itself, such as on accepting an invitation (`signInProven`) |
16
- | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — or proving an address on your own proof, such as an invitation (`verifyEmail.proveFirst`) — and how requests that send e-mail are throttled per address or user |
16
+ | [E-mail verification, password reset and changing an e-mail](guide/email-flows.md) | You are sending a verification, reset or e-mail-change link (`changeEmail`), and handling what comes back — or proving an address on your own proof, such as an invitation (`verifyEmail.proveFirst`) — and how requests that send e-mail are throttled per address or user |
17
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 |
18
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 |
19
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 |
@@ -196,14 +196,14 @@ update. A read followed by a write lets two requests redeem one reset token.
196
196
  A token is its hash, never its secret, and what it is for:
197
197
 
198
198
  ```ts
199
- type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp';
199
+ type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp' | 'changeEmail';
200
200
 
201
201
  interface TokenRecord {
202
202
  readonly tokenHash: string;
203
203
  readonly kind: TokenKind;
204
204
  readonly userId: Id;
205
- readonly address: string; // '' for a secondFactor challenge: nothing was sent
206
- readonly codeHash: string | null; // an e-mailed code, hashed — signInCode, or stepUp by e-mail; else null
205
+ readonly address: string; // '' for a secondFactor challenge: nothing was sent; the NEW address for changeEmail
206
+ readonly codeHash: string | null; // an e-mailed code, hashed — signInCode, or stepUp by e-mail; changeEmail: the address it replaces, keyed; else null
207
207
  readonly attempts: number; // 0 at insertion
208
208
  readonly expiresAt: Date;
209
209
  readonly spentAt: Date | null;
@@ -218,9 +218,15 @@ confirms an action, nor an action's code signs anyone in. A `magicLink`
218
218
  token is a sign-in link's, new in 0.15, kept apart from `signInCode` the same
219
219
  way: a sign-in code's challenge is handed to whoever asked for it, and a
220
220
  store that redeemed it as a link would sign them in with no code —
221
- `tokens.magicLinkKind` holds that it does not. A store that lists the
222
- kinds — a `CHECK`, an enum in a validator — lists `stepUp` and `magicLink`
223
- too, which `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
221
+ `tokens.magicLinkKind` holds that it does not. A `changeEmail` token is the
222
+ link sent to a user's new address; its `address` is that new address, not the
223
+ one the user holds, and its `codeHash` is the address it replaces, keyed by the
224
+ token's secret — a store keeps both byte for byte. It is kept apart from
225
+ `verifyEmail` the same way: redeemed as a verification it would mark the new
226
+ address verified without changing it, and `tokens.changeEmailKind` holds that
227
+ it is not. A store that lists the kinds — a `CHECK`, an enum in a validator —
228
+ lists `stepUp`, `magicLink` and `changeEmail` too, which `tokens.everyKind`
229
+ proves. A `secondFactor` token is the challenge `signIn`
224
230
  answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
225
231
  a token of the same kind whose secret nobody is given, never redeemed and
226
232
  only counted — and its `address` is `''`: a column or a validator that refuses an
@@ -443,7 +449,7 @@ compile error naming the missing method.
443
449
 
444
450
  | Suite | Cases | Harness opens |
445
451
  | --- | --- | --- |
446
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 58: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
452
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 59: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
447
453
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
448
454
 
449
455
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -1,8 +1,8 @@
1
- # E-mail verification and password reset
1
+ # E-mail verification, password reset and changing an e-mail
2
2
 
3
- This page is for the two one-time-token flows: proving a user holds their
4
- e-mail, and resetting a forgotten password. `janus` issues and redeems the
5
- tokens; **sending the e-mail is yours**.
3
+ This page is for the one-time-token flows: proving a user holds their
4
+ e-mail, resetting a forgotten password, and moving a user to a new e-mail.
5
+ `janus` issues and redeems the tokens; **sending the e-mail is yours**.
6
6
 
7
7
  ```ts
8
8
  import { z } from 'zod';
@@ -40,7 +40,12 @@ of a password, an e-mail or a second factor changed, and a welcome on
40
40
  Every link is required but two: `getStarted`, since janus-mail 0.4.0, is
41
41
  where the welcome's **Get started** button leads; `recoveryCodes` and
42
42
  `magicLink` — the page a [sign-in link](magic-link.md) opens, since
43
- janus-mail 0.7.0 — are optional.
43
+ janus-mail 0.7.0 — are optional. **`changeEmail.send` is not among them
44
+ yet**: janus-mail has no template for the link to a new address. Send that
45
+ link with your own mailer; janus-mail sends the notice to the former address
46
+ (`emailChanged`). `mail.verifyEmail(issued)` is not the change-of-e-mail
47
+ mail: it would ask the new address to verify, not say the account moves
48
+ there.
44
49
 
45
50
  ```ts
46
51
  import { janusMail } from '@nxgt/janus-mail';
@@ -104,6 +109,7 @@ with or without a password — see [sign-in codes](sign-in-code.md) and
104
109
  | `email` | a field name | `'email'` | The field the tokens are sent to, per type |
105
110
  | `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
106
111
  | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
112
+ | `tokens.changeEmail` | `Duration` | `'1h'` | How long the link sent to a new address lives |
107
113
 
108
114
  ## `verifyEmail`
109
115
 
@@ -189,6 +195,79 @@ malformed id, an unknown one or one of another type is `NOT_FOUND`, as for
189
195
  and writes `janus.email.verified` with `janus.email.proveFirst: true` when
190
196
  it proved — the user's id, never the address.
191
197
 
198
+ ## `changeEmail`
199
+
200
+ ```ts
201
+ readonly changeEmail: {
202
+ send(user: UserRef, email: string): Promise<IssuedToken>; // { token, email, expiresAt } — email is the NEW address
203
+ confirm(token: string): Promise<User>;
204
+ };
205
+ ```
206
+
207
+ `send` issues a token for the **new** address and changes nothing. The
208
+ address is validated as `update` validates it (`USER_INVALID`), the user must
209
+ be active (`USER_INACTIVE`), and an address equal to the user's current one
210
+ once normalised is `USER_INVALID` too — a change moves to another address;
211
+ verifying the current one is `verifyEmail`'s job. The call is throttled **per
212
+ new address and per user**, five per window each, before anything is issued;
213
+ a refusal carries the user's id. A `USER_INVALID` or a `USER_INACTIVE`
214
+ spends nothing. The user is counted first, so an account past its own count
215
+ spends no address's and can exhaust at most one address per window; an
216
+ address other people exhausted costs the asking user one of their own. The user's
217
+ earlier links are spent: only the last one works. Whether another user holds
218
+ the address is not checked, so `send` tells nobody who has an account.
219
+
220
+ `confirm` spends the token, moves the user to the address and marks it
221
+ verified (an address already verified keeps its date). The password, the
222
+ second factor and the sessions are untouched — the whole account moves with
223
+ the user, so the mail should say "ignore it if it was not you". If
224
+ the address was taken meanwhile, the store answers `LOGIN_TAKEN` and nothing
225
+ is written — the token is already spent. After an `update` that moves the
226
+ e-mail, the pending links are `TOKEN_SPENT`: `update` spends them, so a move
227
+ back to the old address revives none. `TOKEN_STALE` is a link whose e-mail
228
+ is no longer the one it was asked from although nothing spent it: a write
229
+ outside janus, or a `send` racing an `update`. `confirm` sends `user.emailChanged` (with
230
+ `formerEmail`) and `user.emailVerified`.
231
+
232
+ **Gate `send`, and tell the former address.** Whoever changes the e-mail
233
+ takes over every reset: call `send` behind a fresh sign-in or a step-up.
234
+
235
+ ```ts
236
+ import { assertFresh, janus } from '@nxgt/janus';
237
+
238
+ declare function sendMail(to: string, body: string): Promise<void>; // your mailer
239
+
240
+ const auth = janus({
241
+ // …
242
+ // the former address hears of it
243
+ events: async (event) => {
244
+ if (event.type === 'user.emailChanged' && event.formerEmail != null) {
245
+ await sendMail(event.formerEmail, 'Your e-mail was changed.');
246
+ }
247
+ },
248
+ });
249
+
250
+ app.post('/email/change', async (c) => {
251
+ const current = await auth.authenticate(c.req.raw);
252
+ if (current === null) return c.body(null, 401);
253
+ assertFresh(current.session, '5m'); // STEP_UP_REQUIRED past five minutes — or a step-up
254
+ const { email } = await c.req.json<{ email: string }>();
255
+ const issued = await auth.changeEmail.send(current.user, email);
256
+ await sendMail(issued.email, `https://app.example/email/confirm?token=${issued.token}`);
257
+ return c.body(null, 202);
258
+ });
259
+
260
+ // the confirm page POSTs the token back — never confirm on the GET
261
+ app.post('/email/confirm', async (c) => {
262
+ const { token } = await c.req.json<{ token: string }>();
263
+ await auth.changeEmail.confirm(token);
264
+ return c.body(null, 204);
265
+ });
266
+ ```
267
+
268
+ The adapter's store must list the `changeEmail` token kind first:
269
+ `@nxgt/janus-drizzle` 0.8's migration, `@nxgt/janus-mongo` 0.9's sync.
270
+
192
271
  ## `resetPassword`
193
272
 
194
273
  ```ts
@@ -329,6 +408,7 @@ try {
329
408
  | `magicLink.request(email)` | address | Anybody can call it with any address. Counted before the address is looked up, so an address nobody holds is refused like a registered one |
330
409
  | `signInCode.request(email)` | address | The same |
331
410
  | `resetPassword.request(email)` | address | The same |
411
+ | `changeEmail.send(user, email)` | user, and new address | Counted twice (the user, then the new address), five per window each, before anything is issued — a refusal carries the user's id: a loop on one user cannot fill an inbox, and one address cannot be flooded from many users |
332
412
  | `verifyEmail.send(user)` | user | The application calls it for a user it knows: an unauthenticated loop on the address cannot spend this count, so it never blocks the user's own verification, and a user changing e-mail buys no extra sends |
333
413
  | `stepUp.request(user)`, when it e-mails a code (`via: 'email'`) | user | The same. A step-up confirmed with the app (`via: 'secondFactor'`) sends nothing and is not counted |
334
414
 
@@ -534,8 +614,8 @@ no longer yours to make. For an address nobody holds, `send()` answers
534
614
  issues is checked against it. See
535
615
  [the sign-in code guide](sign-in-code.md#requesting-a-code-in-two-steps).
536
616
 
537
- **`verifyEmail.send` and `stepUp.request` have no `prepare`.** They are
538
- counted per user, and called for a user the application already holds — the
617
+ **`verifyEmail.send`, `changeEmail.send` and `stepUp.request` have no
618
+ `prepare`.** They are counted per user, and called for a user the application already holds — the
539
619
  session's, or one it just created — so their time tells nothing about who
540
620
  has an account, and their `MAIL_THROTTLED` already reaches the visitor in
541
621
  the request that asked.
@@ -551,9 +631,9 @@ as by `request`.
551
631
  | Code | When |
552
632
  | --- | --- |
553
633
  | `TOKEN_UNKNOWN` | No token holds that secret — or it was issued for the other flow: a verification token is not a reset token |
554
- | `TOKEN_SPENT` | Already redeemed — every token is single use. A reset link is also spent by a newer `request` for the same user, and by any password written since it was sent |
634
+ | `TOKEN_SPENT` | Already redeemed — every token is single use. A reset link is also spent by a newer `request` for the same user, and by any password written since it was sent; a change link, by a newer send or an update that moves the e-mail |
555
635
  | `TOKEN_EXPIRED` | Its lifespan passed. It is spent all the same, so it cannot be retried |
556
- | `TOKEN_STALE` | Sent to an e-mail the user no longer has |
636
+ | `TOKEN_STALE` | Asked for an e-mail the user no longer has: the link was sent to an address they have since left — or, for `changeEmail`, asked while they held an address they have since changed. Send a new link |
557
637
 
558
638
  Of twenty concurrent redemptions of one token, exactly one succeeds: the store
559
639
  spends it in one conditional write. The store holds the token's `sha256`,
@@ -565,5 +645,5 @@ never the token, and no refusal's message contains it.
565
645
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
566
646
  - [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
567
647
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
568
- - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms and `proveFirst` send
648
+ - [User events](events.md) — `user.emailVerified`, `user.emailChanged` and `user.passwordReset`, which the confirms and `proveFirst` send
569
649
  - [Errors](errors.md) — every code, and the status it deserves
@@ -50,14 +50,14 @@ that wired the library, so no handler needs to tell it apart.
50
50
  | --- | --- | --- | --- | --- |
51
51
  | `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
52
52
  | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, `verifyEmail.proveFirst`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
53
- | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `login`, `userType` |
53
+ | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login — on `changeEmail.confirm`, the new address | `login`, `userType` |
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. From `verifyEmail.proveFirst`, the user was written between its read and its write: nothing was proved — call it again | `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
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
- | `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` only for the per-user flows. No `reason` |
60
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` and `verifyEmail.proveFirst` | `userId` |
59
+ | `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail (`changeEmail.send` included, per user and per new address) was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` for the per-user flows, and for `changeEmail.send` whichever of its two counts refused. No `reason` |
60
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven`, `verifyEmail.proveFirst` and `changeEmail.send` | `userId` |
61
61
  | `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` |
62
62
  | `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` |
63
63
  | `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` |
@@ -44,10 +44,10 @@ The words — **user event**, **listener** — are defined in
44
44
  | `type` | Sent by | Not sent |
45
45
  | --- | --- | --- |
46
46
  | `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`) |
47
- | `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `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
+ | `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `changeEmail.confirm`, whose link reached the new address; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified — `changeEmail.confirm` to the same address in another case included — or a refused confirm |
48
48
  | `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
49
49
  | `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; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
50
- | `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 |
50
+ | `user.emailChanged` | `update` and `changeEmail.confirm`, when they 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 |
51
51
  | `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 |
52
52
  | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
53
53
  | `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
@@ -243,7 +243,8 @@ Where the listener runs within a flow, and what an outage does to it:
243
243
  | `create` | after the insert — its last step | — |
244
244
  | `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
245
245
  | `verifyEmail.confirm` | after the write — the flow's last step | — |
246
- | `update` | after the write — the flow's last step | — |
246
+ | `update` | after the write — and, when it moved the e-mail, after the user's pending change links are spent | fails the call with `STORE_FAILED`, the write landed; the event is sent all the same, from a `finally` |
247
+ | `changeEmail.confirm` | after the write — the flow's last step: `user.emailChanged`, then `user.emailVerified` | — (a `VERSION_CONFLICT` or `LOGIN_TAKEN` leaves nothing written and sends nothing; the token is already spent) |
247
248
  | `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` |
248
249
  | `signInCode.confirm`, `magicLink.confirm`, `verifyEmail.proveFirst` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code, link or `proveFirst` proves the e-mail again |
249
250
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
@@ -359,7 +360,7 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
359
360
 
360
361
  ## See also
361
362
 
362
- - [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
363
+ - [E-mail verification, password reset and changing an e-mail](email-flows.md) — the flows that send `user.emailVerified`, `user.passwordReset` and, with `changeEmail`, `user.emailChanged`
363
364
  - [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled` — sent too by the first proof of an e-mail by a [code](sign-in-code.md) or a [link](magic-link.md#an-account-someone-else-registered); [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
364
365
  - [Devices](devices.md) — the sign-ins that send `user.newDeviceSignedIn`, the device token and the cookie
365
366
  - [Users](users.md) — `create`, `signUp`, `update`, `delete`
@@ -98,7 +98,7 @@ that cuts an emoji in half, say.
98
98
  | `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
99
99
  | `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
100
100
  | `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
101
- | `mail.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 5, window: '10m' }` | E-mails one address (`magicLink`, `signInCode`, `resetPassword` requests) or one user (`verifyEmail.send`, an e-mailed `stepUp.request`) may ask for per flow and window, before the request throws `MAIL_THROTTLED` with `retryAfter` and issues nothing (the last link or code sent still works). Keep `window` no longer than the shortest `tokens.*` lifetime you use. `false` counts nothing. See [e-mail flows](email-flows.md#requests-that-send-e-mail-are-throttled) |
101
+ | `mail.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 5, window: '10m' }` | E-mails one address (`magicLink`, `signInCode`, `resetPassword` requests) or one user (`verifyEmail.send`, an e-mailed `stepUp.request`, and `changeEmail.send`, which counts the new address too) may ask for per flow and window, before the request throws `MAIL_THROTTLED` with `retryAfter` and issues nothing (the last link or code sent still works). Keep `window` no longer than the shortest `tokens.*` lifetime you use. `false` counts nothing. See [e-mail flows](email-flows.md#requests-that-send-e-mail-are-throttled) |
102
102
  | `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
103
103
  | `devices` | `{ keys }` | none | The keys device tokens are signed with, to tell a sign-in from a new device. Without it, no sign-in may be given a `device` — see [devices](devices.md#wiring) |
104
104
  | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
@@ -183,7 +183,7 @@ In the one-type form these are on `auth` itself; with several types, on
183
183
  | `findMany(ids)` | the users, in the order of `ids`, each once; an id `find` answers `null` for is left out — see [below](#several-users-at-once--findmany) | |
184
184
  | `get(id)` | the user | `NOT_FOUND` |
185
185
  | `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
186
- | `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
186
+ | `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed; to move a user to a proved address, use [`changeEmail`](email-flows.md#changeemail) | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
187
187
  | `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
188
188
  | `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
189
189
  | `signInProven(user, { device? })` | **privileged** — after a proof your server verified, such as an invitation token: what `signIn` answers, a session or a second-factor challenge; no credential checked, nothing sent, counted or spent — see [sessions](sessions.md#signing-in-on-your-own-proof--signinproven) | `NOT_FOUND`, `USER_INACTIVE` |
@@ -208,12 +208,14 @@ With `secondFactor` configured, a type with a password also answers
208
208
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
209
209
  [the second factor](second-factor.md).
210
210
 
211
- Every method may also reject with `STORE_FAILED`. `setPassword` and
212
- `changePassword` spend the user's reset links and second-factor challenges
213
- after the write, so a `STORE_FAILED` there may come with the password
214
- already written: a retried `changePassword` with the old `current` then
215
- answers `CREDENTIALS_INVALID`. A `user` argument is a user
216
- or its id (`UserRef = string | { id: string }`).
211
+ Every method may also reject with `STORE_FAILED`. `update` (when it moves
212
+ the e-mail: it spends the user's pending change links), `setPassword` and
213
+ `changePassword` (they spend the user's reset links and second-factor
214
+ challenges) do that after the write, so a `STORE_FAILED` there may come with
215
+ the write already landed — the e-mail moved, or the password changed: a
216
+ retried `changePassword` with the old `current` then answers
217
+ `CREDENTIALS_INVALID`. A `user` argument is a user or its id
218
+ (`UserRef = string | { id: string }`).
217
219
 
218
220
  ```ts
219
221
  const grace = await auth.create({ email: 'grace@example.com', name: 'Grace', active: false });
@@ -53,13 +53,13 @@ either finds this row.
53
53
  | **recovery code** | One of ten single-use codes `secondFactor.activate` and `secondFactor.regenerateRecoveryCodes` answer **once** — ten characters, shown `xxxxx-xxxxx` — that `secondFactor.recover` takes instead of a TOTP code, for a user whose authenticator app is gone. Stored only as keyed hashes. Always written in full: *code* alone is a one-time code, and a recovery code is never one | "backup code", "scratch code", "recovery key" |
54
54
  | **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
55
55
  | **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm` — or `secondFactor.recover`, with a recovery code; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`; `stepUp.request` answers one for `stepUp.confirm`. Stored as its hash, as a one-time token of kind `secondFactor`, `signInCode` or `stepUp`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
56
- | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request`, `magicLink.request`, `stepUp.request` or `resetPassword.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
56
+ | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request`, `magicLink.request`, `stepUp.request`, `resetPassword.request` or `changeEmail.send` for the same user (at most one of each is live at a time), an `update` that moves the e-mail (every change link), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
57
57
  | **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
58
58
  | **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
59
59
  | **device**, **device token** | A device is a browser or an app a user signs in from, as the client's long-lived cookie — or a bearer client's storage — tells it. A **device token** is what janus signs for it with `devices.keys`, answers as `deviceToken`, and takes back as `device`: `d1.<key id>.<device id>.<mac>`, bound to one user, stored nowhere. A sign-in from a **new device** — one whose token did not prove this user signed in there — answers `newDevice: true` and sends `user.newDeviceSignedIn`. See [devices](devices.md) | "fingerprint", "trusted device", "remembered browser"; "token" alone |
60
60
  | **token** | Never alone in prose: a *session token*, a *one-time token* or a *device token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
61
- | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
62
- | **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
61
+ | **e-mail flow** | `verifyEmail`, `resetPassword` or `changeEmail`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
62
+ | **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `changeEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is, except `changeEmail.send`'s: it has no template for that link yet, so send it with your own mailer | "result", "payload" |
63
63
  | **notice** | An e-mail that tells a user of a change and asks for nothing: *password changed*, *e-mail changed* — the latter sent to the former address — *two-factor enabled*, *two-factor disabled*, *recovery code used* and *new sign-in*. `@nxgt/janus-mail` sends all six | "alert", "notification" |
64
64
  | **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.newDeviceSignedIn`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail`, `user.newDeviceSignedIn` adds `sessionId` — with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
65
65
  | **step-up**, **fresh** | A step-up is a signed-in user proving again who they are before a sensitive action — `stepUp.request`, then `stepUp.confirm`, with an e-mailed code or, when their second factor is active, their app's — which moves the session's `authenticatedAt` to now and opens no session. A session is **fresh** for `maxAge` after it proved who it is, at the sign-in or by a step-up; `assertFresh` refuses an older one with `STEP_UP_REQUIRED`. See [step-up](step-up.md) | "re-authentication", "sudo mode", "elevated session"; "recent sign-in" once a step-up counts too |
package/docs/roadmap.md CHANGED
@@ -76,6 +76,19 @@ Nothing yet.
76
76
  The last ten, newest first, each with the version it came in. Everything
77
77
  before is in the [CHANGELOG](../CHANGELOG.md).
78
78
 
79
+ - **Changing a user's e-mail, v0.23.0** —
80
+ `auth.<type>.changeEmail.send(user, email)` issues a link for the new
81
+ address, and `changeEmail.confirm(token)` moves the user there, verified,
82
+ keeping the password, the second factor and the sessions. The address is
83
+ validated as `update` does and never checked for a holder at `send`;
84
+ `confirm` answers `LOGIN_TAKEN` with the token spent. Throttled per user
85
+ and per new address; a link lives an hour (`tokens.changeEmail`); a newer
86
+ `send` spends the earlier links, and so does an `update` that moves the
87
+ e-mail, so moving back revives none. `user.emailChanged` carries `formerEmail` so the
88
+ former address can be told. Gate `send` behind a fresh sign-in or a
89
+ step-up. **Breaking for an adapter: `TokenKind` gains `'changeEmail'`**,
90
+ which a `CHECK` or a validator's enum must list — the janus-drizzle 0.8
91
+ migration, the janus-mongo 0.9 sync.
79
92
  - **Proving an e-mail on the application's own proof, v0.22.0** —
80
93
  `auth.<type>.verifyEmail.proveFirst(user)` applies the first proof of an
81
94
  e-mail that a sign-in link or code applies — verified, the password and
@@ -157,13 +170,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
157
170
  from a `POST`, so a mail scanner that opens the link spends nothing.
158
171
  **Breaking for an adapter: `TokenKind` gains `'magicLink'`**, which a
159
172
  `CHECK` or a validator's enum must list.
160
- - **Password guessing throttled per login, v0.14.0** — past ten passwords
161
- tried at one login in a 15-minute window, `signIn` answers
162
- `CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
163
- password included, until the window ends. Nothing locks; a login nobody
164
- holds is counted alike; a password sign-in that opens a session —
165
- after its second factor, when one is active — starts the count again.
166
- On by default: `signIn: { throttle: { attempts, window } }` changes it,
167
- `signIn: { throttle: false }` turns it off. A tokens store that cannot
168
- count fails the sign-in with `STORE_FAILED`. No change for adapters: the
169
- counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
@@ -49,6 +49,7 @@ How the messages are shaped:
49
49
 
50
50
  **Users, sessions and tokens**
51
51
  - [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
52
+ - [`STORE_FAILED` — `tokens.insertToken: the store could not answer`, on `changeEmail.send` alone](#store_failed--tokensinserttoken-the-store-could-not-answer-on-changeemailsend-alone)
52
53
  - [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
53
54
  - [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
54
55
  - [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
@@ -167,6 +168,7 @@ How the messages are shaped:
167
168
  - [`reauthenticateSession racing revokeSession: the session should stay revoked — one conditional write, never a read then a write`](#reauthenticatesession-racing-revokesession-the-session-should-stay-revoked--one-conditional-write-never-a-read-then-a-write)
168
169
  - [`tokens.insertToken: the store could not answer`, in `tokens.everyKind`](#tokensinserttoken-the-store-could-not-answer-in-tokenseverykind)
169
170
  - [`consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`](#consumetoken-for-a-signincode-token-redeemed-as-magiclink-in-tokensmagiclinkkind)
171
+ - [`consumeToken for a verifyEmail token redeemed as changeEmail`, in `tokens.changeEmailKind`](#consumetoken-for-a-verifyemail-token-redeemed-as-changeemail-in-tokenschangeemailkind)
170
172
 
171
173
  ---
172
174
 
@@ -366,7 +368,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
366
368
 
367
369
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
368
370
 
369
- The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.magicLink`, `tokens.stepUp`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
371
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.magicLink`, `tokens.stepUp`, `tokens.changeEmail`, `secondFactor.challenge` and `signIn.throttle.window` — and, at call time rather than in `janus()`, `assertFresh: maxAge: "<value>" is not a duration; …`, from a `maxAge` written wrong. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
370
372
 
371
373
  **When:** `janus({...})`.
372
374
  **Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
@@ -546,6 +548,14 @@ try {
546
548
  }
547
549
  ```
548
550
 
551
+ ### `STORE_FAILED` — `tokens.insertToken: the store could not answer`, on `changeEmail.send` alone
552
+
553
+ `StoreFailure`, whose `error.cause` reads `new row for relation "tokens" violates check constraint "tokens_kind"` with `@nxgt/janus-drizzle`, or `Document failed validation` with `@nxgt/janus-mongo`.
554
+
555
+ **When:** `changeEmail.send`, on a database not yet migrated for the `changeEmail` token kind; every other call works.
556
+ **Why:** `changeEmail` is a token kind of its own, and the adapter's table (drizzle's `tokens_kind` check) or collection validator (mongo's `kind` enum) does not list it yet.
557
+ **Fix:** `@nxgt/janus-drizzle` 0.8: apply its migration (`drizzle-kit migrate`). `@nxgt/janus-mongo` 0.9: run `syncMongoAdapter` once at start-up (`syncMongoStores` for an application that only authenticates, as the sync guide says). Until then answer 503, as for any outage.
558
+
549
559
  ### `STORE_FAILED` — `<slot>.<method> answered undefined: an absence is null, so this store forgot to answer`
550
560
 
551
561
  **When:** with a store you wrote, on the first call to that method.
@@ -617,7 +627,8 @@ const user = await auth.find(id); // null when there is nobody
617
627
 
618
628
  **When:** `signUp`, `create`, or an `update` that changes the login, when another user of **the same type** holds it after normalisation (`Ada@Example.com` and `ada@example.com` collide by default).
619
629
  **Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient user and a staff user.
620
- **Fix:** answer 409. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
630
+ **On `changeEmail.confirm`** the address was free when `changeEmail.send` asked — `send` never checks — and somebody took it before the link was used. The link is already spent and nothing was written: the user keeps their e-mail.
631
+ **Fix:** answer 409. On `changeEmail.confirm`, tell the user the address is taken and ask for another with a new `changeEmail.send`. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
621
632
 
622
633
  ### `VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`
623
634
 
@@ -683,8 +694,8 @@ does the first.
683
694
 
684
695
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
685
696
 
686
- **When:** `signUp`, `create`, `update`.
687
- **Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
697
+ **When:** `signUp`, `create`, `update`, and `changeEmail.send`.
698
+ **Why:** your schema refused the fields. `changeEmail.send` also answers it, with one issue at the e-mail field (`is the current e-mail of the user`), for an address equal to the user's current one once normalised: nothing is counted or issued; answer 400, or offer `verifyEmail.send` to prove the current one. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
688
699
  **Fix:** answer 400, field by field:
689
700
 
690
701
  ```ts
@@ -747,7 +758,8 @@ janus({ ..., signIn: { throttle: { attempts: 20, window: '15m' } } });
747
758
  it, or wait for the next window` and carry `userId`.
748
759
 
749
760
  **When:** `magicLink.request`, `signInCode.request` or
750
- `resetPassword.request` (per address), or `verifyEmail.send` and an e-mailed
761
+ `resetPassword.request` (per address), or `verifyEmail.send`,
762
+ `changeEmail.send` (per user, and per new address) and an e-mailed
751
763
  `stepUp.request` (per user), past five requests of that flow in the current
752
764
  10-minute window — whether or not anybody holds the address. `<call>` is the
753
765
  call, such as `magicLink.request` or `patient.signInCode.request`.
@@ -917,7 +929,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
917
929
 
918
930
  ### `USER_INACTIVE` — `<call>: the user is inactive`
919
931
 
920
- **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
932
+ **When:** `signIn`, with the **right** password, for a user set inactive. Also `changeEmail.send` and `changeEmail.confirm` (the link is spent), and `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
921
933
  **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
922
934
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
923
935
 
@@ -925,8 +937,8 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
925
937
 
926
938
  `TokenError`: `<call>: no such token`, `<call>: the token was already used`, `<call>: the token has expired`.
927
939
 
928
- **When:** `verifyEmail.confirm`, `resetPassword.confirm`.
929
- **Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword`.
940
+ **When:** `verifyEmail.confirm`, `resetPassword.confirm`, `changeEmail.confirm`.
941
+ **Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted, and a change link confirmed through another user type's `changeEmail` or passed to another flow. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword` and for `changeEmail`. A change link is also spent by a newer `changeEmail.send` for the same user (only the last one works), and by an `update` that changes the user's e-mail.
930
942
  A reset link answers `resetPassword.confirm: the token was already used` in
931
943
  two more cases: a newer `resetPassword.request` for the same user spent it —
932
944
  only the last link sent works, so a visitor who asked twice and clicked the
@@ -1014,9 +1026,9 @@ janus({ ..., tokens: { magicLink: '30m' } });
1014
1026
 
1015
1027
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
1016
1028
 
1017
- **When:** `verifyEmail.confirm`, `resetPassword.confirm` or `magicLink.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces.
1029
+ **When:** `verifyEmail.confirm`, `resetPassword.confirm`, `magicLink.confirm` or `changeEmail.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces. A change link is `TOKEN_SPENT`, not stale, after an `update` that moved the e-mail; it is stale after a write outside janus, or a `send` racing an `update`.
1018
1030
  **Why:** confirming it would verify an address nobody holds any more, or reset a password through one. The token is spent, and nothing is written.
1019
- **Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)` — or, for a sign-in link, ask for a new one: `magicLink.request` sends it to the current address.
1031
+ **Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)` — or, for a sign-in link, ask for a new one: `magicLink.request` sends it to the current address — or, for a change link, send a new one: `await auth.changeEmail.send(user, newEmail)`.
1020
1032
 
1021
1033
  ### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
1022
1034
 
@@ -2201,8 +2213,8 @@ In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is nu
2201
2213
  ### `tokens.insertToken: the store could not answer`, in `tokens.everyKind`
2202
2214
 
2203
2215
  **When:** the case `tokens.everyKind`, on an adapter whose database lists the token kinds — a `CHECK`, a validator's enum.
2204
- **Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, or `magicLink`, which came in 0.15, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
2205
- **Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp'`.
2216
+ **Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, `magicLink`, which came in 0.15, or `changeEmail`, which came in 0.23, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
2217
+ **Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp', 'changeEmail'`.
2206
2218
 
2207
2219
  ### `consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`
2208
2220
 
@@ -2222,3 +2234,11 @@ await tokens.findOneAndUpdate(
2222
2234
  ```
2223
2235
 
2224
2236
  In SQL, `… where token_hash = $1 and kind = $2`; `spendUserTokens` holds `user_id = $1 and kind = $2`. See [`TokenStore`](guide/adapters.md#sessionstore-and-tokenstore).
2237
+
2238
+ ### `consumeToken for a verifyEmail token redeemed as changeEmail`, in `tokens.changeEmailKind`
2239
+
2240
+ Also `consumeToken for a changeEmail token redeemed as verifyEmail`, `countAttempt for a changeEmail token counted as verifyEmail`, `spendUserTokens of changeEmail: the change only`, and `spendUserTokens of changeEmail should not touch a verification, nor anything else have counted it`.
2241
+
2242
+ **When:** the case `tokens.changeEmailKind`, on an adapter that matches a token by its hash alone, or spends a user's tokens of every kind at once. The case stores a `changeEmail` and a `verifyEmail` token for one user and one address.
2243
+ **Why:** a verification link proves the address its user already holds; an e-mail change moves the user to the address its token names. Redeemed as one another, a link would verify an address it should have changed, or change one it should only have verified — the kind is what keeps the two apart, so every token method must match it.
2244
+ **Fix:** put `kind` in the condition of `consumeToken`, `countAttempt` and `spendUserTokens`, exactly as for `tokens.magicLinkKind` above. If the case instead fails with `expected …, got …` on the record, the adapter dropped `address` or `codeHash`: a `changeEmail` token keeps the new address in the first and the address it replaces, keyed, in the second.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,9 +62,10 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/bun": "^1.4.2",
65
+ "typescript-6": "npm:typescript@~6.0.3",
65
66
  "zod": "^4"
66
67
  },
67
68
  "peerDependencies": {
68
- "typescript": "^6.0.3"
69
+ "typescript": "^6.0.3 || ^7.0.0"
69
70
  }
70
71
  }