@nxgt/janus 0.19.0 → 0.21.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 (52) hide show
  1. package/README.md +164 -10
  2. package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
  3. package/dist/auth/events.d.ts +3 -3
  4. package/dist/auth/index.d.ts +1 -1
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/magic-link.d.ts +3 -0
  7. package/dist/auth/magic-link.d.ts.map +1 -1
  8. package/dist/auth/one-time/issue.d.ts +6 -0
  9. package/dist/auth/one-time/issue.d.ts.map +1 -1
  10. package/dist/auth/prepared.d.ts +21 -0
  11. package/dist/auth/prepared.d.ts.map +1 -0
  12. package/dist/auth/proven-sign-in.d.ts +22 -0
  13. package/dist/auth/proven-sign-in.d.ts.map +1 -0
  14. package/dist/auth/sign-in-code.d.ts +4 -0
  15. package/dist/auth/sign-in-code.d.ts.map +1 -1
  16. package/dist/auth/types/email-flows.d.ts +18 -0
  17. package/dist/auth/types/email-flows.d.ts.map +1 -1
  18. package/dist/auth/types/find-by-email.d.ts +31 -0
  19. package/dist/auth/types/find-by-email.d.ts.map +1 -0
  20. package/dist/auth/types/index.d.ts +3 -0
  21. package/dist/auth/types/index.d.ts.map +1 -1
  22. package/dist/auth/types/janus.d.ts +8 -5
  23. package/dist/auth/types/janus.d.ts.map +1 -1
  24. package/dist/auth/types/magic-link.d.ts +11 -0
  25. package/dist/auth/types/magic-link.d.ts.map +1 -1
  26. package/dist/auth/types/prepared.d.ts +41 -0
  27. package/dist/auth/types/prepared.d.ts.map +1 -0
  28. package/dist/auth/types/proven-sign-in.d.ts +43 -0
  29. package/dist/auth/types/proven-sign-in.d.ts.map +1 -0
  30. package/dist/auth/types/sign-in.d.ts +2 -2
  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/find-by-email.d.ts +11 -0
  34. package/dist/auth/users/find-by-email.d.ts.map +1 -0
  35. package/dist/auth/users/type-api.d.ts +2 -2
  36. package/dist/auth/users/type-api.d.ts.map +1 -1
  37. package/dist/index.js +105 -17
  38. package/dist/index.js.map +12 -9
  39. package/docs/README.md +2 -2
  40. package/docs/guide/devices.md +4 -0
  41. package/docs/guide/email-flows.md +105 -0
  42. package/docs/guide/errors.md +2 -2
  43. package/docs/guide/events.md +2 -2
  44. package/docs/guide/magic-link.md +49 -2
  45. package/docs/guide/passwords.md +5 -2
  46. package/docs/guide/second-factor.md +1 -1
  47. package/docs/guide/sessions.md +105 -1
  48. package/docs/guide/sign-in-code.md +54 -2
  49. package/docs/guide/users.md +70 -0
  50. package/docs/roadmap.md +24 -33
  51. package/docs/troubleshooting.md +100 -6
  52. package/package.json +1 -1
package/docs/roadmap.md CHANGED
@@ -76,7 +76,30 @@ 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
- - **Several users by id in one query, v0.18.0** — `auth.<type>.findMany(ids)`
79
+ - **Finding a user by e-mail on every type, v0.21.0** —
80
+ `auth.<type>.findByEmail(email)` answers the user of the type holding the
81
+ address, or `null`, on every type with an e-mail — a type with no
82
+ password included, which has no `findByLogin`. The address is normalised
83
+ and compared as the e-mail flows do; nothing is counted, issued or
84
+ written. It is the lookup before `signInProven` when an invitation holds
85
+ an address. Server-side only: it tells whether an account exists.
86
+ - **Signing in on the application's own proof, v0.20.0** —
87
+ `auth.<type>.signInProven(user, { device })` signs in a user the
88
+ application proved itself — an invitation token it issued — and answers
89
+ what every sign-in answers: a session, or a second-factor challenge, with
90
+ the device and `USER_INACTIVE` handled alike. It sends no e-mail, counts
91
+ in no throttle and spends no link already sent, so accepting an
92
+ invitation no longer goes through `magicLink.request` and its mail
93
+ throttle. Privileged: never with an id a request supplied.
94
+ - **E-mail requests in two steps, v0.20.0** — `magicLink.prepare(email)`,
95
+ `signInCode.prepare(email)` and `resetPassword.prepare(email)` count the
96
+ address and look nobody up — the same work for any address — so
97
+ `MAIL_THROTTLED` reaches the visitor while `send()`, the lookup and the
98
+ issue, runs off their request, where its time tells nothing. `send()`
99
+ answers what `request` does, once; a second call is a `TypeError`. A sign-in
100
+ code's prepared request carries its `challenge`, minted before the lookup,
101
+ so no decoy is needed for an address nobody holds.
102
+ - **Several users by id in one query, v0.19.0** — `auth.<type>.findMany(ids)`
80
103
  answers the type's users in the order of `ids`, each once, and leaves out an
81
104
  id nobody of that type holds — for a list of rows that would otherwise make
82
105
  one `find` per row and fill the connection pool. The users store gains an
@@ -143,35 +166,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
143
166
  can reach the inbox the account just left, as `@nxgt/janus-mail`'s
144
167
  `passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
145
168
  that exhausts `UserEventType`.
146
- - **An old reset link stops working, v0.13.0** — `resetPassword.request`
147
- spends the links sent before, so only the last e-mail's works, and writing
148
- a password — a link's `confirm`, `changePassword`, `setPassword` — spends
149
- every reset link still live, so a link sent before the change cannot
150
- replace the new password. Both answer `TOKEN_SPENT`; an outage spending
151
- them fails the call with `STORE_FAILED`. No change for adapters: the
152
- existing `TokenStore.spendUserTokens` does it.
153
- - **Confirm an action with a code (step-up), v0.12.0** —
154
- `auth.stepUp.request(user)` and `stepUp.confirm(request, challenge, code)`:
155
- a signed-in user proves again who they are before a sensitive action, with
156
- a six-digit code sent by e-mail — or, when their second factor is active,
157
- the code from their app. The confirmation moves the session's
158
- `authenticatedAt` and opens no session, and `assertFresh(session, maxAge)`
159
- refuses an older one with `STEP_UP_REQUIRED` (403). A token kind of its
160
- own, `stepUp`, and `SessionStore.reauthenticateSession`, in the three
161
- adapters and the conformance suite.
162
- - **The recovery codes left, read again, v0.11.0** —
163
- `secondFactor.recoveryCodesLeft(user)` answers the count `recover`
164
- answered, for a `user.recoveryCodeUsed` listener — the event names the
165
- user only — or a security settings page; `null` for a user with no active
166
- factor. The listener can now tell the user how many codes remain, as
167
- `@nxgt/janus-mail`'s `recoveryCodeUsed` e-mail does.
168
- - **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
169
- own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
170
- links })` takes what each flow answers —
171
- `await mail.verifyEmail(await auth.verifyEmail.send(user), { name, locale })`
172
- — and sends five e-mails, in English and French, over any `@nxgt/mail`
173
- transport: e-mail verification, password reset, sign-in code, and the
174
- notices *password changed* and *e-mail changed*. They are built once with
175
- Maizzle when the package is built, and only filled in at send time, every
176
- value escaped: no template engine in your server. A transport that fails
177
- throws, like a store.
@@ -9,8 +9,9 @@ How the messages are shaped:
9
9
  `resetPassword.confirm: …`, `can: …`. With several user types, that call is
10
10
  prefixed by the type: `staff.signIn: …`. Below, `<call>` stands for it.
11
11
  - **A `TypeError` is a wiring mistake**: it comes from how the application was
12
- put together — a configuration, a store, a model — and never from a request.
13
- Fix the code; no handler should answer one.
12
+ put together — a configuration, a store, a model, a prepared request sent
13
+ twice — and never from a request. Fix the code; no handler should answer
14
+ one.
14
15
  - **A `JanusError` is a refusal at call time**, on a value that could have come
15
16
  from a request. It carries a `code` you can `switch` on; the heading of each
16
17
  entry below names it.
@@ -52,6 +53,7 @@ How the messages are shaped:
52
53
  - [`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)
53
54
  - [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
54
55
  - [`findMany` answers fewer users than ids](#findmany-answers-fewer-users-than-ids)
56
+ - [`findByEmail` answers `null` for an address somebody holds](#findbyemail-answers-null-for-an-address-somebody-holds)
55
57
  - [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
56
58
  - [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
57
59
  - [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
@@ -60,6 +62,9 @@ How the messages are shaped:
60
62
  - [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
61
63
  - [`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)
62
64
  - [`MAIL_THROTTLED` — `<call>: too many e-mails asked for this address …`, or `… for this user …`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window)
65
+ - [Accepting an invitation answers `MAIL_THROTTLED`](#accepting-an-invitation-answers-mail_throttled)
66
+ - [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
67
+ - [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
63
68
  - [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
64
69
  - [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
65
70
  - [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
@@ -580,12 +585,25 @@ const byId = new Map((await auth.staff.findMany(ids)).map((user) => [user.id, us
580
585
  const member = byId.get(row.staffId); // undefined: nobody of this type holds it
581
586
  ```
582
587
 
588
+ ### `findByEmail` answers `null` for an address somebody holds
589
+
590
+ **When:** `auth.<type>.findByEmail(email)` on an address you can see on a user.
591
+ **Why:** one of three. The user is of **another type**: each type answers its own users, and the same address may hold one of each. The address is the type's **login, not its e-mail**: on a type that signs in with a username, only the field `email` names is looked up, and a username that looks like an address is nobody's. Or the value is **no address**: not a string — a `{ email }` object from JavaScript, an `undefined` read off a record — empty once trimmed, or holding a NUL or a lone surrogate; it answers `null` without asking the store, as `find` answers a malformed id.
592
+ **Fix:** ask the type the user is of, pass the e-mail itself, and for a username use `findByLogin`:
593
+
594
+ ```ts
595
+ const member = await clinic.member.findByEmail(invitation.email); // a string: the address the invitation holds
596
+ const staff = await clinic.staff.findByLogin('grace'); // a username is a login, not an e-mail
597
+ ```
598
+
599
+ An outage is never `null` here: it rejects with `STORE_FAILED`.
600
+
583
601
  ### `NOT_FOUND` — `<call>: no <type> has this id`
584
602
 
585
- `NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
603
+ `NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
586
604
 
587
605
  **When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
588
- **Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead.
606
+ **Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead. `signInProven` is a `get`: a sign-in that found nobody throws, and on it `NOT_FOUND` means the proof you verified names a user deleted since, or you passed the id of another type's user — or a value that is not the proof's id at all.
589
607
  **Fix:** answer 404, or use `find` when absence is an ordinary outcome:
590
608
 
591
609
  ```ts
@@ -760,6 +778,82 @@ if (error instanceof MailThrottledError) {
760
778
  janus({ ..., mail: { throttle: { attempts: 10, window: '10m' } } });
761
779
  ```
762
780
 
781
+ ### Accepting an invitation answers `MAIL_THROTTLED`
782
+
783
+ **When:** the application signs an invitee in by calling
784
+ `magicLink.request(email)` and confirming the token at once, and somebody
785
+ asked for links to that address five times in the window — the default mail
786
+ throttle. The acceptance answers 429, and the message says the last link
787
+ sent still works, though the invitee was sent none.
788
+ **Why:** `request` is counted per address, whoever asks: that is what stops a
789
+ loop from filling an inbox. A sign-in your server already proved — the
790
+ invitation token it issued — does not need an e-mailed link, and should not
791
+ share its count.
792
+ **Fix:** call `signInProven` with the user from the invitation's record, after
793
+ verifying the token yourself. It counts nothing, sends nothing, spends no
794
+ link already sent, and answers what `signIn` answers. An invitation that
795
+ holds an address rather than an id finds the account with `findByEmail`,
796
+ which counts nothing either — on any type with an e-mail, password or not.
797
+
798
+ ```ts
799
+ const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
800
+ if (invitation === null) return new Response(null, { status: 404 });
801
+ const user = await auth.findByEmail(invitation.email); // or invitation.userId, when you stored the id
802
+ if (user === null) return new Response(null, { status: 404 });
803
+ const result = await auth.signInProven(user, { device: deviceOf(request) });
804
+ ```
805
+
806
+ Never pass it an id the request carries: it checks no credential. See
807
+ [sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
808
+
809
+ ### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
810
+
811
+ **When:** the application runs `magicLink.request`, `signInCode.request` or
812
+ `resetPassword.request` off the visitor's request — a queue, a promise it
813
+ does not await — so that the time of the answer does not tell whether the
814
+ address has an account. Past the limit, the refusal is thrown there, and
815
+ nobody tells the visitor to use the last e-mail they received.
816
+ **Why:** `request` counts, then looks the address up and issues: its
817
+ `MAIL_THROTTLED` comes from the same call whose time must be hidden.
818
+ **Fix:** call `prepare(email)` in the visitor's request — validated and
819
+ counted, nobody looked up, the same store calls for any address — and
820
+ answer its `MAIL_THROTTLED` with 429; call `send()` on what it answered in the
821
+ background. `send()` counts nothing and answers what `request` would have.
822
+
823
+ ```ts
824
+ const pending = await auth.magicLink.prepare(email); // MAIL_THROTTLED here, in the visitor's request
825
+ void pending.send().then(mailIt).catch(reportError); // looked up and issued off it
826
+ return new Response(null, { status: 202 });
827
+ ```
828
+
829
+ See [requests in two steps](guide/email-flows.md#requests-in-two-steps).
830
+
831
+ ### `<flow>.prepare(…).send: already called — a prepared request sends once; call <flow>.prepare again for another`
832
+
833
+ A `TypeError`, such as `magicLink.prepare(…).send: already called — a
834
+ prepared request sends once; call magicLink.prepare again for another`, or
835
+ `patient.signInCode.prepare(…)…` with several user types.
836
+ **When:** `send()` was called a second time on what one `prepare` answered —
837
+ after it issued, after it answered `null`, after it threw (`STORE_FAILED`),
838
+ or while the first call was still running.
839
+ **Why:** `prepare` counted one request against the mail throttle, and that
840
+ count pays for one issue. A `send()` that could run again would issue
841
+ uncounted, so a loop could mail an address past the limit; nothing a caller
842
+ passes skips the count. It is a bug in the calling code, never a value from
843
+ a request, so it is a `TypeError` with no `code`.
844
+ **Fix:** call `send()` once per `prepare`. To retry — after a failure, in a
845
+ queue that retries — `prepare` again, which counts again (and, for a sign-in
846
+ code, mints another challenge: give the visitor the new one):
847
+
848
+ ```ts
849
+ async function sendLink(email: string): Promise<void> {
850
+ const pending = await auth.magicLink.prepare(email); // one count…
851
+ const issued = await pending.send(); // …one issue
852
+ if (issued !== null) await mailer.send(issued.email, linkTo(issued.token));
853
+ }
854
+ // a retry calls sendLink again: never pending.send() twice
855
+ ```
856
+
763
857
  ### `STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`
764
858
 
765
859
  `StoreFailure`, with `slot: 'tokens'` and `operation: 'countAttempt'`.
@@ -791,7 +885,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
791
885
 
792
886
  ### `USER_INACTIVE` — `<call>: the user is inactive`
793
887
 
794
- **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.
888
+ **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`, for an inactive user: the application's proof does not reactivate anybody.
795
889
  **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.
796
890
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
797
891
 
@@ -930,7 +1024,7 @@ const page = await access.list(user, 'view', 'record', {
930
1024
 
931
1025
  **When:** `signIn`, `findByLogin`, `setPassword`, `changePassword`, `resetPassword.*`, from JavaScript. In TypeScript, these methods are absent from a type without `password`.
932
1026
  **Why:** the user type has no `password` option.
933
- **Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }`.
1027
+ **Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }` — when the type should sign in with a password. To **find** a user of a type with no password, call `findByEmail(email)` instead of `findByLogin`: declaring a password only to look users up opens `signIn` and `resetPassword` on a type that has none by design.
934
1028
 
935
1029
  ### `authenticate()` answers `null` although a valid cookie was sent
936
1030
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",