@nxgt/janus 0.24.0 → 0.26.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 (57) hide show
  1. package/README.md +39 -12
  2. package/dist/auth/config/reserved.d.ts +1 -1
  3. package/dist/auth/config/reserved.d.ts.map +1 -1
  4. package/dist/auth/email-flows/any-type-change-email.d.ts +18 -0
  5. package/dist/auth/email-flows/any-type-change-email.d.ts.map +1 -0
  6. package/dist/auth/email-flows/change-email-token.d.ts +6 -1
  7. package/dist/auth/email-flows/change-email-token.d.ts.map +1 -1
  8. package/dist/auth/email-flows/change-email.d.ts +6 -0
  9. package/dist/auth/email-flows/change-email.d.ts.map +1 -1
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/janus.d.ts.map +1 -1
  13. package/dist/auth/outage.d.ts +2 -2
  14. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  15. package/dist/auth/port/memory/tokens.d.ts.map +1 -1
  16. package/dist/auth/port/types/stores.d.ts +2 -0
  17. package/dist/auth/port/types/stores.d.ts.map +1 -1
  18. package/dist/auth/port/types/tokens.d.ts +12 -0
  19. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  20. package/dist/auth/sessions/collect-expired.d.ts +14 -0
  21. package/dist/auth/sessions/collect-expired.d.ts.map +1 -0
  22. package/dist/auth/sessions/shared-api.d.ts +1 -1
  23. package/dist/auth/sessions/shared-api.d.ts.map +1 -1
  24. package/dist/auth/types/change-email.d.ts +31 -0
  25. package/dist/auth/types/change-email.d.ts.map +1 -1
  26. package/dist/auth/types/index.d.ts +1 -1
  27. package/dist/auth/types/index.d.ts.map +1 -1
  28. package/dist/auth/types/janus.d.ts +13 -5
  29. package/dist/auth/types/janus.d.ts.map +1 -1
  30. package/dist/chunks/{index-6bjfv4xv.js → index-q3w3vr69.js} +13 -3
  31. package/dist/chunks/{index-6bjfv4xv.js.map → index-q3w3vr69.js.map} +3 -3
  32. package/dist/chunks/{index-cntabs9d.js → index-rpp0bw6e.js} +2 -2
  33. package/dist/chunks/{index-kppkcbn5.js → index-spwh8b9z.js} +2 -5
  34. package/dist/chunks/{index-kppkcbn5.js.map → index-spwh8b9z.js.map} +2 -2
  35. package/dist/chunks/{index-4khsm7mw.js → index-y9b327j8.js} +18 -18
  36. package/dist/chunks/{index-4khsm7mw.js.map → index-y9b327j8.js.map} +5 -5
  37. package/dist/conformance/cases/outage.d.ts.map +1 -1
  38. package/dist/conformance/cases/tokens/deletion.d.ts.map +1 -1
  39. package/dist/conformance/describe.d.ts +1 -0
  40. package/dist/conformance/describe.d.ts.map +1 -1
  41. package/dist/conformance/index.js +39 -5
  42. package/dist/conformance/index.js.map +5 -5
  43. package/dist/conformance/types.d.ts +1 -1
  44. package/dist/conformance/types.d.ts.map +1 -1
  45. package/dist/index.js +478 -453
  46. package/dist/index.js.map +21 -19
  47. package/dist/permissions/index.js +3 -3
  48. package/docs/guide/adapters.md +40 -3
  49. package/docs/guide/email-flows.md +16 -2
  50. package/docs/guide/errors.md +1 -1
  51. package/docs/guide/passwords.md +7 -10
  52. package/docs/guide/sessions.md +15 -4
  53. package/docs/guide/users.md +1 -1
  54. package/docs/roadmap.md +14 -20
  55. package/docs/troubleshooting.md +12 -10
  56. package/package.json +1 -1
  57. /package/dist/chunks/{index-cntabs9d.js.map → index-rpp0bw6e.js.map} +0 -0
@@ -4,16 +4,16 @@ import {
4
4
  UNSTORABLE,
5
5
  MAX_PAGE_SIZE2,
6
6
  pageLimit2
7
- } from "../chunks/index-4khsm7mw.js";
7
+ } from "../chunks/index-y9b327j8.js";
8
8
  import {
9
9
  PermissionDepthError2,
10
10
  isSubjectSet2,
11
11
  isSetOf2,
12
12
  formatEntity2
13
- } from "../chunks/index-kppkcbn5.js";
13
+ } from "../chunks/index-spwh8b9z.js";
14
14
  import {
15
15
  createMemoryRelations2
16
- } from "../chunks/index-cntabs9d.js";
16
+ } from "../chunks/index-rpp0bw6e.js";
17
17
 
18
18
  // src/permissions/resolve/resolved.ts
19
19
  function admits(holders, subject) {
@@ -31,7 +31,7 @@ describeJanusStores({
31
31
 
32
32
  | Port | Taken by | Methods |
33
33
  | --- | --- | --- |
34
- | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 |
34
+ | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 (+ 1 optional) |
35
35
  | `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
36
36
 
37
37
  They are separate on purpose: an application that only authenticates
@@ -184,6 +184,7 @@ interface TokenStore {
184
184
  countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
185
185
  spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
186
186
  deleteUserTokens(userId: Id): Promise<number>;
187
+ deleteExpiredTokens?(before: Date): Promise<number>; // optional: omit it if the database expires on its own
187
188
  }
188
189
  ```
189
190
 
@@ -368,6 +369,41 @@ of the right `kind` whose `spentAt` is empty, skipping the one named by
368
369
  `except`. `spendUserTokens` has its own
369
370
  outage case.
370
371
 
372
+ ### `TokenStore.deleteExpiredTokens` (optional)
373
+
374
+ What `collectExpired()` asks for, beside `SessionStore.deleteExpiredSessions`:
375
+ **delete every token whose `expiresAt` is at or before `before`**, and answer
376
+ how many. Leave it out when your database expires records on its own (a TTL
377
+ index, a key TTL) — nothing breaks, the core compares `expiresAt` on every
378
+ redemption — and implement it when it does not, or the rows pile up.
379
+
380
+ | The stored token | Written |
381
+ | --- | --- |
382
+ | `expiresAt` at or before `before`, spent or not, of any kind and any user | deleted, and counted |
383
+ | `expiresAt` later than `before`, spent or not | **kept** |
384
+ | none at all | `0`, an absence — never a failure |
385
+
386
+ **Never delete a token whose `expiresAt` is later, spent or not**: a spent
387
+ token is kept, answered as spent, until it lapses, or a count would start
388
+ over. That includes the throttles' counts — ordinary `secondFactor` tokens
389
+ under a keyed-hash user id, which `deleteUserTokens` never reaches and this
390
+ method is the only thing to collect. The core calls it with `clock.now()`, no
391
+ margin: a link collected answers `TOKEN_UNKNOWN`.
392
+
393
+ ```ts
394
+ import type { TokenStore } from '@nxgt/janus';
395
+
396
+ // PostgreSQL through Drizzle — one statement, over an index on expires_at
397
+ deleteExpiredTokens: async (before) =>
398
+ (await db.delete(tokens).where(lte(tokens.expiresAt, before)).returning({ tokenHash: tokens.tokenHash })).length,
399
+ ```
400
+
401
+ `janus()` reads its presence once (`StoreCapabilities.collectExpiredTokens`),
402
+ and refuses a `deleteExpiredTokens` that is there but not a function with a
403
+ `TypeError`. The suite's `tokens.deleteExpired` and `outage.deleteExpiredTokens`
404
+ cases check it, and are skipped with their reason for a store that leaves it
405
+ out. A failure throws, as everywhere on the port.
406
+
371
407
  ### `RelationStore`
372
408
 
373
409
  ```ts
@@ -449,7 +485,7 @@ compile error naming the missing method.
449
485
 
450
486
  | Suite | Cases | Harness opens |
451
487
  | --- | --- | --- |
452
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 59: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
488
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 61: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — sixteen of them | `{ stores, faults?, close? }` |
453
489
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
454
490
 
455
491
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -546,7 +582,8 @@ describeJanusStores({
546
582
 
547
583
  A skipped case still appears in the run, with its reason in its name. A case
548
584
  that cannot run on the stores it was given — an outage case with no `faults`,
549
- `collectExpired` on a store without `deleteExpiredSessions`, a `findUsers`
585
+ `sessions.deleteExpired` on a store without `deleteExpiredSessions`,
586
+ `tokens.deleteExpired` on one without `deleteExpiredTokens`, a `findUsers`
550
587
  case on a store without `findUsers` — passes, and emits a
551
588
  `JANUS_CONFORMANCE_SKIPPED` warning with the reason. A case's `needs` says what
552
589
  it cannot run without — one `CaseNeed` or a list: `outage.findUsers` needs
@@ -265,6 +265,20 @@ is no longer the one it was asked from although nothing spent it: a write
265
265
  outside janus, or a `send` racing an `update`. `confirm` sends `user.emailChanged` (with
266
266
  `formerEmail`) and `user.emailVerified`.
267
267
 
268
+ **With several user types**, a type's own `confirm` answers `TOKEN_UNKNOWN`
269
+ for a token issued to another type's user, and spends it — a later
270
+ confirmation through the right type is then `TOKEN_SPENT`. A page that does
271
+ not know the type calls the root's `confirm`, which reads the user the token
272
+ was issued to and confirms as their type does: same move, same events, same
273
+ errors, and `type` in the answer says which type it was. `send` stays on each
274
+ type. The root's exists when one type has an e-mail, which is why no type may
275
+ be named `changeEmail`.
276
+
277
+ ```ts
278
+ // one anonymous page for every type
279
+ const changed = await auth.changeEmail.confirm(token); // changed.type: 'admin' | 'member'
280
+ ```
281
+
268
282
  **Gate `send`, and tell the former address.** Whoever changes the e-mail
269
283
  takes over every reset: call `send` behind a fresh sign-in or a step-up.
270
284
 
@@ -511,8 +525,8 @@ answers again. A refused request is counted too but cannot extend the wait.
511
525
  **Where the counts live.** In the tokens store, as `secondFactor` tokens named
512
526
  by a keyed hash of the flow and the address or user id: no port change, no new
513
527
  infrastructure, and never the address. That is one row per flow per address or
514
- user per window. On PostgreSQL, `collectExpired()` does not collect lapsed
515
- ones: schedule the delete, as for the sign-in throttle. A flushed or evicting
528
+ user per window. `collectExpired()` collects the lapsed ones on a tokens store that implements
529
+ `deleteExpiredTokens`; a store with a TTL expires them on its own. A flushed or evicting
516
530
  Redis forgets the counts, and a tokens store that cannot count throws
517
531
  `STORE_FAILED` with nothing issued: it fails closed.
518
532
 
@@ -64,7 +64,7 @@ that wired the library, so no handler needs to tell it apart.
64
64
  | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
65
65
  | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
66
66
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
67
- | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` without `deleteExpiredSessions` | `slot`, `operation` |
67
+ | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` with neither `deleteExpiredSessions` nor `deleteExpiredTokens` | `slot`, `operation` |
68
68
  | `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
69
69
 
70
70
  Every class extends `JanusError`, which extends `Error`, so no `catch` block
@@ -195,16 +195,13 @@ counts correctly. Each is named by a keyed hash of the login, never the login
195
195
  otherwise, which only keeps the login out of plain sight: a dump of the
196
196
  tokens then tells which logins were tried, for a guessed list. Each expires
197
197
  two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
198
- PostgreSQL keeps it** until something deletes it, and every login tried,
199
- registered or not, adds a row per window. Schedule a delete with
200
- `@nxgt/janus-drizzle`:
201
-
202
- ```sql
203
- delete from tokens where expires_at < now() - interval '1 hour'; -- janus.tokens when your tables have a schema of their own
204
- ```
205
-
206
- `tokens` has no index on `expires_at`, so the delete scans the table: add
207
- `create index on tokens (expires_at)` in a migration of yours if it grows.
198
+ a store with no TTL keeps it** until `collectExpired()` deletes it (the
199
+ tokens store's optional `deleteExpiredTokens`), and every login tried,
200
+ registered or not, adds a row per window. Schedule `collectExpired()` on such
201
+ a store: `@nxgt/janus-drizzle` implements `deleteExpiredTokens` (since 0.9),
202
+ with an index on `expires_at` that its
203
+ [migration](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-drizzle/docs/guide/migrations.md#to-09-an-index-on-tokensexpires_at)
204
+ adds. A store of your own implements the method, or its rows stay.
208
205
 
209
206
  The key is your first `secondFactor` key: wiring
210
207
  `secondFactor` for the first time, or putting a new key first, starts every
@@ -329,10 +329,21 @@ a link or a code already in the person's inbox still works afterwards.
329
329
  const removed: number = await auth.collectExpired();
330
330
  ```
331
331
 
332
- Deletes lapsed sessions, for a store that keeps them. It rejects with
333
- `UNSUPPORTED` when the sessions store does not implement the optional
334
- `deleteExpiredSessions` — a store with its own TTL, such as
335
- `@nxgt/janus-mongo`'s, does not. The reference store implements it.
332
+ Deletes lapsed sessions and lapsed one-time tokens, from the stores that keep
333
+ them, and answers how many in all. The tokens include the sign-in and mail
334
+ throttles' counts, which are ordinary tokens. It runs whichever of the
335
+ optional `sessions.deleteExpiredSessions` and `tokens.deleteExpiredTokens` the
336
+ stores implement, sessions first, and rejects with `UNSUPPORTED` only when
337
+ neither does — a store with its own TTL, such as `@nxgt/janus-mongo`'s, does
338
+ not implement them. The reference store implements both.
339
+
340
+ A token is deleted once its `expiresAt` is at or before now, spent or not;
341
+ one that has not lapsed is never deleted, so a throttle keeps counting and a
342
+ link still redeems until its time is up. After a token is collected, its link
343
+ answers `TOKEN_UNKNOWN` rather than `TOKEN_EXPIRED` or `TOKEN_SPENT`, as it
344
+ already does on a store with a TTL. A failure in the tokens store, after the
345
+ sessions went, is `STORE_FAILED`; both deletions are idempotent, so call it
346
+ again.
336
347
 
337
348
  ## Testing expiry
338
349
 
@@ -168,7 +168,7 @@ and `schemaVersion`, with the defaults above. A login is unique **per type**:
168
168
  the same e-mail may hold a patient user and a staff user. A type name is
169
169
  camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
170
170
  `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
171
- `collectExpired`, `types`) — a compile error, and a `TypeError` for a
171
+ `collectExpired`, `types`, `changeEmail`) — a compile error, and a `TypeError` for a
172
172
  JavaScript caller.
173
173
 
174
174
  ## What each user type answers
package/docs/roadmap.md CHANGED
@@ -5,7 +5,7 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing between releases.
8
+ Nothing yet.
9
9
 
10
10
  ## Next
11
11
 
@@ -13,13 +13,6 @@ Nothing yet.
13
13
 
14
14
  ## Later
15
15
 
16
- - **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
17
- sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
18
- until you delete them, and the sign-in throttle adds one per login tried
19
- per window, and the mail throttle one per flow per address or user per
20
- window. A port method to collect them, as sessions are, would let the
21
- core do it.
22
-
23
16
  - **A batch read across types** — `findMany` reads one user type, as `find`
24
17
  does. The shared `findUser(id)` has no batch counterpart yet; one on the
25
18
  instance, answering a union narrowed by `user.type`, would serve a list that
@@ -76,6 +69,19 @@ Nothing yet.
76
69
  The last ten, newest first, each with the version it came in. Everything
77
70
  before is in the [CHANGELOG](../CHANGELOG.md).
78
71
 
72
+ - **Root `changeEmail.confirm` for instances with several user types, v0.26.0** —
73
+ `auth.changeEmail.confirm(token)` confirms a change of e-mail for a page that
74
+ does not know the user's type: it reads the user the link was issued to and
75
+ confirms as their type does. A type's own `confirm` still answers
76
+ `TOKEN_UNKNOWN`, and spends the token, for another type's link; no user type
77
+ may be named `changeEmail`.
78
+ - **Collecting lapsed one-time tokens, v0.25.0** — `collectExpired()` deletes
79
+ lapsed sessions and, through a new optional `TokenStore.deleteExpiredTokens`,
80
+ lapsed one-time tokens too: the sign-in and mail throttles' counts
81
+ included, which a store with no TTL (PostgreSQL) kept for ever, one per
82
+ login tried per window and one per flow per address or user per window.
83
+ The reference store and `@nxgt/janus-drizzle` 0.9 implement it; MongoDB and
84
+ Redis expire tokens themselves and do not.
79
85
  - **Finding a user, or creating them, v0.24.0** —
80
86
  `auth.<type>.findOrCreate(key, input)` answers `{ user, created }`: the
81
87
  holder of the key — the login on a type with a password, the e-mail on one
@@ -153,15 +159,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
153
159
  optional `findUsers`, implemented by the reference store,
154
160
  `@nxgt/janus-drizzle` 0.7.0 and `@nxgt/janus-mongo` 0.8.0; a store without it
155
161
  is read with `findUser`, ten at a time, so third-party adapters keep working.
156
- - **Requests that send e-mail are throttled, v0.18.0** —
157
- `janus({ mail: { throttle: { attempts, window } } })`, five per ten
158
- minutes by default and `false` to turn it off. `magicLink.request`,
159
- `signInCode.request` and `resetPassword.request` count per address;
160
- `verifyEmail.send` and an e-mailed `stepUp.request` count per user; each
161
- flow on its own. Past the limit a request throws `MailThrottledError`
162
- (`MAIL_THROTTLED`, 429) with `retryAfter` and issues, spends and rotates
163
- nothing: the last link or code sent still works, so a loop on an address
164
- cannot lock its owner out.
165
- The window is no longer than the shortest default token lifetime. The counts
166
- live in the tokens store, with no port change. `MailConfig` and
167
- `MailThrottleConfig` are exported.
@@ -75,7 +75,7 @@ How the messages are shaped:
75
75
  - [`TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has)
76
76
  - [`INVALID_CURSOR` — `<call>: this cursor was not minted by this store …`](#invalid_cursor--call-this-cursor-was-not-minted-by-this-store-or-was-minted-for-another-ordering-n-characters)
77
77
  - [`<call>: limit must be an integer of at least 1, or absent`](#call-limit-must-be-an-integer-of-at-least-1-or-absent)
78
- - [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions--its-store-expires-sessions-on-its-own-or-implement-the-method)
78
+ - [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions-nor-storetokens-deleteexpiredtokens--their-stores-expire-records-on-their-own-or-implement-the-methods)
79
79
  - [`<call>: the <type> type does not sign in with a password …`](#call-the-type-type-does-not-sign-in-with-a-password--add-password--login--to-it)
80
80
  - [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
81
81
 
@@ -297,10 +297,10 @@ janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryp
297
297
 
298
298
  ### `janus: store.<slot> has no method <method>, which the port requires`
299
299
 
300
- Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
300
+ Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.tokens.deleteExpiredTokens must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
301
301
 
302
302
  **When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
303
- **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Two methods are optional — `sessions.deleteExpiredSessions` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
303
+ **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Three methods are optional — `sessions.deleteExpiredSessions`, `tokens.deleteExpiredTokens` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
304
304
  An adapter written for an earlier `@nxgt/janus` reports the method a later
305
305
  release added to the port: `store.tokens has no method countAttempt` for one
306
306
  written against 0.3 (the method came in 0.4), and
@@ -366,8 +366,8 @@ janus({ user: z.object({ username: z.string() }), password: { login: 'username'
366
366
  Also `janus: "<name>" cannot name a user type — janus() answers a method of that name` and `janus: users declares no user type`.
367
367
 
368
368
  **When:** `janus({ users: {...} })`.
369
- **Why:** each type becomes a property of what `janus()` answers — `auth.staff` — so it must be a name, and not one of the shared methods (`authenticate`, `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`, `collectExpired`, `types`).
370
- **Fix:** `users: { staffMember: {...} }`, not `'staff-member'` or `cookie`.
369
+ **Why:** each type becomes a property of what `janus()` answers — `auth.staff` — so it must be a name, and not one of the shared methods (`authenticate`, `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`, `collectExpired`, `types`, `changeEmail`).
370
+ **Fix:** `users: { staffMember: {...} }`, not `'staff-member'`, `cookie` or `changeEmail`.
371
371
 
372
372
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
373
373
 
@@ -949,7 +949,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
949
949
  `TokenError`: `<call>: no such token`, `<call>: the token was already used`, `<call>: the token has expired`.
950
950
 
951
951
  **When:** `verifyEmail.confirm`, `resetPassword.confirm`, `changeEmail.confirm`.
952
- **Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted, and a change link confirmed through another user type's `changeEmail` or passed to another flow. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword` and for `changeEmail`. A change link is also spent by a newer `changeEmail.send` for the same user (only the last one works), and by an `update` that changes the user's e-mail.
952
+ **Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted, and a change link confirmed through another user type's `changeEmail` (which spends it, so the right type then answers `TOKEN_SPENT`) or passed to another flow. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword` and for `changeEmail`. A change link is also spent by a newer `changeEmail.send` for the same user (only the last one works), and by an `update` that changes the user's e-mail.
953
953
  A reset link answers `resetPassword.confirm: the token was already used` in
954
954
  two more cases: a newer `resetPassword.request` for the same user spent it —
955
955
  only the last link sent works, so a visitor who asked twice and clicked the
@@ -958,7 +958,9 @@ sent, by another link's `confirm`, `changePassword` or `setPassword`. When two
958
958
  requests race, even the last link can be spent: at most one survives,
959
959
  sometimes none.
960
960
  **Fix:** answer 400 and offer to send a new link — and tell the visitor to use
961
- the latest e-mail. To change the lifetimes:
961
+ the latest e-mail. With several user types, confirm a change link from a page
962
+ that does not know the type with the root's `auth.changeEmail.confirm(token)`,
963
+ never through a guessed type. To change the lifetimes:
962
964
 
963
965
  ```ts
964
966
  janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
@@ -1069,11 +1071,11 @@ const page = await access.list(user, 'view', 'record', {
1069
1071
  });
1070
1072
  ```
1071
1073
 
1072
- ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions — its store expires sessions on its own, or implement the method`
1074
+ ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods`
1073
1075
 
1074
1076
  **When:** `auth.collectExpired()`.
1075
- **Why:** the sessions store does not implement the optional `deleteExpiredSessions` — usually because it expires sessions itself (a MongoDB TTL index, a Redis `EXPIRE`).
1076
- **Fix:** do not schedule `collectExpired` for that store. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate`.
1077
+ **Why:** neither the sessions store implements the optional `deleteExpiredSessions` nor the tokens store the optional `deleteExpiredTokens` — usually because they expire records themselves (a MongoDB TTL index, a Redis `EXPIRE`). With one of the two, `collectExpired` collects that one and does not throw.
1078
+ **Fix:** do not schedule `collectExpired` for those stores. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate` and on every redemption. If your database has no TTL, implement the methods ([adapters](guide/adapters.md#tokenstoredeleteexpiredtokens-optional)).
1077
1079
 
1078
1080
  ### `<call>: the <type> type does not sign in with a password — add password: { login } to it`
1079
1081
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",