@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/README.md CHANGED
@@ -11,8 +11,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
11
11
 
12
12
  | Page | Read it when |
13
13
  | --- | --- |
14
- | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
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 |
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
+ | [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
16
  | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — 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 |
@@ -95,6 +95,7 @@ Every call that opens a session takes `{ device }` as its last argument, a
95
95
  | `secondFactor.recover(challenge, code, { device })` | the same, after `user.recoveryCodeUsed` |
96
96
  | `signInCode.confirm(challenge, code, { device })` | the same — or a challenge, with an active second factor |
97
97
  | `magicLink.confirm(token, { device })` | the same — or a challenge, with an active second factor |
98
+ | `signInProven(user, { device })` | the same — or a challenge, with an active second factor |
98
99
 
99
100
  A step-up opens no session, and takes no device. With several user types,
100
101
  the calls are the type's: `clinic.staff.signIn(input, { device })`.
@@ -412,6 +413,9 @@ secondFactor.recover(challenge: string, code: string, options?: SignInOptions):
412
413
  signInCode.confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
413
414
  magicLink.confirm(token: string, options?: SignInOptions): Promise<Answer>;
414
415
 
416
+ // On every user type:
417
+ signInProven(user: UserRef, options?: SignInOptions): Promise<Answer>;
418
+
415
419
  janus({ ..., devices?: DevicesConfig });
416
420
  ```
417
421
 
@@ -135,6 +135,7 @@ factor, and signs out every session](magic-link.md#an-account-someone-else-regis
135
135
  ```ts
136
136
  readonly resetPassword: {
137
137
  request(email: string): Promise<(IssuedToken & { user: User }) | null>;
138
+ prepare(email: string): Promise<PreparedRequest<IssuedToken & { user: User }>>; // { send() }, below
138
139
  confirm(token: string, password: string): Promise<User>;
139
140
  };
140
141
  ```
@@ -179,6 +180,12 @@ inbox with at most five reset e-mails a window, but cannot shut the reset:
179
180
  the last one sent still works. Limit the route per client yourself too, and
180
181
  see [the mail throttle](#requests-that-send-e-mail-are-throttled).
181
182
 
183
+ `request`'s time still tells an account from nobody — a lookup, then a link
184
+ written and the earlier ones spent, against a lookup alone. To keep that off
185
+ the visitor's request and still answer them `MAIL_THROTTLED`, call
186
+ `resetPassword.prepare(email)` there and its `send()` in the background:
187
+ [requests in two steps](#requests-in-two-steps).
188
+
182
189
  **Writing a password spends every reset link still live.** A `confirm`,
183
190
  `changePassword` and `setPassword` each do, so a link sent before the
184
191
  password changed cannot replace it again:
@@ -280,6 +287,11 @@ janus: tokens.signInCode is 5m, shorter than mail.throttle.window, 10m — past
280
287
  Shorten `mail.throttle.window`, or raise the lifetime. Nothing is refused, so
281
288
  a window you chose on purpose only costs the warning.
282
289
 
290
+ **`prepare` counts as `request` does**, in the same window: see
291
+ [requests in two steps](#requests-in-two-steps), for an application that
292
+ sends the e-mail off the visitor's request and still wants to tell them
293
+ they are throttled.
294
+
283
295
  **Each flow counts on its own**: a loop on `magicLink.request` never shuts
284
296
  `signInCode.request`. The address is normalised (trimmed, lowercased) and is
285
297
  the same count whatever the user type.
@@ -382,6 +394,99 @@ bounds sign-ins), not a request to mail an address:
382
394
  `@nxgt/janus-telemetry` writes a `janus.mail.throttled` warning with
383
395
  `janus.mail.flow` and `janus.mail.retryAfter`, never the address.
384
396
 
397
+ ## Requests in two steps
398
+
399
+ `request(email)` counts the address, then looks it up and issues. Its time
400
+ tells what its answer does not: an address with an account costs a lookup,
401
+ a token written and the earlier ones spent; one nobody holds stops at the
402
+ lookup. So an application that must not tell the two apart runs `request`
403
+ **off the visitor's request** — and there, a `MAIL_THROTTLED` reaches
404
+ nobody: the visitor is never told to use the last e-mail they received.
405
+
406
+ `prepare(email)` cuts `request` in two at the count, on the three flows
407
+ counted per address:
408
+
409
+ | Call | Does | Answers |
410
+ | --- | --- | --- |
411
+ | `magicLink.prepare(email)` | validates, counts the address, looks nobody up | `PreparedRequest<IssuedToken & { user }>`: `{ send() }` |
412
+ | `signInCode.prepare(email)` | the same, and mints the challenge | `PreparedCode<User>`: `{ challenge, send() }` |
413
+ | `resetPassword.prepare(email)` | the same, refusing a type with no password as `request` does | `PreparedRequest<IssuedToken & { user }>`: `{ send() }` |
414
+ | `send()`, on any of them | the rest of `request` — the lookup, the token issued, the earlier ones spent — **once**, and counts nothing | what `request` answers: the token or code, or `null` for nobody |
415
+
416
+ Call `prepare` in the visitor's request, where its `MailThrottledError` can be
417
+ answered; call `send()` off it:
418
+
419
+ ```ts
420
+ import { Hono } from 'hono';
421
+ import { janusErrors } from '@nxgt/janus-hono';
422
+
423
+ const app = new Hono();
424
+ app.onError(janusErrors()); // MAIL_THROTTLED → 429, Retry-After and retryAfter in the body
425
+
426
+ app.post('/reset-password', async (c) => {
427
+ const { email } = await c.req.json();
428
+ const pending = await auth.resetPassword.prepare(email); // counted: throws MAIL_THROTTLED past the limit
429
+ void mailOff(async () => {
430
+ const issued = await pending.send(); // looked up and issued, off the visitor's request
431
+ if (issued !== null) {
432
+ await mailer.send(issued.email, `https://app.example/reset?token=${issued.token}`);
433
+ }
434
+ });
435
+ return c.body(null, 202); // the same answer either way
436
+ });
437
+
438
+ /** Runs `work` off the request, and reports what it throws: nobody else will see it. */
439
+ function mailOff(work: () => Promise<void>): Promise<void> {
440
+ return work().catch((error: unknown) => console.error(error));
441
+ }
442
+ ```
443
+
444
+ The visitor sees `429` with `retryAfter` past the limit — "use the last
445
+ e-mail you received, or wait" — and `202` otherwise, in the same time whether
446
+ the address has an account or not. In production, `send()` belongs in the
447
+ queue that sends the e-mail, so a failure is retried by preparing again.
448
+
449
+ **What `prepare` does is the same for every address.** It looks nobody up:
450
+ the store calls are the count's, the same calls whoever holds the address,
451
+ and none at all with `mail: { throttle: false }`, where `prepare` still
452
+ validates and answers a pending request. `prepare` and `request` share one
453
+ count: three `prepare` and two `request` of one flow for one address fill
454
+ the window.
455
+
456
+ **`send()` runs once.** The count `prepare` made pays for one issue, and
457
+ nothing a caller passes can skip it — there is no option to say "already
458
+ counted". A second `send()` is a `TypeError`:
459
+
460
+ ```
461
+ magicLink.prepare(…).send: already called — a prepared request sends once; call magicLink.prepare again for another
462
+ ```
463
+
464
+ A `send()` that failed — `STORE_FAILED` — is spent too: prepare again, which
465
+ counts again. Two `send()` at once issue one token; the other is the
466
+ `TypeError`.
467
+
468
+ **A sign-in code's challenge comes with `prepare`.** The visitor keeps the
469
+ challenge, and the code goes to the inbox, so a code requested in the
470
+ background still needs its challenge in the foreground answer.
471
+ `signInCode.prepare` mints it before the lookup, so it is in the visitor's
472
+ cookie whoever holds the address — the decoy an unknown address needed is
473
+ no longer yours to make. For an address nobody holds, `send()` answers
474
+ `null` and the challenge is `TOKEN_UNKNOWN`; otherwise the code `send()`
475
+ issues is checked against it. See
476
+ [the sign-in code guide](sign-in-code.md#requesting-a-code-in-two-steps).
477
+
478
+ **`verifyEmail.send` and `stepUp.request` have no `prepare`.** They are
479
+ counted per user, and called for a user the application already holds — the
480
+ session's, or one it just created — so their time tells nothing about who
481
+ has an account, and their `MAIL_THROTTLED` already reaches the visitor in
482
+ the request that asked.
483
+
484
+ **`@nxgt/janus-telemetry`** traces `prepare` as
485
+ `janus.magicLink.prepare` and its `send()` as `janus.magicLink.prepare.send`;
486
+ `janus.mail.throttled` names `janus.mail.flow: magicLink.prepare`, and
487
+ `janus.magicLink.sent` and `janus.signInCode.sent` are written by `send()`,
488
+ as by `request`.
489
+
385
490
  ## What a token refusal means
386
491
 
387
492
  | Code | When |
@@ -49,7 +49,7 @@ that wired the library, so no handler needs to tell it apart.
49
49
  | Code | Class | Status | When | Carries |
50
50
  | --- | --- | --- | --- | --- |
51
51
  | `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
52
- | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
52
+ | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
53
53
  | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `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 | `expectedVersion`, `actualVersion` |
55
55
  | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
@@ -57,7 +57,7 @@ that wired the library, so no handler needs to tell it apart.
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
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 | `userId` |
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` | `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` |
@@ -52,7 +52,7 @@ The words — **user event**, **listener** — are defined in
52
52
  | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `magicLink.confirm` 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 |
54
54
  | `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
55
- | `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and `magicLink.confirm`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
55
+ | `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` and `signInProven`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
56
56
  | `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
57
57
 
58
58
  A reset whose link verifies the e-mail sends both, `user.passwordReset`
@@ -248,7 +248,7 @@ Where the listener runs within a flow, and what an outage does to it:
248
248
  | `signInCode.confirm`, `magicLink.confirm` | 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 or link proves the e-mail again |
249
249
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
250
250
  | `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
251
- | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
251
+ | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm`, `signInProven` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
252
252
  | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked, and the other reset links and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
253
253
  | `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
254
254
 
@@ -53,6 +53,15 @@ clinic.member.magicLink.request; // exists: a passwordless type signs in by lin
53
53
  clinic.staff.magicLink;
54
54
  ```
55
55
 
56
+ **Accepting an invitation? Use `signInProven` instead of `request` +
57
+ `confirm`.** When your server already verified the person — the invitation
58
+ token it issued and they followed — call
59
+ [`auth.signInProven(userId)`](sessions.md#signing-in-on-your-own-proof--signinproven):
60
+ the same sign-in, second factor included, with no e-mail sent, no mail
61
+ throttle counted and no earlier link spent. A `request` made only to be
62
+ confirmed at once is counted per address, so five anonymous requests for the
63
+ invitee's address would make the acceptance answer `MAIL_THROTTLED`.
64
+
56
65
  ## Requesting a link
57
66
 
58
67
  ```ts
@@ -130,6 +139,38 @@ addresses is not counted. Add a per-IP ceiling with `@nxgt/redis` rate limits
130
139
  [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
131
140
  for the options.
132
141
 
142
+ ## Requesting a link in two steps
143
+
144
+ `request` takes longer for an address with an account — a lookup, a token
145
+ written, the earlier ones spent — than for one nobody holds. Run it off the
146
+ visitor's request to hide that, and its `MAIL_THROTTLED` reaches nobody.
147
+ `prepare` keeps the count in the visitor's request and moves the rest off
148
+ it:
149
+
150
+ ```ts
151
+ // in the visitor's request: validated and counted, nobody looked up
152
+ const pending = await auth.magicLink.prepare(email); // MAIL_THROTTLED past the limit: answer 429
153
+
154
+ // off it — a queue, or a promise you do not await
155
+ void pending
156
+ .send() // what request answers: IssuedToken & { user } | null — and it counts nothing
157
+ .then((issued) => {
158
+ // ORIGIN as in As routes, below
159
+ if (issued !== null) return sendMail(issued.email, 'Your sign-in link', `${ORIGIN}/sign-in/link?token=${issued.token}`);
160
+ })
161
+ .catch(reportError);
162
+
163
+ return new Response(null, { status: 202 }); // the same answer either way
164
+ ```
165
+
166
+ `prepare` makes the same store calls whoever holds the address — the
167
+ count's, none with the throttle off — so its time tells nothing. `send()`
168
+ runs **once**: a second call, or one after a `send()` that failed, is a
169
+ `TypeError` (`magicLink.prepare(…).send: already called — …`); prepare
170
+ again, which counts again. The details, shared with sign-in codes and
171
+ password resets, are in
172
+ [the e-mail flows guide](email-flows.md#requests-in-two-steps).
173
+
133
174
  ## Building the link
134
175
 
135
176
  The token is base64url — safe in a URL without escaping — and goes in a
@@ -469,15 +510,21 @@ it('signs in with the e-mailed link, once, and not after fifteen minutes', async
469
510
  interface MagicLinkApi<U, Answer = SignedIn<U>> {
470
511
  readonly magicLink: {
471
512
  request(email: string): Promise<(IssuedToken & { readonly user: U }) | null>;
513
+ prepare(email: string): Promise<PreparedRequest<IssuedToken & { readonly user: U }>>;
472
514
  confirm(token: string, options?: SignInOptions): Promise<Answer>;
473
515
  };
474
516
  }
517
+
518
+ interface PreparedRequest<Issued> {
519
+ send(): Promise<Issued | null>; // once: a second call is a TypeError
520
+ };
521
+ }
475
522
  ```
476
523
 
477
524
  `Answer` is `SignInResult<U>` on a type with a password in an instance given
478
525
  a `secondFactor`, and `SignedIn<U>` everywhere else. `MagicLinkApi`,
479
- `IssuedToken` and `SignInOptions` are exported from `@nxgt/janus`, as
480
- types; `SignInOptions` is `{ device?: string | null }` — see
526
+ `IssuedToken`, `PreparedRequest` and `SignInOptions` are exported from
527
+ `@nxgt/janus`, as types; `SignInOptions` is `{ device?: string | null }` — see
481
528
  [devices](devices.md#giving-a-sign-in-the-device).
482
529
 
483
530
  ## For an adapter
@@ -234,7 +234,7 @@ The throttle counts per login. It does not see:
234
234
  - `changePassword`, which compares the current password too: limit it per
235
235
  user.
236
236
  - `resetPassword.request`, `signInCode.request` and `magicLink.request`,
237
- which send e-mail: [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
237
+ which send e-mail — and their `prepare`: [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
238
238
  counts them per address, but not per client. Limit them per client.
239
239
 
240
240
  ```ts
@@ -250,7 +250,10 @@ const signedIn = await auth.signIn({ email, password });
250
250
  - The hash never reaches a `User` — `hasPassword` says whether there is one.
251
251
  - `signIn` compares against a dummy hash when nobody holds the login, so the
252
252
  time taken does not reveal which users exist. The store's own latency
253
- still can, and that limit is stated rather than denied.
253
+ still can, and that limit is stated rather than denied. For a reset, a
254
+ sign-in code or a link, `prepare` takes that latency off the visitor's
255
+ request: it counts and looks nobody up, and `send()` does the rest in the
256
+ background ([requests in two steps](email-flows.md#requests-in-two-steps)).
254
257
 
255
258
  ## See also
256
259
 
@@ -406,7 +406,7 @@ is as good as the user's phone.
406
406
  ### Signing in with one
407
407
 
408
408
  `recover(challenge, code)` redeems the challenge `signIn` answered — or
409
- `signInCode.confirm`'s, or `magicLink.confirm`'s — with a recovery code instead of the app's code, and
409
+ `signInCode.confirm`'s, `magicLink.confirm`'s or `signInProven`'s — with a recovery code instead of the app's code, and
410
410
  opens the session. The recovery code is **spent**: its hash leaves the store,
411
411
  and `recoveryCodesLeft` says how many remain.
412
412
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  This page is for everything after a user has signed in: finding who a request
4
4
  belongs to, the session cookie, renewal, signing out, and testing expiry.
5
- Signing up and signing in are on the [users](users.md) page.
5
+ Signing up and signing in are on the [users](users.md) page; signing in a
6
+ user your application proved itself — accepting an invitation — is
7
+ [below](#signing-in-on-your-own-proof--signinproven).
6
8
 
7
9
  ```ts
8
10
  import { z } from 'zod';
@@ -194,6 +196,107 @@ do `signInCode.confirm` and `magicLink.confirm` when they prove an e-mail
194
196
  never verified, before opening their own session
195
197
  ([why](magic-link.md#an-account-someone-else-registered)).
196
198
 
199
+ ## Signing in on your own proof — `signInProven`
200
+
201
+ Some sign-ins are proved by the application, not by janus: a person accepts
202
+ an invitation your server issued, and the token they followed is the proof.
203
+ `auth.<type>.signInProven(user, options?)` signs that user in and answers
204
+ what every other sign-in answers — a `SignedIn`, or a challenge when their
205
+ second factor is active.
206
+
207
+ ```ts
208
+ signInProven(user: UserRef, options?: SignInOptions): Promise<SignInResult<User>>
209
+ // SignedIn<User> on a type that cannot have a second factor
210
+ ```
211
+
212
+ **It checks no credential: your server carries the proof.** It is a
213
+ privileged call, like `setPassword`. Call it only right after verifying a
214
+ proof your server issued, and take the id from that proof's own record —
215
+ never from the request body, a query string, or a cookie the client wrote.
216
+ An id a visitor can choose signs that visitor in as anybody.
217
+
218
+ ### Accepting an invitation
219
+
220
+ The recipe the call is made for. The invitation is yours: a random token,
221
+ stored hashed, used once, with an expiry — the same rules janus applies to
222
+ its own links.
223
+
224
+ ```ts
225
+ import { createHash, randomBytes } from 'node:crypto';
226
+
227
+ const hash = (token: string) => createHash('sha256').update(token).digest('base64url');
228
+
229
+ // Inviting: mail a link of yours to the address — nothing of janus's is sent.
230
+ export async function invite(email: string) {
231
+ const token = randomBytes(32).toString('base64url');
232
+ await invitations.insert({ hash: hash(token), email, expiresAt: inSevenDays() });
233
+ await sendMail(email, `Join us: https://app.example/invitation?token=${token}`);
234
+ }
235
+
236
+ // Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
237
+ export async function accept(request: Request): Promise<Response> {
238
+ const { token } = (await request.json()) as { token: string };
239
+ // One conditional write: spent once, and only while it has not expired.
240
+ const invitation = await invitations.spend(hash(String(token)), new Date());
241
+ if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
242
+
243
+ // The address is the invitation's, never the request's. The person may already have an
244
+ // account — findByEmail counts nothing, sends nothing — or gets one, with no password.
245
+ const user =
246
+ (await auth.findByEmail(invitation.email)) ??
247
+ (await auth.create({ email: invitation.email, name: '' }));
248
+ const result = await auth.signInProven(user, { device: deviceOf(request) });
249
+ if (result.status === 'secondFactor') {
250
+ // keep result.challenge for the next request — never in a URL — and ask for a code
251
+ return Response.json({ next: 'code' });
252
+ }
253
+ return Response.json(
254
+ { id: result.user.id },
255
+ { headers: { 'Set-Cookie': auth.cookie.serialize(result.token, result.session) } },
256
+ );
257
+ }
258
+ ```
259
+
260
+ **`findByEmail` works on every type with an e-mail, password or not** —
261
+ since 0.21. A type with no password has no `findByLogin`, and must not
262
+ declare one just to be looked up: that would open the password and reset
263
+ flows on it. It answers whether an account exists, so call it only here,
264
+ after the invitation was verified, on the address the invitation holds
265
+ ([users](users.md#finding-a-user-by-e-mail--findbyemail)).
266
+
267
+ **Before 0.20 this was `magicLink.request(email)` then `magicLink.confirm`,
268
+ at once — use `signInProven` instead.** `request` is counted against the
269
+ mail throttle per address, five per 10 minutes by default: anybody asking
270
+ five links for the invitee's address made the real acceptance answer
271
+ `MAIL_THROTTLED`, with a message about "the last one sent" when nothing had
272
+ been sent. `signInProven` counts nothing, sends nothing, and spends nothing:
273
+ a link or a code already in the person's inbox still works afterwards.
274
+
275
+ ### What it does, and what it does not
276
+
277
+ - **The same ending as every sign-in.** A user whose second factor is active
278
+ gets `{ status: 'secondFactor', challenge }` — the application's proof is
279
+ a first factor — and `secondFactor.confirm` opens the session. Given a
280
+ `device`, it answers `newDevice` and `deviceToken`, and sends
281
+ `user.newDeviceSignedIn` for a device new to the user
282
+ ([devices](devices.md)). `@nxgt/janus-telemetry` traces it as
283
+ `janus.<type>.signInProven` and writes `janus.signIn` with
284
+ `janus.signIn.proven: true`.
285
+ - **Refused as every sign-in refuses.** An inactive user is `USER_INACTIVE`;
286
+ a malformed id, an unknown one, a deleted user or a user of another type
287
+ is `NOT_FOUND`, as for `get` — never `null`, since a sign-in that found
288
+ nobody must not be read past. Options that are not `{ device }` are a
289
+ `TypeError`, as on every sign-in.
290
+ - **No e-mail proved.** `emailVerified` stays as it was: the call does not
291
+ know your proof reached the inbox. Nothing is dropped either — unlike a
292
+ first proof by link or code, the password and second factor stay.
293
+ - **No throttle.** Neither the mail throttle nor the sign-in throttle counts
294
+ it, and it restarts neither: a login past its password attempts is still
295
+ throttled for `signIn`. Limit your own acceptance route per client, as
296
+ any route that redeems a token.
297
+ - **No route.** `@nxgt/janus-hono` and `@nxgt/janus-graphql` expose nothing
298
+ for it: the proof is yours, so the route is yours.
299
+
197
300
  ## `collectExpired`
198
301
 
199
302
  ```ts
@@ -242,6 +345,7 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
242
345
  ## See also
243
346
 
244
347
  - [Users](users.md) — `signUp`, `signIn`, the configuration
348
+ - [Signing in with an e-mailed link](magic-link.md) — when janus, not your application, proves the person
245
349
  - [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
246
350
  - [Step-up](step-up.md) — proving again who you are, and `assertFresh`
247
351
  - [Devices](devices.md) — the device cookie beside the session cookie, and a sign-in from a new device
@@ -134,6 +134,52 @@ earlier ones: only the last code sent works — see
134
134
  throttle](email-flows.md#requests-that-send-e-mail-are-throttled); limit the
135
135
  route per client yourself too.
136
136
 
137
+ ## Requesting a code in two steps
138
+
139
+ Sending the e-mail after answering hides the mailer's time, not the store's:
140
+ `request` looks the address up and writes a code for an account, and stops
141
+ at the lookup for nobody. Running all of `request` off the visitor's request
142
+ hides that too — but then its `MAIL_THROTTLED` reaches nobody, and the
143
+ challenge, which the visitor must keep, is not there to set in a cookie.
144
+ `prepare` answers both:
145
+
146
+ ```ts
147
+ // in the visitor's request: validated and counted, nobody looked up, a challenge minted
148
+ const pending = await auth.signInCode.prepare(email); // MAIL_THROTTLED past the limit: answer 429
149
+
150
+ // off it — a queue, or a promise you do not await
151
+ void pending
152
+ .send() // what request answers: IssuedCode | null, its challenge pending.challenge — and it counts nothing
153
+ .then((issued) => {
154
+ if (issued !== null) return sendMail(issued.email, 'Your sign-in code', `Your code is ${issued.code}.`);
155
+ })
156
+ .catch(reportError);
157
+
158
+ // the same answer either way, the challenge with it: no decoy to make
159
+ // (CHALLENGE and scope as in [As routes](#as-routes), below)
160
+ return Response.json(
161
+ { next: 'code' },
162
+ { status: 202, headers: { 'Set-Cookie': `${CHALLENGE}=${pending.challenge}; Max-Age=600; ${scope}` } },
163
+ );
164
+ ```
165
+
166
+ **The challenge is minted before the lookup**, so every visitor gets one,
167
+ whoever holds the address. For an address nobody holds, `send()` answers
168
+ `null` and the challenge confirms as `TOKEN_UNKNOWN`, exactly as the decoy
169
+ did; for an account, the code `send()` issues is checked against it. Until
170
+ `send()` has run, the challenge is `TOKEN_UNKNOWN` for an account too — a
171
+ visitor who types a code first reads the e-mail first.
172
+
173
+ `prepare` makes the same store calls whoever holds the address — the
174
+ count's, none with the throttle off. `send()` runs **once**: a second call,
175
+ or one after a `send()` that failed, is a `TypeError`
176
+ (`signInCode.prepare(…).send: already called — …`); prepare again, which
177
+ counts again and mints another challenge. The confirmation still tells a
178
+ real challenge from a decoy by `attemptsLeft`: answer every refusal alike
179
+ where that matters, as [above](#requesting-a-code). The details, shared
180
+ with links and password resets, are in
181
+ [the e-mail flows guide](email-flows.md#requests-in-two-steps).
182
+
137
183
  ## Sending the code by e-mail
138
184
 
139
185
  Put **the code, and only the code**, in the e-mail — with its lifetime, so
@@ -554,15 +600,21 @@ it('signs in with the e-mailed code, and not after ten minutes', async () => {
554
600
  interface SignInCodeApi<U, Answer = SignedIn<U>> {
555
601
  readonly signInCode: {
556
602
  request(email: string): Promise<IssuedCode<U> | null>;
603
+ prepare(email: string): Promise<PreparedCode<U>>;
557
604
  confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
558
605
  };
559
606
  }
607
+
608
+ interface PreparedCode<U> {
609
+ readonly challenge: string; // for the visitor, now: the one send() issues the code under
610
+ send(): Promise<IssuedCode<U> | null>; // once: a second call is a TypeError
611
+ }
560
612
  ```
561
613
 
562
614
  `Answer` is `SignInResult<U>` on a type with a password in an instance given
563
615
  a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode`,
564
- `SignInCodeApi` and `SignInOptions` are exported from `@nxgt/janus`, as
565
- types; `SignInOptions` is `{ device?: string | null }` — see
616
+ `PreparedCode`, `SignInCodeApi` and `SignInOptions` are exported from
617
+ `@nxgt/janus`, as types; `SignInOptions` is `{ device?: string | null }` — see
566
618
  [devices](devices.md#giving-a-sign-in-the-device).
567
619
 
568
620
  ## See also
@@ -186,6 +186,7 @@ In the one-type form these are on `auth` itself; with several types, on
186
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` |
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
+ | `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` |
189
190
 
190
191
  With a `password`, besides:
191
192
 
@@ -197,6 +198,12 @@ With a `password`, besides:
197
198
  | `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` |
198
199
  | `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` |
199
200
 
201
+ With an e-mail — the field `email` names, password or not — besides:
202
+
203
+ | Method | Answers | Rejects with |
204
+ | --- | --- | --- |
205
+ | `findByEmail(email)` | the user, or `null`; the address trimmed and lowercased as the e-mail flows do, whatever the login; a value that is no address is nobody's — **server-side only**, see [below](#finding-a-user-by-e-mail--findbyemail) | `STORE_FAILED` only |
206
+
200
207
  With `secondFactor` configured, a type with a password also answers
201
208
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
202
209
  [the second factor](second-factor.md).
@@ -215,6 +222,7 @@ await auth.get(grace.id); // User, or NOT_FOUND
215
222
  const renamed = await auth.update(grace, { name: 'Grace Hopper' }, { ifVersion: grace.version });
216
223
  await auth.setActive(renamed, true);
217
224
  await auth.findByLogin(' GRACE@example.com '); // found: normalised as sign-up normalised it
225
+ await auth.findByEmail(' GRACE@example.com '); // found: normalised as the e-mail flows normalise it
218
226
  await auth.setPassword(renamed, 'a new password');
219
227
  await auth.changePassword(renamed, { current: 'a new password', next: 'another password' });
220
228
  await auth.delete(renamed); // true
@@ -297,6 +305,68 @@ const staffById = new DataLoader(async (ids: readonly string[]) => {
297
305
  });
298
306
  ```
299
307
 
308
+ ### Finding a user by e-mail — `findByEmail`
309
+
310
+ ```ts
311
+ findByEmail(email: string): Promise<User | null>
312
+ // typed by FindByEmailApi<User>, exported from @nxgt/janus
313
+ ```
314
+
315
+ Every type with an e-mail has it, **with or without a password** — and a
316
+ type with no password has no `findByLogin`. It is the lookup an invitation
317
+ needs: the person was invited at an address, may already hold an account of
318
+ a passwordless type, and is signed in with `signInProven` once your server
319
+ verified the invitation
320
+ ([sessions](sessions.md#accepting-an-invitation)):
321
+
322
+ ```ts
323
+ const clinic = janus({
324
+ users: {
325
+ patient: { schema: Patient, password: { login: 'email' } },
326
+ member: { schema: z.object({ email: z.email(), name: z.string() }) }, // no password, by design
327
+ },
328
+ store,
329
+ hasher,
330
+ });
331
+
332
+ const invitation = await invitations.spend(hash(token), new Date()); // your proof, verified first
333
+ if (invitation === null) return notFound();
334
+ const member =
335
+ (await clinic.member.findByEmail(invitation.email)) ??
336
+ (await clinic.member.create({ email: invitation.email, name: '' }));
337
+ await clinic.member.signInProven(member);
338
+ ```
339
+
340
+ Do not declare a `password` on such a type to get `findByLogin`: it would
341
+ open `signIn`, `resetPassword` and the password changes on a type that has
342
+ none by design.
343
+
344
+ - **The holder the e-mail flows find.** The address is trimmed and
345
+ lowercased — the rule every e-mail follows, whatever the type's
346
+ `password.normalize` — and compared with the stored e-mail. On a type that
347
+ signs in with a username, the user is found by their e-mail, and a
348
+ username that only looks like an address is nobody's:
349
+ `magicLink.request`, `signInCode.request` and `resetPassword.request`
350
+ would find the same user, or nobody.
351
+ - **Per type.** The same address may hold a patient and a member; each
352
+ type answers its own, and `null` for the other's.
353
+ - **Inactive users are answered**, as by `find`. `signInProven` then
354
+ refuses them with `USER_INACTIVE`.
355
+ - **Nothing counted, issued or written.** Neither the mail throttle nor the
356
+ sign-in throttle counts it, so it answers past both, and leaves both as
357
+ they were. The store is read once, by `findUserByLogin`.
358
+ - **A value that is no address is nobody's.** Not a string, empty once
359
+ trimmed, or holding a NUL or a lone surrogate: `null` without asking the
360
+ store — as `find` answers a malformed id, never a throw, since the value
361
+ may come from a request.
362
+ - **An outage is `STORE_FAILED`**, never `null`: a lookup that could not
363
+ ask is not an absence.
364
+ - **Server-side only.** It answers whether an account exists, at once and
365
+ with no throttle. Never call it on an address a visitor typed into an
366
+ anonymous route; call it on the address your own proof holds.
367
+ `@nxgt/janus-telemetry` traces it as `janus.<type>.findByEmail`, with the
368
+ `user.id` it found and never the address.
369
+
300
370
  ### Paging every user
301
371
 
302
372
  ```ts