@nxgt/janus-hono 0.7.1 → 0.7.3

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
@@ -5,7 +5,7 @@ give the detail.
5
5
 
6
6
  | Page | Read it when |
7
7
  | --- | --- |
8
- | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, the device cookie of a sign-in from a new device, sign-out and the e-mail flows as Hono routes, with one user type or several |
8
+ | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, accepting an invitation, the device cookie of a sign-in from a new device, sign-out and the e-mail flows as Hono routes, with one user type or several |
9
9
  | [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
10
10
  | [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
11
11
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -379,22 +379,25 @@ the **challenge** in a cookie scoped to the second, which takes the code and
379
379
  opens the session. Sending the e-mail is yours.
380
380
 
381
381
  ```ts
382
- import { randomBytes } from 'node:crypto';
383
382
  import { sendSession } from '@nxgt/janus-hono';
384
383
  import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
385
384
 
386
385
  const CODE_CHALLENGE = 'sign-in-code';
387
386
  const codeScope = { path: '/sign-in/email', httpOnly: true, secure: true, sameSite: 'Strict' } as const;
388
387
 
388
+ /** Runs `work` off the request, and reports what it throws: nobody else will see it. */
389
+ const offRequest = (work: () => Promise<unknown>): void => void work().catch(reportError);
390
+
389
391
  app.post('/sign-in/email', async (c) => {
390
392
  const { email } = await c.req.json();
391
- const issued = await auth.signInCode.request(email);
392
- if (issued !== null) {
393
- void mailer.send(issued.email, `Your sign-in code: ${issued.code}`); // the code only; not awaited
394
- }
395
- // the same answer either way: a decoy challenge when nobody holds the e-mail
396
- const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
397
- setCookie(c, CODE_CHALLENGE, challenge, { ...codeScope, maxAge: 600 });
393
+ // counted, nobody looked up, a challenge minted: MAIL_THROTTLED → 429 by janusErrors()
394
+ const pending = await auth.signInCode.prepare(email);
395
+ offRequest(async () => {
396
+ const issued = await pending.send(); // the lookup and the code, off the visitor's request
397
+ if (issued !== null) await mailer.send(issued.email, `Your sign-in code: ${issued.code}`); // the code only
398
+ });
399
+ // the same answer either way: a challenge for everybody, TOKEN_UNKNOWN when nobody holds the e-mail
400
+ setCookie(c, CODE_CHALLENGE, pending.challenge, { ...codeScope, maxAge: 600 });
398
401
  return c.json({ next: 'code' }, 202);
399
402
  });
400
403
 
@@ -409,17 +412,20 @@ app.post('/sign-in/email/code', async (c) => {
409
412
  ```
410
413
 
411
414
  **The request route answers the same whoever asked** — the same status, the
412
- same body, and a cookie either way. `signInCode.request` answers `null` for
413
- an address nobody holds, and a route that answered differently, or set no
414
- cookie, would tell anyone which addresses have an account. The decoy is 32
415
- random bytes, the shape of a real challenge; confirming it is
416
- `TOKEN_UNKNOWN`. `maxAge: 600` is the default ten minutes of
417
- `tokens.signInCode`, written out so both cookies match. Not awaiting the
418
- mailer keeps the answer's time from telling either. `@nxgt/janus` throttles
419
- each address — five requests per 10 minutes, then `MAIL_THROTTLED`, which
420
- `janusErrors()` answers 429 with `Retry-After` (the last code sent still
421
- works); rate-limit the route per client yourself, since one client can ask for
422
- many addresses.
415
+ same body, and a cookie either way, in the same time. `signInCode.prepare`
416
+ (`@nxgt/janus` 0.20) counts the address and mints the challenge **without
417
+ looking anybody up**, so it makes the same store calls for any address;
418
+ `send()`, which looks the address up and issues the code under that
419
+ challenge, runs after the answer. For an address nobody holds `send()`
420
+ answers `null`, and the challenge confirms as `TOKEN_UNKNOWN`. `maxAge: 600`
421
+ is the default ten minutes of `tokens.signInCode`. `@nxgt/janus` throttles
422
+ each address — five requests per 10 minutes, then `MAIL_THROTTLED` from
423
+ `prepare`, in the visitor's request, which `janusErrors()` answers 429 with
424
+ `Retry-After` (the last code sent still works); rate-limit the route per
425
+ client yourself, since one client can ask for many addresses. `send()` runs
426
+ once: a second call is a `TypeError`, so a retry prepares again. With
427
+ `signInCode.request(email)` instead, the route would wait for the lookup —
428
+ its time would tell — and would set a random decoy challenge for `null`.
423
429
 
424
430
  The code route still tells a decoy apart: a wrong code against it is 400
425
431
  `TOKEN_UNKNOWN`, against a real challenge 401 `CODE_INVALID` with
@@ -488,6 +494,8 @@ A sign-in by e-mailed link takes three routes. The first asks for a link.
488
494
  The link then opens **a page that spends nothing**, and that page's button
489
495
  posts the token back to the third route, which signs the user in.
490
496
 
497
+ `offRequest` is the helper of [a code sent by e-mail](#a-code-sent-by-e-mail).
498
+
491
499
  Never sign in on the link's `GET`. Mail scanners open every link in an
492
500
  e-mail and would spend the token before the user clicks. Sending the e-mail
493
501
  is your job.
@@ -502,10 +510,12 @@ const TOKEN = /^[A-Za-z0-9_-]{43}$/; // 32 bytes, base64url: the only thing echo
502
510
 
503
511
  app.post('/sign-in/email/link', async (c) => {
504
512
  const { email } = await c.req.json();
505
- const issued = await auth.magicLink.request(email);
506
- if (issued !== null) {
507
- void mailer.send(issued.email, `${ORIGIN}/sign-in/link?token=${issued.token}`); // not awaited, so the answer's time tells nothing; in production, a queue that awaits and retries
508
- }
513
+ const pending = await auth.magicLink.prepare(email); // counted, nobody looked up: MAIL_THROTTLED → 429
514
+ offRequest(async () => { // offRequest: see A code sent by e-mail (#a-code-sent-by-e-mail)
515
+ // the lookup and the token, after the answer, so its time tells nothing; in production, a queue
516
+ const issued = await pending.send();
517
+ if (issued !== null) await mailer.send(issued.email, `${ORIGIN}/sign-in/link?token=${issued.token}`);
518
+ });
509
519
  return c.body(null, 202); // the same answer either way: nothing of a link reaches the visitor
510
520
  });
511
521
 
@@ -593,6 +603,36 @@ this e-mail with `mail.magicLink(issued)`, building its button with
593
603
  For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
594
604
  guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
595
605
 
606
+ ## Accepting an invitation
607
+
608
+ An invitation is the application's own proof: it issued the token, and the
609
+ person followed it. `auth.signInProven(userId)` signs them in on that proof
610
+ — no e-mail, no mail-throttle count, no link spent — and answers what
611
+ `signIn` answers, so `sendSession` and the second factor work as above.
612
+ There is no route of this package for it: the proof is yours to check, and
613
+ the id must come from your invitation's record, **never from the request**.
614
+
615
+ ```ts
616
+ app.post('/invitation/accept', csrf({ origin: ORIGIN }), async (c) => {
617
+ const token = String((await c.req.parseBody()).token ?? '');
618
+ const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
619
+ if (invitation === null) return c.json({ code: 'INVITATION_INVALID' }, 404);
620
+
621
+ const result = await auth.signInProven(invitation.userId, { device: deviceOf(c) });
622
+ if (result.status === 'secondFactor') {
623
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
624
+ return c.redirect('/sign-in/second-factor', 303);
625
+ }
626
+ sendSession(c, auth, result);
627
+ return c.redirect('/', 303);
628
+ });
629
+ ```
630
+
631
+ Not `magicLink.request` then `confirm` at once: `request` counts against
632
+ the mail throttle per address, so five anonymous requests for the invitee's
633
+ address would answer this route 429. See [`@nxgt/janus`'s
634
+ sessions guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md#signing-in-on-your-own-proof--signinproven).
635
+
596
636
  ## The device cookie
597
637
 
598
638
  With `janus({ devices })`, every sign-in can tell the user it came from a
@@ -771,7 +811,8 @@ cookie drops it too.
771
811
 
772
812
  ## E-mail verification and password reset
773
813
 
774
- Sending the e-mail is yours; the one-time token goes in a link.
814
+ Sending the e-mail is yours; the one-time token goes in a link. `offRequest`
815
+ is the helper of [a code sent by e-mail](#a-code-sent-by-e-mail).
775
816
 
776
817
  ```ts
777
818
  app.post('/verify-email', session(auth, { required: true }), async (c) => {
@@ -788,11 +829,12 @@ app.post('/verify-email/confirm', async (c) => {
788
829
 
789
830
  app.post('/reset-password', async (c) => {
790
831
  const { email } = await c.req.json();
791
- const issued = await auth.resetPassword.request(email);
792
- if (issued !== null) {
793
- await mailer.send(issued.email, `https://app.test/reset?token=${issued.token}`);
794
- }
795
- return c.body(null, 202); // the same answer either way: never say which e-mails exist
832
+ const pending = await auth.resetPassword.prepare(email); // counted, nobody looked up: MAIL_THROTTLED → 429
833
+ offRequest(async () => { // offRequest: see A code sent by e-mail (#a-code-sent-by-e-mail)
834
+ const issued = await pending.send(); // the lookup and the link, off the visitor's request
835
+ if (issued !== null) await mailer.send(issued.email, `https://app.test/reset?token=${issued.token}`);
836
+ });
837
+ return c.body(null, 202); // the same answer, in the same time, either way: never say which e-mails exist
796
838
  });
797
839
 
798
840
  app.post('/reset-password/confirm', async (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus-hono",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Hono middleware for @nxgt/janus: the signed-in user on every request, the session cookie, routes guarded by a permission, and every error as its status",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,13 +49,13 @@
49
49
  ]
50
50
  },
51
51
  "devDependencies": {
52
- "@nxgt/janus": "^0.19.0",
52
+ "@nxgt/janus": "^0.21.0",
53
53
  "@types/bun": "^1.4.2",
54
54
  "hono": "^4.13.9",
55
55
  "zod": "^4.6.5"
56
56
  },
57
57
  "peerDependencies": {
58
- "@nxgt/janus": "^0.19.0",
58
+ "@nxgt/janus": "^0.21.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }