@nxgt/janus 0.6.0 → 0.8.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 -17
- package/dist/auth/config.d.ts +8 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +10 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/email-flows.d.ts +11 -0
- package/dist/auth/email-flows.d.ts.map +1 -0
- package/dist/auth/events.d.ts +52 -0
- package/dist/auth/events.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/one-time.d.ts +7 -0
- package/dist/auth/one-time.d.ts.map +1 -1
- package/dist/auth/password-written.d.ts +24 -0
- package/dist/auth/password-written.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +23 -1
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts +6 -3
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types.d.ts +6 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
- package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/index.js +73 -2
- package/dist/conformance/index.js.map +4 -4
- package/dist/index.js +207 -95
- package/dist/index.js.map +14 -11
- package/docs/README.md +2 -1
- package/docs/guide/adapters.md +56 -3
- package/docs/guide/email-flows.md +11 -1
- package/docs/guide/events.md +199 -0
- package/docs/guide/second-factor.md +27 -2
- package/docs/guide/sign-in-code.md +28 -9
- package/docs/guide/users.md +8 -4
- package/docs/guide/vocabulary.md +3 -0
- package/docs/roadmap.md +25 -13
- package/docs/troubleshooting.md +89 -26
- package/package.json +1 -1
package/docs/troubleshooting.md
CHANGED
|
@@ -39,6 +39,7 @@ How the messages are shaped:
|
|
|
39
39
|
- [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
|
|
40
40
|
- [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
|
|
41
41
|
- [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
|
|
42
|
+
- [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
|
|
42
43
|
- [Other `janus:` wiring messages](#other-janus-wiring-messages)
|
|
43
44
|
|
|
44
45
|
**Users, sessions and tokens**
|
|
@@ -81,6 +82,10 @@ How the messages are shaped:
|
|
|
81
82
|
- [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
|
|
82
83
|
- `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
|
|
83
84
|
|
|
85
|
+
**User events**
|
|
86
|
+
- [`[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`](#janus_event_failed-warning-janus-the-events-listener-failed-on-type-event-id-for-user-user-id-name)
|
|
87
|
+
- [An event you expected never arrived](#an-event-you-expected-never-arrived)
|
|
88
|
+
|
|
84
89
|
**Permissions**
|
|
85
90
|
- [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
|
|
86
91
|
- [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
|
|
@@ -232,9 +237,11 @@ Also: `janus: store.<slot> is missing`, `janus: store must be an object with use
|
|
|
232
237
|
|
|
233
238
|
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
234
239
|
**Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. `deleteExpiredSessions` is the one optional method: absent, or a function.
|
|
235
|
-
An adapter written
|
|
236
|
-
`store.tokens has no method countAttempt`
|
|
237
|
-
|
|
240
|
+
An adapter written for an earlier `@nxgt/janus` reports the method a later
|
|
241
|
+
release added to the port: `store.tokens has no method countAttempt` for one
|
|
242
|
+
written against 0.3 (the method came in 0.4), and
|
|
243
|
+
`store.tokens has no method spendUserTokens` for one written against 0.6 (the
|
|
244
|
+
method came in 0.7).
|
|
238
245
|
|
|
239
246
|
**Fix:** pass the three stores, whole:
|
|
240
247
|
|
|
@@ -244,17 +251,17 @@ import { createMemoryStores, janus } from '@nxgt/janus';
|
|
|
244
251
|
janus({ ..., store: createMemoryStores() });
|
|
245
252
|
```
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`@nxgt/janus-redis` 0.2:
|
|
254
|
+
Upgrade the published adapter to the release that implements both —
|
|
255
|
+
`@nxgt/janus-drizzle` 0.3, `@nxgt/janus-mongo` 0.4, `@nxgt/janus-redis` 0.3:
|
|
250
256
|
|
|
251
257
|
```bash
|
|
252
|
-
bun add @nxgt/janus@^0.
|
|
258
|
+
bun add @nxgt/janus@^0.7 @nxgt/janus-drizzle@^0.3 # or @nxgt/janus-mongo@^0.4, @nxgt/janus-redis@^0.3
|
|
253
259
|
```
|
|
254
260
|
|
|
255
|
-
Your own adapter implements
|
|
256
|
-
[`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt)
|
|
257
|
-
|
|
261
|
+
Your own adapter implements them as
|
|
262
|
+
[`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) and
|
|
263
|
+
[`TokenStore.spendUserTokens`](guide/adapters.md#tokenstorespendusertokens)
|
|
264
|
+
set out, then runs the conformance suite.
|
|
258
265
|
|
|
259
266
|
### `janus: relations must be a relation store — relations.deleteEntity is missing`
|
|
260
267
|
|
|
@@ -358,6 +365,21 @@ janus({
|
|
|
358
365
|
**Why:** `user.hasSecondFactor` is janus's own answer: whether the user's second factor is active. A field of yours with that name would be shadowed. From JavaScript, a validated input holding it is refused with `USER_INVALID` and the issue `set by janus, not by a request`.
|
|
359
366
|
**Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
|
|
360
367
|
|
|
368
|
+
### `janus: events must be a function that takes a user event — webhooks({ … }) from @nxgt/janus-webhooks, or your own`
|
|
369
|
+
|
|
370
|
+
**When:** `janus({ events })` with something other than a function — most often an object of functions, one per event type.
|
|
371
|
+
**Why:** `events` is one listener, called with every user event; the event's `type` says which.
|
|
372
|
+
**Fix:** pass one function, and switch on `type`:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
janus({
|
|
376
|
+
...config,
|
|
377
|
+
events(event) {
|
|
378
|
+
if (event.type === 'user.created') return welcome(event.userId);
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
361
383
|
### Other `janus:` wiring messages
|
|
362
384
|
|
|
363
385
|
| Message | Fix |
|
|
@@ -473,7 +495,7 @@ if (error instanceof UserInvalidError) {
|
|
|
473
495
|
Also `changePassword: the current password does not match`.
|
|
474
496
|
|
|
475
497
|
**When:** `signIn`, `changePassword`.
|
|
476
|
-
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
|
|
498
|
+
**Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. Also a sign-in that verified a password written over while it ran (`reason: 'wrongPassword'`): its session is revoked, or its challenge spent, before the refusal. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
|
|
477
499
|
**Fix:** answer 401 with the same body whatever the reason:
|
|
478
500
|
|
|
479
501
|
```ts
|
|
@@ -522,10 +544,14 @@ answered: `secondFactor.confirm: no such challenge`,
|
|
|
522
544
|
|
|
523
545
|
**When:** `secondFactor.confirm(challenge, code)`.
|
|
524
546
|
**Why:** a challenge lives five minutes and takes five codes. It is spent by
|
|
525
|
-
the code that opens the session, by the fifth wrong code,
|
|
526
|
-
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`)
|
|
527
|
-
|
|
528
|
-
|
|
547
|
+
the code that opens the session, by the fifth wrong code, by a refusal
|
|
548
|
+
that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`), and by a
|
|
549
|
+
password written — `resetPassword.confirm`, `setPassword`, `changePassword` —
|
|
550
|
+
which ends every sign-in left waiting on its code.
|
|
551
|
+
`TOKEN_UNKNOWN` also covers a challenge whose user was deleted, one
|
|
552
|
+
confirmed through another user type's `secondFactor`, and a challenge passed
|
|
553
|
+
where a code was expected — the two arguments swapped. Another type's
|
|
554
|
+
`confirm` still costs an attempt, and the fifth spends the challenge.
|
|
529
555
|
**Fix:** answer 400 and send the visitor back to sign in, which asks for a new
|
|
530
556
|
code. To give slower visitors more time:
|
|
531
557
|
|
|
@@ -540,15 +566,20 @@ janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
|
|
|
540
566
|
|
|
541
567
|
**When:** `signInCode.confirm(challenge, code)`.
|
|
542
568
|
**Why:** a challenge lives ten minutes and takes five codes. It is spent by
|
|
543
|
-
the code that signs the user in, by the fifth wrong code,
|
|
544
|
-
that ends it (`TOKEN_STALE`, `USER_INACTIVE`)
|
|
569
|
+
the code that signs the user in, by the fifth wrong code, by a refusal
|
|
570
|
+
that ends it (`TOKEN_STALE`, `USER_INACTIVE`), and by the next `request` for
|
|
571
|
+
the same user: only the last code sent works, so a visitor who asked twice
|
|
572
|
+
and typed the first code gets `TOKEN_SPENT` — and when two requests race,
|
|
573
|
+
even the last code can be spent: at most one survives, sometimes none. `TOKEN_UNKNOWN` also covers a
|
|
545
574
|
challenge whose user was deleted, one confirmed through another user type's
|
|
546
575
|
`signInCode`, the decoy challenge of a `request` that answered `null`, and
|
|
547
|
-
the two arguments swapped. Another type's `confirm` compares no code
|
|
548
|
-
|
|
576
|
+
the two arguments swapped. Another type's `confirm` compares no code, but it
|
|
577
|
+
has already cost one of the challenge's five
|
|
549
578
|
attempts: the attempt is counted before the type is known. The challenge is
|
|
550
|
-
left for its own type, with one attempt fewer
|
|
551
|
-
|
|
579
|
+
left for its own type, with one attempt fewer — and the fifth such call
|
|
580
|
+
spends it, as a fifth wrong code would.
|
|
581
|
+
**Fix:** answer 400 and offer to send a new code — and tell the visitor to
|
|
582
|
+
use the latest e-mail. To give slower inboxes
|
|
552
583
|
more time:
|
|
553
584
|
|
|
554
585
|
```ts
|
|
@@ -557,8 +588,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
|
|
|
557
588
|
|
|
558
589
|
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
559
590
|
|
|
560
|
-
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
|
|
561
|
-
**Why:** confirming it would verify an address nobody holds any more. The token is spent.
|
|
591
|
+
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail — even while the link was being redeemed: the address is checked again on the very record the write replaces.
|
|
592
|
+
**Why:** confirming it would verify an address nobody holds any more, or reset a password through one. The token is spent, and nothing is written.
|
|
562
593
|
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)`.
|
|
563
594
|
|
|
564
595
|
### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
|
|
@@ -626,7 +657,7 @@ each of those entries has a paragraph for it.
|
|
|
626
657
|
The same for `session` and `user`.
|
|
627
658
|
|
|
628
659
|
**When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s, on a user type with a password.
|
|
629
|
-
**Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
|
|
660
|
+
**Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt, userId }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
|
|
630
661
|
**Fix:** switch on `status`:
|
|
631
662
|
|
|
632
663
|
```ts
|
|
@@ -849,8 +880,8 @@ form field now holds the second challenge, so the first code does not
|
|
|
849
880
|
match it — and costs an attempt.
|
|
850
881
|
**Fix:** tell the visitor that only the last code sent works, and put the
|
|
851
882
|
time it was sent in the e-mail's subject or text so they can tell the
|
|
852
|
-
e-mails apart. The earlier challenge is
|
|
853
|
-
own
|
|
883
|
+
e-mails apart. The earlier challenge is spent by the new `request`: the
|
|
884
|
+
first code, even with its own challenge, answers `TOKEN_SPENT`.
|
|
854
885
|
|
|
855
886
|
### `signInCode.request` answers `null` for a user who exists
|
|
856
887
|
|
|
@@ -895,6 +926,38 @@ janus({
|
|
|
895
926
|
|
|
896
927
|
---
|
|
897
928
|
|
|
929
|
+
## User events
|
|
930
|
+
|
|
931
|
+
### `[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`
|
|
932
|
+
|
|
933
|
+
A process warning, not a thrown error: the flow that sent the event answered as if nothing happened.
|
|
934
|
+
|
|
935
|
+
**When:** the function given to `janus({ events })` threw or rejected — a queue that was down, a bug in the listener.
|
|
936
|
+
**Why:** the write the event reports has landed. Failing the flow would tell the visitor it did not happen, and their retry would hit `LOGIN_TAKEN`. So the failure is warned about, with the event's type, its id and the user's id, and the failure's name — never its message, which may hold anything.
|
|
937
|
+
**Fix:** make the listener only store the event (a queue, an outbox table) and fix whatever refused it. To send the lost event again, rebuild it from the warning — its type, its `id`, the user's id; the warning has no `occurredAt`, so take the user's `updatedAt` (or the warning's own time) as an approximation:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
process.on('warning', (warning) => {
|
|
941
|
+
if ((warning as { code?: string }).code === 'JANUS_EVENT_FAILED') logger.error(warning.message);
|
|
942
|
+
});
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
### An event you expected never arrived
|
|
946
|
+
|
|
947
|
+
**When:** a `user.emailVerified` after a confirm, a `user.deleted` after a delete, a `user.created` from a sign-up.
|
|
948
|
+
**Why:** one of these, in order of likelihood:
|
|
949
|
+
- the e-mail was already verified: nothing changed, so nothing is sent;
|
|
950
|
+
- `delete` deleted nobody — a replay, or an id of another user type;
|
|
951
|
+
- the flow was refused, or the store failed during the write itself — the call threw, and nothing was written or sent;
|
|
952
|
+
- the process stopped between the write and the listener — events are sent at most once, from memory;
|
|
953
|
+
- the listener threw: look for `JANUS_EVENT_FAILED` in the process's warnings.
|
|
954
|
+
|
|
955
|
+
**Fix:** for the last two, reconcile against the users themselves and treat events as the fast path, not the record: page through `auth.list()` and compare with the receiver's copy — a user it lacks is a missed `user.created`, a user the receiver has that `auth.find` answers `null` for is a missed `user.deleted`, and a user whose `emailVerified` differs is a missed `user.emailVerified`.
|
|
956
|
+
|
|
957
|
+
A store outage **after** the write does not lose the event. `create`, `signUp` and `signInCode.confirm` send it before the steps that follow; `delete` and `resetPassword.confirm` run theirs — removing the sessions, tokens and tuples; revoking the sessions — in a `try`, and send it from its `finally`, whether they succeeded or not.
|
|
958
|
+
|
|
959
|
+
---
|
|
960
|
+
|
|
898
961
|
## Permissions
|
|
899
962
|
|
|
900
963
|
### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
|