@nxgt/janus 0.5.0 → 0.7.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 (48) hide show
  1. package/README.md +116 -23
  2. package/dist/auth/config.d.ts +3 -0
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +12 -0
  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/index.d.ts +1 -1
  9. package/dist/auth/index.d.ts.map +1 -1
  10. package/dist/auth/one-time.d.ts +66 -6
  11. package/dist/auth/one-time.d.ts.map +1 -1
  12. package/dist/auth/password-written.d.ts +24 -0
  13. package/dist/auth/password-written.d.ts.map +1 -0
  14. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  15. package/dist/auth/port/types.d.ts +23 -1
  16. package/dist/auth/port/types.d.ts.map +1 -1
  17. package/dist/auth/second-factor/challenge.d.ts +0 -6
  18. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  19. package/dist/auth/second-factor/factor.d.ts +1 -4
  20. package/dist/auth/second-factor/factor.d.ts.map +1 -1
  21. package/dist/auth/second-factor/flows.d.ts +7 -3
  22. package/dist/auth/second-factor/flows.d.ts.map +1 -1
  23. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -1
  24. package/dist/auth/sign-in-code.d.ts +20 -0
  25. package/dist/auth/sign-in-code.d.ts.map +1 -0
  26. package/dist/auth/types.d.ts +56 -1
  27. package/dist/auth/types.d.ts.map +1 -1
  28. package/dist/auth/users.d.ts +2 -2
  29. package/dist/auth/users.d.ts.map +1 -1
  30. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  31. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  32. package/dist/conformance/cases/outage.d.ts.map +1 -1
  33. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  34. package/dist/conformance/index.js +73 -2
  35. package/dist/conformance/index.js.map +4 -4
  36. package/dist/index.js +253 -116
  37. package/dist/index.js.map +15 -12
  38. package/docs/README.md +2 -1
  39. package/docs/guide/adapters.md +56 -3
  40. package/docs/guide/email-flows.md +11 -1
  41. package/docs/guide/errors.md +3 -3
  42. package/docs/guide/second-factor.md +31 -4
  43. package/docs/guide/sign-in-code.md +489 -0
  44. package/docs/guide/users.md +5 -4
  45. package/docs/guide/vocabulary.md +7 -6
  46. package/docs/roadmap.md +28 -13
  47. package/docs/troubleshooting.md +193 -28
  48. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -14,6 +14,7 @@ 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 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 |
17
18
  | [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, disabling |
18
19
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
19
20
 
@@ -29,6 +30,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
29
30
  | --- | --- |
30
31
  | [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
31
32
  | [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
32
- | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt` and the second factor, or running the conformance suites |
33
+ | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt`, `spendUserTokens` and the second factor, or running the conformance suites |
33
34
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
34
35
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -133,6 +133,7 @@ interface TokenStore {
133
133
  insertToken(record: TokenRecord): Promise<void>;
134
134
  consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
135
135
  countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
136
+ spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
136
137
  deleteUserTokens(userId: Id): Promise<number>;
137
138
  }
138
139
  ```
@@ -222,6 +223,50 @@ Lua script: `HINCRBY` only when `spentAt` is empty, then `HGETALL`. Wrap the
222
223
  driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
223
224
  `countAttempt` has its own outage case.
224
225
 
226
+ ### `TokenStore.spendUserTokens`
227
+
228
+ Spends every **unspent** token of one user and one `kind` at `at` — but the
229
+ one whose hash is `except`, when given — and answers how many it spent. The
230
+ core calls it right after issuing a sign-in code, with that code's hash as
231
+ `except`, so only the last code sent works; and after writing a password,
232
+ for the user's `secondFactor` challenges:
233
+
234
+ | The stored token | Written | Counted |
235
+ | --- | --- | --- |
236
+ | unspent, of this user and this `kind` | `spentAt: at` | yes |
237
+ | already spent | nothing — `spentAt` never changes once set | no |
238
+ | another `kind`, another user, or the one named by `except` | nothing | no |
239
+ | none at all | nothing | `0`, an absence — never a failure |
240
+
241
+ Each token is spent by a **conditional write**, as `consumeToken` spends one:
242
+ a token that a racing `consumeToken` spends at the same moment is counted by
243
+ exactly one of the two calls, never both. An expired token is spent all the
244
+ same, or not counted by a store that already dropped it.
245
+
246
+ In MongoDB, one `updateMany` through the `userId` index `deleteUserTokens`
247
+ already reads:
248
+
249
+ ```ts
250
+ import type { TokenStore } from '@nxgt/janus';
251
+
252
+ export const spendUserTokens: TokenStore['spendUserTokens'] = async (userId, kind, at, except) => {
253
+ const result = await tokens.updateMany(
254
+ { userId, kind, spentAt: null, ...(except === undefined ? {} : { _id: { $ne: except } }) },
255
+ { $set: { spentAt: at } },
256
+ );
257
+ return result.modifiedCount;
258
+ };
259
+ ```
260
+
261
+ In SQL, `update … set spent_at = $3 where user_id = $1 and kind = $2 and
262
+ spent_at is null returning token_hash`, with `and token_hash <> $4` added
263
+ only when `except` is given — bound to `NULL`, `<>` matches no row — answering the row count: PostgreSQL
264
+ re-checks `spent_at is null` on a row a racing redemption just committed. In
265
+ Redis, one Lua script over the user's set of tokens, `HSET spentAt` on each
266
+ of the right `kind` whose `spentAt` is empty, skipping the one named by
267
+ `except`. `spendUserTokens` has its own
268
+ outage case.
269
+
225
270
  ### `RelationStore`
226
271
 
227
272
  ```ts
@@ -257,7 +302,11 @@ Written on the port's types, and checked by the suites:
257
302
  normalises logins before a store sees them, and never hands a store
258
303
  `\u0000` or a lone surrogate; every other character comes back as written.
259
304
  5. **Every method is atomic on its own.** The core opens no transaction; an
260
- adapter may open one inside a method.
305
+ adapter may open one inside a method. And **a read sees every write that
306
+ completed before it** — never a secondary or a read replica: a sign-in
307
+ re-reads the user to see a password written while it ran, and a new
308
+ sign-in code spends the ones issued before it. No suite can check this
309
+ one; a `readPreference: 'secondaryPreferred'` breaks it silently.
261
310
  6. **Schema management is not on the port.** Expose your own `sync`; the core
262
311
  never calls it.
263
312
 
@@ -298,7 +347,7 @@ compile error naming the missing method.
298
347
 
299
348
  | Suite | Cases | Harness opens |
300
349
  | --- | --- | --- |
301
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 45: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — twelve of them | `{ stores, faults?, close? }` |
350
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 49: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — thirteen of them | `{ stores, faults?, close? }` |
302
351
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
303
352
 
304
353
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -315,7 +364,7 @@ stores: a case that leaks into the next is the hardest failure to debug.
315
364
 
316
365
  The suites import no test framework and no assertion library.
317
366
 
318
- The second factor and attempts have their own cases — skip one by its id
367
+ The second factor, attempts and a user's tokens spent have their own cases — skip one by its id
319
368
  while you work on it, never to ship:
320
369
 
321
370
  | Case | Checks |
@@ -327,6 +376,10 @@ while you work on it, never to ship:
327
376
  | `tokens.challenge` | a second-factor challenge, whose `address` is `''`, kept, counted and spent like any token |
328
377
  | `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
329
378
  | `outage.countAttempt` | a store that cannot answer rejects, never `null` |
379
+ | `tokens.spendUserTokens` | spends the unspent tokens of one user and kind at `at`, keeping their attempts, and counts them; a spent token keeps its `spentAt`; another kind and another user are untouched; `0` for none |
380
+ | `tokens.spendUserTokensExcept` | spares the token named by `except`, and spends the user's others of that kind |
381
+ | `tokens.spendUserTokensRace` | racing one `consumeToken`, ten times over: exactly one of the two spends the token |
382
+ | `outage.spendUserTokens` | a store that cannot answer rejects, never `0` |
330
383
 
331
384
  ### `faults`: prove the outage invariant
332
385
 
@@ -51,6 +51,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
51
51
  clinic.staff.verifyEmail;
52
52
  ```
53
53
 
54
+ A type with an e-mail can also sign in with a code sent to it, with or
55
+ without a password — see [sign-in codes](sign-in-code.md).
56
+
54
57
  ## Options
55
58
 
56
59
  | Option | Type | Default | Effect |
@@ -71,6 +74,9 @@ readonly verifyEmail: {
71
74
  `send` issues a token for the user's **current** e-mail. `confirm` redeems it
72
75
  and sets `emailVerified`. A token sent to an e-mail the user has since changed
73
76
  is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
77
+ The address is checked again on the very record the write replaces, so an
78
+ e-mail changed while the link is being redeemed is `TOKEN_STALE` too, and
79
+ nothing is written.
74
80
  Changing the e-mail with `update` sets `emailVerified` back to `false`.
75
81
 
76
82
  ## `resetPassword`
@@ -97,7 +103,10 @@ export async function forgotPassword(request: Request): Promise<Response> {
97
103
  ```
98
104
 
99
105
  `confirm` sets the password, marks the e-mail verified — the link proved it —
100
- and **signs the user out everywhere**. It opens no session: call `signIn` next
106
+ and **signs the user out everywhere**: their sessions are revoked, and every
107
+ second-factor challenge still open is spent, so a sign-in started with the
108
+ old password cannot be finished. The e-mail is checked again on the record
109
+ written, as for `verifyEmail`. It opens no session: call `signIn` next
101
110
  if that is your policy. A password refused for its length does not spend the
102
111
  token, so the visitor can try again with the same link.
103
112
 
@@ -142,5 +151,6 @@ never the token, and no refusal's message contains it.
142
151
  ## See also
143
152
 
144
153
  - [Users](users.md) — `email`, `update`, and the other per-type methods
154
+ - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
145
155
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
146
156
  - [Errors](errors.md) — every code, and the status it deserves
@@ -83,9 +83,9 @@ that wired the library, so no handler needs to tell it apart.
83
83
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
84
84
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
85
85
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
86
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
87
- | `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 | |
88
- | `CODE_INVALID` | `TokenError` | 401 | A second factor's code that does not match, or was already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) | `attemptsLeft` from `confirm`: what the challenge has left, `0` once it is spent. None from `activate` |
86
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
87
+ | `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` |
88
+ | `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) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm`: what the challenge has left, `0` once it is spent. None from `activate`. `userId` |
89
89
  | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
90
90
  | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
91
91
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
@@ -203,11 +203,13 @@ interface SecondFactorRequired {
203
203
  readonly status: 'secondFactor';
204
204
  readonly challenge: string; // a secret, like a session token
205
205
  readonly expiresAt: Date; // five minutes from now, by default
206
+ readonly userId: Id; // for your logs and rate limits — not for the visitor
206
207
  }
207
208
  ```
208
209
 
209
- A user whose factor is active gets **no session from their password**: they
210
- get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
210
+ A user whose factor is active gets **no session from their password** — nor
211
+ from a [code sent by e-mail](sign-in-code.md#a-second-factor-is-still-asked-for),
212
+ whose `confirm` answers the same union: they get a **challenge**, which `secondFactor.confirm` redeems with a code. Anyone
211
213
  else gets a session, as before. `signIn`'s refusals — `CREDENTIALS_INVALID`,
212
214
  `USER_INACTIVE` — are unchanged, and come before any challenge: a wrong
213
215
  password never tells anyone that a second factor exists.
@@ -260,8 +262,8 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
260
262
  | Rejects with | When | What to do |
261
263
  | --- | --- | --- |
262
264
  | `CODE_INVALID`, with `attemptsLeft` | the code does not match, is not six digits, or was already accepted | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: sign in again |
263
- | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
264
- | `TOKEN_SPENT` | the challenge already opened a session, or its attempts ran out | sign in again |
265
+ | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | sign in again |
266
+ | `TOKEN_SPENT` | the challenge already opened a session, its attempts ran out, or a password reset spent it | sign in again |
265
267
  | `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
266
268
  | `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
267
269
  | `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
@@ -291,6 +293,30 @@ Five attempts at a million values is a one-in-200,000 chance per password
291
293
  guessed right. A new challenge takes a new sign-in, with the password, so the
292
294
  attempts are bounded by your sign-in rate limit too.
293
295
 
296
+ A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
297
+ for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
298
+ its attempt counts all the same: the fifth spends the challenge, as a wrong
299
+ code would.
300
+
301
+ **Writing a password ends the sign-ins left waiting.** `resetPassword.confirm`,
302
+ `setPassword` and `changePassword` spend every challenge of the user still
303
+ open, so whoever had the old password cannot finish a sign-in they started
304
+ with it:
305
+
306
+ ```ts
307
+ const result = await auth.signIn({ email, password: oldPassword }); // a challenge
308
+ await auth.resetPassword.confirm(resetToken, newPassword);
309
+ await auth.secondFactor.confirm(result.challenge, code); // TOKEN_SPENT
310
+ ```
311
+
312
+ A sign-in still running when the password is written is refused too.
313
+ `signIn` reads the user again once it answered: if the password it verified
314
+ is no longer theirs, it spends its own challenge — or revokes the session it
315
+ opened — and throws `CREDENTIALS_INVALID`. The writer spends after writing,
316
+ the sign-in reads after issuing, so however the two interleave one of them
317
+ sees the other. A hash rewritten for the same password — another sign-in
318
+ rehashing it — is not a change.
319
+
294
320
  ### Lifetime
295
321
 
296
322
  A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
@@ -562,6 +588,7 @@ with `STORE_FAILED`.
562
588
 
563
589
  ## See also
564
590
 
591
+ - [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
565
592
  - [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
566
593
  - [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
567
594
  - [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor