knowless 1.1.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -30,6 +30,62 @@ v1.0.0 are:
30
30
  - Documentation corrections
31
31
  - Helper exports that pull existing mechanism back into the library
32
32
 
33
+ ## [1.1.3] — 2026-05-03
34
+
35
+ Documentation-only release. Surfaces a partial enumeration leak
36
+ in the default `failureRedirect` fallback that previously read as
37
+ "Mode-A only" guidance but actually applies to every adopter.
38
+ POST /login is built to make valid/invalid/rate-limited responses
39
+ indistinguishable (timing equivalence, sham tokens, sham-recipient
40
+ mailing, identical response shapes); the link-click stage extends
41
+ that contract, but the default `failureRedirect` cascade points at
42
+ `loginPath` (typically `/login`) — so a user clicking a sham link
43
+ lands on a "Sign in" page and immediately knows the link was
44
+ rejected, defeating the POST-stage work. plato wraps knowless with
45
+ `failureRedirect: '/'` for exactly this reason. No code changes.
46
+
47
+ ### Documented
48
+
49
+ - `GUIDE.md` Step-4 callout — replaced the narrow "Mode-A
50
+ heads-up" with a broader "set `failureRedirect: '/'` — it's
51
+ part of the silent-miss contract, not just a UX knob"
52
+ guidance, with the reasoning chain (POST-stage work, link-
53
+ click extension, leak-free landing) and the explicit opt-in
54
+ for adopters who want "try again" UX (`failureRedirect:
55
+ cfg.loginPath`). plato cited as the reference adopter.
56
+ - `GUIDE.md` config-table row for `failureRedirect` —
57
+ expanded the cell to lead with the silent-miss framing
58
+ rather than the Mode-A 404 framing.
59
+ - `knowless.context.md` `failureRedirect` comment in the
60
+ options block — flagged the default as a leak, pointer to
61
+ the new gotcha.
62
+ - `knowless.context.md` gotcha #20 — full silent-miss
63
+ contract write-up with the trade-off and reference adopter.
64
+
65
+ ## [1.1.2] — 2026-05-03
66
+
67
+ Documentation-only release. Adopters were repeatedly missing that
68
+ `auth.loginForm` is an unstyled contract-minimal fallback they
69
+ should override, and that `/login` is also where every silent-miss
70
+ failure (used / expired / sham / malformed token) redirects — so
71
+ the page deserves first-class UI in the host app, not the bundled
72
+ default. No code changes.
73
+
74
+ ### Documented
75
+
76
+ - `GUIDE.md` FAQ — added section "Branding the GET /login page
77
+ (you almost certainly want to override it)" with the three
78
+ reasons (brand consistency, silent-miss redirect target, opt-in
79
+ fallback) and the override pattern. Extended the existing
80
+ custom-form contract with `next` validation against
81
+ `baseUrl` + `cookieDomain` and the optional honeypot field name
82
+ from `cfg.honeypotFieldName`.
83
+ - `knowless.context.md` gotcha #10 — expanded the "operators
84
+ wanting branding fork the project" note to surface the more
85
+ common path (skip mounting `auth.loginForm`, serve your own
86
+ `GET /login`) and call out that `/login` is the silent-miss
87
+ redirect target.
88
+
33
89
  ## [1.1.1] — 2026-05-02
34
90
 
35
91
  Documentation-only release. Adds the wrong-shape-integration
package/GUIDE.md CHANGED
@@ -384,12 +384,25 @@ and `startLogin` would compute. The bare `deriveHandle` re-export
384
384
  takes pre-normalized input; use the instance method unless you
385
385
  have a specific reason to call the lower-level primitive.
386
386
 
387
- > **Mode-A heads-up: set `failureRedirect`.** If you only mount
388
- > `auth.callback` (not `auth.loginForm`), the default
389
- > `failureRedirect` cascade points at `/login` a route you
390
- > don't serve. An expired or replayed magic-link click will 302
391
- > to a 404. Set `failureRedirect: '/'` (or any route you do
392
- > serve) when wiring Mode A.
387
+ > **Set `failureRedirect: '/'` it's part of the silent-miss
388
+ > contract, not just a UX knob.** The default falls back to
389
+ > `loginPath` (typically `/login`). That's a partial enumeration
390
+ > leak: `POST /login` goes to significant lengths to make
391
+ > valid/invalid/rate-limited submissions indistinguishable
392
+ > (timing equivalence, sham tokens, sham-recipient mailing,
393
+ > identical response shapes) — but if a user clicks a sham or
394
+ > expired magic link and lands on a "Sign in" page, they
395
+ > immediately know the link was rejected, defeating the work the
396
+ > POST stage did. The leak-free landing is the same page any
397
+ > logged-out visitor sees on first arrival — usually `/`. Mode-A
398
+ > adopters have an extra reason: the default `failureRedirect`
399
+ > cascade points at `/login`, a route Mode A doesn't serve, so
400
+ > expired/replayed clicks 302 to a 404. Reference: plato wraps
401
+ > knowless with `failureRedirect: '/'` for this reason.
402
+ > Adopters who genuinely want a "try again" UX after failure
403
+ > can opt in explicitly with `failureRedirect: cfg.loginPath`,
404
+ > but understand you're trading the silent-miss guarantee at
405
+ > the link-click stage for that UX.
393
406
 
394
407
  ### Step 5: Pre-seed users (closed-registration mode, default)
395
408
 
@@ -808,7 +821,7 @@ Full options table:
808
821
  | `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
809
822
  | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
810
823
  | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
811
- | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. **Mode-A adopters:** if you don't mount `loginForm`, set this to a route you actually serve (e.g. `/`) otherwise expired/replayed magic-link clicks 302 to a 404. |
824
+ | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures (expired / used / sham / malformed token) redirect. **Set this to `'/'` (or any logged-out landing).** Default falls back to `loginPath`, which is a silent-miss leak: a user who clicks a sham/expired link and lands on a "Sign in" page knows the link was rejected, defeating the anti-enumeration work done at POST /login. Part of the silent-miss contract, not a UX knob. Mode-A adopters who don't mount `loginForm` *also* hit a 404 with the default. Opt back into the "try again" UX by passing `loginPath` explicitly if you understand the trade. |
812
825
  | `store` | no | (built-in `node:sqlite`) | Inject your own store implementation. |
813
826
  | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
814
827
 
@@ -929,7 +942,48 @@ own form, provided it satisfies the handler contract:
929
942
  stream itself. A body-parser middleware mounted before `auth.login`
930
943
  will silently steal the data (see gotcha #15 in
931
944
  [`knowless.context.md`](knowless.context.md)).
932
- - Optional: include a `next` field for the post-callback redirect URL.
945
+ - Optional: include a `next` field for the post-callback redirect URL
946
+ (knowless validates `next` against `baseUrl` + `cookieDomain`).
947
+ - Optional but recommended: include the honeypot field using the name
948
+ from `cfg.honeypotFieldName`.
949
+
950
+ ### Branding the GET /login page (you almost certainly want to override it)
951
+
952
+ Knowless ships `auth.loginForm(req, res)` as a turnkey fallback so
953
+ adopters can wire `GET /login` in one line and have a working magic-link
954
+ form. The page is intentionally unstyled — it's the contract-minimal
955
+ renderer needed to make the flow demonstrable, not a UI you ship to
956
+ end users. Three reasons most adopters override it:
957
+
958
+ 1. **Brand consistency.** The fallback page has no header, footer, nav,
959
+ or styling, so users redirected here from elsewhere in your app land
960
+ on what looks like a different site. That's especially jarring after
961
+ a sham-token failure (a deliberate part of the silent-miss design —
962
+ see "Silent miss" in [`knowless.context.md`](knowless.context.md)),
963
+ where a user clicked a magic link and ended up on a "Sign in" page
964
+ that looks unrelated to where they started.
965
+ 2. **`/login` is load-bearing in the silent-miss contract.** Knowless
966
+ redirects to `loginPath` on every failure mode that must be
967
+ indistinguishable from success — used token, expired token, sham
968
+ token, malformed token. That redirect is correct and required. But
969
+ it means `/login` is the page users actually land on during
970
+ anti-enumeration failures, not just the page they navigate to
971
+ deliberately. It deserves first-class UI in your app.
972
+ 3. **`auth.loginForm` is opt-in, not opt-out.** Adopters who never wire
973
+ the GET route still get a working app — just without a friendly
974
+ `/login` page. Override it whenever you want your app's chrome on
975
+ the page. The POST handler can stay as-is (or also be wrapped — for
976
+ example, plato wraps it for the "we sent a link" confirmation).
977
+
978
+ Override pattern (mount your own handler instead of `auth.loginForm`):
979
+
980
+ ```js
981
+ app.get('/login', (req, res) => renderMyOwnLogin(req, res));
982
+ app.post('/login', auth.login); // unchanged
983
+ ```
984
+
985
+ The form just needs to satisfy the contract listed in the previous
986
+ question.
933
987
 
934
988
  ### How do I add 2FA / WebAuthn / TOTP?
935
989
 
@@ -114,7 +114,7 @@ const auth = knowless({
114
114
  linkPath: '/auth/callback',
115
115
  verifyPath: '/verify',
116
116
  logoutPath: '/logout',
117
- failureRedirect: null, // null → loginPath
117
+ failureRedirect: null, // null → loginPath (LEAK — set '/'; see gotcha #20)
118
118
 
119
119
  // --- Mail / SMTP ---
120
120
  smtpHost: 'localhost',
@@ -699,7 +699,13 @@ rate-limits) belongs above the library.
699
699
  10. **No JavaScript in any HTML page.** The login form, the
700
700
  confirmation page, error pages — all static HTML5. Works in
701
701
  text-mode browsers (Lynx, w3m). Operators wanting branding
702
- fork the project.
702
+ fork the project — **or, more commonly, skip mounting
703
+ `auth.loginForm` and serve their own `GET /login`**. The
704
+ fallback is intentionally a contract-minimal renderer, not a
705
+ UI to ship. `/login` is also where every silent-miss failure
706
+ redirects (used / expired / sham / malformed token), so it
707
+ deserves first-class chrome in the host app. See GUIDE.md
708
+ § "Branding the GET /login page".
703
709
 
704
710
  11. **Process cleanup matters.** `auth.close()` stops the
705
711
  sweeper and closes the SQLite handle. Without it, your
@@ -764,6 +770,24 @@ rate-limits) belongs above the library.
764
770
  return shape. Don't wrap `startLogin` in something that surfaces
765
771
  the branch to the caller; that re-opens the enumeration oracle.
766
772
 
773
+ 20. **`failureRedirect` is part of the silent-miss contract.**
774
+ Default falls back to `loginPath` — a partial enumeration
775
+ leak. POST /login goes to significant lengths to keep
776
+ valid/invalid/rate-limited submissions indistinguishable
777
+ (timing, sham tokens, sham-recipient mailing, identical
778
+ response shapes), and the link-click stage extends that
779
+ contract: real tokens issue a session + redirect to
780
+ `nextUrl`; failures (expired, used, sham, malformed) go to
781
+ `failureRedirect`. If that's `/login`, a user who clicks a
782
+ sham link lands on a "Sign in" page and immediately knows
783
+ the link was rejected — defeating the POST-stage work. Set
784
+ `failureRedirect: '/'` (or any route a logged-out visitor
785
+ would see on first arrival) so a sham click is
786
+ indistinguishable from a never-clicked link. Adopters who
787
+ genuinely want a "try again" UX after failure opt back in
788
+ explicitly with `failureRedirect: cfg.loginPath`. plato is
789
+ the reference adopter for the leak-free wiring.
790
+
767
791
  ## Constraints
768
792
 
769
793
  - **Node 20+** -- targeting LTS; tested on Node 22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",