@nxgt/janus 0.7.0 → 0.8.1
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/README.md +56 -4
- package/dist/auth/config.d.ts +8 -0
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +4 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/email-flows.d.ts.map +1 -1
- package/dist/auth/events.d.ts +52 -0
- package/dist/auth/events.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/index.js +67 -12
- package/dist/index.js.map +10 -9
- package/docs/README.md +1 -0
- package/docs/guide/email-flows.md +5 -1
- package/docs/guide/events.md +199 -0
- package/docs/guide/sign-in-code.md +2 -1
- package/docs/guide/users.md +5 -1
- package/docs/guide/vocabulary.md +2 -0
- package/docs/roadmap.md +13 -10
- package/docs/troubleshooting.md +52 -0
- 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`
|
|
@@ -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
|
|
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
|
|
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
|
|
package/docs/guide/users.md
CHANGED
|
@@ -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`
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -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
|
@@ -5,7 +5,13 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
- **Webhooks** — in a package of its own, `@nxgt/janus-webhooks`: the user
|
|
9
|
+
events `janus({ events })` already hands over, signed and sent over HTTP,
|
|
10
|
+
so another service can follow without polling. A signature it can check —
|
|
11
|
+
the Standard Webhooks headers, HMAC-SHA256, secrets that rotate — retries
|
|
12
|
+
with backoff on failure, and retries that run out reported to a function
|
|
13
|
+
you give, never dropped in silence. The payload carries the event: the user
|
|
14
|
+
named by id, every key camelCase.
|
|
9
15
|
|
|
10
16
|
## Next
|
|
11
17
|
|
|
@@ -36,12 +42,6 @@ Nothing between releases.
|
|
|
36
42
|
catalogue, or replace any one template with your own function of the same
|
|
37
43
|
shape — built with the same toolkit, React Email or a plain string — and
|
|
38
44
|
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.
|
|
45
45
|
|
|
46
46
|
## Later
|
|
47
47
|
|
|
@@ -93,6 +93,12 @@ Nothing between releases.
|
|
|
93
93
|
The last ten, newest first, each with the version it came in. Everything
|
|
94
94
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
95
95
|
|
|
96
|
+
- **User events, v0.8.0** — `janus({ events })` takes one listener, called
|
|
97
|
+
with `user.created`, `user.emailVerified`, `user.passwordReset` and
|
|
98
|
+
`user.deleted` once the write landed, and awaited before the flow answers.
|
|
99
|
+
An event names the user by id alone, with a UUIDv7 of its own to deliver
|
|
100
|
+
it once; a listener that throws fails no flow and is a
|
|
101
|
+
`JANUS_EVENT_FAILED` warning.
|
|
96
102
|
- **At most one sign-in code live per user, and challenges that end when
|
|
97
103
|
they should, v0.7.0** — `signInCode.request` spends the codes sent before,
|
|
98
104
|
even when requests race, so only the last e-mail's works; writing a
|
|
@@ -158,6 +164,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
158
164
|
- **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
|
|
159
165
|
store and in both adapters; `error.login` still names it, and the
|
|
160
166
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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`
|