@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
@@ -82,7 +82,7 @@ that cuts an emoji in half, say.
82
82
  | `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
83
83
  | `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
84
84
  | `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
85
- | `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword` and `signInCode` send to. Without one, those flows are absent from the type |
85
+ | `email` | a field name | `'email'` | The field `verifyEmail`, `resetPassword`, `signInCode` and `magicLink` send to. Without one, those flows are absent from the type |
86
86
  | `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
87
87
  | `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
88
88
  | `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
@@ -95,7 +95,9 @@ that cuts an emoji in half, say.
95
95
  | `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
96
96
  | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
97
97
  | `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
98
+ | `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
98
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
+ | `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) |
99
101
  | `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) |
100
102
  | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
101
103
 
@@ -178,7 +180,7 @@ In the one-type form these are on `auth` itself; with several types, on
178
180
  | `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
179
181
  | `get(id)` | the user | `NOT_FOUND` |
180
182
  | `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
181
- | `update(user, patch, { ifVersion? })` | the user as written | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
183
+ | `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
182
184
  | `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
183
185
  | `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
184
186
 
@@ -187,10 +189,10 @@ With a `password`, besides:
187
189
  | Method | Answers | Rejects with |
188
190
  | --- | --- | --- |
189
191
  | `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
190
- | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
192
+ | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
191
193
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
192
- | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
193
- | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
194
+ | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
195
+ | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
194
196
 
195
197
  With `secondFactor` configured, a type with a password also answers
196
198
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
@@ -220,7 +222,11 @@ await auth.delete(renamed); // true
220
222
  The patch is spread over the stored fields and the result is checked against
221
223
  the schema, so a patch can never leave a user the schema would refuse.
222
224
  Changing the e-mail sets `emailVerified` back to `false`, and moves the login
223
- with it when the e-mail is the login.
225
+ with it when the e-mail is the login. It also sends a
226
+ [`user.emailChanged` event](events.md#telling-the-user-their-password-or-e-mail-changed)
227
+ carrying `formerEmail`, the address before — so the notice goes to the inbox
228
+ the account just left. A change of case only (`Ada@…` for `ada@…`) is no
229
+ change, and sends nothing.
224
230
 
225
231
  ### `ifVersion`
226
232
 
@@ -293,6 +299,6 @@ fields against your schema, not that `password` is a string.
293
299
  ## See also
294
300
 
295
301
  - [Sessions](sessions.md) — `authenticate`, the cookie, signing out
296
- - [User events](events.md) — what `create`, `signUp` and `delete` send
302
+ - [User events](events.md) — what `create`, `signUp`, `update`, `setPassword`, `changePassword` and `delete` send
297
303
  - [Errors](errors.md) — every code, and the status it deserves
298
304
  - [Writing an adapter](adapters.md) — the identity stores behind `store`
@@ -46,20 +46,21 @@ either finds this row.
46
46
  | **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a password reset; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
47
47
  | **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
48
48
  | **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
49
- | **one-time token** | A single-use token sent by e-mail in a link, for verification or a password reset | "code": a code is typed, a token is followed in a link |
49
+ | **one-time token** | A single-use token sent by e-mail in a link, for verification, a password reset or a sign-in | "code": a code is typed, a token is followed in a link |
50
+ | **sign-in link** | A one-time token sent by e-mail in a link that signs the user in, with no password — what `magicLink.request` issues and `magicLink.confirm` takes, see [sign-in links](magic-link.md). *Magic link* is its common name, and its API's; in prose, *sign-in link*, or *link* where the sentence says which | "magic code", "login link", "passwordless link" |
50
51
  | **one-time code** | Six digits a user types, checked against a challenge. Two kinds: a **TOTP code**, computed by an authenticator app from the user's secret and never sent — what `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes` and `secondFactor.confirm` take, see [the second factor](second-factor.md); and a **sign-in code**, sent by e-mail — what `signInCode.request` issues and `signInCode.confirm` takes, see [sign-in codes](sign-in-code.md). **"Code" alone always means a one-time code**, the kind named by the sentence around it. An error's is written `error.code`, or by its value (`CODE_INVALID`), and said *error code*; the program is *your code* | "OTP", "PIN", "passcode", "magic code"; "code" for an error's `code`, unless written *error code* |
51
52
  | **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
52
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" |
53
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 |
54
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" |
55
- | **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`, `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` 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
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 |
57
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" |
58
59
  | **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
59
- | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
60
- | **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send` or `resetPassword.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
60
+ | **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 | |
61
+ | **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
62
  | **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 — and *two-factor enabled*, *two-factor disabled*. `@nxgt/janus-mail` sends all four | "alert", "notification" |
62
- | **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone, 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 |
63
+ | **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.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — 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 |
63
64
  | **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 |
64
65
  | **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
65
66
 
package/docs/roadmap.md CHANGED
@@ -13,6 +13,12 @@ Nothing yet.
13
13
 
14
14
  ## Later
15
15
 
16
+ - **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
17
+ sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
18
+ until you delete them, and the sign-in throttle adds one per login tried
19
+ per window. A port method to collect them, as sessions are, would let the
20
+ core do it.
21
+
16
22
  - **More official adapters** — the ports are cut where atomicity is not
17
23
  required, so users, sessions and permission tuples can each live in the
18
24
  database that suits them. MongoDB is the first adapter
@@ -46,6 +52,9 @@ Nothing yet.
46
52
  Valibot, ArkType); none is imposed as a peer.
47
53
  - **A silent hasher fallback** — a user type with a password and no `hasher`
48
54
  is refused at wiring, rather than hashed with something you did not choose.
55
+ - **An account lockout** — a login past its attempts waits for the next
56
+ window, and no number of wrong passwords blocks an account for longer:
57
+ a lockout would let anyone lock anyone out.
49
58
  - **Answering `null` or `false` on an outage** — a store that cannot answer
50
59
  throws `STORE_FAILED`, and a permission walk past `maxDepth` throws
51
60
  `PERMISSION_DEPTH`. Neither will become a denial: that turns an outage into
@@ -61,6 +70,34 @@ Nothing yet.
61
70
  The last ten, newest first, each with the version it came in. Everything
62
71
  before is in the [CHANGELOG](../CHANGELOG.md).
63
72
 
73
+ - **Sign in with an e-mailed link (magic link), v0.15.0** —
74
+ `auth.magicLink.request(email)` and `magicLink.confirm(token)` on every
75
+ user type with an e-mail, with or without a password: the sign-in code
76
+ with nothing to type. `request` answers `null` for nobody and for an
77
+ inactive user alike; only the last link sent works; a link lives fifteen
78
+ minutes (`tokens.magicLink`) and is spent by its first `confirm`, which
79
+ proves the e-mail and still asks for an active second factor. Confirmed
80
+ from a `POST`, so a mail scanner that opens the link spends nothing.
81
+ **Breaking for an adapter: `TokenKind` gains `'magicLink'`**, which a
82
+ `CHECK` or a validator's enum must list.
83
+ - **Password guessing throttled per login, v0.14.0** — past ten passwords
84
+ tried at one login in a 15-minute window, `signIn` answers
85
+ `CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
86
+ password included, until the window ends. Nothing locks; a login nobody
87
+ holds is counted alike; a password sign-in that opens a session —
88
+ after its second factor, when one is active — starts the count again.
89
+ On by default: `signIn: { throttle: { attempts, window } }` changes it,
90
+ `signIn: { throttle: false }` turns it off. A tokens store that cannot
91
+ count fails the sign-in with `STORE_FAILED`. No change for adapters: the
92
+ counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
93
+ - **A password or an e-mail changed, in the user events, v0.14.0** —
94
+ `user.passwordChanged`, sent by `changePassword` and `setPassword` once the
95
+ older reset links are spent (a reset stays `user.passwordReset` alone), and
96
+ `user.emailChanged`, sent by an `update` that changed the e-mail, carrying
97
+ `formerEmail` — the one event with more than the user's id — so a notice
98
+ can reach the inbox the account just left, as `@nxgt/janus-mail`'s
99
+ `passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
100
+ that exhausts `UserEventType`.
64
101
  - **An old reset link stops working, v0.13.0** — `resetPassword.request`
65
102
  spends the links sent before, so only the last e-mail's works, and writing
66
103
  a password — a link's `confirm`, `changePassword`, `setPassword` — spends
@@ -122,26 +159,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
122
159
  `STORE_FAILED` on PostgreSQL alone. The relation store suite holds every
123
160
  adapter to reading back every other character in an id exactly as written.
124
161
  — v0.8.3
125
- - **Webhooks, `@nxgt/janus-webhooks` v0.1.0** — a package of its own: the
126
- user events `janus({ events })` hands over, signed by the Standard
127
- Webhooks specification (HMAC-SHA256, secrets that rotate) and posted to
128
- your endpoints, retried with backoff, and reported to `onGivingUp` when
129
- given up — never dropped in silence. `verifyWebhook` is the receiving side.
130
- - **User events, v0.8.0** — `janus({ events })` takes one listener, called
131
- with `user.created`, `user.emailVerified`, `user.passwordReset` and
132
- `user.deleted` once the write landed, and awaited before the flow answers.
133
- An event names the user by id alone, with a UUIDv7 of its own to deliver
134
- it once; a listener that throws fails no flow and is a
135
- `JANUS_EVENT_FAILED` warning.
136
- - **At most one sign-in code live per user, and challenges that end when
137
- they should, v0.7.0** — `signInCode.request` spends the codes sent before,
138
- even when requests race, so only the last e-mail's works; writing a
139
- password spends the second-factor challenges left waiting, and a sign-in
140
- still running when it lands is refused; another user type's `confirm` spends a challenge
141
- at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
142
- check the e-mail again on the record they write. `SecondFactorRequired`
143
- carries `userId`, for logs and rate limits. For adapters:
144
- `TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
145
- cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
146
- `@nxgt/janus-redis` — and the port now says a read sees every write that
147
- completed before it: never a secondary or a read replica.
@@ -40,6 +40,7 @@ How the messages are shaped:
40
40
  - [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
41
41
  - [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
42
42
  - [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
43
+ - [`janus: signIn.throttle …` wiring messages](#janus-signinthrottle-wiring-messages)
43
44
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
44
45
 
45
46
  **Users, sessions and tokens**
@@ -51,6 +52,8 @@ How the messages are shaped:
51
52
  - [`USER_INVALID` — `<call>: the fields do not match the <type> schema …`](#user_invalid--call-the-fields-do-not-match-the-type-schema-n-issues-at-paths)
52
53
  - [`PASSWORD_TOO_SHORT` — `<call>: the password is shorter than the policy's <n> characters`](#password_too_short--call-the-password-is-shorter-than-the-policys-n-characters)
53
54
  - [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
55
+ - [`CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login …`](#credentials_invalid--call-too-many-passwords-tried-at-this-login--wait-for-the-next-window)
56
+ - [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
54
57
  - [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
55
58
  - [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
56
59
  - [`USER_INACTIVE` — `<call>: the user is inactive`](#user_inactive--call-the-user-is-inactive)
@@ -88,6 +91,14 @@ How the messages are shaped:
88
91
  - [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
89
92
  - `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
90
93
 
94
+ **Sign-in links**
95
+ - [A sign-in link answers `TOKEN_SPENT` on the user's first click](#a-sign-in-link-answers-token_spent-on-the-users-first-click)
96
+ - [The link signed in another device than the one that asked](#the-link-signed-in-another-device-than-the-one-that-asked)
97
+ - [`magicLink.request` answers `null` for a user who exists](#magiclinkrequest-answers-null-for-a-user-who-exists)
98
+ - [`TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`](#ts2339-property-magiclink-does-not-exist-on-type-typeapi)
99
+ - [`TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`](#ts2554-expected-1-arguments-but-got-2-on-magiclinkconfirm)
100
+ - `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE`, `USER_INACTIVE` and `VERSION_CONFLICT` from `magicLink.confirm`, and `TS2339` on its `token`: in their entries above.
101
+
91
102
  **Step-up**
92
103
  - [`STEP_UP_REQUIRED` — `assertFresh: the session proved who it is longer ago than maxAge …`](#step_up_required--assertfresh-the-session-proved-who-it-is-longer-ago-than-maxage--confirm-with-a-step-up)
93
104
  - [`TOKEN_UNKNOWN` — `stepUp.confirm: no such challenge`, with a challenge just issued](#token_unknown--stepupconfirm-no-such-challenge-with-a-challenge-just-issued)
@@ -129,6 +140,7 @@ How the messages are shaped:
129
140
  - [`findUser should answer recoveryCodes [] as [] — never null, never undefined`](#finduser-should-answer-recoverycodes--as---never-null-never-undefined)
130
141
  - [`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)
131
142
  - [`tokens.insertToken: the store could not answer`, in `tokens.everyKind`](#tokensinserttoken-the-store-could-not-answer-in-tokenseverykind)
143
+ - [`consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`](#consumetoken-for-a-signincode-token-redeemed-as-magiclink-in-tokensmagiclinkkind)
132
144
 
133
145
  ---
134
146
 
@@ -328,7 +340,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
328
340
 
329
341
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
330
342
 
331
- The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode`, `tokens.stepUp` and `secondFactor.challenge` — 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`.
343
+ 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`.
332
344
 
333
345
  **When:** `janus({...})`.
334
346
  **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.
@@ -403,6 +415,15 @@ janus({
403
415
  });
404
416
  ```
405
417
 
418
+ ### `janus: signIn.throttle` wiring messages
419
+
420
+ `TypeError`, at `janus()`: `janus: signIn must be an object — { throttle }`,
421
+ `janus: signIn.throttle must be { attempts, window }, or false to count
422
+ nothing`, `janus: signIn.throttle.attempts must be a whole number above
423
+ zero`, and a `janus: signIn.throttle.window` that is not a duration.
424
+ **Fix:** `signIn: { throttle: { attempts: 10, window: '15m' } }` — the
425
+ defaults — or `signIn: { throttle: false }` to count nothing.
426
+
406
427
  ### Other `janus:` wiring messages
407
428
 
408
429
  | Message | Fix |
@@ -495,6 +516,17 @@ challenge — the same code will not, it was used.
495
516
  form.addEventListener('submit', () => form.querySelector('button')?.setAttribute('disabled', ''));
496
517
  ```
497
518
 
519
+ **From `signInCode.confirm` and `magicLink.confirm`**, the message names the
520
+ call: `magicLink.confirm: expected version <n>, found <m>`.
521
+
522
+ **When:** the user was written — an `update`, a sign-in that rehashed the
523
+ password — between `confirm` reading them and writing the e-mail as
524
+ verified. Rare: the window is one read.
525
+ **Why:** the proof is written under the version read, so an address changed
526
+ meanwhile is never the one marked verified. Nothing was written and no
527
+ session opened, but the code's challenge or the link is already spent.
528
+ **Fix:** answer it as a spent link or code: ask for a new one.
529
+
498
530
  **From `secondFactor.recover`**, the same store message.
499
531
 
500
532
  **When:** one recovery code used by two sign-ins at once — the same
@@ -532,7 +564,7 @@ if (error instanceof UserInvalidError) {
532
564
  Also `changePassword: the current password does not match`.
533
565
 
534
566
  **When:** `signIn`, `changePassword`.
535
- **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
567
+ **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter; a login past its attempts has its own message, below. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
536
568
  **Fix:** answer 401 with the same body whatever the reason:
537
569
 
538
570
  ```ts
@@ -540,6 +572,47 @@ Also `changePassword: the current password does not match`.
540
572
  return new Response('Wrong e-mail or password', { status: 401 });
541
573
  ```
542
574
 
575
+ ### `CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login — wait for the next window`
576
+
577
+ `CredentialError`, with `reason: 'throttled'` and `retryAfter`.
578
+
579
+ **When:** `signIn`, after ten passwords were tried at this login in the
580
+ current 15-minute window — whatever this password is, **the right one
581
+ included**, and whether or not anybody holds the login. Nothing is compared.
582
+ **Why:** `signIn` throttles password guessing per login, on by default:
583
+ counted by the store, in fixed windows of the clock, shared by every process
584
+ over the same store. Nothing locks: the next window signs in, and a sign-in
585
+ that succeeds starts the count again. In tests with `fixedClock`, the window
586
+ never ends: advance the clock past `retryAfter`, or wire
587
+ `signIn: { throttle: false }`.
588
+ **Fix:** tell the visitor to wait `retryAfter` seconds — not that the
589
+ password is wrong — and answer 401, as `@nxgt/janus-hono`'s `janusErrors()`
590
+ does, with a `Retry-After` header. If nobody you know tried ten passwords,
591
+ somebody is guessing: look for `janus.signIn.throttled` in your logs. To
592
+ change the limit or turn it off:
593
+
594
+ ```ts
595
+ if (error instanceof JanusError && error.retryAfter !== undefined) {
596
+ const headers = { 'retry-after': String(error.retryAfter) };
597
+ return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 401, headers });
598
+ }
599
+
600
+ janus({ ..., signIn: { throttle: { attempts: 20, window: '15m' } } });
601
+ ```
602
+
603
+ ### `STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`
604
+
605
+ `StoreFailure`, with `slot: 'tokens'` and `operation: 'countAttempt'`.
606
+
607
+ **When:** `signIn`, when the tokens store answers `countAttempt` with `null`
608
+ for the count it stored an instant before.
609
+ **Why:** the store lost it — a Redis evicting keys under memory pressure, a
610
+ flush between the two calls, or an adapter whose `countAttempt` does not
611
+ find what `insertToken` wrote. The throttle cannot count, so the sign-in
612
+ fails rather than pass uncounted.
613
+ **Fix:** answer 503. Run the conformance suite against the adapter, and give
614
+ a Redis tokens store `maxmemory-policy noeviction`.
615
+
543
616
  ### `HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`
544
617
 
545
618
  **When:** `signIn` or `changePassword`, for a user whose stored hash was written by another system — typically after an import.
@@ -558,8 +631,8 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
558
631
 
559
632
  ### `USER_INACTIVE` — `<call>: the user is inactive`
560
633
 
561
- **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, and `signInCode.confirm`, for a user set inactive after the code was sent.
562
- **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.
634
+ **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.
635
+ **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.
563
636
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
564
637
 
565
638
  ### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
@@ -633,11 +706,31 @@ more time:
633
706
  janus({ ..., tokens: { signInCode: '15m' } });
634
707
  ```
635
708
 
709
+ **On `magicLink.confirm`**, the messages name the token, as for the other
710
+ links: `magicLink.confirm: no such token`, `magicLink.confirm: the token was
711
+ already used`, `magicLink.confirm: the token has expired`.
712
+
713
+ **When:** `magicLink.confirm(token)`.
714
+ **Why:** a sign-in link lives fifteen minutes and is spent by its first
715
+ `confirm`, whether that call signs in or is refused — so a second click, a
716
+ refresh that posts again, or a link [a mail scanner opened](#a-sign-in-link-answers-token_spent-on-the-users-first-click)
717
+ first answers `TOKEN_SPENT`. The next `request` for the same user spends it
718
+ too: only the last link sent works. `TOKEN_UNKNOWN` also covers a link
719
+ whose user was deleted, one confirmed through another user type's
720
+ `magicLink` — which spends it — a sign-in code's challenge, and a token cut
721
+ short by the mail client wrapping the URL.
722
+ **Fix:** send the visitor back to ask for a new link — every `TOKEN_*`
723
+ means that to them — and tell them to use the latest e-mail.
724
+
725
+ ```ts
726
+ janus({ ..., tokens: { magicLink: '30m' } });
727
+ ```
728
+
636
729
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
637
730
 
638
- **When:** `verifyEmail.confirm` or `resetPassword.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.
731
+ **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.
639
732
  **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.
640
- **Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)`.
733
+ **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.
641
734
 
642
735
  ### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
643
736
 
@@ -705,7 +798,7 @@ each of those entries has a paragraph for it.
705
798
 
706
799
  The same for `session` and `user`.
707
800
 
708
- **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s, on a user type with a password.
801
+ **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s and `magicLink.confirm`'s, on a user type with a password.
709
802
  **Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt, userId }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
710
803
  **Fix:** switch on `status`:
711
804
 
@@ -1098,6 +1191,88 @@ janus({
1098
1191
 
1099
1192
  ---
1100
1193
 
1194
+ ## Sign-in links
1195
+
1196
+ `magicLink.confirm`'s messages start with `magicLink.confirm` — prefixed by
1197
+ the type with several user types: `patient.magicLink.confirm: …`.
1198
+ `magicLink.request` throws nothing but `STORE_FAILED`: an e-mail it cannot
1199
+ sign in is `null`. `magicLink.confirm` rejects with
1200
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
1201
+ [`TOKEN_STALE`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has),
1202
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
1203
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
1204
+ each of those entries has a paragraph for it. On a type with a password in
1205
+ an instance given a `secondFactor`, reading its `token` before narrowing on
1206
+ `status` is the `TS2339` of `signIn`'s. [The sign-in link guide](guide/magic-link.md)
1207
+ has the whole flow.
1208
+
1209
+ ### A sign-in link answers `TOKEN_SPENT` on the user's first click
1210
+
1211
+ `magicLink.confirm: the token was already used`, when the user swears they
1212
+ clicked once.
1213
+
1214
+ **When:** a route calls `magicLink.confirm` on the `GET` of the link — and
1215
+ most often for users behind a corporate mail gateway, or of a webmail that
1216
+ checks links.
1217
+ **Why:** a mail scanner opened the link before the user did. Gateways,
1218
+ safe-browsing checks and antivirus fetch every URL in an e-mail, and some
1219
+ run the page's scripts; a `GET` that confirms spent the token for them.
1220
+ **Fix:** make the link's `GET` a page that spends nothing, and confirm from
1221
+ its button's `POST` — not from a script that submits by itself, which a
1222
+ scanner that runs scripts submits too:
1223
+
1224
+ ```ts
1225
+ // GET /sign-in/link?token=… → a page whose <form method="post"> holds the token, and a button
1226
+ // POST /sign-in/link → the one place that calls confirm
1227
+ const token = String((await request.formData()).get('token') ?? '');
1228
+ const signedIn = await auth.magicLink.confirm(token);
1229
+ ```
1230
+
1231
+ The [guide](guide/magic-link.md#confirming-from-a-post-never-from-the-links-get)
1232
+ has the page, with the headers it needs.
1233
+
1234
+ ### The link signed in another device than the one that asked
1235
+
1236
+ **When:** a user asks for a link on a laptop and opens the e-mail on their
1237
+ phone: the phone is signed in, the laptop still waits.
1238
+ **Why:** the session opens in the browser that posts the token — whoever
1239
+ opens the link signs in, wherever they open it. That is what a link is.
1240
+ **Fix:** say on the page that asked "open the link on this device", or send
1241
+ a [sign-in code](guide/sign-in-code.md) instead: it is typed into the page
1242
+ that asked for it, so the sign-in completes there.
1243
+
1244
+ ### `magicLink.request` answers `null` for a user who exists
1245
+
1246
+ **When:** `magicLink.request(email)`, for an address you can see in the
1247
+ database.
1248
+ **Why:** the same three causes as a
1249
+ [sign-in code's `null`](#signincoderequest-answers-null-for-a-user-who-exists):
1250
+ the user is inactive, is of another user type, or the address is not the
1251
+ type's e-mail field.
1252
+ **Fix:** as there. Do not tell the visitor which — the route answers the
1253
+ same page either way.
1254
+
1255
+ ### `TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`
1256
+
1257
+ Also `Property 'magicLink' does not exist on type 'Janus<…>'.`, with the
1258
+ single-type form.
1259
+
1260
+ **When:** `tsc`, on `auth.magicLink` or `clinic.<type>.magicLink`.
1261
+ **Why:** the user type has no e-mail, so a link has nowhere to be sent — as
1262
+ for [`signInCode`](#ts2339-property-signincode-does-not-exist-on-type-typeapi).
1263
+ Or `@nxgt/janus` is older than 0.15, which brought `magicLink`.
1264
+ **Fix:** name the field that holds the e-mail with the type's `email`
1265
+ option, or upgrade `@nxgt/janus`.
1266
+
1267
+ ### `TS2554: Expected 1 arguments, but got 2.`, on `magicLink.confirm`
1268
+
1269
+ **When:** `tsc`, on `magicLink.confirm(challenge, code)` — a sign-in code's
1270
+ route copied for a link.
1271
+ **Why:** a link carries no code: its token is the whole secret.
1272
+ **Fix:** `magicLink.confirm(token)`, with the token the link's page posted.
1273
+
1274
+ ---
1275
+
1101
1276
  ## Step-up
1102
1277
 
1103
1278
  The messages start with `stepUp.request` or `stepUp.confirm` — prefixed by
@@ -1547,5 +1722,24 @@ In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is nu
1547
1722
  ### `tokens.insertToken: the store could not answer`, in `tokens.everyKind`
1548
1723
 
1549
1724
  **When:** the case `tokens.everyKind`, on an adapter whose database lists the token kinds — a `CHECK`, a validator's enum.
1550
- **Why:** the list does not hold `stepUp`, which came in `@nxgt/janus` 0.12, so the database refuses the insert, and the adapter reports it as the outage it cannot tell apart.
1551
- **Fix:** add `stepUp` to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'stepUp'`.
1725
+ **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.
1726
+ **Fix:** add the missing kind to the list, and ship the migration or the validator with it: `'verifyEmail', 'resetPassword', 'secondFactor', 'signInCode', 'magicLink', 'stepUp'`.
1727
+
1728
+ ### `consumeToken for a signInCode token redeemed as magicLink`, in `tokens.magicLinkKind`
1729
+
1730
+ Also `countAttempt for a magicLink token counted as signInCode`, `spendUserTokens of magicLink: the link only`, and `spendUserTokens of magicLink should not touch a sign-in code, nor anything else have counted it`.
1731
+
1732
+ **When:** the case `tokens.magicLinkKind`, on an adapter that matches a token by its hash alone, or spends a user's tokens of every kind at once.
1733
+ **Why:** a sign-in code's challenge is handed to whoever asked for the code. Redeemed as a link, it would sign them in with no code at all — the kind is what keeps the two apart, so every token method must match it.
1734
+ **Fix:** put `kind` in the condition of `consumeToken`, `countAttempt` and `spendUserTokens`, beside the hash or the user:
1735
+
1736
+ ```ts
1737
+ // MongoDB: consumeToken — the hash and the kind, never the hash alone
1738
+ await tokens.findOneAndUpdate(
1739
+ { _id: tokenHash, kind },
1740
+ [{ $set: { spentAt: { $ifNull: ['$spentAt', at] } } }],
1741
+ { returnDocument: 'before' },
1742
+ );
1743
+ ```
1744
+
1745
+ 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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",