@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.
- package/README.md +164 -10
- package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
- package/dist/auth/events.d.ts +3 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts +3 -0
- package/dist/auth/magic-link.d.ts.map +1 -1
- package/dist/auth/one-time/issue.d.ts +6 -0
- package/dist/auth/one-time/issue.d.ts.map +1 -1
- package/dist/auth/prepared.d.ts +21 -0
- package/dist/auth/prepared.d.ts.map +1 -0
- package/dist/auth/proven-sign-in.d.ts +22 -0
- package/dist/auth/proven-sign-in.d.ts.map +1 -0
- package/dist/auth/sign-in-code.d.ts +4 -0
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +18 -0
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/find-by-email.d.ts +31 -0
- package/dist/auth/types/find-by-email.d.ts.map +1 -0
- package/dist/auth/types/index.d.ts +3 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +8 -5
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +11 -0
- package/dist/auth/types/magic-link.d.ts.map +1 -1
- package/dist/auth/types/prepared.d.ts +41 -0
- package/dist/auth/types/prepared.d.ts.map +1 -0
- package/dist/auth/types/proven-sign-in.d.ts +43 -0
- package/dist/auth/types/proven-sign-in.d.ts.map +1 -0
- package/dist/auth/types/sign-in.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts.map +1 -1
- package/dist/auth/users/find-by-email.d.ts +11 -0
- package/dist/auth/users/find-by-email.d.ts.map +1 -0
- package/dist/auth/users/type-api.d.ts +2 -2
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/index.js +105 -17
- package/dist/index.js.map +12 -9
- package/docs/README.md +2 -2
- package/docs/guide/devices.md +4 -0
- package/docs/guide/email-flows.md +105 -0
- package/docs/guide/errors.md +2 -2
- package/docs/guide/events.md +2 -2
- package/docs/guide/magic-link.md +49 -2
- package/docs/guide/passwords.md +5 -2
- package/docs/guide/second-factor.md +1 -1
- package/docs/guide/sessions.md +105 -1
- package/docs/guide/sign-in-code.md +54 -2
- package/docs/guide/users.md +70 -0
- package/docs/roadmap.md +24 -33
- package/docs/troubleshooting.md +100 -6
- 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 |
|
package/docs/guide/devices.md
CHANGED
|
@@ -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 |
|
package/docs/guide/errors.md
CHANGED
|
@@ -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` |
|
package/docs/guide/events.md
CHANGED
|
@@ -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 `
|
|
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
|
|
package/docs/guide/magic-link.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/guide/passwords.md
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
package/docs/guide/sessions.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/guide/users.md
CHANGED
|
@@ -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
|