@nxgt/janus 0.18.0 → 0.20.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 +148 -12
- 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/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/memory/users.d.ts.map +1 -1
- package/dist/auth/port/types/stores.d.ts +5 -0
- package/dist/auth/port/types/stores.d.ts.map +1 -1
- package/dist/auth/port/types/users.d.ts +17 -0
- package/dist/auth/port/types/users.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/index.d.ts +2 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +7 -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/types/user-type.d.ts +15 -0
- package/dist/auth/types/user-type.d.ts.map +1 -1
- 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-many.d.ts +29 -0
- package/dist/auth/users/find-many.d.ts.map +1 -0
- package/dist/auth/users/record-flows.d.ts.map +1 -1
- package/dist/auth/users/type-api.d.ts +2 -2
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/chunks/{index-kvjj5xkp.js → index-6bjfv4xv.js} +8 -2
- package/dist/chunks/{index-kvjj5xkp.js.map → index-6bjfv4xv.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/users/batch.d.ts +8 -0
- package/dist/conformance/cases/users/batch.d.ts.map +1 -0
- package/dist/conformance/cases/users/index.d.ts.map +1 -1
- package/dist/conformance/describe.d.ts +2 -1
- package/dist/conformance/describe.d.ts.map +1 -1
- package/dist/conformance/index.d.ts +1 -1
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +104 -44
- package/dist/conformance/index.js.map +7 -6
- package/dist/conformance/types.d.ts +10 -2
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/index.js +150 -23
- package/dist/index.js.map +14 -11
- package/docs/README.md +1 -1
- package/docs/guide/adapters.md +44 -4
- 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 +94 -1
- package/docs/guide/sign-in-code.md +54 -2
- package/docs/guide/users.md +49 -0
- package/docs/roadmap.md +28 -39
- package/docs/troubleshooting.md +120 -7
- package/package.json +1 -1
package/docs/guide/users.md
CHANGED
|
@@ -180,11 +180,13 @@ In the one-type form these are on `auth` itself; with several types, on
|
|
|
180
180
|
| --- | --- | --- |
|
|
181
181
|
| `create(fields & { password?, active? })` | the user; no session | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
182
182
|
| `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
|
|
183
|
+
| `findMany(ids)` | the users, in the order of `ids`, each once; an id `find` answers `null` for is left out — see [below](#several-users-at-once--findmany) | |
|
|
183
184
|
| `get(id)` | the user | `NOT_FOUND` |
|
|
184
185
|
| `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
|
|
185
186
|
| `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
186
187
|
| `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
187
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` |
|
|
188
190
|
|
|
189
191
|
With a `password`, besides:
|
|
190
192
|
|
|
@@ -249,6 +251,53 @@ idempotent: calling it again finishes the job. When it deleted the user, it
|
|
|
249
251
|
sends a [`user.deleted` event](events.md) — once, after the sessions, tokens and relation tuples are gone, and even when an outage interrupts removing them;
|
|
250
252
|
`create` and `signUp` send `user.created`.
|
|
251
253
|
|
|
254
|
+
### Several users at once — `findMany`
|
|
255
|
+
|
|
256
|
+
A list of rows that each name a user — a staff list showing every member's
|
|
257
|
+
account state — reads them in one call, not one `find` per row:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const rows = await db.staffRows(); // your own table, 100 rows
|
|
261
|
+
const members = await clinic.staff.findMany(rows.map((row) => row.staffId));
|
|
262
|
+
const byId = new Map(members.map((member) => [member.id, member]));
|
|
263
|
+
|
|
264
|
+
const shown = rows.map((row) => {
|
|
265
|
+
const member = byId.get(row.staffId); // undefined: nobody holds this id
|
|
266
|
+
return { ...row, active: member?.active ?? false, verified: member?.emailVerified ?? false };
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- **Order**: the order of `ids`. A repeated id is answered once, at its first
|
|
271
|
+
place.
|
|
272
|
+
- **Absence**: an id `find` would answer `null` for — malformed, unknown, or
|
|
273
|
+
held by a user of another type — is **left out**, never an error. The answer
|
|
274
|
+
may be shorter than `ids`, so match by `user.id`, never by position.
|
|
275
|
+
- **Empty**: `[]`, or a list with no well-formed id, answers `[]` without
|
|
276
|
+
reaching the store.
|
|
277
|
+
- **Size**: any length. The store is asked 100 ids at a time, one batch after
|
|
278
|
+
another, so no query grows with your list.
|
|
279
|
+
- **Failure**: `STORE_FAILED`, never a shorter list. Something other than an
|
|
280
|
+
array is a bare `TypeError`: only your code passes one.
|
|
281
|
+
- **Cost**: one query per 100 ids when the users store implements the
|
|
282
|
+
optional `findUsers` — the reference store, `@nxgt/janus-drizzle` and
|
|
283
|
+
`@nxgt/janus-mongo` do. A store that does not is read with `findUser`,
|
|
284
|
+
**ten at a time**, so a third-party adapter keeps working and never opens
|
|
285
|
+
more than ten reads at once for one call; see
|
|
286
|
+
[writing an adapter](adapters.md#userstorefindusers-optional).
|
|
287
|
+
|
|
288
|
+
**A GraphQL resolver per row** can batch through a DataLoader of your own —
|
|
289
|
+
`@nxgt/janus-graphql` loads no users itself:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import DataLoader from 'dataloader';
|
|
293
|
+
|
|
294
|
+
// One per request, so nothing is cached across users' requests.
|
|
295
|
+
const staffById = new DataLoader(async (ids: readonly string[]) => {
|
|
296
|
+
const found = new Map((await clinic.staff.findMany(ids)).map((u) => [u.id, u]));
|
|
297
|
+
return ids.map((id) => found.get(id) ?? null);
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
252
301
|
### Paging every user
|
|
253
302
|
|
|
254
303
|
```ts
|
package/docs/roadmap.md
CHANGED
|
@@ -20,6 +20,11 @@ Nothing yet.
|
|
|
20
20
|
window. A port method to collect them, as sessions are, would let the
|
|
21
21
|
core do it.
|
|
22
22
|
|
|
23
|
+
- **A batch read across types** — `findMany` reads one user type, as `find`
|
|
24
|
+
does. The shared `findUser(id)` has no batch counterpart yet; one on the
|
|
25
|
+
instance, answering a union narrowed by `user.type`, would serve a list that
|
|
26
|
+
mixes types.
|
|
27
|
+
|
|
23
28
|
- **More official adapters** — the ports are cut where atomicity is not
|
|
24
29
|
required, so users, sessions and permission tuples can each live in the
|
|
25
30
|
database that suits them. MongoDB is the first adapter
|
|
@@ -71,6 +76,29 @@ Nothing yet.
|
|
|
71
76
|
The last ten, newest first, each with the version it came in. Everything
|
|
72
77
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
73
78
|
|
|
79
|
+
- **Signing in on the application's own proof, v0.20.0** —
|
|
80
|
+
`auth.<type>.signInProven(user, { device })` signs in a user the
|
|
81
|
+
application proved itself — an invitation token it issued — and answers
|
|
82
|
+
what every sign-in answers: a session, or a second-factor challenge, with
|
|
83
|
+
the device and `USER_INACTIVE` handled alike. It sends no e-mail, counts
|
|
84
|
+
in no throttle and spends no link already sent, so accepting an
|
|
85
|
+
invitation no longer goes through `magicLink.request` and its mail
|
|
86
|
+
throttle. Privileged: never with an id a request supplied.
|
|
87
|
+
- **E-mail requests in two steps, v0.20.0** — `magicLink.prepare(email)`,
|
|
88
|
+
`signInCode.prepare(email)` and `resetPassword.prepare(email)` count the
|
|
89
|
+
address and look nobody up — the same work for any address — so
|
|
90
|
+
`MAIL_THROTTLED` reaches the visitor while `send()`, the lookup and the
|
|
91
|
+
issue, runs off their request, where its time tells nothing. `send()`
|
|
92
|
+
answers what `request` does, once; a second call is a `TypeError`. A sign-in
|
|
93
|
+
code's prepared request carries its `challenge`, minted before the lookup,
|
|
94
|
+
so no decoy is needed for an address nobody holds.
|
|
95
|
+
- **Several users by id in one query, v0.19.0** — `auth.<type>.findMany(ids)`
|
|
96
|
+
answers the type's users in the order of `ids`, each once, and leaves out an
|
|
97
|
+
id nobody of that type holds — for a list of rows that would otherwise make
|
|
98
|
+
one `find` per row and fill the connection pool. The users store gains an
|
|
99
|
+
optional `findUsers`, implemented by the reference store,
|
|
100
|
+
`@nxgt/janus-drizzle` 0.7.0 and `@nxgt/janus-mongo` 0.8.0; a store without it
|
|
101
|
+
is read with `findUser`, ten at a time, so third-party adapters keep working.
|
|
74
102
|
- **Requests that send e-mail are throttled, v0.18.0** —
|
|
75
103
|
`janus({ mail: { throttle: { attempts, window } } })`, five per ten
|
|
76
104
|
minutes by default and `false` to turn it off. `magicLink.request`,
|
|
@@ -138,42 +166,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
138
166
|
replace the new password. Both answer `TOKEN_SPENT`; an outage spending
|
|
139
167
|
them fails the call with `STORE_FAILED`. No change for adapters: the
|
|
140
168
|
existing `TokenStore.spendUserTokens` does it.
|
|
141
|
-
- **Confirm an action with a code (step-up), v0.12.0** —
|
|
142
|
-
`auth.stepUp.request(user)` and `stepUp.confirm(request, challenge, code)`:
|
|
143
|
-
a signed-in user proves again who they are before a sensitive action, with
|
|
144
|
-
a six-digit code sent by e-mail — or, when their second factor is active,
|
|
145
|
-
the code from their app. The confirmation moves the session's
|
|
146
|
-
`authenticatedAt` and opens no session, and `assertFresh(session, maxAge)`
|
|
147
|
-
refuses an older one with `STEP_UP_REQUIRED` (403). A token kind of its
|
|
148
|
-
own, `stepUp`, and `SessionStore.reauthenticateSession`, in the three
|
|
149
|
-
adapters and the conformance suite.
|
|
150
|
-
- **The recovery codes left, read again, v0.11.0** —
|
|
151
|
-
`secondFactor.recoveryCodesLeft(user)` answers the count `recover`
|
|
152
|
-
answered, for a `user.recoveryCodeUsed` listener — the event names the
|
|
153
|
-
user only — or a security settings page; `null` for a user with no active
|
|
154
|
-
factor. The listener can now tell the user how many codes remain, as
|
|
155
|
-
`@nxgt/janus-mail`'s `recoveryCodeUsed` e-mail does.
|
|
156
|
-
- **Recovery codes, v0.10.0** — a user who loses their authenticator app
|
|
157
|
-
still signs in, with no operator resetting the account.
|
|
158
|
-
`secondFactor.activate` answers `{ user, recoveryCodes }`: ten single-use
|
|
159
|
-
codes, shown once and stored only as keyed hashes (breaking: read `.user`).
|
|
160
|
-
`secondFactor.recover(challenge, code)` redeems a sign-in's challenge with
|
|
161
|
-
one and answers the session with `recoveryCodesLeft`;
|
|
162
|
-
`secondFactor.regenerateRecoveryCodes(user, code)` replaces them all on a
|
|
163
|
-
fresh code from the app, and counts its attempts itself — five per user
|
|
164
|
-
per 15-minute window, in the store — so a stolen session cannot guess the
|
|
165
|
-
code, and the route needs no limiter of its own. Two user events,
|
|
166
|
-
`user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`. For adapters:
|
|
167
|
-
`SecondFactorRecord.recoveryCodes`,
|
|
168
|
-
stored by the users stores of `@nxgt/janus-drizzle` and
|
|
169
|
-
`@nxgt/janus-mongo`, with conformance cases.
|
|
170
|
-
- **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
|
|
171
|
-
own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
|
|
172
|
-
links })` takes what each flow answers —
|
|
173
|
-
`await mail.verifyEmail(await auth.verifyEmail.send(user), { name, locale })`
|
|
174
|
-
— and sends five e-mails, in English and French, over any `@nxgt/mail`
|
|
175
|
-
transport: e-mail verification, password reset, sign-in code, and the
|
|
176
|
-
notices *password changed* and *e-mail changed*. They are built once with
|
|
177
|
-
Maizzle when the package is built, and only filled in at send time, every
|
|
178
|
-
value escaped: no template engine in your server. A transport that fails
|
|
179
|
-
throws, like a store.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -9,8 +9,9 @@ How the messages are shaped:
|
|
|
9
9
|
`resetPassword.confirm: …`, `can: …`. With several user types, that call is
|
|
10
10
|
prefixed by the type: `staff.signIn: …`. Below, `<call>` stands for it.
|
|
11
11
|
- **A `TypeError` is a wiring mistake**: it comes from how the application was
|
|
12
|
-
put together — a configuration, a store, a model
|
|
13
|
-
Fix the code; no handler should answer
|
|
12
|
+
put together — a configuration, a store, a model, a prepared request sent
|
|
13
|
+
twice — and never from a request. Fix the code; no handler should answer
|
|
14
|
+
one.
|
|
14
15
|
- **A `JanusError` is a refusal at call time**, on a value that could have come
|
|
15
16
|
from a request. It carries a `code` you can `switch` on; the heading of each
|
|
16
17
|
entry below names it.
|
|
@@ -49,6 +50,9 @@ How the messages are shaped:
|
|
|
49
50
|
**Users, sessions and tokens**
|
|
50
51
|
- [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
|
|
51
52
|
- [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
|
|
53
|
+
- [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
|
|
54
|
+
- [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
|
|
55
|
+
- [`findMany` answers fewer users than ids](#findmany-answers-fewer-users-than-ids)
|
|
52
56
|
- [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
|
|
53
57
|
- [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
|
|
54
58
|
- [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
|
|
@@ -57,6 +61,9 @@ How the messages are shaped:
|
|
|
57
61
|
- [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
|
|
58
62
|
- [`CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login …`](#credentials_invalid--call-too-many-passwords-tried-at-this-login--wait-for-the-next-window)
|
|
59
63
|
- [`MAIL_THROTTLED` — `<call>: too many e-mails asked for this address …`, or `… for this user …`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window)
|
|
64
|
+
- [Accepting an invitation answers `MAIL_THROTTLED`](#accepting-an-invitation-answers-mail_throttled)
|
|
65
|
+
- [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
|
|
66
|
+
- [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
|
|
60
67
|
- [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
|
|
61
68
|
- [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
|
|
62
69
|
- [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
|
|
@@ -283,10 +290,10 @@ janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryp
|
|
|
283
290
|
|
|
284
291
|
### `janus: store.<slot> has no method <method>, which the port requires`
|
|
285
292
|
|
|
286
|
-
Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`.
|
|
293
|
+
Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
|
|
287
294
|
|
|
288
295
|
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
289
|
-
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. `deleteExpiredSessions`
|
|
296
|
+
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Two methods are optional — `sessions.deleteExpiredSessions` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
|
|
290
297
|
An adapter written for an earlier `@nxgt/janus` reports the method a later
|
|
291
298
|
release added to the port: `store.tokens has no method countAttempt` for one
|
|
292
299
|
written against 0.3 (the method came in 0.4), and
|
|
@@ -543,12 +550,46 @@ try {
|
|
|
543
550
|
**Why:** a method that can find nothing must answer `null`. `undefined` is also what a missing `return` produces, so it is treated as a bug in the store, not as "not found".
|
|
544
551
|
**Fix:** `return doc ?? null;` — and run `@nxgt/janus/conformance` against the store.
|
|
545
552
|
|
|
553
|
+
### `STORE_FAILED` — `users.findUsers answered no list: an absence is a shorter list, so this store forgot to answer`
|
|
554
|
+
|
|
555
|
+
**When:** `findMany`, over a users store you wrote that implements the optional `findUsers`.
|
|
556
|
+
**Why:** `findUsers` answered `null`, `undefined` or something else than an array. An id nobody holds is left out of the list, and nobody at all is `[]`; anything else is a store that forgot to answer, so it fails rather than read as "nobody".
|
|
557
|
+
**Fix:** answer the rows you found, mapped to records — or leave `findUsers` out, and `findMany` reads with `findUser`:
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
findUsers: async (ids) => (await db.users.where('id', 'in', ids)).map(toUser), // [] when none
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### `<call>: ids must be an array of user ids`
|
|
564
|
+
|
|
565
|
+
A bare `TypeError` — `findMany: …`, or `staff.findMany: …` with several user types.
|
|
566
|
+
|
|
567
|
+
**When:** `findMany` was given something other than an array: one id, a `Set`, a comma-separated string.
|
|
568
|
+
**Why:** `findMany` takes a list; one id is `find`. Only your code passes the wrong shape, and TypeScript refuses it.
|
|
569
|
+
**Fix:** pass an array, or use `find` for one id:
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
await auth.staff.findMany([...selectedIds]); // from a Set
|
|
573
|
+
await auth.staff.find(id); // one id
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### `findMany` answers fewer users than ids
|
|
577
|
+
|
|
578
|
+
**When:** always, when an id is malformed, unknown, held by a user of another type, or repeated.
|
|
579
|
+
**Why:** `findMany` leaves out what `find` answers `null` for, and answers a repeated id once — it never puts `null` in a place. Reading `users[i]` as the user of `ids[i]` then shows one row's account on another.
|
|
580
|
+
**Fix:** match by id:
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
const byId = new Map((await auth.staff.findMany(ids)).map((user) => [user.id, user]));
|
|
584
|
+
const member = byId.get(row.staffId); // undefined: nobody of this type holds it
|
|
585
|
+
```
|
|
586
|
+
|
|
546
587
|
### `NOT_FOUND` — `<call>: no <type> has this id`
|
|
547
588
|
|
|
548
|
-
`NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
|
|
589
|
+
`NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
|
|
549
590
|
|
|
550
591
|
**When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
|
|
551
|
-
**Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead.
|
|
592
|
+
**Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead. `signInProven` is a `get`: a sign-in that found nobody throws, and on it `NOT_FOUND` means the proof you verified names a user deleted since, or you passed the id of another type's user — or a value that is not the proof's id at all.
|
|
552
593
|
**Fix:** answer 404, or use `find` when absence is an ordinary outcome:
|
|
553
594
|
|
|
554
595
|
```ts
|
|
@@ -723,6 +764,78 @@ if (error instanceof MailThrottledError) {
|
|
|
723
764
|
janus({ ..., mail: { throttle: { attempts: 10, window: '10m' } } });
|
|
724
765
|
```
|
|
725
766
|
|
|
767
|
+
### Accepting an invitation answers `MAIL_THROTTLED`
|
|
768
|
+
|
|
769
|
+
**When:** the application signs an invitee in by calling
|
|
770
|
+
`magicLink.request(email)` and confirming the token at once, and somebody
|
|
771
|
+
asked for links to that address five times in the window — the default mail
|
|
772
|
+
throttle. The acceptance answers 429, and the message says the last link
|
|
773
|
+
sent still works, though the invitee was sent none.
|
|
774
|
+
**Why:** `request` is counted per address, whoever asks: that is what stops a
|
|
775
|
+
loop from filling an inbox. A sign-in your server already proved — the
|
|
776
|
+
invitation token it issued — does not need an e-mailed link, and should not
|
|
777
|
+
share its count.
|
|
778
|
+
**Fix:** call `signInProven` with the id from the invitation's record, after
|
|
779
|
+
verifying the token yourself. It counts nothing, sends nothing, spends no
|
|
780
|
+
link already sent, and answers what `signIn` answers.
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
|
|
784
|
+
if (invitation === null) return new Response(null, { status: 404 });
|
|
785
|
+
const result = await auth.signInProven(invitation.userId, { device: deviceOf(request) });
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
Never pass it an id the request carries: it checks no credential. See
|
|
789
|
+
[sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
|
|
790
|
+
|
|
791
|
+
### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
|
|
792
|
+
|
|
793
|
+
**When:** the application runs `magicLink.request`, `signInCode.request` or
|
|
794
|
+
`resetPassword.request` off the visitor's request — a queue, a promise it
|
|
795
|
+
does not await — so that the time of the answer does not tell whether the
|
|
796
|
+
address has an account. Past the limit, the refusal is thrown there, and
|
|
797
|
+
nobody tells the visitor to use the last e-mail they received.
|
|
798
|
+
**Why:** `request` counts, then looks the address up and issues: its
|
|
799
|
+
`MAIL_THROTTLED` comes from the same call whose time must be hidden.
|
|
800
|
+
**Fix:** call `prepare(email)` in the visitor's request — validated and
|
|
801
|
+
counted, nobody looked up, the same store calls for any address — and
|
|
802
|
+
answer its `MAIL_THROTTLED` with 429; call `send()` on what it answered in the
|
|
803
|
+
background. `send()` counts nothing and answers what `request` would have.
|
|
804
|
+
|
|
805
|
+
```ts
|
|
806
|
+
const pending = await auth.magicLink.prepare(email); // MAIL_THROTTLED here, in the visitor's request
|
|
807
|
+
void pending.send().then(mailIt).catch(reportError); // looked up and issued off it
|
|
808
|
+
return new Response(null, { status: 202 });
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
See [requests in two steps](guide/email-flows.md#requests-in-two-steps).
|
|
812
|
+
|
|
813
|
+
### `<flow>.prepare(…).send: already called — a prepared request sends once; call <flow>.prepare again for another`
|
|
814
|
+
|
|
815
|
+
A `TypeError`, such as `magicLink.prepare(…).send: already called — a
|
|
816
|
+
prepared request sends once; call magicLink.prepare again for another`, or
|
|
817
|
+
`patient.signInCode.prepare(…)…` with several user types.
|
|
818
|
+
**When:** `send()` was called a second time on what one `prepare` answered —
|
|
819
|
+
after it issued, after it answered `null`, after it threw (`STORE_FAILED`),
|
|
820
|
+
or while the first call was still running.
|
|
821
|
+
**Why:** `prepare` counted one request against the mail throttle, and that
|
|
822
|
+
count pays for one issue. A `send()` that could run again would issue
|
|
823
|
+
uncounted, so a loop could mail an address past the limit; nothing a caller
|
|
824
|
+
passes skips the count. It is a bug in the calling code, never a value from
|
|
825
|
+
a request, so it is a `TypeError` with no `code`.
|
|
826
|
+
**Fix:** call `send()` once per `prepare`. To retry — after a failure, in a
|
|
827
|
+
queue that retries — `prepare` again, which counts again (and, for a sign-in
|
|
828
|
+
code, mints another challenge: give the visitor the new one):
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
async function sendLink(email: string): Promise<void> {
|
|
832
|
+
const pending = await auth.magicLink.prepare(email); // one count…
|
|
833
|
+
const issued = await pending.send(); // …one issue
|
|
834
|
+
if (issued !== null) await mailer.send(issued.email, linkTo(issued.token));
|
|
835
|
+
}
|
|
836
|
+
// a retry calls sendLink again: never pending.send() twice
|
|
837
|
+
```
|
|
838
|
+
|
|
726
839
|
### `STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`
|
|
727
840
|
|
|
728
841
|
`StoreFailure`, with `slot: 'tokens'` and `operation: 'countAttempt'`.
|
|
@@ -754,7 +867,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
754
867
|
|
|
755
868
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
756
869
|
|
|
757
|
-
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent.
|
|
870
|
+
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven`, for an inactive user: the application's proof does not reactivate anybody.
|
|
758
871
|
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
|
|
759
872
|
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
760
873
|
|