@nxgt/janus 0.19.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 +117 -7
- 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/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/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.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/index.js +92 -17
- package/dist/index.js.map +11 -9
- package/docs/README.md +1 -1
- 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 +1 -0
- package/docs/roadmap.md +17 -26
- package/docs/troubleshooting.md +81 -5
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -12,7 +12,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
12
12
|
| Page | Read it when |
|
|
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
|
-
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
|
|
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,96 @@ 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: create the user, then mail a link of yours — nothing of janus's is sent.
|
|
230
|
+
export async function invite(email: string) {
|
|
231
|
+
const user = await auth.create({ email, name: '' }); // no password: they will never need one
|
|
232
|
+
const token = randomBytes(32).toString('base64url');
|
|
233
|
+
await invitations.insert({ hash: hash(token), userId: user.id, expiresAt: inSevenDays() });
|
|
234
|
+
await sendMail(email, `Join us: https://app.example/invitation?token=${token}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
|
|
238
|
+
export async function accept(request: Request): Promise<Response> {
|
|
239
|
+
const { token } = (await request.json()) as { token: string };
|
|
240
|
+
// One conditional write: spent once, and only while it has not expired.
|
|
241
|
+
const invitation = await invitations.spend(hash(String(token)), new Date());
|
|
242
|
+
if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
|
|
243
|
+
|
|
244
|
+
const result = await auth.signInProven(invitation.userId, { device: deviceOf(request) });
|
|
245
|
+
if (result.status === 'secondFactor') {
|
|
246
|
+
// keep result.challenge for the next request — never in a URL — and ask for a code
|
|
247
|
+
return Response.json({ next: 'code' });
|
|
248
|
+
}
|
|
249
|
+
return Response.json(
|
|
250
|
+
{ id: result.user.id },
|
|
251
|
+
{ headers: { 'Set-Cookie': auth.cookie.serialize(result.token, result.session) } },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Before 0.20 this was `magicLink.request(email)` then `magicLink.confirm`,
|
|
257
|
+
at once — use `signInProven` instead.** `request` is counted against the
|
|
258
|
+
mail throttle per address, five per 10 minutes by default: anybody asking
|
|
259
|
+
five links for the invitee's address made the real acceptance answer
|
|
260
|
+
`MAIL_THROTTLED`, with a message about "the last one sent" when nothing had
|
|
261
|
+
been sent. `signInProven` counts nothing, sends nothing, and spends nothing:
|
|
262
|
+
a link or a code already in the person's inbox still works afterwards.
|
|
263
|
+
|
|
264
|
+
### What it does, and what it does not
|
|
265
|
+
|
|
266
|
+
- **The same ending as every sign-in.** A user whose second factor is active
|
|
267
|
+
gets `{ status: 'secondFactor', challenge }` — the application's proof is
|
|
268
|
+
a first factor — and `secondFactor.confirm` opens the session. Given a
|
|
269
|
+
`device`, it answers `newDevice` and `deviceToken`, and sends
|
|
270
|
+
`user.newDeviceSignedIn` for a device new to the user
|
|
271
|
+
([devices](devices.md)). `@nxgt/janus-telemetry` traces it as
|
|
272
|
+
`janus.<type>.signInProven` and writes `janus.signIn` with
|
|
273
|
+
`janus.signIn.proven: true`.
|
|
274
|
+
- **Refused as every sign-in refuses.** An inactive user is `USER_INACTIVE`;
|
|
275
|
+
a malformed id, an unknown one, a deleted user or a user of another type
|
|
276
|
+
is `NOT_FOUND`, as for `get` — never `null`, since a sign-in that found
|
|
277
|
+
nobody must not be read past. Options that are not `{ device }` are a
|
|
278
|
+
`TypeError`, as on every sign-in.
|
|
279
|
+
- **No e-mail proved.** `emailVerified` stays as it was: the call does not
|
|
280
|
+
know your proof reached the inbox. Nothing is dropped either — unlike a
|
|
281
|
+
first proof by link or code, the password and second factor stay.
|
|
282
|
+
- **No throttle.** Neither the mail throttle nor the sign-in throttle counts
|
|
283
|
+
it, and it restarts neither: a login past its password attempts is still
|
|
284
|
+
throttled for `signIn`. Limit your own acceptance route per client, as
|
|
285
|
+
any route that redeems a token.
|
|
286
|
+
- **No route.** `@nxgt/janus-hono` and `@nxgt/janus-graphql` expose nothing
|
|
287
|
+
for it: the proof is yours, so the route is yours.
|
|
288
|
+
|
|
197
289
|
## `collectExpired`
|
|
198
290
|
|
|
199
291
|
```ts
|
|
@@ -242,6 +334,7 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
|
|
|
242
334
|
## See also
|
|
243
335
|
|
|
244
336
|
- [Users](users.md) — `signUp`, `signIn`, the configuration
|
|
337
|
+
- [Signing in with an e-mailed link](magic-link.md) — when janus, not your application, proves the person
|
|
245
338
|
- [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
|
|
246
339
|
- [Step-up](step-up.md) — proving again who you are, and `assertFresh`
|
|
247
340
|
- [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
|
|
package/docs/roadmap.md
CHANGED
|
@@ -76,7 +76,23 @@ Nothing yet.
|
|
|
76
76
|
The last ten, newest first, each with the version it came in. Everything
|
|
77
77
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
78
78
|
|
|
79
|
-
- **
|
|
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)`
|
|
80
96
|
answers the type's users in the order of `ids`, each once, and leaves out an
|
|
81
97
|
id nobody of that type holds — for a list of rows that would otherwise make
|
|
82
98
|
one `find` per row and fill the connection pool. The users store gains an
|
|
@@ -150,28 +166,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
150
166
|
replace the new password. Both answer `TOKEN_SPENT`; an outage spending
|
|
151
167
|
them fails the call with `STORE_FAILED`. No change for adapters: the
|
|
152
168
|
existing `TokenStore.spendUserTokens` does it.
|
|
153
|
-
- **Confirm an action with a code (step-up), v0.12.0** —
|
|
154
|
-
`auth.stepUp.request(user)` and `stepUp.confirm(request, challenge, code)`:
|
|
155
|
-
a signed-in user proves again who they are before a sensitive action, with
|
|
156
|
-
a six-digit code sent by e-mail — or, when their second factor is active,
|
|
157
|
-
the code from their app. The confirmation moves the session's
|
|
158
|
-
`authenticatedAt` and opens no session, and `assertFresh(session, maxAge)`
|
|
159
|
-
refuses an older one with `STEP_UP_REQUIRED` (403). A token kind of its
|
|
160
|
-
own, `stepUp`, and `SessionStore.reauthenticateSession`, in the three
|
|
161
|
-
adapters and the conformance suite.
|
|
162
|
-
- **The recovery codes left, read again, v0.11.0** —
|
|
163
|
-
`secondFactor.recoveryCodesLeft(user)` answers the count `recover`
|
|
164
|
-
answered, for a `user.recoveryCodeUsed` listener — the event names the
|
|
165
|
-
user only — or a security settings page; `null` for a user with no active
|
|
166
|
-
factor. The listener can now tell the user how many codes remain, as
|
|
167
|
-
`@nxgt/janus-mail`'s `recoveryCodeUsed` e-mail does.
|
|
168
|
-
- **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
|
|
169
|
-
own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
|
|
170
|
-
links })` takes what each flow answers —
|
|
171
|
-
`await mail.verifyEmail(await auth.verifyEmail.send(user), { name, locale })`
|
|
172
|
-
— and sends five e-mails, in English and French, over any `@nxgt/mail`
|
|
173
|
-
transport: e-mail verification, password reset, sign-in code, and the
|
|
174
|
-
notices *password changed* and *e-mail changed*. They are built once with
|
|
175
|
-
Maizzle when the package is built, and only filled in at send time, every
|
|
176
|
-
value escaped: no template engine in your server. A transport that fails
|
|
177
|
-
throws, like a store.
|