@nxgt/janus 0.20.0 → 0.22.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 (39) hide show
  1. package/README.md +112 -12
  2. package/dist/auth/context/index.d.ts +1 -1
  3. package/dist/auth/context/index.d.ts.map +1 -1
  4. package/dist/auth/context/user.d.ts +7 -0
  5. package/dist/auth/context/user.d.ts.map +1 -1
  6. package/dist/auth/email-flows/prove-first.d.ts +23 -0
  7. package/dist/auth/email-flows/prove-first.d.ts.map +1 -0
  8. package/dist/auth/email-flows/verify-email.d.ts +6 -2
  9. package/dist/auth/email-flows/verify-email.d.ts.map +1 -1
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/proven-sign-in.d.ts.map +1 -1
  13. package/dist/auth/types/email-flows.d.ts +43 -0
  14. package/dist/auth/types/email-flows.d.ts.map +1 -1
  15. package/dist/auth/types/find-by-email.d.ts +31 -0
  16. package/dist/auth/types/find-by-email.d.ts.map +1 -0
  17. package/dist/auth/types/index.d.ts +2 -1
  18. package/dist/auth/types/index.d.ts.map +1 -1
  19. package/dist/auth/types/janus.d.ts +2 -1
  20. package/dist/auth/types/janus.d.ts.map +1 -1
  21. package/dist/auth/users/any-type-api.d.ts +2 -2
  22. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  23. package/dist/auth/users/find-by-email.d.ts +11 -0
  24. package/dist/auth/users/find-by-email.d.ts.map +1 -0
  25. package/dist/auth/users/type-api.d.ts +1 -1
  26. package/dist/auth/users/type-api.d.ts.map +1 -1
  27. package/dist/index.js +74 -41
  28. package/dist/index.js.map +10 -8
  29. package/docs/README.md +2 -2
  30. package/docs/guide/email-flows.md +60 -1
  31. package/docs/guide/errors.md +3 -3
  32. package/docs/guide/events.md +4 -4
  33. package/docs/guide/magic-link.md +7 -2
  34. package/docs/guide/sessions.md +37 -6
  35. package/docs/guide/users.md +70 -0
  36. package/docs/guide/vocabulary.md +1 -1
  37. package/docs/roadmap.md +16 -15
  38. package/docs/troubleshooting.md +56 -6
  39. package/package.json +1 -1
package/docs/README.md CHANGED
@@ -11,9 +11,9 @@ below are defined once, in [Words](guide/vocabulary.md#words).
11
11
 
12
12
  | Page | Read it when |
13
13
  | --- | --- |
14
- | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
14
+ | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` — or finding a user by e-mail on a type with no password (`findByEmail`) |
15
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry — or sign in a user your application proved itself, such as on accepting an invitation (`signInProven`) |
16
- | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — and how requests that send e-mail are throttled per address or user |
16
+ | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — or proving an address on your own proof, such as an invitation (`verifyEmail.proveFirst`) — and how requests that send e-mail are throttled per address or user |
17
17
  | [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
18
18
  | [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 |
19
19
  | [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, recovery codes for a lost phone, disabling |
@@ -111,6 +111,7 @@ with or without a password — see [sign-in codes](sign-in-code.md) and
111
111
  readonly verifyEmail: {
112
112
  send(user: UserRef): Promise<IssuedToken>; // { token, email, expiresAt }
113
113
  confirm(token: string): Promise<User>;
114
+ proveFirst(user: UserRef): Promise<FirstProof<User>>; // { user, proved } — privileged, below
114
115
  };
115
116
  ```
116
117
 
@@ -130,6 +131,64 @@ Changing the e-mail with `update` sets `emailVerified` back to `false` — and
130
131
  the next sign-in by code or link then [drops the password and the second
131
132
  factor, and signs out every session](magic-link.md#an-account-someone-else-registered).
132
133
 
134
+ ## Proving an e-mail on your own proof — `proveFirst`
135
+
136
+ `verifyEmail.confirm` proves an address and changes nothing else: the user
137
+ asked for the link from inside their own account. A first sign-in by link
138
+ or code goes further, since anybody may have signed up with the address
139
+ before its holder ([why](magic-link.md#an-account-someone-else-registered)).
140
+ `verifyEmail.proveFirst(user)` applies that same first proof when the proof
141
+ is **yours** — an invitation token your server mailed to the address, and
142
+ the person followed:
143
+
144
+ ```ts
145
+ interface FirstProof<User> {
146
+ readonly user: User; // after the call: emailVerified is true either way
147
+ readonly proved: boolean; // true: this call proved it; false: it was already proved
148
+ }
149
+
150
+ const { user, proved } = await auth.verifyEmail.proveFirst(invited);
151
+ ```
152
+
153
+ On an e-mail **never proved**, it is exactly the ending of
154
+ `magicLink.confirm` and `signInCode.confirm` — the same code runs:
155
+
156
+ - one write, under the version it read, marks the e-mail verified and drops
157
+ the password and the second factor, active or waiting, with its recovery
158
+ codes;
159
+ - every session is revoked, and the reset links and second-factor
160
+ challenges are spent — before the write, so an outage there leaves the
161
+ e-mail unproved and a retry runs it all again, and after it, so a sign-in
162
+ landing in between is ended too;
163
+ - `user.emailVerified` is sent, then `user.passwordChanged` when a password
164
+ was dropped and `user.secondFactorDisabled` when an active factor was.
165
+
166
+ On an e-mail **already proved**, nothing is written, revoked or sent, and
167
+ `proved` is `false`. Whether it was proved is read off the user, as the
168
+ confirms read it: `emailVerified`.
169
+
170
+ **Call it before `setPassword` and before `signInProven`.** It drops the
171
+ password and revokes every session, so in the other order it erases the
172
+ password the invitee just chose, and signs out the session just opened.
173
+ Accepting an invitation is `findByEmail` (or `create`), `proveFirst`,
174
+ `setPassword` for a type with a password, then `signInProven` —
175
+ [sessions](sessions.md#accepting-an-invitation) has the route in full.
176
+
177
+ **It is privileged.** janus checks no credential: call it only right after
178
+ your server verified a proof that reached the inbox — an invitation token
179
+ it mailed to that address — on the user the proof names, whose current
180
+ e-mail is the address it reached. Never with an id a request supplied:
181
+ whoever chooses it can drop anybody's password and second factor.
182
+
183
+ It sends no e-mail, issues no token, and counts in neither the mail
184
+ throttle nor the sign-in throttle. An inactive user is `USER_INACTIVE`; a
185
+ malformed id, an unknown one or one of another type is `NOT_FOUND`, as for
186
+ `signInProven`; a user written between the read and the write is
187
+ `VERSION_CONFLICT`, and nothing is proved; an outage is `STORE_FAILED`.
188
+ `@nxgt/janus-telemetry` traces it as `janus.<type>.verifyEmail.proveFirst`
189
+ and writes `janus.email.verified` with `janus.email.proveFirst: true` when
190
+ it proved — the user's id, never the address.
191
+
133
192
  ## `resetPassword`
134
193
 
135
194
  ```ts
@@ -506,5 +565,5 @@ never the token, and no refusal's message contains it.
506
565
  - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
507
566
  - [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
508
567
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
509
- - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
568
+ - [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms and `proveFirst` send
510
569
  - [Errors](errors.md) — every code, and the status it deserves
@@ -49,15 +49,15 @@ that wired the library, so no handler needs to tell it apart.
49
49
  | Code | Class | Status | When | Carries |
50
50
  | --- | --- | --- | --- | --- |
51
51
  | `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
52
- | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
52
+ | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, `verifyEmail.proveFirst`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
53
53
  | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `login`, `userType` |
54
- | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session | `expectedVersion`, `actualVersion` |
54
+ | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session. From `verifyEmail.proveFirst`, the user was written between its read and its write: nothing was proved — call it again | `expectedVersion`, `actualVersion` |
55
55
  | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
56
56
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
57
57
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
58
58
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
59
59
  | `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` only for the per-user flows. No `reason` |
60
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` | `userId` |
60
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` and `verifyEmail.proveFirst` | `userId` |
61
61
  | `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
62
62
  | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
63
63
  | `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — a recovery code not the user's, or already used — see [recovery codes](second-factor.md#signing-in-with-one) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm` and from `recover`: what the challenge has left, `0` once it is spent; from `regenerateRecoveryCodes`, what the user's 15-minute window has left, `0` past the fifth — see [regenerating them](second-factor.md#regenerating-them). None from `activate`. `userId` |
@@ -44,12 +44,12 @@ The words — **user event**, **listener** — are defined in
44
44
  | `type` | Sent by | Not sent |
45
45
  | --- | --- | --- |
46
46
  | `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`) |
47
- | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
47
+ | `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
48
48
  | `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
49
- | `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
49
+ | `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
50
50
  | `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
51
51
  | `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
52
- | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
52
+ | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
53
53
  | `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
54
54
  | `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
55
55
  | `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` and `signInProven`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
@@ -245,7 +245,7 @@ Where the listener runs within a flow, and what an outage does to it:
245
245
  | `verifyEmail.confirm` | after the write — the flow's last step | — |
246
246
  | `update` | after the write — the flow's last step | — |
247
247
  | `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
248
- | `signInCode.confirm`, `magicLink.confirm` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code or link proves the e-mail again |
248
+ | `signInCode.confirm`, `magicLink.confirm`, `verifyEmail.proveFirst` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code, link or `proveFirst` proves the e-mail again |
249
249
  | `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
250
250
  | `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
251
251
  | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm`, `signInProven` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
@@ -58,7 +58,10 @@ clinic.staff.magicLink;
58
58
  token it issued and they followed — call
59
59
  [`auth.signInProven(userId)`](sessions.md#signing-in-on-your-own-proof--signinproven):
60
60
  the same sign-in, second factor included, with no e-mail sent, no mail
61
- throttle counted and no earlier link spent. A `request` made only to be
61
+ throttle counted and no earlier link spent. Call
62
+ [`verifyEmail.proveFirst`](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst)
63
+ before it: the invitation reached the inbox, and a first proof drops what
64
+ somebody who signed up with the address left on it, as a link does. A `request` made only to be
62
65
  confirmed at once is counted per address, so five anonymous requests for the
63
66
  invitee's address would make the acceptance answer `MAIL_THROTTLED`.
64
67
 
@@ -307,7 +310,9 @@ their own phone. When Ada later signs in by link, `confirm` proves an e-mail
307
310
  never verified, and **drops the password and the second factor, and signs
308
311
  out every session** before it opens hers — as a
309
312
  [password reset](email-flows.md) does. The same holds for a
310
- [code](sign-in-code.md).
313
+ [code](sign-in-code.md), and for
314
+ [`verifyEmail.proveFirst`](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst),
315
+ which applies this proof when the proof is your own — an invitation.
311
316
 
312
317
  - In the write that proves the e-mail, the password is removed
313
318
  (`hasPassword` turns `false`), and so is the second factor, active or
@@ -226,22 +226,33 @@ import { createHash, randomBytes } from 'node:crypto';
226
226
 
227
227
  const hash = (token: string) => createHash('sha256').update(token).digest('base64url');
228
228
 
229
- // Inviting: create the user, then mail a link of yours — nothing of janus's is sent.
229
+ // Inviting: mail a link of yours to the address — nothing of janus's is sent.
230
230
  export async function invite(email: string) {
231
- const user = await auth.create({ email, name: '' }); // no password: they will never need one
232
231
  const token = randomBytes(32).toString('base64url');
233
- await invitations.insert({ hash: hash(token), userId: user.id, expiresAt: inSevenDays() });
232
+ await invitations.insert({ hash: hash(token), email, expiresAt: inSevenDays() });
234
233
  await sendMail(email, `Join us: https://app.example/invitation?token=${token}`);
235
234
  }
236
235
 
237
236
  // Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
238
237
  export async function accept(request: Request): Promise<Response> {
239
- const { token } = (await request.json()) as { token: string };
238
+ const body = (await request.json()) as { token: string; password?: string };
239
+ const { token } = body;
240
240
  // One conditional write: spent once, and only while it has not expired.
241
241
  const invitation = await invitations.spend(hash(String(token)), new Date());
242
242
  if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
243
243
 
244
- const result = await auth.signInProven(invitation.userId, { device: deviceOf(request) });
244
+ // 1. The address is the invitation's, never the request's. The person may already have an
245
+ // account — findByEmail counts nothing, sends nothing — or gets one, with no password.
246
+ const user =
247
+ (await auth.findByEmail(invitation.email)) ??
248
+ (await auth.create({ email: invitation.email, name: '' }));
249
+ // 2. The invitation reached the inbox: prove the e-mail. On an address never proved this
250
+ // drops whatever someone who signed up with it set — password, second factor, sessions.
251
+ await auth.verifyEmail.proveFirst(user);
252
+ // 3. Only now the invitee's own password, if your form asks for one: proveFirst would drop it.
253
+ if (typeof body.password === 'string') await auth.setPassword(user, body.password);
254
+ // 4. Last: proveFirst revokes every session, so the new one opens after it.
255
+ const result = await auth.signInProven(user, { device: deviceOf(request) });
245
256
  if (result.status === 'secondFactor') {
246
257
  // keep result.challenge for the next request — never in a URL — and ask for a code
247
258
  return Response.json({ next: 'code' });
@@ -253,6 +264,25 @@ export async function accept(request: Request): Promise<Response> {
253
264
  }
254
265
  ```
255
266
 
267
+ **The order is the recipe.** `verifyEmail.proveFirst` (since 0.22) applies
268
+ the first proof of the e-mail that a sign-in link would: on an address
269
+ never proved, somebody may have signed up with it first, with a password
270
+ and a second factor of their own, and `signInProven` alone would leave them
271
+ both — and that person's sessions — in place. It drops them and revokes
272
+ every session, so it comes **before** `setPassword`, which would otherwise
273
+ be erased, and **before** `signInProven`, whose session would otherwise be
274
+ revoked. On an address already proved it does nothing. It is privileged as
275
+ `signInProven` is: call it on the user your verified invitation names,
276
+ never on an id from the request
277
+ ([e-mail flows](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst)).
278
+
279
+ **`findByEmail` works on every type with an e-mail, password or not** —
280
+ since 0.21. A type with no password has no `findByLogin`, and must not
281
+ declare one just to be looked up: that would open the password and reset
282
+ flows on it. It answers whether an account exists, so call it only here,
283
+ after the invitation was verified, on the address the invitation holds
284
+ ([users](users.md#finding-a-user-by-e-mail--findbyemail)).
285
+
256
286
  **Before 0.20 this was `magicLink.request(email)` then `magicLink.confirm`,
257
287
  at once — use `signInProven` instead.** `request` is counted against the
258
288
  mail throttle per address, five per 10 minutes by default: anybody asking
@@ -278,7 +308,8 @@ a link or a code already in the person's inbox still works afterwards.
278
308
  `TypeError`, as on every sign-in.
279
309
  - **No e-mail proved.** `emailVerified` stays as it was: the call does not
280
310
  know your proof reached the inbox. Nothing is dropped either — unlike a
281
- first proof by link or code, the password and second factor stay.
311
+ first proof by link or code, the password and second factor stay. When
312
+ your proof did reach the inbox, call `verifyEmail.proveFirst` first.
282
313
  - **No throttle.** Neither the mail throttle nor the sign-in throttle counts
283
314
  it, and it restarts neither: a login past its password attempts is still
284
315
  throttled for `signIn`. Limit your own acceptance route per client, as
@@ -198,6 +198,12 @@ With a `password`, besides:
198
198
  | `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
199
199
  | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
200
200
 
201
+ With an e-mail — the field `email` names, password or not — besides:
202
+
203
+ | Method | Answers | Rejects with |
204
+ | --- | --- | --- |
205
+ | `findByEmail(email)` | the user, or `null`; the address trimmed and lowercased as the e-mail flows do, whatever the login; a value that is no address is nobody's — **server-side only**, see [below](#finding-a-user-by-e-mail--findbyemail) | `STORE_FAILED` only |
206
+
201
207
  With `secondFactor` configured, a type with a password also answers
202
208
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
203
209
  [the second factor](second-factor.md).
@@ -216,6 +222,7 @@ await auth.get(grace.id); // User, or NOT_FOUND
216
222
  const renamed = await auth.update(grace, { name: 'Grace Hopper' }, { ifVersion: grace.version });
217
223
  await auth.setActive(renamed, true);
218
224
  await auth.findByLogin(' GRACE@example.com '); // found: normalised as sign-up normalised it
225
+ await auth.findByEmail(' GRACE@example.com '); // found: normalised as the e-mail flows normalise it
219
226
  await auth.setPassword(renamed, 'a new password');
220
227
  await auth.changePassword(renamed, { current: 'a new password', next: 'another password' });
221
228
  await auth.delete(renamed); // true
@@ -298,6 +305,69 @@ const staffById = new DataLoader(async (ids: readonly string[]) => {
298
305
  });
299
306
  ```
300
307
 
308
+ ### Finding a user by e-mail — `findByEmail`
309
+
310
+ ```ts
311
+ findByEmail(email: string): Promise<User | null>
312
+ // typed by FindByEmailApi<User>, exported from @nxgt/janus
313
+ ```
314
+
315
+ Every type with an e-mail has it, **with or without a password** — and a
316
+ type with no password has no `findByLogin`. It is the lookup an invitation
317
+ needs: the person was invited at an address, may already hold an account of
318
+ a passwordless type, and is signed in with `signInProven` once your server
319
+ verified the invitation and `verifyEmail.proveFirst` proved the address
320
+ ([sessions](sessions.md#accepting-an-invitation)):
321
+
322
+ ```ts
323
+ const clinic = janus({
324
+ users: {
325
+ patient: { schema: Patient, password: { login: 'email' } },
326
+ member: { schema: z.object({ email: z.email(), name: z.string() }) }, // no password, by design
327
+ },
328
+ store,
329
+ hasher,
330
+ });
331
+
332
+ const invitation = await invitations.spend(hash(token), new Date()); // your proof, verified first
333
+ if (invitation === null) return notFound();
334
+ const member =
335
+ (await clinic.member.findByEmail(invitation.email)) ??
336
+ (await clinic.member.create({ email: invitation.email, name: '' }));
337
+ await clinic.member.verifyEmail.proveFirst(member); // before signInProven: it revokes every session
338
+ await clinic.member.signInProven(member);
339
+ ```
340
+
341
+ Do not declare a `password` on such a type to get `findByLogin`: it would
342
+ open `signIn`, `resetPassword` and the password changes on a type that has
343
+ none by design.
344
+
345
+ - **The holder the e-mail flows find.** The address is trimmed and
346
+ lowercased — the rule every e-mail follows, whatever the type's
347
+ `password.normalize` — and compared with the stored e-mail. On a type that
348
+ signs in with a username, the user is found by their e-mail, and a
349
+ username that only looks like an address is nobody's:
350
+ `magicLink.request`, `signInCode.request` and `resetPassword.request`
351
+ would find the same user, or nobody.
352
+ - **Per type.** The same address may hold a patient and a member; each
353
+ type answers its own, and `null` for the other's.
354
+ - **Inactive users are answered**, as by `find`. `signInProven` then
355
+ refuses them with `USER_INACTIVE`.
356
+ - **Nothing counted, issued or written.** Neither the mail throttle nor the
357
+ sign-in throttle counts it, so it answers past both, and leaves both as
358
+ they were. The store is read once, by `findUserByLogin`.
359
+ - **A value that is no address is nobody's.** Not a string, empty once
360
+ trimmed, or holding a NUL or a lone surrogate: `null` without asking the
361
+ store — as `find` answers a malformed id, never a throw, since the value
362
+ may come from a request.
363
+ - **An outage is `STORE_FAILED`**, never `null`: a lookup that could not
364
+ ask is not an absence.
365
+ - **Server-side only.** It answers whether an account exists, at once and
366
+ with no throttle. Never call it on an address a visitor typed into an
367
+ anonymous route; call it on the address your own proof holds.
368
+ `@nxgt/janus-telemetry` traces it as `janus.<type>.findByEmail`, with the
369
+ `user.id` it found and never the address.
370
+
301
371
  ### Paging every user
302
372
 
303
373
  ```ts
@@ -43,7 +43,7 @@ either finds this row.
43
43
  | **credential** | What a caller presents to prove who they are: a login and a password at sign-in (`CredentialError`, `CREDENTIALS_INVALID`), or a token on a request — always written *session credential* | |
44
44
  | **password policy** | The rules a new password must meet: `minLength`, `normalize` | the model |
45
45
  | **session** | A signed-in period, carried by a **session token** | |
46
- | **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out, a password reset, or the first proof of an e-mail by a code or a link; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
46
+ | **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out, a password reset, or the first proof of an e-mail by a code, a link or `verifyEmail.proveFirst`; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
47
47
  | **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
48
48
  | **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
49
49
  | **one-time token** | A single-use token sent by e-mail in a link, for verification, a password reset or a sign-in | "code": a code is typed, a token is followed in a link |
package/docs/roadmap.md CHANGED
@@ -76,6 +76,22 @@ Nothing yet.
76
76
  The last ten, newest first, each with the version it came in. Everything
77
77
  before is in the [CHANGELOG](../CHANGELOG.md).
78
78
 
79
+ - **Proving an e-mail on the application's own proof, v0.22.0** —
80
+ `auth.<type>.verifyEmail.proveFirst(user)` applies the first proof of an
81
+ e-mail that a sign-in link or code applies — verified, the password and
82
+ the second factor dropped, every session revoked — when the proof is the
83
+ application's, such as an invitation token mailed to the address. An
84
+ e-mail already proved changes nothing; the answer says which,
85
+ `{ user, proved }`. Nothing is counted, sent or issued. An invitation is
86
+ accepted in this order: find or create, `proveFirst`, `setPassword`,
87
+ `signInProven`. Privileged: never with an id a request supplied.
88
+ - **Finding a user by e-mail on every type, v0.21.0** —
89
+ `auth.<type>.findByEmail(email)` answers the user of the type holding the
90
+ address, or `null`, on every type with an e-mail — a type with no
91
+ password included, which has no `findByLogin`. The address is normalised
92
+ and compared as the e-mail flows do; nothing is counted, issued or
93
+ written. It is the lookup before `signInProven` when an invitation holds
94
+ an address. Server-side only: it tells whether an account exists.
79
95
  - **Signing in on the application's own proof, v0.20.0** —
80
96
  `auth.<type>.signInProven(user, { device })` signs in a user the
81
97
  application proved itself — an invitation token it issued — and answers
@@ -151,18 +167,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
151
167
  `signIn: { throttle: false }` turns it off. A tokens store that cannot
152
168
  count fails the sign-in with `STORE_FAILED`. No change for adapters: the
153
169
  counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
154
- - **A password or an e-mail changed, in the user events, v0.14.0** —
155
- `user.passwordChanged`, sent by `changePassword` and `setPassword` once the
156
- older reset links are spent (a reset stays `user.passwordReset` alone), and
157
- `user.emailChanged`, sent by an `update` that changed the e-mail, carrying
158
- `formerEmail` — the one event with more than the user's id — so a notice
159
- can reach the inbox the account just left, as `@nxgt/janus-mail`'s
160
- `passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
161
- that exhausts `UserEventType`.
162
- - **An old reset link stops working, v0.13.0** — `resetPassword.request`
163
- spends the links sent before, so only the last e-mail's works, and writing
164
- a password — a link's `confirm`, `changePassword`, `setPassword` — spends
165
- every reset link still live, so a link sent before the change cannot
166
- replace the new password. Both answer `TOKEN_SPENT`; an outage spending
167
- them fails the call with `STORE_FAILED`. No change for adapters: the
168
- existing `TokenStore.spendUserTokens` does it.
@@ -53,6 +53,7 @@ How the messages are shaped:
53
53
  - [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
54
54
  - [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
55
55
  - [`findMany` answers fewer users than ids](#findmany-answers-fewer-users-than-ids)
56
+ - [`findByEmail` answers `null` for an address somebody holds](#findbyemail-answers-null-for-an-address-somebody-holds)
56
57
  - [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
57
58
  - [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
58
59
  - [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
@@ -62,6 +63,7 @@ How the messages are shaped:
62
63
  - [`CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login …`](#credentials_invalid--call-too-many-passwords-tried-at-this-login--wait-for-the-next-window)
63
64
  - [`MAIL_THROTTLED` — `<call>: too many e-mails asked for this address …`, or `… for this user …`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window)
64
65
  - [Accepting an invitation answers `MAIL_THROTTLED`](#accepting-an-invitation-answers-mail_throttled)
66
+ - [The password chosen when accepting an invitation answers `CREDENTIALS_INVALID` (`noPassword`)](#the-password-chosen-when-accepting-an-invitation-answers-credentials_invalid-nopassword)
65
67
  - [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
66
68
  - [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
67
69
  - [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
@@ -584,9 +586,22 @@ const byId = new Map((await auth.staff.findMany(ids)).map((user) => [user.id, us
584
586
  const member = byId.get(row.staffId); // undefined: nobody of this type holds it
585
587
  ```
586
588
 
589
+ ### `findByEmail` answers `null` for an address somebody holds
590
+
591
+ **When:** `auth.<type>.findByEmail(email)` on an address you can see on a user.
592
+ **Why:** one of three. The user is of **another type**: each type answers its own users, and the same address may hold one of each. The address is the type's **login, not its e-mail**: on a type that signs in with a username, only the field `email` names is looked up, and a username that looks like an address is nobody's. Or the value is **no address**: not a string — a `{ email }` object from JavaScript, an `undefined` read off a record — empty once trimmed, or holding a NUL or a lone surrogate; it answers `null` without asking the store, as `find` answers a malformed id.
593
+ **Fix:** ask the type the user is of, pass the e-mail itself, and for a username use `findByLogin`:
594
+
595
+ ```ts
596
+ const member = await clinic.member.findByEmail(invitation.email); // a string: the address the invitation holds
597
+ const staff = await clinic.staff.findByLogin('grace'); // a username is a login, not an e-mail
598
+ ```
599
+
600
+ An outage is never `null` here: it rejects with `STORE_FAILED`.
601
+
587
602
  ### `NOT_FOUND` — `<call>: no <type> has this id`
588
603
 
589
- `NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
604
+ `NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `verifyEmail.proveFirst`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
590
605
 
591
606
  **When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
592
607
  **Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead. `signInProven` is a `get`: a sign-in that found nobody throws, and on it `NOT_FOUND` means the proof you verified names a user deleted since, or you passed the id of another type's user — or a value that is not the proof's id at all.
@@ -617,6 +632,13 @@ const user = await auth.get(id);
617
632
  await auth.update(user, { name }, { ifVersion: user.version });
618
633
  ```
619
634
 
635
+ **From `verifyEmail.proveFirst`**, with no `ifVersion` of yours: the user
636
+ was written between the read that found the e-mail never proved and the
637
+ write that proves it — by the user, or by a second acceptance of the same
638
+ invitation. Nothing was proved, though the sessions may already be revoked.
639
+ Call it again: it reads the user anew, and answers `proved: false` when the
640
+ other write proved the e-mail.
641
+
620
642
  **From `secondFactor.confirm`**, the message is the store's:
621
643
  `updateUser: expected version <n>, found <m>`.
622
644
 
@@ -775,19 +797,47 @@ sent still works, though the invitee was sent none.
775
797
  loop from filling an inbox. A sign-in your server already proved — the
776
798
  invitation token it issued — does not need an e-mailed link, and should not
777
799
  share its count.
778
- **Fix:** call `signInProven` with the id from the invitation's record, after
800
+ **Fix:** call `signInProven` with the user from the invitation's record, after
779
801
  verifying the token yourself. It counts nothing, sends nothing, spends no
780
- link already sent, and answers what `signIn` answers.
802
+ link already sent, and answers what `signIn` answers. An invitation that
803
+ holds an address rather than an id finds the account with `findByEmail`,
804
+ which counts nothing either — on any type with an e-mail, password or not.
781
805
 
782
806
  ```ts
783
807
  const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
784
808
  if (invitation === null) return new Response(null, { status: 404 });
785
- const result = await auth.signInProven(invitation.userId, { device: deviceOf(request) });
809
+ const user = await auth.findByEmail(invitation.email); // or invitation.userId, when you stored the id
810
+ if (user === null) return new Response(null, { status: 404 });
811
+ await auth.verifyEmail.proveFirst(user); // the invitation reached the inbox — before signInProven
812
+ const result = await auth.signInProven(user, { device: deviceOf(request) });
786
813
  ```
787
814
 
788
815
  Never pass it an id the request carries: it checks no credential. See
789
816
  [sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
790
817
 
818
+ ### The password chosen when accepting an invitation answers `CREDENTIALS_INVALID` (`noPassword`)
819
+
820
+ **When:** the acceptance route calls `setPassword` with the password the
821
+ invitee chose, then `verifyEmail.proveFirst`; the invitee is signed in, and
822
+ their next `signIn` with that password answers `CREDENTIALS_INVALID` with
823
+ `reason: 'noPassword'`. Or the route calls `signInProven` before
824
+ `proveFirst`, and the session it just opened is anonymous on the next
825
+ request.
826
+ **Why:** on an e-mail never proved, `proveFirst` drops the password and the
827
+ second factor and revokes every session — whatever was set before it is
828
+ taken for what somebody who signed up with the address left there. It
829
+ cannot tell the invitee's fresh password from a squatter's.
830
+ **Fix:** prove first, then set the password, then sign in:
831
+
832
+ ```ts
833
+ const user = (await auth.findByEmail(invitation.email)) ?? (await auth.create({ email: invitation.email, name }));
834
+ await auth.verifyEmail.proveFirst(user); // 1. drops what was there before
835
+ await auth.setPassword(user, body.password); // 2. the invitee's own
836
+ const result = await auth.signInProven(user, { device: deviceOf(request) }); // 3. last
837
+ ```
838
+
839
+ See [e-mail flows](guide/email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst).
840
+
791
841
  ### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
792
842
 
793
843
  **When:** the application runs `magicLink.request`, `signInCode.request` or
@@ -867,7 +917,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
867
917
 
868
918
  ### `USER_INACTIVE` — `<call>: the user is inactive`
869
919
 
870
- **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven`, for an inactive user: the application's proof does not reactivate anybody.
920
+ **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
871
921
  **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
872
922
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
873
923
 
@@ -1006,7 +1056,7 @@ const page = await access.list(user, 'view', 'record', {
1006
1056
 
1007
1057
  **When:** `signIn`, `findByLogin`, `setPassword`, `changePassword`, `resetPassword.*`, from JavaScript. In TypeScript, these methods are absent from a type without `password`.
1008
1058
  **Why:** the user type has no `password` option.
1009
- **Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }`.
1059
+ **Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }` — when the type should sign in with a password. To **find** a user of a type with no password, call `findByEmail(email)` instead of `findByLogin`: declaring a password only to look users up opens `signIn` and `resetPassword` on a type that has none by design.
1010
1060
 
1011
1061
  ### `authenticate()` answers `null` although a valid cookie was sent
1012
1062
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",