@nxgt/janus 0.6.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 (36) hide show
  1. package/README.md +52 -14
  2. package/dist/auth/context.d.ts +6 -0
  3. package/dist/auth/context.d.ts.map +1 -1
  4. package/dist/auth/email-flows.d.ts +11 -0
  5. package/dist/auth/email-flows.d.ts.map +1 -0
  6. package/dist/auth/one-time.d.ts +7 -0
  7. package/dist/auth/one-time.d.ts.map +1 -1
  8. package/dist/auth/password-written.d.ts +24 -0
  9. package/dist/auth/password-written.d.ts.map +1 -0
  10. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  11. package/dist/auth/port/types.d.ts +23 -1
  12. package/dist/auth/port/types.d.ts.map +1 -1
  13. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  14. package/dist/auth/sign-in-code.d.ts +6 -3
  15. package/dist/auth/sign-in-code.d.ts.map +1 -1
  16. package/dist/auth/types.d.ts +6 -0
  17. package/dist/auth/types.d.ts.map +1 -1
  18. package/dist/auth/users.d.ts.map +1 -1
  19. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  20. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  21. package/dist/conformance/cases/outage.d.ts.map +1 -1
  22. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  23. package/dist/conformance/index.js +73 -2
  24. package/dist/conformance/index.js.map +4 -4
  25. package/dist/index.js +145 -88
  26. package/dist/index.js.map +11 -9
  27. package/docs/README.md +1 -1
  28. package/docs/guide/adapters.md +56 -3
  29. package/docs/guide/email-flows.md +7 -1
  30. package/docs/guide/second-factor.md +27 -2
  31. package/docs/guide/sign-in-code.md +26 -8
  32. package/docs/guide/users.md +3 -3
  33. package/docs/guide/vocabulary.md +1 -0
  34. package/docs/roadmap.md +12 -4
  35. package/docs/troubleshooting.md +37 -26
  36. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -30,6 +30,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
30
30
  | --- | --- |
31
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` |
32
32
  | [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
33
- | [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 |
34
34
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
35
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
 
@@ -74,6 +74,9 @@ readonly verifyEmail: {
74
74
  `send` issues a token for the user's **current** e-mail. `confirm` redeems it
75
75
  and sets `emailVerified`. A token sent to an e-mail the user has since changed
76
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.
77
80
  Changing the e-mail with `update` sets `emailVerified` back to `false`.
78
81
 
79
82
  ## `resetPassword`
@@ -100,7 +103,10 @@ export async function forgotPassword(request: Request): Promise<Response> {
100
103
  ```
101
104
 
102
105
  `confirm` sets the password, marks the e-mail verified — the link proved it —
103
- 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
104
110
  if that is your policy. A password refused for its length does not spend the
105
111
  token, so the visitor can try again with the same link.
106
112
 
@@ -203,6 +203,7 @@ 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
 
@@ -261,8 +262,8 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
261
262
  | Rejects with | When | What to do |
262
263
  | --- | --- | --- |
263
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 |
264
- | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, or one a store's TTL already dropped | sign in again |
265
- | `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 |
266
267
  | `TOKEN_EXPIRED` | `expiresAt` has passed | sign in again |
267
268
  | `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
268
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 |
@@ -292,6 +293,30 @@ Five attempts at a million values is a one-in-200,000 chance per password
292
293
  guessed right. A new challenge takes a new sign-in, with the password, so the
293
294
  attempts are bounded by your sign-in rate limit too.
294
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
+
295
320
  ### Lifetime
296
321
 
297
322
  A challenge lives `'5m'` unless `secondFactor.challenge` says otherwise, and
@@ -181,10 +181,10 @@ hash, like a session token, so the store cannot give it back either.
181
181
 
182
182
  A challenge belongs to the user type that issued it: confirm a patient's
183
183
  challenge with `clinic.patient.signInCode.confirm`. Another type's `confirm`
184
- answers `TOKEN_UNKNOWN`, compares no code and spends nothing — but it has
185
- already cost one of the challenge's five attempts, because the attempt is
186
- counted before the type is known. The challenge is left for its own type,
187
- with one attempt fewer.
184
+ answers `TOKEN_UNKNOWN` and compares no code — but it has already cost one of
185
+ the challenge's five attempts, because the attempt is counted before the type
186
+ is known. The challenge is left for its own type, with one attempt fewer, and
187
+ the fifth such call spends it, as a fifth wrong code would.
188
188
 
189
189
  ## Confirming the code
190
190
 
@@ -221,8 +221,26 @@ try {
221
221
  Five attempts at a million values is a one-in-200,000 chance per challenge.
222
222
  Codes sent at once past the fifth attempt are all refused, the right one
223
223
  included: the store counts, and nothing reads the count before writing it.
224
- A new challenge takes a new `request` and a new e-mail — which is why that
225
- route is the one to rate-limit.
224
+
225
+ **At most one code is live per user.** A `request` issues its code, then
226
+ spends every other challenge of the user, so the code in an earlier e-mail
227
+ answers `TOKEN_SPENT` — only the last one works:
228
+
229
+ ```ts
230
+ const first = await auth.signInCode.request(email);
231
+ const second = await auth.signInCode.request(email); // the visitor asked again
232
+ await auth.signInCode.confirm(first.challenge, first.code); // TOKEN_SPENT
233
+ await auth.signInCode.confirm(second.challenge, second.code); // signed in
234
+ ```
235
+
236
+ Requests that arrive at once cannot each keep a code: each spends the others'
237
+ once it issued its own, so at most one survives — sometimes none, and the
238
+ visitor asks again. Guesses therefore never run against two live challenges.
239
+
240
+ **Rate-limit `request` per address.** A new challenge takes only a new
241
+ `request`, and each one sends an e-mail and cancels the code before it:
242
+ without a limit, anyone who knows an address can fill its inbox, or keep
243
+ its owner from ever typing a code in time.
226
244
 
227
245
  ### Lifetime
228
246
 
@@ -270,8 +288,8 @@ answers `SignedIn`.
270
288
  | Rejects with | When | What to do |
271
289
  | --- | --- | --- |
272
290
  | `CODE_INVALID`, with `attemptsLeft` | the code does not match, or is not six digits. Message: `signInCode.confirm: the code does not match, or was already used` | ask again while `attemptsLeft > 0`; at `0` the challenge is spent: request a new code |
273
- | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts | request a new code |
274
- | `TOKEN_SPENT` | the challenge already signed someone in, or its attempts ran out | request a new code |
291
+ | `TOKEN_UNKNOWN` | no such challenge — a typo, another user type's, one whose user was deleted, or one a store's TTL already dropped. Another type's challenge still loses one of its attempts, and its fifth spends it | request a new code |
292
+ | `TOKEN_SPENT` | the challenge already signed someone in, its attempts ran out, or a newer `request` for the same user spent it — only the last code sent works | request a new code, and use the latest e-mail |
275
293
  | `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
276
294
  | `TOKEN_STALE` | the user changed their e-mail since the code was sent. Message: `signInCode.confirm: the code was sent to an e-mail the user no longer has`. The challenge is spent | request a new code, to the current address |
277
295
  | `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
@@ -185,10 +185,10 @@ With a `password`, besides:
185
185
  | Method | Answers | Rejects with |
186
186
  | --- | --- | --- |
187
187
  | `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
188
- | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
188
+ | `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
189
189
  | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
190
- | `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
191
- | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
190
+ | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
191
+ | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
192
192
 
193
193
  With `secondFactor` configured, a type with a password also answers
194
194
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
@@ -51,6 +51,7 @@ either finds this row.
51
51
  | **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
52
52
  | **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
53
53
  | **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm`; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `signInCode`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
54
+ | **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` for the same user (at most one sign-in code is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
54
55
  | **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
55
56
  | **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
56
57
  | **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
package/docs/roadmap.md CHANGED
@@ -93,6 +93,18 @@ Nothing between releases.
93
93
  The last ten, newest first, each with the version it came in. Everything
94
94
  before is in the [CHANGELOG](../CHANGELOG.md).
95
95
 
96
+ - **At most one sign-in code live per user, and challenges that end when
97
+ they should, v0.7.0** — `signInCode.request` spends the codes sent before,
98
+ even when requests race, so only the last e-mail's works; writing a
99
+ password spends the second-factor challenges left waiting, and a sign-in
100
+ still running when it lands is refused; another user type's `confirm` spends a challenge
101
+ at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
102
+ check the e-mail again on the record they write. `SecondFactorRequired`
103
+ carries `userId`, for logs and rate limits. For adapters:
104
+ `TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
105
+ cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
106
+ `@nxgt/janus-redis` — and the port now says a read sees every write that
107
+ completed before it: never a secondary or a read replica.
96
108
  - **Sign in with a code sent by e-mail, v0.6.0** —
97
109
  `auth.<type>.signInCode.request(email)` answers a six-digit code to send
98
110
  and a challenge to keep with the visitor, or `null` for nobody — never
@@ -149,7 +161,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
149
161
  - **The conformance suite accepts a store with its own expiry** — a store
150
162
  that drops a lapsed session at once, as a Redis TTL does, passes
151
163
  `sessions.deleteUser`; one that still holds it must count it. — v0.2.0
152
- - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
153
- the session middleware, the cookie, a route guarded by a permission,
154
- `bindJanus()` to bind the instances once, and every error as its status.
155
- Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
@@ -232,9 +232,11 @@ Also: `janus: store.<slot> is missing`, `janus: store must be an object with use
232
232
 
233
233
  **When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
234
234
  **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.
235
+ An adapter written for an earlier `@nxgt/janus` reports the method a later
236
+ release added to the port: `store.tokens has no method countAttempt` for one
237
+ written against 0.3 (the method came in 0.4), and
238
+ `store.tokens has no method spendUserTokens` for one written against 0.6 (the
239
+ method came in 0.7).
238
240
 
239
241
  **Fix:** pass the three stores, whole:
240
242
 
@@ -244,17 +246,17 @@ import { createMemoryStores, janus } from '@nxgt/janus';
244
246
  janus({ ..., store: createMemoryStores() });
245
247
  ```
246
248
 
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:
249
+ Upgrade the published adapter to the release that implements both —
250
+ `@nxgt/janus-drizzle` 0.3, `@nxgt/janus-mongo` 0.4, `@nxgt/janus-redis` 0.3:
250
251
 
251
252
  ```bash
252
- bun add @nxgt/janus@^0.4 @nxgt/janus-drizzle@^0.2 # or @nxgt/janus-mongo@^0.3, @nxgt/janus-redis@^0.2
253
+ bun add @nxgt/janus@^0.7 @nxgt/janus-drizzle@^0.3 # or @nxgt/janus-mongo@^0.4, @nxgt/janus-redis@^0.3
253
254
  ```
254
255
 
255
- Your own adapter implements it as
256
- [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) sets
257
- out, then runs the conformance suite.
256
+ Your own adapter implements them as
257
+ [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) and
258
+ [`TokenStore.spendUserTokens`](guide/adapters.md#tokenstorespendusertokens)
259
+ set out, then runs the conformance suite.
258
260
 
259
261
  ### `janus: relations must be a relation store — relations.deleteEntity is missing`
260
262
 
@@ -473,7 +475,7 @@ if (error instanceof UserInvalidError) {
473
475
  Also `changePassword: the current password does not match`.
474
476
 
475
477
  **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.
478
+ **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
479
  **Fix:** answer 401 with the same body whatever the reason:
478
480
 
479
481
  ```ts
@@ -522,10 +524,14 @@ answered: `secondFactor.confirm: no such challenge`,
522
524
 
523
525
  **When:** `secondFactor.confirm(challenge, code)`.
524
526
  **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.
527
+ the code that opens the session, by the fifth wrong code, by a refusal
528
+ that ends it (`USER_INACTIVE`, `SECOND_FACTOR_NOT_ENROLLED`), and by a
529
+ password written — `resetPassword.confirm`, `setPassword`, `changePassword` —
530
+ which ends every sign-in left waiting on its code.
531
+ `TOKEN_UNKNOWN` also covers a challenge whose user was deleted, one
532
+ confirmed through another user type's `secondFactor`, and a challenge passed
533
+ where a code was expected — the two arguments swapped. Another type's
534
+ `confirm` still costs an attempt, and the fifth spends the challenge.
529
535
  **Fix:** answer 400 and send the visitor back to sign in, which asks for a new
530
536
  code. To give slower visitors more time:
531
537
 
@@ -540,15 +546,20 @@ janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
540
546
 
541
547
  **When:** `signInCode.confirm(challenge, code)`.
542
548
  **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
549
+ the code that signs the user in, by the fifth wrong code, by a refusal
550
+ that ends it (`TOKEN_STALE`, `USER_INACTIVE`), and by the next `request` for
551
+ the same user: only the last code sent works, so a visitor who asked twice
552
+ and typed the first code gets `TOKEN_SPENT` — and when two requests race,
553
+ even the last code can be spent: at most one survives, sometimes none. `TOKEN_UNKNOWN` also covers a
545
554
  challenge whose user was deleted, one confirmed through another user type's
546
555
  `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
556
+ the two arguments swapped. Another type's `confirm` compares no code, but it
557
+ has already cost one of the challenge's five
549
558
  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
559
+ left for its own type, with one attempt fewer — and the fifth such call
560
+ spends it, as a fifth wrong code would.
561
+ **Fix:** answer 400 and offer to send a new code — and tell the visitor to
562
+ use the latest e-mail. To give slower inboxes
552
563
  more time:
553
564
 
554
565
  ```ts
@@ -557,8 +568,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
557
568
 
558
569
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
559
570
 
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.
571
+ **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.
572
+ **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
573
  **Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)`.
563
574
 
564
575
  ### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
@@ -626,7 +637,7 @@ each of those entries has a paragraph for it.
626
637
  The same for `session` and `user`.
627
638
 
628
639
  **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.
640
+ **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
641
  **Fix:** switch on `status`:
631
642
 
632
643
  ```ts
@@ -849,8 +860,8 @@ form field now holds the second challenge, so the first code does not
849
860
  match it — and costs an attempt.
850
861
  **Fix:** tell the visitor that only the last code sent works, and put the
851
862
  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.
863
+ e-mails apart. The earlier challenge is spent by the new `request`: the
864
+ first code, even with its own challenge, answers `TOKEN_SPENT`.
854
865
 
855
866
  ### `signInCode.request` answers `null` for a user who exists
856
867
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",