@nxgt/janus 0.17.2 → 0.19.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 +107 -27
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +31 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-throttle.d.ts +30 -0
- package/dist/auth/config/resolve-throttle.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +5 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/config/warn-throttle-window.d.ts +14 -0
- package/dist/auth/config/warn-throttle-window.d.ts.map +1 -0
- package/dist/auth/counter-key.d.ts +19 -0
- package/dist/auth/counter-key.d.ts.map +1 -0
- package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
- package/dist/auth/email-flows/verify-email.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts.map +1 -1
- package/dist/auth/mail-requests.d.ts +51 -0
- package/dist/auth/mail-requests.d.ts.map +1 -0
- 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/sign-in-attempts.d.ts +3 -6
- package/dist/auth/sign-in-attempts.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +3 -2
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/step-up/request.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +16 -6
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +4 -2
- package/dist/auth/types/magic-link.d.ts.map +1 -1
- package/dist/auth/types/step-up.d.ts +4 -1
- package/dist/auth/types/step-up.d.ts.map +1 -1
- 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/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/chunks/{index-6fxq0kzf.js → index-4khsm7mw.js} +2 -2
- package/dist/chunks/{index-7xe5x3eg.js → index-6bjfv4xv.js} +9 -3
- package/dist/chunks/{index-7xe5x3eg.js.map → index-6bjfv4xv.js.map} +3 -3
- package/dist/chunks/{index-k136102s.js → index-cntabs9d.js} +2 -2
- package/dist/chunks/{index-ztng6018.js → index-kppkcbn5.js} +10 -3
- package/dist/chunks/{index-ztng6018.js.map → index-kppkcbn5.js.map} +4 -4
- 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 +106 -46
- 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/errors/janus-error/codes.d.ts +10 -0
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/index.d.ts +1 -1
- package/dist/errors/janus-error/index.d.ts.map +1 -1
- package/dist/errors/janus-error/options.d.ts +4 -3
- package/dist/errors/janus-error/options.d.ts.map +1 -1
- package/dist/errors/janus-error/refusals.d.ts +8 -0
- package/dist/errors/janus-error/refusals.d.ts.map +1 -1
- package/dist/errors/janus-error/status.d.ts +1 -1
- package/dist/errors/janus-error/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +171 -30
- package/dist/index.js.map +18 -14
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +1 -1
- package/docs/guide/adapters.md +44 -4
- package/docs/guide/email-flows.md +164 -4
- package/docs/guide/errors.md +12 -3
- package/docs/guide/magic-link.md +41 -5
- package/docs/guide/passwords.md +2 -1
- package/docs/guide/sign-in-code.md +46 -7
- package/docs/guide/step-up.md +38 -7
- package/docs/guide/users.md +49 -0
- package/docs/roadmap.md +26 -15
- package/docs/troubleshooting.md +127 -5
- package/package.json +1 -1
- package/dist/auth/config/resolve-sign-in.d.ts +0 -5
- package/dist/auth/config/resolve-sign-in.d.ts.map +0 -1
- /package/dist/chunks/{index-6fxq0kzf.js.map → index-4khsm7mw.js.map} +0 -0
- /package/dist/chunks/{index-k136102s.js.map → index-cntabs9d.js.map} +0 -0
|
@@ -4,15 +4,15 @@ import {
|
|
|
4
4
|
UNSTORABLE,
|
|
5
5
|
MAX_PAGE_SIZE2,
|
|
6
6
|
pageLimit2
|
|
7
|
-
} from "../chunks/index-
|
|
7
|
+
} from "../chunks/index-4khsm7mw.js";
|
|
8
8
|
import {
|
|
9
9
|
PermissionDepthError2,
|
|
10
10
|
isSetOf2,
|
|
11
11
|
formatEntity2
|
|
12
|
-
} from "../chunks/index-
|
|
12
|
+
} from "../chunks/index-kppkcbn5.js";
|
|
13
13
|
import {
|
|
14
14
|
createMemoryRelations2
|
|
15
|
-
} from "../chunks/index-
|
|
15
|
+
} from "../chunks/index-cntabs9d.js";
|
|
16
16
|
|
|
17
17
|
// src/permissions/resolve/resolved.ts
|
|
18
18
|
function admits(holders, subject) {
|
package/docs/README.md
CHANGED
|
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
15
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 |
|
|
16
|
-
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
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 |
|
|
19
19
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -31,7 +31,7 @@ describeJanusStores({
|
|
|
31
31
|
|
|
32
32
|
| Port | Taken by | Methods |
|
|
33
33
|
| --- | --- | --- |
|
|
34
|
-
| `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 +
|
|
34
|
+
| `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 |
|
|
35
35
|
| `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
|
|
36
36
|
|
|
37
37
|
They are separate on purpose: an application that only authenticates
|
|
@@ -57,6 +57,7 @@ record, a session is derived state, a token is ephemeral.
|
|
|
57
57
|
interface UserStore {
|
|
58
58
|
insertUser(record: UserRecord): Promise<UserRecord>;
|
|
59
59
|
findUser(id: Id): Promise<UserRecord | null>;
|
|
60
|
+
findUsers?(ids: readonly Id[]): Promise<readonly UserRecord[]>; // optional: one query for many ids
|
|
60
61
|
findUserByLogin(type: string, login: string): Promise<UserRecord | null>;
|
|
61
62
|
listUsers(page: UserPageRequest): Promise<CursorPage<UserRecord>>;
|
|
62
63
|
updateUser(id: Id, patch: UserPatch, ifVersion: number): Promise<UserRecord>;
|
|
@@ -77,6 +78,41 @@ interface UserStore {
|
|
|
77
78
|
- `listUsers` pages in ascending id order; `after` is the last id of the
|
|
78
79
|
previous page, already checked by the core.
|
|
79
80
|
|
|
81
|
+
#### `UserStore.findUsers` (optional)
|
|
82
|
+
|
|
83
|
+
What `findMany` asks for: the users holding these ids, **in one query**.
|
|
84
|
+
Leave it out and nothing breaks — `findMany` reads with `findUser` instead,
|
|
85
|
+
ten at a time — so implement it when your database can answer a list of ids
|
|
86
|
+
in one round trip.
|
|
87
|
+
|
|
88
|
+
- **Whatever their type**, as `findUser`: the core keeps the type's own users,
|
|
89
|
+
and puts them back in its caller's order. Answer them **in any order**.
|
|
90
|
+
- **An id nobody holds is left out.** An absence is a shorter list — `[]` when
|
|
91
|
+
nobody holds any — never `null`, never a rejection.
|
|
92
|
+
- **A failure throws**, as everywhere on the port (rule 2). Answering `[]` for
|
|
93
|
+
an outage tells the application those users do not exist.
|
|
94
|
+
- The core calls it with **1 to 100 distinct, well-formed ids**, never an
|
|
95
|
+
empty list, and reads a longer list in several calls one after another — no
|
|
96
|
+
`IN` grows past 100.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import type { UserStore } from '@nxgt/janus';
|
|
100
|
+
|
|
101
|
+
// PostgreSQL through Drizzle — @nxgt/janus-drizzle's, in one statement
|
|
102
|
+
findUsers: async (ids) =>
|
|
103
|
+
(await db.select().from(users).where(inArray(users.id, [...ids]))).map(toUser),
|
|
104
|
+
|
|
105
|
+
// MongoDB — @nxgt/janus-mongo's, in one query
|
|
106
|
+
findUsers: async (ids) =>
|
|
107
|
+
(await collection.find({ _id: { $in: [...ids] } }).toArray()).map(toUser),
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`janus()` reads its presence once (`StoreCapabilities.findUsers`), and refuses
|
|
111
|
+
a `findUsers` that is there but not a function with a `TypeError`: a value in
|
|
112
|
+
the wrong place is a wiring mistake, not an absent capability. The suite's
|
|
113
|
+
`users.findUsers`, `users.findUsersNone` and `outage.findUsers` cases check it,
|
|
114
|
+
and are skipped with their reason for a store that leaves it out.
|
|
115
|
+
|
|
80
116
|
#### A user's password and second factor
|
|
81
117
|
|
|
82
118
|
Both are one field of `UserRecord`, `null` when the user has none, and a
|
|
@@ -407,7 +443,7 @@ compile error naming the missing method.
|
|
|
407
443
|
|
|
408
444
|
| Suite | Cases | Harness opens |
|
|
409
445
|
| --- | --- | --- |
|
|
410
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
446
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 58: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
|
|
411
447
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
412
448
|
|
|
413
449
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -504,8 +540,12 @@ describeJanusStores({
|
|
|
504
540
|
|
|
505
541
|
A skipped case still appears in the run, with its reason in its name. A case
|
|
506
542
|
that cannot run on the stores it was given — an outage case with no `faults`,
|
|
507
|
-
`collectExpired` on a store without `deleteExpiredSessions
|
|
508
|
-
|
|
543
|
+
`collectExpired` on a store without `deleteExpiredSessions`, a `findUsers`
|
|
544
|
+
case on a store without `findUsers` — passes, and emits a
|
|
545
|
+
`JANUS_CONFORMANCE_SKIPPED` warning with the reason. A case's `needs` says what
|
|
546
|
+
it cannot run without — one `CaseNeed` or a list: `outage.findUsers` needs
|
|
547
|
+
`['faults', 'findUsers']`, and is skipped for whichever is missing, `faults`
|
|
548
|
+
named first.
|
|
509
549
|
|
|
510
550
|
### Without a test runner
|
|
511
551
|
|
|
@@ -114,7 +114,11 @@ readonly verifyEmail: {
|
|
|
114
114
|
};
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
`send` issues a token for the user's **current** e-mail.
|
|
117
|
+
`send` issues a token for the user's **current** e-mail. It is throttled
|
|
118
|
+
**per user**, five per 10-minute window: past it, `MailThrottledError`
|
|
119
|
+
(`MAIL_THROTTLED`, with `userId`) and no token. The application calls it for a
|
|
120
|
+
user it knows, so an unauthenticated loop on the address cannot spend the
|
|
121
|
+
count, and a user who changes e-mail buys no extra sends. `confirm` redeems it
|
|
118
122
|
and sets `emailVerified`. A token sent to an e-mail the user has since changed
|
|
119
123
|
is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
|
|
120
124
|
The address is checked again on the very record the write replaces, so an
|
|
@@ -165,9 +169,15 @@ if (first !== null && second !== null) {
|
|
|
165
169
|
|
|
166
170
|
Requests that arrive at once cannot each keep a link: each spends the
|
|
167
171
|
others' once it issued its own, so at most one survives — sometimes none, and
|
|
168
|
-
the visitor asks again.
|
|
169
|
-
codes: each one sends an e-mail and cancels the link before it, so
|
|
170
|
-
|
|
172
|
+
the visitor asks again. **`request` is throttled per address**, as for
|
|
173
|
+
sign-in codes: each one sends an e-mail and cancels the link before it, so
|
|
174
|
+
past five requests for one address in a 10-minute window it throws
|
|
175
|
+
`MailThrottledError` (`MAIL_THROTTLED`) and issues nothing; the link last sent
|
|
176
|
+
still works. An address nobody holds is counted and refused alike, and under
|
|
177
|
+
the limit still answers `null`. Somebody who knows an address can fill its
|
|
178
|
+
inbox with at most five reset e-mails a window, but cannot shut the reset:
|
|
179
|
+
the last one sent still works. Limit the route per client yourself too, and
|
|
180
|
+
see [the mail throttle](#requests-that-send-e-mail-are-throttled).
|
|
171
181
|
|
|
172
182
|
**Writing a password spends every reset link still live.** A `confirm`,
|
|
173
183
|
`changePassword` and `setPassword` each do, so a link sent before the
|
|
@@ -222,6 +232,156 @@ export async function resetPassword(request: Request): Promise<Response> {
|
|
|
222
232
|
}
|
|
223
233
|
```
|
|
224
234
|
|
|
235
|
+
## Requests that send e-mail are throttled
|
|
236
|
+
|
|
237
|
+
**Every request that hands out something to e-mail is counted**, on by default:
|
|
238
|
+
five per 10 minutes, per flow. Past the limit the request throws
|
|
239
|
+
`MailThrottledError` (`MAIL_THROTTLED`, 429) with `retryAfter` — the whole
|
|
240
|
+
seconds to the end of the window, at least 1 — and **a refused request spends,
|
|
241
|
+
invalidates and rotates nothing**: no token, code or challenge is minted, and
|
|
242
|
+
nothing earlier is spent, so **the last link or code sent still confirms until
|
|
243
|
+
it expires**, whoever asked for it. The count comes before anything is issued,
|
|
244
|
+
and the earlier tokens are spent only after a new one is issued.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { MailThrottledError } from '@nxgt/janus';
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await auth.resetPassword.request(email);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof MailThrottledError) {
|
|
253
|
+
// 429 and Retry-After: error.retryAfter — safe to show, it reveals nothing about accounts.
|
|
254
|
+
// Tell the visitor: "Check your inbox: the last e-mail we sent still works.
|
|
255
|
+
// You can ask for a new one in N minutes."
|
|
256
|
+
}
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
| Flow | Counted per | Why |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| `magicLink.request(email)` | address | Anybody can call it with any address. Counted before the address is looked up, so an address nobody holds is refused like a registered one |
|
|
264
|
+
| `signInCode.request(email)` | address | The same |
|
|
265
|
+
| `resetPassword.request(email)` | address | The same |
|
|
266
|
+
| `verifyEmail.send(user)` | user | The application calls it for a user it knows: an unauthenticated loop on the address cannot spend this count, so it never blocks the user's own verification, and a user changing e-mail buys no extra sends |
|
|
267
|
+
| `stepUp.request(user)`, when it e-mails a code (`via: 'email'`) | user | The same. A step-up confirmed with the app (`via: 'secondFactor'`) sends nothing and is not counted |
|
|
268
|
+
|
|
269
|
+
**Keep the window no longer than the shortest token lifetime you set.** A
|
|
270
|
+
refused request spends nothing, but the last link or code sent still has to be
|
|
271
|
+
alive. With `tokens.signInCode: '5m'` and the 10-minute window, a code can lapse
|
|
272
|
+
while the visitor is still refused. `janus()` warns for it, once per call, with
|
|
273
|
+
`process.emitWarning` and the code `JANUS_THROTTLE_WINDOW`, naming each flow
|
|
274
|
+
whose lifetime is shorter than the window:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
janus: tokens.signInCode is 5m, shorter than mail.throttle.window, 10m — past the limit the last token sent can expire before the refusal ends. Set mail.throttle.window to 5m or less, or raise tokens.signInCode to 10m or more.
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Shorten `mail.throttle.window`, or raise the lifetime. Nothing is refused, so
|
|
281
|
+
a window you chose on purpose only costs the warning.
|
|
282
|
+
|
|
283
|
+
**Each flow counts on its own**: a loop on `magicLink.request` never shuts
|
|
284
|
+
`signInCode.request`. The address is normalised (trimmed, lowercased) and is
|
|
285
|
+
the same count whatever the user type.
|
|
286
|
+
|
|
287
|
+
| Option | Type | Default | Effect |
|
|
288
|
+
| --- | --- | --- | --- |
|
|
289
|
+
| `mail.throttle.attempts` | whole number above zero | `5` | Requests per flow, per address or user, per window |
|
|
290
|
+
| `mail.throttle.window` | `Duration` | `'10m'` | How long a window lasts |
|
|
291
|
+
| `mail.throttle` | `false` | | Counts nothing: limit those requests yourself |
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
janus({ user, store, mail: { throttle: { attempts: 3, window: '1h' } } });
|
|
295
|
+
janus({ user, store, mail: { throttle: false } });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
export interface MailConfig {
|
|
300
|
+
readonly throttle?: MailThrottleConfig | false;
|
|
301
|
+
}
|
|
302
|
+
export interface MailThrottleConfig {
|
|
303
|
+
readonly attempts?: number;
|
|
304
|
+
readonly window?: Duration;
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Five per ten minutes is no longer than the shortest default token lifetime
|
|
309
|
+
(`signInCode` and `stepUp` `'10m'`; `magicLink` `'15m'`, `resetPassword` `'1h'`,
|
|
310
|
+
`verifyEmail` `'24h'`), so under the defaults the last link or code sent —
|
|
311
|
+
issued inside the window — is still live for the whole refusal. It leaves room
|
|
312
|
+
for a visitor who asks again, and holds a loop to thirty e-mails an hour per
|
|
313
|
+
flow and address. A wrong value is refused when `janus()` is called, with
|
|
314
|
+
a `TypeError`: `janus: mail must be an object — { throttle }`, `janus:
|
|
315
|
+
mail.throttle must be { attempts, window }, or false to count nothing`,
|
|
316
|
+
`janus: mail.throttle.attempts must be a whole number above zero`, or a
|
|
317
|
+
`janus: mail.throttle.window` duration message.
|
|
318
|
+
|
|
319
|
+
**Fixed windows, and nothing locks.** The window is a fixed slice of the
|
|
320
|
+
clock, as the sign-in throttle's. Requests at once are counted in one store
|
|
321
|
+
write each: of twenty at once, exactly five are issued. The next window
|
|
322
|
+
answers again. A refused request is counted too but cannot extend the wait.
|
|
323
|
+
|
|
324
|
+
**Where the counts live.** In the tokens store, as `secondFactor` tokens named
|
|
325
|
+
by a keyed hash of the flow and the address or user id: no port change, no new
|
|
326
|
+
infrastructure, and never the address. That is one row per flow per address or
|
|
327
|
+
user per window. On PostgreSQL, `collectExpired()` does not collect lapsed
|
|
328
|
+
ones: schedule the delete, as for the sign-in throttle. A flushed or evicting
|
|
329
|
+
Redis forgets the counts, and a tokens store that cannot count throws
|
|
330
|
+
`STORE_FAILED` with nothing issued: it fails closed.
|
|
331
|
+
|
|
332
|
+
**What it does not do.**
|
|
333
|
+
|
|
334
|
+
- **It cannot lock somebody out.** Somebody who asks for an address in a loop
|
|
335
|
+
can fill its inbox with at most five e-mails per window per flow, but the
|
|
336
|
+
last e-mail they caused to be sent was a real e-mail to the real owner and
|
|
337
|
+
still works until it expires. The password sign-in is untouched.
|
|
338
|
+
- **A lifetime shorter than the window breaks that.** With
|
|
339
|
+
`tokens.signInCode: '5m'` and the default window, the last code can expire
|
|
340
|
+
before the window ends and the visitor has no live code until it does. Keep
|
|
341
|
+
`mail.throttle.window` no longer than the shortest of the `tokens.*`
|
|
342
|
+
lifetimes you use.
|
|
343
|
+
- **It is not per client.** `janus` never sees IP addresses, so one client
|
|
344
|
+
asking for many addresses is not counted. Add a per-IP ceiling in front of
|
|
345
|
+
the routes: `@nxgt/redis` (0.5.0 and later) has rate limits for it.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import { bindRateLimit, defineRateLimit, GuardError } from '@nxgt/redis';
|
|
349
|
+
|
|
350
|
+
export const mailRequests = defineRateLimit({
|
|
351
|
+
name: 'mail-requests',
|
|
352
|
+
key: (p: { ip: string }) => p.ip,
|
|
353
|
+
limit: 20,
|
|
354
|
+
per: 3_600_000,
|
|
355
|
+
});
|
|
356
|
+
const perIp = bindRateLimit(client, mailRequests); // or redis.limits.<name> when wired
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await perIp.enforce({ ip }); // before auth.magicLink.request(email)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error instanceof GuardError && error.code === 'RATE_LIMITED') {
|
|
362
|
+
// error.retryAfter is in MILLISECONDS: Math.ceil(error.retryAfter / 1000) for Retry-After
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
- **A test suite** that requests more than five of one flow for one address or
|
|
368
|
+
user over a `fixedClock` is throttled: advance the clock past `retryAfter`,
|
|
369
|
+
or wire `mail: { throttle: false }`.
|
|
370
|
+
|
|
371
|
+
**Not throttled**, because they follow a write or a sign-in, which are bounded
|
|
372
|
+
elsewhere (the [sign-in throttle](passwords.md#password-guessing-is-throttled)
|
|
373
|
+
bounds sign-ins), not a request to mail an address:
|
|
374
|
+
|
|
375
|
+
- `update` changing the e-mail: an application write. Its notice to the former
|
|
376
|
+
address is sent from the `user.emailChanged` event.
|
|
377
|
+
- The notices sent from [events](events.md): a new device, a password changed,
|
|
378
|
+
a recovery code used, and the like.
|
|
379
|
+
|
|
380
|
+
`@nxgt/janus-hono` and `@nxgt/janus-graphql` answer `MAIL_THROTTLED` with 429,
|
|
381
|
+
`retryAfter` in the body (or the extensions) and a `Retry-After` header;
|
|
382
|
+
`@nxgt/janus-telemetry` writes a `janus.mail.throttled` warning with
|
|
383
|
+
`janus.mail.flow` and `janus.mail.retryAfter`, never the address.
|
|
384
|
+
|
|
225
385
|
## What a token refusal means
|
|
226
386
|
|
|
227
387
|
| Code | When |
|
package/docs/guide/errors.md
CHANGED
|
@@ -17,11 +17,11 @@ export function toResponse(error: unknown): Response {
|
|
|
17
17
|
|
|
18
18
|
`statusOf(code)` is the table under [The codes](#the-codes), exported so every
|
|
19
19
|
integration answers a code the same way — `@nxgt/janus-hono`'s `statusOf` is
|
|
20
|
-
this one. It answers `JanusErrorStatus`, a union of the
|
|
21
|
-
give (`400 | 401 | 403 | 404 | 409 | 500 | 501 | 503`), so a framework whose
|
|
20
|
+
this one. It answers `JanusErrorStatus`, a union of the nine statuses it can
|
|
21
|
+
give (`400 | 401 | 403 | 404 | 409 | 429 | 500 | 501 | 503`), so a framework whose
|
|
22
22
|
response takes a narrower type than `number` accepts it as it is.
|
|
23
23
|
|
|
24
|
-
`JanusErrorCode` is a union of
|
|
24
|
+
`JanusErrorCode` is a union of twenty-one string literals, and `statusOf` is a
|
|
25
25
|
`switch` over it that is exhaustive: when a code is added, it stops compiling
|
|
26
26
|
instead of answering `undefined` — and so does a `switch` of your own.
|
|
27
27
|
|
|
@@ -56,6 +56,7 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
56
56
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
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
|
+
| `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` |
|
|
59
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
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` |
|
|
61
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` |
|
|
@@ -104,6 +105,14 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
104
105
|
`Retry-After` header: the login is throttled, and the right password is
|
|
105
106
|
refused too until then. It says nothing of whether the login exists — an
|
|
106
107
|
unknown one is throttled alike.
|
|
108
|
+
- **`MAIL_THROTTLED`'s `retryAfter`** belongs in the body and a `Retry-After`
|
|
109
|
+
header with a 429. Unlike `reason`, it is **safe to show the visitor**: an
|
|
110
|
+
address nobody holds is counted and refused alike, so it reveals nothing
|
|
111
|
+
about accounts. Its message names the call and whether the address or the
|
|
112
|
+
user was counted: `magicLink.request: too many e-mails asked for this
|
|
113
|
+
address — the last one sent still works; use it, or wait for the next window`,
|
|
114
|
+
or `… for this user …`. Tell the visitor to use the last e-mail they received,
|
|
115
|
+
or to wait `retryAfter` seconds.
|
|
107
116
|
- **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
|
|
108
117
|
how many attempts are left. `0` means the challenge is spent: send the visitor
|
|
109
118
|
back to the password.
|
package/docs/guide/magic-link.md
CHANGED
|
@@ -90,9 +90,45 @@ once cannot each keep a link: at most one survives, sometimes none, and the
|
|
|
90
90
|
visitor asks again. A link and a [sign-in code](sign-in-code.md) are
|
|
91
91
|
separate kinds: asking for one leaves the other live.
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
**`request` is throttled per address**: every call sends an e-mail and
|
|
94
|
+
cancels the link before it, so past five requests for one address in a
|
|
95
|
+
10-minute window, `request` throws `MailThrottledError` (`MAIL_THROTTLED`)
|
|
96
|
+
and issues nothing: **a refused request spends and rotates nothing, so the
|
|
97
|
+
link last sent still works** until it expires, even when somebody else's
|
|
98
|
+
request caused it. The address is counted
|
|
99
|
+
before anything is looked up, so an address nobody holds is counted and
|
|
100
|
+
refused alike, and under the limit it still answers `null`. Sign-in codes and
|
|
101
|
+
password resets count on their own: a loop on this route never shuts them.
|
|
102
|
+
Answer it with a 429 and `Retry-After`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { MailThrottledError } from '@nxgt/janus';
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const issued = await auth.magicLink.request(email);
|
|
109
|
+
// …send the e-mail from `issued`, whether or not there is one to send
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof MailThrottledError) {
|
|
112
|
+
return Response.json(
|
|
113
|
+
{
|
|
114
|
+
error: 'too many e-mails — the last one we sent still works; use it, or wait',
|
|
115
|
+
retryAfter: error.retryAfter,
|
|
116
|
+
},
|
|
117
|
+
{ status: 429, headers: { 'retry-after': String(error.retryAfter) } },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Tell the visitor to use the last e-mail they received, or to wait
|
|
125
|
+
`retryAfter` seconds: "Check your inbox: the last e-mail we sent still works.
|
|
126
|
+
You can ask for a new one in N minutes." The throttle is per address, not per
|
|
127
|
+
client: `janus` never sees IP addresses, so one client asking for many
|
|
128
|
+
addresses is not counted. Add a per-IP ceiling with `@nxgt/redis` rate limits
|
|
129
|
+
([example](email-flows.md#requests-that-send-e-mail-are-throttled)). See
|
|
130
|
+
[the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
|
|
131
|
+
for the options.
|
|
96
132
|
|
|
97
133
|
## Building the link
|
|
98
134
|
|
|
@@ -332,8 +368,8 @@ has each message with its cause.
|
|
|
332
368
|
for sign-in codes.
|
|
333
369
|
- **Signing out everywhere.** It revokes sessions; a link opens a new one.
|
|
334
370
|
- **The sign-in throttle.** `signIn.throttle` counts passwords tried at one
|
|
335
|
-
login; a link is not a password, and its token cannot be guessed.
|
|
336
|
-
|
|
371
|
+
login; a link is not a password, and its token cannot be guessed. The
|
|
372
|
+
[mail throttle](#requesting-a-link) counts its requests instead.
|
|
337
373
|
|
|
338
374
|
What does: its own redemption, a newer `request`, its fifteen minutes, an
|
|
339
375
|
e-mail changed since (`TOKEN_STALE`), the user deactivated
|
package/docs/guide/passwords.md
CHANGED
|
@@ -234,7 +234,8 @@ 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: [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled)
|
|
238
|
+
counts them per address, but not per client. Limit them per client.
|
|
238
239
|
|
|
239
240
|
```ts
|
|
240
241
|
if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
|
|
@@ -130,9 +130,9 @@ patient observer; that limit is stated rather than denied.
|
|
|
130
130
|
|
|
131
131
|
Every `request` issues a **new** code with its own challenge, and spends the
|
|
132
132
|
earlier ones: only the last code sent works — see
|
|
133
|
-
[attempts](#attempts).
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
[attempts](#attempts). How often a code is asked for is limited per address by [the mail
|
|
134
|
+
throttle](email-flows.md#requests-that-send-e-mail-are-throttled); limit the
|
|
135
|
+
route per client yourself too.
|
|
136
136
|
|
|
137
137
|
## Sending the code by e-mail
|
|
138
138
|
|
|
@@ -252,10 +252,46 @@ Requests that arrive at once cannot each keep a code: each spends the others'
|
|
|
252
252
|
once it issued its own, so at most one survives — sometimes none, and the
|
|
253
253
|
visitor asks again. Guesses therefore never run against two live challenges.
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
`request`, and each one sends an e-mail and cancels the code before it
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
**`request` is throttled per address.** A new challenge takes only a new
|
|
256
|
+
`request`, and each one sends an e-mail and cancels the code before it, so
|
|
257
|
+
past five requests for one address in a 10-minute window `request` throws
|
|
258
|
+
`MailThrottledError` (`MAIL_THROTTLED`) and issues nothing — no challenge is
|
|
259
|
+
minted, a refused request spends and rotates nothing, and the code last sent
|
|
260
|
+
still works until it expires, even when somebody else's request caused it. The address is counted before
|
|
261
|
+
anything is looked up, so an address nobody holds is counted and refused
|
|
262
|
+
alike; under the limit it still answers `null`. This flow counts on its own:
|
|
263
|
+
a loop on `magicLink.request` never shuts it. Answer it with a 429 and
|
|
264
|
+
`Retry-After`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { MailThrottledError } from '@nxgt/janus';
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const issued = await auth.signInCode.request(email);
|
|
271
|
+
// …send the e-mail from `issued`, whether or not there is one to send
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error instanceof MailThrottledError) {
|
|
274
|
+
return Response.json(
|
|
275
|
+
{
|
|
276
|
+
error: 'too many e-mails — the last one we sent still works; use it, or wait',
|
|
277
|
+
retryAfter: error.retryAfter,
|
|
278
|
+
},
|
|
279
|
+
{ status: 429, headers: { 'retry-after': String(error.retryAfter) } },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
With several user types the message is prefixed with the type, as in
|
|
287
|
+
`patient.signInCode.request: too many e-mails asked for this address — the last
|
|
288
|
+
one sent still works; use it, or wait for the next window`. Somebody who knows
|
|
289
|
+
an address cannot shut its e-mailed sign-in: the last code they caused to be
|
|
290
|
+
sent still works, and they can only fill the inbox, five per window. Tell the
|
|
291
|
+
visitor to use the last e-mail they received, or to wait `retryAfter` seconds.
|
|
292
|
+
`janus` never sees IP addresses, so one client asking for many addresses is
|
|
293
|
+
not counted: add a per-IP ceiling with `@nxgt/redis` rate limits
|
|
294
|
+
([example](email-flows.md#requests-that-send-e-mail-are-throttled)).
|
|
259
295
|
|
|
260
296
|
### Lifetime
|
|
261
297
|
|
|
@@ -267,6 +303,9 @@ arrive and be read; a longer window is a longer life for a stolen challenge.
|
|
|
267
303
|
janus({ ..., tokens: { signInCode: '15m' } });
|
|
268
304
|
```
|
|
269
305
|
|
|
306
|
+
Keep the mail window (`mail.throttle.window`, `'10m'` by default) no longer
|
|
307
|
+
than the lifetime: with `tokens.signInCode: '5m'` the last code can expire
|
|
308
|
+
before the window ends, and the visitor has none live until it does.
|
|
270
309
|
### The e-mail is verified
|
|
271
310
|
|
|
272
311
|
A user whose `emailVerified` was `false` has it `true` once `confirm`
|
package/docs/guide/step-up.md
CHANGED
|
@@ -181,13 +181,44 @@ counted **per user**, five per 15-minute window, in the same count as
|
|
|
181
181
|
challenge still gets five guesses per window, not five per challenge. The
|
|
182
182
|
app's code accepted is spent, as at a sign-in: it confirms nothing else.
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
five guesses per challenge, and a new challenge takes only a new
|
|
186
|
-
A stolen session could ask again and again — each request also
|
|
187
|
-
e-mail and cancels the code before it.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
the
|
|
184
|
+
**`request` is throttled per user, when it e-mails.** An e-mailed code has no
|
|
185
|
+
such window: five guesses per challenge, and a new challenge takes only a new
|
|
186
|
+
`request`. A stolen session could ask again and again — each request also
|
|
187
|
+
sends an e-mail and cancels the code before it. So `stepUp.request` counts
|
|
188
|
+
the user, five per 10-minute window (`mail: { throttle }`), and past it throws
|
|
189
|
+
`MailThrottledError` (`MAIL_THROTTLED`, with `userId`) and issues nothing: a
|
|
190
|
+
refused request spends nothing, so the code last sent still works until it
|
|
191
|
+
expires. Tell the user to use the last e-mail they received, or to wait
|
|
192
|
+
`retryAfter` seconds.
|
|
193
|
+
It counts **only** `via: 'email'`: a step-up confirmed with the app
|
|
194
|
+
(`via: 'secondFactor'`) sends nothing and is not counted.
|
|
195
|
+
|
|
196
|
+
It is per user rather than per address because the application calls it for a
|
|
197
|
+
user it knows: nobody without their session can spend the count, so an
|
|
198
|
+
unauthenticated loop on their address never blocks their own step-up, and a
|
|
199
|
+
user who changes e-mail buys no extra sends. Answer it with a 429 and
|
|
200
|
+
`Retry-After`:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { MailThrottledError } from '@nxgt/janus';
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const issued = await auth.stepUp.request(current.user);
|
|
207
|
+
// …send the e-mail from `issued`, whether or not there is one to send
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error instanceof MailThrottledError) {
|
|
210
|
+
return Response.json(
|
|
211
|
+
{ error: 'too many e-mails', retryAfter: error.retryAfter },
|
|
212
|
+
{ status: 429, headers: { 'retry-after': String(error.retryAfter) } },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The `janus.stepUp.asked` event of `@nxgt/janus-telemetry` is the signal to
|
|
220
|
+
alert on; it also writes a `janus.mail.throttled` warning when the throttle
|
|
221
|
+
refuses. See [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled).
|
|
191
222
|
|
|
192
223
|
A step-up and a sign-in code are **two kinds of token**: a sign-in code's
|
|
193
224
|
challenge confirms no action, and a step-up's code signs nobody in.
|
package/docs/guide/users.md
CHANGED
|
@@ -98,6 +98,7 @@ that cuts an emoji in half, say.
|
|
|
98
98
|
| `tokens.magicLink` | `Duration` | `'15m'` | How long an e-mailed sign-in link lives. See [sign-in links](magic-link.md) |
|
|
99
99
|
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
100
100
|
| `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
|
|
101
|
+
| `mail.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 5, window: '10m' }` | E-mails one address (`magicLink`, `signInCode`, `resetPassword` requests) or one user (`verifyEmail.send`, an e-mailed `stepUp.request`) may ask for per flow and window, before the request throws `MAIL_THROTTLED` with `retryAfter` and issues nothing (the last link or code sent still works). Keep `window` no longer than the shortest `tokens.*` lifetime you use. `false` counts nothing. See [e-mail flows](email-flows.md#requests-that-send-e-mail-are-throttled) |
|
|
101
102
|
| `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
|
|
102
103
|
| `devices` | `{ keys }` | none | The keys device tokens are signed with, to tell a sign-in from a new device. Without it, no sign-in may be given a `device` — see [devices](devices.md#wiring) |
|
|
103
104
|
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
@@ -179,6 +180,7 @@ In the one-type form these are on `auth` itself; with several types, on
|
|
|
179
180
|
| --- | --- | --- |
|
|
180
181
|
| `create(fields & { password?, active? })` | the user; no session | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
181
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) | |
|
|
182
184
|
| `get(id)` | the user | `NOT_FOUND` |
|
|
183
185
|
| `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
|
|
184
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` |
|
|
@@ -248,6 +250,53 @@ idempotent: calling it again finishes the job. When it deleted the user, it
|
|
|
248
250
|
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;
|
|
249
251
|
`create` and `signUp` send `user.created`.
|
|
250
252
|
|
|
253
|
+
### Several users at once — `findMany`
|
|
254
|
+
|
|
255
|
+
A list of rows that each name a user — a staff list showing every member's
|
|
256
|
+
account state — reads them in one call, not one `find` per row:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const rows = await db.staffRows(); // your own table, 100 rows
|
|
260
|
+
const members = await clinic.staff.findMany(rows.map((row) => row.staffId));
|
|
261
|
+
const byId = new Map(members.map((member) => [member.id, member]));
|
|
262
|
+
|
|
263
|
+
const shown = rows.map((row) => {
|
|
264
|
+
const member = byId.get(row.staffId); // undefined: nobody holds this id
|
|
265
|
+
return { ...row, active: member?.active ?? false, verified: member?.emailVerified ?? false };
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- **Order**: the order of `ids`. A repeated id is answered once, at its first
|
|
270
|
+
place.
|
|
271
|
+
- **Absence**: an id `find` would answer `null` for — malformed, unknown, or
|
|
272
|
+
held by a user of another type — is **left out**, never an error. The answer
|
|
273
|
+
may be shorter than `ids`, so match by `user.id`, never by position.
|
|
274
|
+
- **Empty**: `[]`, or a list with no well-formed id, answers `[]` without
|
|
275
|
+
reaching the store.
|
|
276
|
+
- **Size**: any length. The store is asked 100 ids at a time, one batch after
|
|
277
|
+
another, so no query grows with your list.
|
|
278
|
+
- **Failure**: `STORE_FAILED`, never a shorter list. Something other than an
|
|
279
|
+
array is a bare `TypeError`: only your code passes one.
|
|
280
|
+
- **Cost**: one query per 100 ids when the users store implements the
|
|
281
|
+
optional `findUsers` — the reference store, `@nxgt/janus-drizzle` and
|
|
282
|
+
`@nxgt/janus-mongo` do. A store that does not is read with `findUser`,
|
|
283
|
+
**ten at a time**, so a third-party adapter keeps working and never opens
|
|
284
|
+
more than ten reads at once for one call; see
|
|
285
|
+
[writing an adapter](adapters.md#userstorefindusers-optional).
|
|
286
|
+
|
|
287
|
+
**A GraphQL resolver per row** can batch through a DataLoader of your own —
|
|
288
|
+
`@nxgt/janus-graphql` loads no users itself:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
import DataLoader from 'dataloader';
|
|
292
|
+
|
|
293
|
+
// One per request, so nothing is cached across users' requests.
|
|
294
|
+
const staffById = new DataLoader(async (ids: readonly string[]) => {
|
|
295
|
+
const found = new Map((await clinic.staff.findMany(ids)).map((u) => [u.id, u]));
|
|
296
|
+
return ids.map((id) => found.get(id) ?? null);
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
251
300
|
### Paging every user
|
|
252
301
|
|
|
253
302
|
```ts
|