@nxgt/janus 0.21.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.
package/docs/README.md CHANGED
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
13
13
  | --- | --- |
14
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
@@ -235,16 +235,23 @@ export async function invite(email: string) {
235
235
 
236
236
  // Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
237
237
  export async function accept(request: Request): Promise<Response> {
238
- const { token } = (await request.json()) as { token: string };
238
+ const body = (await request.json()) as { token: string; password?: string };
239
+ const { token } = body;
239
240
  // One conditional write: spent once, and only while it has not expired.
240
241
  const invitation = await invitations.spend(hash(String(token)), new Date());
241
242
  if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
242
243
 
243
- // The address is the invitation's, never the request's. The person may already have an
244
+ // 1. The address is the invitation's, never the request's. The person may already have an
244
245
  // account — findByEmail counts nothing, sends nothing — or gets one, with no password.
245
246
  const user =
246
247
  (await auth.findByEmail(invitation.email)) ??
247
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.
248
255
  const result = await auth.signInProven(user, { device: deviceOf(request) });
249
256
  if (result.status === 'secondFactor') {
250
257
  // keep result.challenge for the next request — never in a URL — and ask for a code
@@ -257,6 +264,18 @@ export async function accept(request: Request): Promise<Response> {
257
264
  }
258
265
  ```
259
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
+
260
279
  **`findByEmail` works on every type with an e-mail, password or not** —
261
280
  since 0.21. A type with no password has no `findByLogin`, and must not
262
281
  declare one just to be looked up: that would open the password and reset
@@ -289,7 +308,8 @@ a link or a code already in the person's inbox still works afterwards.
289
308
  `TypeError`, as on every sign-in.
290
309
  - **No e-mail proved.** `emailVerified` stays as it was: the call does not
291
310
  know your proof reached the inbox. Nothing is dropped either — unlike a
292
- 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.
293
313
  - **No throttle.** Neither the mail throttle nor the sign-in throttle counts
294
314
  it, and it restarts neither: a login past its password attempts is still
295
315
  throttled for `signIn`. Limit your own acceptance route per client, as
@@ -316,7 +316,7 @@ Every type with an e-mail has it, **with or without a password** — and a
316
316
  type with no password has no `findByLogin`. It is the lookup an invitation
317
317
  needs: the person was invited at an address, may already hold an account of
318
318
  a passwordless type, and is signed in with `signInProven` once your server
319
- verified the invitation
319
+ verified the invitation and `verifyEmail.proveFirst` proved the address
320
320
  ([sessions](sessions.md#accepting-an-invitation)):
321
321
 
322
322
  ```ts
@@ -334,6 +334,7 @@ if (invitation === null) return notFound();
334
334
  const member =
335
335
  (await clinic.member.findByEmail(invitation.email)) ??
336
336
  (await clinic.member.create({ email: invitation.email, name: '' }));
337
+ await clinic.member.verifyEmail.proveFirst(member); // before signInProven: it revokes every session
337
338
  await clinic.member.signInProven(member);
338
339
  ```
339
340
 
@@ -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,15 @@ 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.
79
88
  - **Finding a user by e-mail on every type, v0.21.0** —
80
89
  `auth.<type>.findByEmail(email)` answers the user of the type holding the
81
90
  address, or `null`, on every type with an e-mail — a type with no
@@ -158,11 +167,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
158
167
  `signIn: { throttle: false }` turns it off. A tokens store that cannot
159
168
  count fails the sign-in with `STORE_FAILED`. No change for adapters: the
160
169
  counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
161
- - **A password or an e-mail changed, in the user events, v0.14.0** —
162
- `user.passwordChanged`, sent by `changePassword` and `setPassword` once the
163
- older reset links are spent (a reset stays `user.passwordReset` alone), and
164
- `user.emailChanged`, sent by an `update` that changed the e-mail, carrying
165
- `formerEmail` — the one event with more than the user's id — so a notice
166
- can reach the inbox the account just left, as `@nxgt/janus-mail`'s
167
- `passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
168
- that exhausts `UserEventType`.
@@ -63,6 +63,7 @@ How the messages are shaped:
63
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)
64
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)
65
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)
66
67
  - [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
67
68
  - [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
68
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)
@@ -600,7 +601,7 @@ An outage is never `null` here: it rejects with `STORE_FAILED`.
600
601
 
601
602
  ### `NOT_FOUND` — `<call>: no <type> has this id`
602
603
 
603
- `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`.
604
605
 
605
606
  **When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
606
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.
@@ -631,6 +632,13 @@ const user = await auth.get(id);
631
632
  await auth.update(user, { name }, { ifVersion: user.version });
632
633
  ```
633
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
+
634
642
  **From `secondFactor.confirm`**, the message is the store's:
635
643
  `updateUser: expected version <n>, found <m>`.
636
644
 
@@ -800,12 +808,36 @@ const invitation = await invitations.spend(hash(token), new Date()); // yours: o
800
808
  if (invitation === null) return new Response(null, { status: 404 });
801
809
  const user = await auth.findByEmail(invitation.email); // or invitation.userId, when you stored the id
802
810
  if (user === null) return new Response(null, { status: 404 });
811
+ await auth.verifyEmail.proveFirst(user); // the invitation reached the inbox — before signInProven
803
812
  const result = await auth.signInProven(user, { device: deviceOf(request) });
804
813
  ```
805
814
 
806
815
  Never pass it an id the request carries: it checks no credential. See
807
816
  [sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
808
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
+
809
841
  ### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
810
842
 
811
843
  **When:** the application runs `magicLink.request`, `signInCode.request` or
@@ -885,7 +917,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
885
917
 
886
918
  ### `USER_INACTIVE` — `<call>: the user is inactive`
887
919
 
888
- **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.
889
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.
890
922
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
891
923
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.21.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",