@nxgt/janus 0.24.0 → 0.25.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 (41) hide show
  1. package/README.md +24 -10
  2. package/dist/auth/outage.d.ts +2 -2
  3. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  4. package/dist/auth/port/memory/tokens.d.ts.map +1 -1
  5. package/dist/auth/port/types/stores.d.ts +2 -0
  6. package/dist/auth/port/types/stores.d.ts.map +1 -1
  7. package/dist/auth/port/types/tokens.d.ts +12 -0
  8. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  9. package/dist/auth/sessions/collect-expired.d.ts +14 -0
  10. package/dist/auth/sessions/collect-expired.d.ts.map +1 -0
  11. package/dist/auth/sessions/shared-api.d.ts +1 -1
  12. package/dist/auth/sessions/shared-api.d.ts.map +1 -1
  13. package/dist/auth/types/janus.d.ts +4 -3
  14. package/dist/auth/types/janus.d.ts.map +1 -1
  15. package/dist/chunks/{index-6bjfv4xv.js → index-q3w3vr69.js} +13 -3
  16. package/dist/chunks/{index-6bjfv4xv.js.map → index-q3w3vr69.js.map} +3 -3
  17. package/dist/chunks/{index-cntabs9d.js → index-rpp0bw6e.js} +2 -2
  18. package/dist/chunks/{index-kppkcbn5.js → index-spwh8b9z.js} +2 -5
  19. package/dist/chunks/{index-kppkcbn5.js.map → index-spwh8b9z.js.map} +2 -2
  20. package/dist/chunks/{index-4khsm7mw.js → index-y9b327j8.js} +18 -18
  21. package/dist/chunks/{index-4khsm7mw.js.map → index-y9b327j8.js.map} +5 -5
  22. package/dist/conformance/cases/outage.d.ts.map +1 -1
  23. package/dist/conformance/cases/tokens/deletion.d.ts.map +1 -1
  24. package/dist/conformance/describe.d.ts +1 -0
  25. package/dist/conformance/describe.d.ts.map +1 -1
  26. package/dist/conformance/index.js +39 -5
  27. package/dist/conformance/index.js.map +5 -5
  28. package/dist/conformance/types.d.ts +1 -1
  29. package/dist/conformance/types.d.ts.map +1 -1
  30. package/dist/index.js +19 -9
  31. package/dist/index.js.map +7 -6
  32. package/dist/permissions/index.js +3 -3
  33. package/docs/guide/adapters.md +40 -3
  34. package/docs/guide/email-flows.md +2 -2
  35. package/docs/guide/errors.md +1 -1
  36. package/docs/guide/passwords.md +7 -10
  37. package/docs/guide/sessions.md +15 -4
  38. package/docs/roadmap.md +8 -8
  39. package/docs/troubleshooting.md +6 -6
  40. package/package.json +1 -1
  41. /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
@@ -511,8 +511,8 @@ answers again. A refused request is counted too but cannot extend the wait.
511
511
  **Where the counts live.** In the tokens store, as `secondFactor` tokens named
512
512
  by a keyed hash of the flow and the address or user id: no port change, no new
513
513
  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
514
+ user per window. `collectExpired()` collects the lapsed ones on a tokens store that implements
515
+ `deleteExpiredTokens`; a store with a TTL expires them on its own. A flushed or evicting
516
516
  Redis forgets the counts, and a tokens store that cannot count throws
517
517
  `STORE_FAILED` with nothing issued: it fails closed.
518
518
 
@@ -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
 
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,13 @@ 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
+ - **Collecting lapsed one-time tokens, v0.25.0** — `collectExpired()` deletes
73
+ lapsed sessions and, through a new optional `TokenStore.deleteExpiredTokens`,
74
+ lapsed one-time tokens too: the sign-in and mail throttles' counts
75
+ included, which a store with no TTL (PostgreSQL) kept for ever, one per
76
+ login tried per window and one per flow per address or user per window.
77
+ The reference store and `@nxgt/janus-drizzle` 0.9 implement it; MongoDB and
78
+ Redis expire tokens themselves and do not.
79
79
  - **Finding a user, or creating them, v0.24.0** —
80
80
  `auth.<type>.findOrCreate(key, input)` answers `{ user, created }`: the
81
81
  holder of the key — the login on a type with a password, the e-mail on one
@@ -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
@@ -1069,11 +1069,11 @@ const page = await access.list(user, 'view', 'record', {
1069
1069
  });
1070
1070
  ```
1071
1071
 
1072
- ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions — its store expires sessions on its own, or implement the method`
1072
+ ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods`
1073
1073
 
1074
1074
  **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`.
1075
+ **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.
1076
+ **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
1077
 
1078
1078
  ### `<call>: the <type> type does not sign in with a password — add password: { login } to it`
1079
1079
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",