@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.
Files changed (44) hide show
  1. package/README.md +107 -17
  2. package/dist/auth/config.d.ts +8 -0
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +10 -1
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/email-flows.d.ts +11 -0
  7. package/dist/auth/email-flows.d.ts.map +1 -0
  8. package/dist/auth/events.d.ts +52 -0
  9. package/dist/auth/events.d.ts.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/janus.d.ts.map +1 -1
  13. package/dist/auth/one-time.d.ts +7 -0
  14. package/dist/auth/one-time.d.ts.map +1 -1
  15. package/dist/auth/password-written.d.ts +24 -0
  16. package/dist/auth/password-written.d.ts.map +1 -0
  17. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  18. package/dist/auth/port/types.d.ts +23 -1
  19. package/dist/auth/port/types.d.ts.map +1 -1
  20. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  21. package/dist/auth/sign-in-code.d.ts +6 -3
  22. package/dist/auth/sign-in-code.d.ts.map +1 -1
  23. package/dist/auth/types.d.ts +6 -0
  24. package/dist/auth/types.d.ts.map +1 -1
  25. package/dist/auth/users.d.ts.map +1 -1
  26. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  27. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  28. package/dist/conformance/cases/outage.d.ts.map +1 -1
  29. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  30. package/dist/conformance/index.js +73 -2
  31. package/dist/conformance/index.js.map +4 -4
  32. package/dist/index.js +207 -95
  33. package/dist/index.js.map +14 -11
  34. package/docs/README.md +2 -1
  35. package/docs/guide/adapters.md +56 -3
  36. package/docs/guide/email-flows.md +11 -1
  37. package/docs/guide/events.md +199 -0
  38. package/docs/guide/second-factor.md +27 -2
  39. package/docs/guide/sign-in-code.md +28 -9
  40. package/docs/guide/users.md +8 -4
  41. package/docs/guide/vocabulary.md +3 -0
  42. package/docs/roadmap.md +25 -13
  43. package/docs/troubleshooting.md +89 -26
  44. package/package.json +1 -1
@@ -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 against `@nxgt/janus` 0.3 reports
236
- `store.tokens has no method countAttempt` until it implements the method 0.4
237
- added.
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
- For `countAttempt`, upgrade the published adapter to the release that
248
- implements it — `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3,
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.4 @nxgt/janus-drizzle@^0.2 # or @nxgt/janus-mongo@^0.3, @nxgt/janus-redis@^0.2
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 it as
256
- [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) sets
257
- out, then runs the conformance suite.
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, and by a refusal
526
- that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`). `TOKEN_UNKNOWN`
527
- also covers a challenge whose user was deleted, and a challenge passed where a
528
- code was expected — the two arguments swapped.
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, and by a refusal
544
- that ends it (`TOKEN_STALE`, `USER_INACTIVE`). `TOKEN_UNKNOWN` also covers a
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 and
548
- spends nothing, but it has already cost one of the challenge's five
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
- **Fix:** answer 400 and offer to send a new code. To give slower inboxes
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 not revoked: it still expires on its
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",