@nxgt/janus 0.7.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.
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`
@@ -77,6 +77,8 @@ is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
77
77
  The address is checked again on the very record the write replaces, so an
78
78
  e-mail changed while the link is being redeemed is `TOKEN_STALE` too, and
79
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.
80
82
  Changing the e-mail with `update` sets `emailVerified` back to `false`.
81
83
 
82
84
  ## `resetPassword`
@@ -106,7 +108,8 @@ export async function forgotPassword(request: Request): Promise<Response> {
106
108
  and **signs the user out everywhere**: their sessions are revoked, and every
107
109
  second-factor challenge still open is spent, so a sign-in started with the
108
110
  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
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
110
113
  if that is your policy. A password refused for its length does not spend the
111
114
  token, so the visitor can try again with the same link.
112
115
 
@@ -153,4 +156,5 @@ never the token, and no refusal's message contains it.
153
156
  - [Users](users.md) — `email`, `update`, and the other per-type methods
154
157
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
155
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
156
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`
@@ -256,7 +256,8 @@ janus({ ..., tokens: { signInCode: '15m' } });
256
256
 
257
257
  A user whose `emailVerified` was `false` has it `true` once `confirm`
258
258
  succeeds, and their `version` moves: the code proves the address as a
259
- 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.
260
261
 
261
262
  ### A second factor is still asked for
262
263
 
@@ -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.
@@ -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`
@@ -56,6 +56,8 @@ either finds this row.
56
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" |
57
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 | |
58
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" |
59
61
 
60
62
  ### Permissions
61
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,12 @@ 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.
96
103
  - **At most one sign-in code live per user, and challenges that end when
97
104
  they should, v0.7.0** — `signInCode.request` spends the codes sent before,
98
105
  even when requests race, so only the last e-mail's works; writing a
@@ -158,6 +165,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
158
165
  - **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
159
166
  store and in both adapters; `error.login` still names it, and the
160
167
  conformance suite checks it. — v0.2.0
161
- - **The conformance suite accepts a store with its own expiry** — a store
162
- that drops a lapsed session at once, as a Redis TTL does, passes
163
- `sessions.deleteUser`; one that still holds it must count it. — v0.2.0
@@ -39,6 +39,7 @@ How the messages are shaped:
39
39
  - [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
40
40
  - [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
41
41
  - [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
42
+ - [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
42
43
  - [Other `janus:` wiring messages](#other-janus-wiring-messages)
43
44
 
44
45
  **Users, sessions and tokens**
@@ -81,6 +82,10 @@ How the messages are shaped:
81
82
  - [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
82
83
  - `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
83
84
 
85
+ **User events**
86
+ - [`[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`](#janus_event_failed-warning-janus-the-events-listener-failed-on-type-event-id-for-user-user-id-name)
87
+ - [An event you expected never arrived](#an-event-you-expected-never-arrived)
88
+
84
89
  **Permissions**
85
90
  - [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
86
91
  - [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
@@ -360,6 +365,21 @@ janus({
360
365
  **Why:** `user.hasSecondFactor` is janus's own answer: whether the user's second factor is active. A field of yours with that name would be shadowed. From JavaScript, a validated input holding it is refused with `USER_INVALID` and the issue `set by janus, not by a request`.
361
366
  **Fix:** rename the field in your schema, and read `user.hasSecondFactor` for janus's answer.
362
367
 
368
+ ### `janus: events must be a function that takes a user event — webhooks({ … }) from @nxgt/janus-webhooks, or your own`
369
+
370
+ **When:** `janus({ events })` with something other than a function — most often an object of functions, one per event type.
371
+ **Why:** `events` is one listener, called with every user event; the event's `type` says which.
372
+ **Fix:** pass one function, and switch on `type`:
373
+
374
+ ```ts
375
+ janus({
376
+ ...config,
377
+ events(event) {
378
+ if (event.type === 'user.created') return welcome(event.userId);
379
+ },
380
+ });
381
+ ```
382
+
363
383
  ### Other `janus:` wiring messages
364
384
 
365
385
  | Message | Fix |
@@ -906,6 +926,38 @@ janus({
906
926
 
907
927
  ---
908
928
 
929
+ ## User events
930
+
931
+ ### `[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`
932
+
933
+ A process warning, not a thrown error: the flow that sent the event answered as if nothing happened.
934
+
935
+ **When:** the function given to `janus({ events })` threw or rejected — a queue that was down, a bug in the listener.
936
+ **Why:** the write the event reports has landed. Failing the flow would tell the visitor it did not happen, and their retry would hit `LOGIN_TAKEN`. So the failure is warned about, with the event's type, its id and the user's id, and the failure's name — never its message, which may hold anything.
937
+ **Fix:** make the listener only store the event (a queue, an outbox table) and fix whatever refused it. To send the lost event again, rebuild it from the warning — its type, its `id`, the user's id; the warning has no `occurredAt`, so take the user's `updatedAt` (or the warning's own time) as an approximation:
938
+
939
+ ```ts
940
+ process.on('warning', (warning) => {
941
+ if ((warning as { code?: string }).code === 'JANUS_EVENT_FAILED') logger.error(warning.message);
942
+ });
943
+ ```
944
+
945
+ ### An event you expected never arrived
946
+
947
+ **When:** a `user.emailVerified` after a confirm, a `user.deleted` after a delete, a `user.created` from a sign-up.
948
+ **Why:** one of these, in order of likelihood:
949
+ - the e-mail was already verified: nothing changed, so nothing is sent;
950
+ - `delete` deleted nobody — a replay, or an id of another user type;
951
+ - the flow was refused, or the store failed during the write itself — the call threw, and nothing was written or sent;
952
+ - the process stopped between the write and the listener — events are sent at most once, from memory;
953
+ - the listener threw: look for `JANUS_EVENT_FAILED` in the process's warnings.
954
+
955
+ **Fix:** for the last two, reconcile against the users themselves and treat events as the fast path, not the record: page through `auth.list()` and compare with the receiver's copy — a user it lacks is a missed `user.created`, a user the receiver has that `auth.find` answers `null` for is a missed `user.deleted`, and a user whose `emailVerified` differs is a missed `user.emailVerified`.
956
+
957
+ A store outage **after** the write does not lose the event. `create`, `signUp` and `signInCode.confirm` send it before the steps that follow; `delete` and `resetPassword.confirm` run theirs — removing the sessions, tokens and tuples; revoking the sessions — in a `try`, and send it from its `finally`, whether they succeeded or not.
958
+
959
+ ---
960
+
909
961
  ## Permissions
910
962
 
911
963
  ### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",