@nxgt/janus 0.13.0 → 0.15.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 +132 -25
- 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 +25 -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-sign-in.d.ts +5 -0
- package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +6 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/derived-keys.d.ts +9 -0
- package/dist/auth/derived-keys.d.ts.map +1 -0
- package/dist/auth/email-sign-in.d.ts +40 -0
- package/dist/auth/email-sign-in.d.ts.map +1 -0
- package/dist/auth/events.d.ts +22 -6
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts +22 -0
- package/dist/auth/magic-link.d.ts.map +1 -0
- package/dist/auth/one-time/index.d.ts +3 -1
- package/dist/auth/one-time/index.d.ts.map +1 -1
- package/dist/auth/one-time/window.d.ts +50 -0
- package/dist/auth/one-time/window.d.ts.map +1 -0
- package/dist/auth/password-written.d.ts +7 -0
- package/dist/auth/password-written.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +6 -3
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
- package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +43 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -0
- package/dist/auth/sign-in-code.d.ts +1 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +3 -3
- package/dist/auth/types/index.d.ts +1 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +8 -1
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/types/magic-link.d.ts +46 -0
- package/dist/auth/types/magic-link.d.ts.map +1 -0
- package/dist/auth/types/password.d.ts +8 -0
- package/dist/auth/types/password.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/fields-patch.d.ts +8 -2
- package/dist/auth/users/fields-patch.d.ts.map +1 -1
- package/dist/auth/users/record-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +2 -2
- package/dist/auth/users/sign-in.d.ts.map +1 -1
- package/dist/auth/users/type-api.d.ts +2 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/auth/users/update.d.ts +12 -0
- package/dist/auth/users/update.d.ts.map +1 -0
- package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
- package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
- package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
- package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
- package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
- package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
- package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
- package/dist/conformance/index.js +26 -4
- package/dist/conformance/index.js.map +3 -3
- package/dist/errors/janus-error/base.d.ts +1 -0
- package/dist/errors/janus-error/base.d.ts.map +1 -1
- package/dist/errors/janus-error/codes.d.ts +3 -1
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/options.d.ts +9 -2
- package/dist/errors/janus-error/options.d.ts.map +1 -1
- package/dist/index.js +296 -101
- package/dist/index.js.map +24 -17
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +4 -3
- package/docs/guide/adapters.md +11 -7
- package/docs/guide/email-flows.md +11 -7
- package/docs/guide/errors.md +12 -4
- package/docs/guide/events.md +75 -14
- package/docs/guide/magic-link.md +411 -0
- package/docs/guide/passwords.md +116 -17
- package/docs/guide/second-factor.md +6 -3
- package/docs/guide/sign-in-code.md +14 -6
- package/docs/guide/users.md +13 -7
- package/docs/guide/vocabulary.md +6 -5
- package/docs/roadmap.md +37 -23
- package/docs/troubleshooting.md +203 -9
- package/package.json +1 -1
- /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
- /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
- /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.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-kj3st57z.js";
|
|
8
8
|
import {
|
|
9
9
|
PermissionDepthError2,
|
|
10
10
|
isSetOf2,
|
|
11
11
|
formatEntity2
|
|
12
|
-
} from "../chunks/index-
|
|
12
|
+
} from "../chunks/index-hnwggyxs.js";
|
|
13
13
|
import {
|
|
14
14
|
createMemoryRelations2
|
|
15
|
-
} from "../chunks/index-
|
|
15
|
+
} from "../chunks/index-6c9fbtv2.js";
|
|
16
16
|
|
|
17
17
|
// src/permissions/resolve/resolved.ts
|
|
18
18
|
function admits(holders, subject) {
|
package/docs/README.md
CHANGED
|
@@ -14,11 +14,12 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
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
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
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 |
|
|
17
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 |
|
|
18
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 |
|
|
19
20
|
| [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
|
|
20
|
-
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
|
|
21
|
-
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or
|
|
21
|
+
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
|
|
22
|
+
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
|
|
22
23
|
|
|
23
24
|
### Permissions — `@nxgt/janus/permissions`
|
|
24
25
|
|
|
@@ -32,6 +33,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
32
33
|
| --- | --- |
|
|
33
34
|
| [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
|
|
34
35
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
35
|
-
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens
|
|
36
|
+
| [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens`, the second factor and a new token kind, or running the conformance suites |
|
|
36
37
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
37
38
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -160,7 +160,7 @@ update. A read followed by a write lets two requests redeem one reset token.
|
|
|
160
160
|
A token is its hash, never its secret, and what it is for:
|
|
161
161
|
|
|
162
162
|
```ts
|
|
163
|
-
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'stepUp';
|
|
163
|
+
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'magicLink' | 'stepUp';
|
|
164
164
|
|
|
165
165
|
interface TokenRecord {
|
|
166
166
|
readonly tokenHash: string;
|
|
@@ -178,9 +178,13 @@ interface TokenRecord {
|
|
|
178
178
|
A token redeemed for another kind is unknown: every method that takes a
|
|
179
179
|
`kind` matches on it. A `stepUp` token is a signed-in user's confirmation of
|
|
180
180
|
a sensitive action; kept apart from `signInCode`, a sign-in code never
|
|
181
|
-
confirms an action, nor an action's code signs anyone in. A
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
confirms an action, nor an action's code signs anyone in. A `magicLink`
|
|
182
|
+
token is a sign-in link's, new in 0.15, kept apart from `signInCode` the same
|
|
183
|
+
way: a sign-in code's challenge is handed to whoever asked for it, and a
|
|
184
|
+
store that redeemed it as a link would sign them in with no code —
|
|
185
|
+
`tokens.magicLinkKind` holds that it does not. A store that lists the
|
|
186
|
+
kinds — a `CHECK`, an enum in a validator — lists `stepUp` and `magicLink`
|
|
187
|
+
too, which `tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
|
|
184
188
|
answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
|
|
185
189
|
a token of the same kind whose secret nobody is given, never redeemed and
|
|
186
190
|
only counted — and its `address` is `''`: a column or a validator that refuses an
|
|
@@ -282,8 +286,8 @@ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
|
|
|
282
286
|
Spends every **unspent** token of one user and one `kind` at `at` — but the
|
|
283
287
|
one whose hash is `except`, when given — and answers how many it spent. The
|
|
284
288
|
core calls it right after issuing a sign-in code, with that code's hash as
|
|
285
|
-
`except`, so only the last code sent works — and so after issuing a
|
|
286
|
-
or a reset link; and after writing a password, for the user's
|
|
289
|
+
`except`, so only the last code sent works — and so after issuing a sign-in
|
|
290
|
+
link, a step-up or a reset link; and after writing a password, for the user's
|
|
287
291
|
`resetPassword` links and `secondFactor` challenges:
|
|
288
292
|
|
|
289
293
|
| The stored token | Written | Counted |
|
|
@@ -403,7 +407,7 @@ compile error naming the missing method.
|
|
|
403
407
|
|
|
404
408
|
| Suite | Cases | Harness opens |
|
|
405
409
|
| --- | --- | --- |
|
|
406
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
410
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 55: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
|
|
407
411
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
408
412
|
|
|
409
413
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
|
@@ -34,11 +34,13 @@ verified.emailVerified; // true
|
|
|
34
34
|
`sendMail` above is yours to write, or
|
|
35
35
|
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)
|
|
36
36
|
writes it: it takes what `send` and `request` answer, as they answer it, and
|
|
37
|
-
sends the verification, reset and sign-in
|
|
37
|
+
sends the verification, reset, sign-in code and sign-in link e-mails — with the notices
|
|
38
38
|
of a password, an e-mail or a second factor changed, and a welcome on
|
|
39
39
|
`user.created` — in English and French, over any `@nxgt/mail` transport.
|
|
40
|
-
Every link is required: `getStarted`, since janus-mail 0.4.0, is
|
|
41
|
-
welcome's **Get started** button leads
|
|
40
|
+
Every link is required but two: `getStarted`, since janus-mail 0.4.0, is
|
|
41
|
+
where the welcome's **Get started** button leads; `recoveryCodes` and
|
|
42
|
+
`magicLink` — the page a [sign-in link](magic-link.md) opens, since
|
|
43
|
+
janus-mail 0.7.0 — are optional.
|
|
42
44
|
|
|
43
45
|
```ts
|
|
44
46
|
import { janusMail } from '@nxgt/janus-mail';
|
|
@@ -91,8 +93,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
|
|
|
91
93
|
clinic.staff.verifyEmail;
|
|
92
94
|
```
|
|
93
95
|
|
|
94
|
-
A type with an e-mail can also sign in with a code sent to it,
|
|
95
|
-
without a password — see [sign-in codes](sign-in-code.md)
|
|
96
|
+
A type with an e-mail can also sign in with a code or a link sent to it,
|
|
97
|
+
with or without a password — see [sign-in codes](sign-in-code.md) and
|
|
98
|
+
[sign-in links](magic-link.md).
|
|
96
99
|
|
|
97
100
|
## Options
|
|
98
101
|
|
|
@@ -180,8 +183,8 @@ The links are spent **after** the password is written, so a link issued
|
|
|
180
183
|
during the write is spent too. If the store fails at that step, the call
|
|
181
184
|
rejects with `STORE_FAILED` although the password is written — answer 503 as
|
|
182
185
|
usual — and the older links may still be live until the next `request`,
|
|
183
|
-
which spends them. Sign-in codes and step-ups are not spent:
|
|
184
|
-
proves
|
|
186
|
+
which spends them. Sign-in codes, sign-in links and step-ups are not spent:
|
|
187
|
+
the password proves none of them.
|
|
185
188
|
|
|
186
189
|
`confirm` sets the password, marks the e-mail verified — the link proved it —
|
|
187
190
|
and **signs the user out everywhere**: their sessions are revoked, and every
|
|
@@ -234,6 +237,7 @@ never the token, and no refusal's message contains it.
|
|
|
234
237
|
|
|
235
238
|
- [Users](users.md) — `email`, `update`, and the other per-type methods
|
|
236
239
|
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
240
|
+
- [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
|
|
237
241
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
238
242
|
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
|
|
239
243
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -54,11 +54,11 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
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 |
|
|
56
56
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
57
|
-
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
|
|
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
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password,
|
|
59
|
+
| `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
60
|
| `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
|
-
| `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) | `userId` on `TOKEN_STALE` |
|
|
61
|
+
| `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` |
|
|
62
62
|
| `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` |
|
|
63
63
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
|
|
64
64
|
| `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
|
|
@@ -81,7 +81,11 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
81
81
|
return Response.json({ token });
|
|
82
82
|
} catch (error) {
|
|
83
83
|
if (error instanceof CredentialError && error.code === 'CREDENTIALS_INVALID') {
|
|
84
|
-
console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword'
|
|
84
|
+
console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword' | 'throttled'
|
|
85
|
+
if (error.retryAfter !== undefined) {
|
|
86
|
+
const headers = { 'retry-after': String(error.retryAfter) };
|
|
87
|
+
return Response.json({ error: 'invalid', retryAfter: error.retryAfter }, { status: 401, headers });
|
|
88
|
+
}
|
|
85
89
|
return Response.json({ error: 'invalid' }, { status: 401 });
|
|
86
90
|
}
|
|
87
91
|
if (error instanceof StoreFailure) return new Response(null, { status: 503 });
|
|
@@ -96,6 +100,10 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
96
100
|
would read as "no".
|
|
97
101
|
- **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
|
|
98
102
|
the new `version`.
|
|
103
|
+
- **`CREDENTIALS_INVALID`'s `retryAfter`** belongs in the body and a
|
|
104
|
+
`Retry-After` header: the login is throttled, and the right password is
|
|
105
|
+
refused too until then. It says nothing of whether the login exists — an
|
|
106
|
+
unknown one is throttled alike.
|
|
99
107
|
- **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
|
|
100
108
|
how many attempts are left. `0` means the challenge is spent: send the visitor
|
|
101
109
|
back to the password.
|
package/docs/guide/events.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# User events
|
|
2
2
|
|
|
3
3
|
This page is for hearing what happens to a user once it is written: created,
|
|
4
|
-
e-mail verified, password reset
|
|
5
|
-
codes regenerated or one used, deleted. Another service can then follow
|
|
4
|
+
e-mail verified, password reset or changed, e-mail changed, second factor
|
|
5
|
+
turned on or off, recovery codes regenerated or one used, deleted. Another service can then follow
|
|
6
6
|
without polling. `janus` hands each event to one function you give it;
|
|
7
7
|
**delivering it is yours** — or
|
|
8
8
|
[`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
|
|
@@ -38,13 +38,15 @@ received[0];
|
|
|
38
38
|
The words — **user event**, **listener** — are defined in
|
|
39
39
|
[the vocabulary](vocabulary.md#identities).
|
|
40
40
|
|
|
41
|
-
## The
|
|
41
|
+
## The ten types
|
|
42
42
|
|
|
43
43
|
| `type` | Sent by | Not sent |
|
|
44
44
|
| --- | --- | --- |
|
|
45
45
|
| `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
|
|
46
|
-
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
46
|
+
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
47
47
|
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
48
|
+
| `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
49
|
+
| `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
|
|
48
50
|
| `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
|
|
49
51
|
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
50
52
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
@@ -52,9 +54,13 @@ The words — **user event**, **listener** — are defined in
|
|
|
52
54
|
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
53
55
|
|
|
54
56
|
A reset whose link verifies the e-mail sends both, `user.passwordReset`
|
|
55
|
-
first.
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
first. **A reset does not also send `user.passwordChanged`**: a listener that
|
|
58
|
+
cares about every new password handles both types, as the `switch` below
|
|
59
|
+
does — one event per write, rather than two a listener would have to tell
|
|
60
|
+
apart. Switch on `type`: the ten are a closed set, and TypeScript refuses an
|
|
61
|
+
eleventh. A new type is a compile error in a `switch` that exhausts them — the
|
|
62
|
+
two recovery-code types, added in 0.10, and the two change types, added in
|
|
63
|
+
0.14, each broke such a `switch` until it handled them.
|
|
58
64
|
|
|
59
65
|
```ts
|
|
60
66
|
function onUserEvent(event: UserEvent): void {
|
|
@@ -64,7 +70,10 @@ function onUserEvent(event: UserEvent): void {
|
|
|
64
70
|
case 'user.emailVerified':
|
|
65
71
|
return unlockFeatures(event.userId);
|
|
66
72
|
case 'user.passwordReset':
|
|
67
|
-
|
|
73
|
+
case 'user.passwordChanged':
|
|
74
|
+
return noticeTheUser(event); // @nxgt/janus-mail's passwordChanged — see below
|
|
75
|
+
case 'user.emailChanged':
|
|
76
|
+
return noticeTheFormerAddress(event); // to event.formerEmail — see below
|
|
68
77
|
case 'user.secondFactorEnabled':
|
|
69
78
|
case 'user.secondFactorDisabled':
|
|
70
79
|
return noticeTheUser(event); // @nxgt/janus-mail's twoFactorEnabled or twoFactorDisabled
|
|
@@ -114,14 +123,59 @@ With several user types, the count is the type's:
|
|
|
114
123
|
`auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
|
|
115
124
|
`event.userType`.
|
|
116
125
|
|
|
126
|
+
### Telling the user their password or e-mail changed
|
|
127
|
+
|
|
128
|
+
A password changed by someone else, or an e-mail moved to their inbox, is how
|
|
129
|
+
an account is taken over — so tell the user each time. For a password, read
|
|
130
|
+
the user and write to their address. For an e-mail, write to the **former**
|
|
131
|
+
address: the new one belongs to whoever changed it. The event carries it,
|
|
132
|
+
since nothing else keeps it once the write landed:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
|
|
136
|
+
const auth = janus({
|
|
137
|
+
...config,
|
|
138
|
+
async events(event) {
|
|
139
|
+
if (event.type === 'user.passwordChanged') {
|
|
140
|
+
const user = await auth.get(event.userId);
|
|
141
|
+
await mail.passwordChanged({ name: user.name, locale: user.locale, email: user.email });
|
|
142
|
+
}
|
|
143
|
+
if (event.type === 'user.emailChanged' && event.formerEmail != null) {
|
|
144
|
+
const user = await auth.get(event.userId);
|
|
145
|
+
await mail.emailChanged({
|
|
146
|
+
name: user.name,
|
|
147
|
+
locale: user.locale,
|
|
148
|
+
formerEmail: event.formerEmail, // to the inbox the account just left
|
|
149
|
+
newEmail: user.email,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`formerEmail` is `null` for a user who had no e-mail before the update:
|
|
157
|
+
there is no former inbox to tell. The example assumes a required e-mail; with
|
|
158
|
+
an optional one, an update that removes it sends the event too, and the user
|
|
159
|
+
read back has no `newEmail` to name — check `user.email` before the notice. Pass the same notices on `user.passwordReset`
|
|
160
|
+
too, if a reset should be told as well.
|
|
161
|
+
|
|
117
162
|
## What an event carries
|
|
118
163
|
|
|
119
|
-
**The user named by id, and nothing else
|
|
120
|
-
the schema, no password or hash, no session or one-time
|
|
164
|
+
**The user named by id, and nothing else** — with one exception, below. No
|
|
165
|
+
login, no field of the schema, no password or hash, no session or one-time
|
|
166
|
+
token. An event can
|
|
121
167
|
land in a queue, a log or another company's endpoint; whoever receives it
|
|
122
168
|
reads the rest from where it is kept — `auth.get(event.userId)` — if they
|
|
123
169
|
may. A user deleted since is `NOT_FOUND` there, which is the answer.
|
|
124
170
|
|
|
171
|
+
**The exception is `formerEmail`, on `user.emailChanged` only**: the address
|
|
172
|
+
the user had before the update, as it was stored, or `null` when they had
|
|
173
|
+
none. Once the write landed it is kept nowhere else, and a notice to the
|
|
174
|
+
inbox the account just left needs it. It is absent from every other type.
|
|
175
|
+
`@nxgt/janus-webhooks` does not carry it: it strips it before the queue, so
|
|
176
|
+
it reaches no endpoint, no queue and no `onGivingUp` report — send the
|
|
177
|
+
notice from the listener, in the process that wrote.
|
|
178
|
+
|
|
125
179
|
`id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
|
|
126
180
|
a queue job id, a primary key, a webhook's `webhook-id` header. Two events
|
|
127
181
|
never share one.
|
|
@@ -148,15 +202,18 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
148
202
|
| `create` | after the insert — its last step | — |
|
|
149
203
|
| `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
|
|
150
204
|
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
151
|
-
| `
|
|
205
|
+
| `update` | after the write — the flow's last step | — |
|
|
206
|
+
| `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
|
|
207
|
+
| `signInCode.confirm`, `magicLink.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
|
|
152
208
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
153
209
|
| `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` |
|
|
154
210
|
| `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` |
|
|
155
211
|
| `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` |
|
|
156
212
|
|
|
157
213
|
So a listener that takes its time never leaves an old session alive after a
|
|
158
|
-
reset,
|
|
159
|
-
|
|
214
|
+
reset, one that hears of a password changed finds the older reset links
|
|
215
|
+
already spent, and one that checks permissions on `user.deleted` finds the
|
|
216
|
+
tuples gone. A retry could not send a lost event either way: `delete` then finds
|
|
160
217
|
nobody, and the reset link is spent.
|
|
161
218
|
|
|
162
219
|
`occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
|
|
@@ -231,6 +288,8 @@ type UserEventType =
|
|
|
231
288
|
| 'user.created'
|
|
232
289
|
| 'user.emailVerified'
|
|
233
290
|
| 'user.passwordReset'
|
|
291
|
+
| 'user.passwordChanged'
|
|
292
|
+
| 'user.emailChanged'
|
|
234
293
|
| 'user.secondFactorEnabled'
|
|
235
294
|
| 'user.secondFactorDisabled'
|
|
236
295
|
| 'user.recoveryCodesRegenerated'
|
|
@@ -243,6 +302,7 @@ interface UserEvent {
|
|
|
243
302
|
readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
|
|
244
303
|
readonly userId: Id;
|
|
245
304
|
readonly userType: string;
|
|
305
|
+
readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
|
|
246
306
|
}
|
|
247
307
|
|
|
248
308
|
type UserEventListener = (event: UserEvent) => void | Promise<void>;
|
|
@@ -257,5 +317,6 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
|
|
|
257
317
|
|
|
258
318
|
- [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
|
|
259
319
|
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled`; [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
260
|
-
- [Users](users.md) — `create`, `signUp`, `delete`
|
|
320
|
+
- [Users](users.md) — `create`, `signUp`, `update`, `delete`
|
|
321
|
+
- [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
|
|
261
322
|
- [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
|