@nxgt/janus 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +107 -17
  2. package/dist/auth/config.d.ts +8 -0
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +10 -1
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/email-flows.d.ts +11 -0
  7. package/dist/auth/email-flows.d.ts.map +1 -0
  8. package/dist/auth/events.d.ts +52 -0
  9. package/dist/auth/events.d.ts.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/janus.d.ts.map +1 -1
  13. package/dist/auth/one-time.d.ts +7 -0
  14. package/dist/auth/one-time.d.ts.map +1 -1
  15. package/dist/auth/password-written.d.ts +24 -0
  16. package/dist/auth/password-written.d.ts.map +1 -0
  17. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  18. package/dist/auth/port/types.d.ts +23 -1
  19. package/dist/auth/port/types.d.ts.map +1 -1
  20. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  21. package/dist/auth/sign-in-code.d.ts +6 -3
  22. package/dist/auth/sign-in-code.d.ts.map +1 -1
  23. package/dist/auth/types.d.ts +6 -0
  24. package/dist/auth/types.d.ts.map +1 -1
  25. package/dist/auth/users.d.ts.map +1 -1
  26. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  27. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  28. package/dist/conformance/cases/outage.d.ts.map +1 -1
  29. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  30. package/dist/conformance/index.js +73 -2
  31. package/dist/conformance/index.js.map +4 -4
  32. package/dist/index.js +207 -95
  33. package/dist/index.js.map +14 -11
  34. package/docs/README.md +2 -1
  35. package/docs/guide/adapters.md +56 -3
  36. package/docs/guide/email-flows.md +11 -1
  37. package/docs/guide/events.md +199 -0
  38. package/docs/guide/second-factor.md +27 -2
  39. package/docs/guide/sign-in-code.md +28 -9
  40. package/docs/guide/users.md +8 -4
  41. package/docs/guide/vocabulary.md +3 -0
  42. package/docs/roadmap.md +25 -13
  43. package/docs/troubleshooting.md +89 -26
  44. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -16,6 +16,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
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
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 |
18
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 |
19
+ | [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password or is deleted — to queue it, sync it, or send it as a webhook |
19
20
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
20
21
 
21
22
  ### Permissions — `@nxgt/janus/permissions`
@@ -30,6 +31,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
30
31
  | --- | --- |
31
32
  | [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
33
  | [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 |
34
+ | [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
35
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
35
36
  | [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,11 @@ 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.
80
+ A confirm that verifies the e-mail sends a [`user.emailVerified`
81
+ event](events.md); one for an e-mail already verified sends nothing.
77
82
  Changing the e-mail with `update` sets `emailVerified` back to `false`.
78
83
 
79
84
  ## `resetPassword`
@@ -100,7 +105,11 @@ export async function forgotPassword(request: Request): Promise<Response> {
100
105
  ```
101
106
 
102
107
  `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
108
+ and **signs the user out everywhere**: their sessions are revoked, and every
109
+ second-factor challenge still open is spent, so a sign-in started with the
110
+ old password cannot be finished. The e-mail is checked again on the record
111
+ written, as for `verifyEmail`. It sends a [`user.passwordReset` event](events.md),
112
+ then `user.emailVerified` when the link verified the e-mail. It opens no session: call `signIn` next
104
113
  if that is your policy. A password refused for its length does not spend the
105
114
  token, so the visitor can try again with the same link.
106
115
 
@@ -147,4 +156,5 @@ never the token, and no refusal's message contains it.
147
156
  - [Users](users.md) — `email`, `update`, and the other per-type methods
148
157
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
149
158
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
159
+ - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
150
160
  - [Errors](errors.md) — every code, and the status it deserves
@@ -0,0 +1,199 @@
1
+ # User events
2
+
3
+ This page is for hearing what happens to a user once it is written: created,
4
+ e-mail verified, password reset, deleted. Another service can then follow
5
+ without polling. `janus` hands each event to one function you give it;
6
+ **delivering it is yours**, or `@nxgt/janus-webhooks`'s once it ships.
7
+
8
+ ```ts
9
+ import { z } from 'zod';
10
+ import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
11
+
12
+ const received: UserEvent[] = [];
13
+
14
+ const auth = janus({
15
+ user: z.object({ email: z.email() }),
16
+ password: { login: 'email' },
17
+ store: createMemoryStores(),
18
+ hasher: scryptHasher(),
19
+ events(event) {
20
+ received.push(event);
21
+ },
22
+ });
23
+
24
+ const { user } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
25
+ received[0];
26
+ // {
27
+ // id: '0199…', a UUIDv7 minted for this event
28
+ // type: 'user.created',
29
+ // occurredAt: Date, when the write landed — its createdAt here
30
+ // userId: user.id,
31
+ // userType: 'user',
32
+ // }
33
+ ```
34
+
35
+ The words — **user event**, **listener** — are defined in
36
+ [the vocabulary](vocabulary.md#identities).
37
+
38
+ ## The four types
39
+
40
+ | `type` | Sent by | Not sent |
41
+ | --- | --- | --- |
42
+ | `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
43
+ | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
44
+ | `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
45
+ | `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
46
+
47
+ A reset whose link verifies the e-mail sends both, `user.passwordReset`
48
+ first. Switch on `type`: the four are a closed set, and TypeScript refuses a
49
+ fifth.
50
+
51
+ ```ts
52
+ function onUserEvent(event: UserEvent): void {
53
+ switch (event.type) {
54
+ case 'user.created':
55
+ return welcome(event.userId);
56
+ case 'user.emailVerified':
57
+ return unlockFeatures(event.userId);
58
+ case 'user.passwordReset':
59
+ return alertSecurityTeam(event.userId);
60
+ case 'user.deleted':
61
+ return forgetEverywhere(event.userId);
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## What an event carries
67
+
68
+ **The user named by id, and nothing else.** No login, no e-mail, no field of
69
+ the schema, no password or hash, no session or one-time token. An event can
70
+ land in a queue, a log or another company's endpoint; whoever receives it
71
+ reads the rest from where it is kept — `auth.get(event.userId)` — if they
72
+ may. A user deleted since is `NOT_FOUND` there, which is the answer.
73
+
74
+ `id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
75
+ a queue job id, a primary key, a webhook's `webhook-id` header. Two events
76
+ never share one.
77
+
78
+ ## When the listener runs
79
+
80
+ After the write, **awaited**, before the flow answers. So a listener
81
+ that stores the event durably — a job queue, an outbox table — has done so
82
+ when `signUp` answers:
83
+
84
+ ```ts
85
+ const auth = janus({
86
+ ...config,
87
+ async events(event) {
88
+ await jobs.add(event.type, event, { jobId: event.id });
89
+ },
90
+ });
91
+ ```
92
+
93
+ Where the listener runs within a flow, and what an outage does to it:
94
+
95
+ | Flow | The listener runs | A store outage after the write |
96
+ | --- | --- | --- |
97
+ | `create` | after the insert — its last step | — |
98
+ | `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
99
+ | `verifyEmail.confirm` | after the write — the flow's last step | — |
100
+ | `signInCode.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
101
+ | `resetPassword.confirm` | **after** the sessions opened with the old password are revoked and the second-factor challenges left open are spent | fails the call; the events are sent all the same, from a `finally` |
102
+ | `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
103
+
104
+ So a listener that takes its time never leaves an old session alive after a
105
+ reset, and one that checks permissions on `user.deleted` finds the tuples
106
+ gone. A retry could not send a lost event either way: `delete` then finds
107
+ nobody, and the reset link is spent.
108
+
109
+ `occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
110
+ wrote, the time read just before the deletion — not when the listener ran.
111
+
112
+ It runs inline, so it costs every flow that sends an event: store the event
113
+ and return. Sending an HTTP request from the listener makes every sign-up as
114
+ slow as the slowest endpoint, and as fragile.
115
+
116
+ ## When the listener fails
117
+
118
+ **It fails no flow.** The write happened: answering `signUp` with an error
119
+ would tell the visitor their account does not exist when it does, and a retry
120
+ would hit `LOGIN_TAKEN`. So a listener that throws, or rejects, is a warning:
121
+
122
+ ```
123
+ (node:4242) [JANUS_EVENT_FAILED] Warning: janus: the events listener failed on user.created 0199… for user 0199…: Error
124
+ ```
125
+
126
+ It names the event's type, its `id` and the user's id — what it takes to send
127
+ it again — and the failure's name, never its message, which may hold anything.
128
+ Listen for it where you watch your process's health:
129
+
130
+ ```ts
131
+ process.on('warning', (warning) => {
132
+ if ((warning as { code?: string }).code === 'JANUS_EVENT_FAILED') {
133
+ metrics.increment('janus.events.failed');
134
+ logger.error(warning.message);
135
+ }
136
+ });
137
+ ```
138
+
139
+ ## What is not promised
140
+
141
+ **At most once, from the process that wrote.** The event is handed to the
142
+ listener after the write, in memory: a crash between the two loses it, and
143
+ nothing sends it later — there is no outbox in the store. What must never
144
+ miss one — a billing sync, a search index — also reads the users now and
145
+ then, and treats events as the fast path.
146
+
147
+ **No order across processes.** Two instances each hand their own events to
148
+ their own listener. `occurredAt` and the time-sorted `id` let a receiver put
149
+ them back in order.
150
+
151
+ ## In a test
152
+
153
+ ```ts
154
+ import { expect, it } from 'bun:test';
155
+ import { z } from 'zod';
156
+ import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';
157
+
158
+ it('tells the CRM about a sign-up', async () => {
159
+ const received: UserEvent[] = [];
160
+ const auth = janus({
161
+ user: z.object({ email: z.email() }),
162
+ password: { login: 'email' },
163
+ store: createMemoryStores(),
164
+ hasher: scryptHasher({ cost: 10 }),
165
+ events: (event) => void received.push(event),
166
+ });
167
+
168
+ const { user } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
169
+
170
+ expect(received).toEqual([expect.objectContaining({ type: 'user.created', userId: user.id })]);
171
+ });
172
+ ```
173
+
174
+ ## Signatures
175
+
176
+ ```ts
177
+ type UserEventType = 'user.created' | 'user.emailVerified' | 'user.passwordReset' | 'user.deleted';
178
+
179
+ interface UserEvent {
180
+ readonly id: Id; // a UUIDv7, the key to deliver it once
181
+ readonly type: UserEventType;
182
+ readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
183
+ readonly userId: Id;
184
+ readonly userType: string;
185
+ }
186
+
187
+ type UserEventListener = (event: UserEvent) => void | Promise<void>;
188
+
189
+ janus({ ..., events?: UserEventListener });
190
+ ```
191
+
192
+ `events` that is not a function is a `TypeError` when `janus()` is called —
193
+ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own).
194
+
195
+ ## See also
196
+
197
+ - [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
198
+ - [Users](users.md) — `create`, `signUp`, `delete`
199
+ - [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
@@ -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
 
@@ -238,7 +256,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
238
256
 
239
257
  A user whose `emailVerified` was `false` has it `true` once `confirm`
240
258
  succeeds, and their `version` moves: the code proves the address as a
241
- verification link would. A user already verified is not written.
259
+ verification link would, and a [`user.emailVerified` event](events.md) is
260
+ sent. A user already verified is not written, and nothing is sent.
242
261
 
243
262
  ### A second factor is still asked for
244
263
 
@@ -270,8 +289,8 @@ answers `SignedIn`.
270
289
  | Rejects with | When | What to do |
271
290
  | --- | --- | --- |
272
291
  | `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 |
292
+ | `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 |
293
+ | `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
294
  | `TOKEN_EXPIRED` | `expiresAt` has passed | request a new code |
276
295
  | `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
296
  | `USER_INACTIVE` | the user was deactivated since the code was sent. The challenge is spent | answer 403 |
@@ -96,6 +96,7 @@ that cuts an emoji in half, say.
96
96
  | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
97
97
  | `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
98
98
  | `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
99
+ | `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
99
100
 
100
101
  A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
101
102
  milliseconds.
@@ -185,10 +186,10 @@ With a `password`, besides:
185
186
  | Method | Answers | Rejects with |
186
187
  | --- | --- | --- |
187
188
  | `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` |
189
+ | `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
190
  | `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` |
191
+ | `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` |
192
+ | `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
193
 
193
194
  With `secondFactor` configured, a type with a password also answers
194
195
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
@@ -228,7 +229,9 @@ read before that sign-in conflicts — see [passwords](passwords.md#rehash-on-si
228
229
  Deletes the user **with every session and one-time token they had**, and
229
230
  every tuple naming them when `relations` is wired. The user goes first, so an
230
231
  outage half-way leaves only sessions and tokens that authenticate nobody. It is
231
- idempotent: calling it again finishes the job.
232
+ idempotent: calling it again finishes the job. When it deleted the user, it
233
+ sends a [`user.deleted` event](events.md) — once, after the sessions, tokens and relation tuples are gone, and even when an outage interrupts removing them;
234
+ `create` and `signUp` send `user.created`.
232
235
 
233
236
  ### Paging every user
234
237
 
@@ -285,5 +288,6 @@ fields against your schema, not that `password` is a string.
285
288
  ## See also
286
289
 
287
290
  - [Sessions](sessions.md) — `authenticate`, the cookie, signing out
291
+ - [User events](events.md) — what `create`, `signUp` and `delete` send
288
292
  - [Errors](errors.md) — every code, and the status it deserves
289
293
  - [Writing an adapter](adapters.md) — the identity stores behind `store`
@@ -51,10 +51,13 @@ 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 | |
57
58
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
59
+ | **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.deleted` — a `UserEvent`, naming the user by id alone, with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
60
+ | **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
58
61
 
59
62
  ### Permissions
60
63
 
package/docs/roadmap.md CHANGED
@@ -36,12 +36,13 @@ Nothing between releases.
36
36
  catalogue, or replace any one template with your own function of the same
37
37
  shape — built with the same toolkit, React Email or a plain string — and
38
38
  keep the defaults for the rest.
39
- - **Webhooks** — signed HTTP events when something happens to a user
40
- (created, e-mail verified, password reset, deleted), so another service can
41
- follow without polling: a signature it can check, retries on failure, and
42
- the same rule as the audit trail — the user named by id, never a login, a
43
- password, a session token or a one-time token in a payload, and every key
44
- camelCase. Retries that run out are reported, never dropped in silence.
39
+ - **Webhooks** — in a package of its own, `@nxgt/janus-webhooks`: the user
40
+ events `janus({ events })` already hands over, signed and sent over HTTP,
41
+ so another service can follow without polling. A signature it can check —
42
+ the Standard Webhooks headers, HMAC-SHA256, secrets that rotate — retries
43
+ with backoff on failure, and retries that run out reported to a function
44
+ you give, never dropped in silence. The payload is the event: the user
45
+ named by id, every key camelCase.
45
46
 
46
47
  ## Later
47
48
 
@@ -93,6 +94,24 @@ Nothing between releases.
93
94
  The last ten, newest first, each with the version it came in. Everything
94
95
  before is in the [CHANGELOG](../CHANGELOG.md).
95
96
 
97
+ - **User events, v0.8.0** — `janus({ events })` takes one listener, called
98
+ with `user.created`, `user.emailVerified`, `user.passwordReset` and
99
+ `user.deleted` once the write landed, and awaited before the flow answers.
100
+ An event names the user by id alone, with a UUIDv7 of its own to deliver
101
+ it once; a listener that throws fails no flow and is a
102
+ `JANUS_EVENT_FAILED` warning.
103
+ - **At most one sign-in code live per user, and challenges that end when
104
+ they should, v0.7.0** — `signInCode.request` spends the codes sent before,
105
+ even when requests race, so only the last e-mail's works; writing a
106
+ password spends the second-factor challenges left waiting, and a sign-in
107
+ still running when it lands is refused; another user type's `confirm` spends a challenge
108
+ at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
109
+ check the e-mail again on the record they write. `SecondFactorRequired`
110
+ carries `userId`, for logs and rate limits. For adapters:
111
+ `TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
112
+ cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
113
+ `@nxgt/janus-redis` — and the port now says a read sees every write that
114
+ completed before it: never a secondary or a read replica.
96
115
  - **Sign in with a code sent by e-mail, v0.6.0** —
97
116
  `auth.<type>.signInCode.request(email)` answers a six-digit code to send
98
117
  and a challenge to keep with the visitor, or `null` for nobody — never
@@ -146,10 +165,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
146
165
  - **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
147
166
  store and in both adapters; `error.login` still names it, and the
148
167
  conformance suite checks it. — v0.2.0
149
- - **The conformance suite accepts a store with its own expiry** — a store
150
- that drops a lapsed session at once, as a Redis TTL does, passes
151
- `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.