knowless 1.0.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -22,10 +22,99 @@ v1.0.0 are:
22
22
  user-visible impact)
23
23
  - Bug fixes that don't change the API surface
24
24
  - Documentation corrections
25
+ - Helper exports that pull existing mechanism back into the library
26
+
27
+ ## [1.1.0] — 2026-05-02
28
+
29
+ Mailer-contract clarification release. Pulls FR-6 sham-routing
30
+ mechanism back into the library, makes the custom-mailer contract
31
+ explicit, and codifies walk-away scope in the docs.
32
+
33
+ ### Added
34
+
35
+ - `dropShamRecipient(envelope, shamRecipient?)` — exported pure
36
+ helper. Custom mailers call it to no-op sham sends without external
37
+ delivery. Exact-match predicate against the configured
38
+ `shamRecipient` (default `null@knowless.invalid`). No behaviour
39
+ change inside the library; ~10 LOC.
40
+
41
+ ### Documented
42
+
43
+ - `knowless.context.md` § "What knowless is and is not" — scope
44
+ doctrine: knowless is the substrate for session-bearing logins, not
45
+ generic confirmation tokens. Adopters with one-shot non-login flows
46
+ keep their own token system.
47
+ - `knowless.context.md` § "Custom mailer contract" — five explicit
48
+ obligations for injected mailers: sham-recipient handling, FR-6
49
+ timing equivalence ownership (≤1ms, mailer's responsibility),
50
+ RFC822 fidelity, `verify()` semantics, `close()` lifecycle.
51
+ - `knowless.context.md` Public API — "Post-callback handle resolution"
52
+ block: no callback hook, `nextUrl` route is the seam.
53
+ - `GUIDE.md` § "Stability commitments under walk-away" — what v1.x
54
+ will and will not accept.
55
+ - `GUIDE.md` § "Custom mailer adapter" — appendix with sendmail-pipe
56
+ skeleton, FR-6 CI smoke-test pattern, and Postfix fallback note.
57
+ - `GUIDE.md` FAQ — custom login form contract, rate-limit defaults
58
+ and response shape, Origin/Referer CSRF failure mode (accurate;
59
+ replaces stale v0.2-era note), SQLite single-process envelope
60
+ (qualitative; no numeric guarantee carried forward).
61
+
62
+ No breaking changes. No behaviour changes inside the library.
25
63
 
26
64
  Feature requests are deflected to PRD §14 NO-GO, to sibling projects,
27
65
  or to forking. The library being "done" is a feature.
28
66
 
67
+ ### Fixed
68
+
69
+ - **XFF/X-Real-IP never honored through handler path (AF-28).**
70
+ `createHandlers` pre-built `trustedProxies` into a `{ has }` object
71
+ and passed it to `determineSourceIp`, which re-called
72
+ `buildTrustedPeers` on it. The pre-built object is not a `BlockList`,
73
+ array, or `Set`, so the peer list fell through to `[]`. Net effect:
74
+ trusted-proxy matching was silently empty in the handler code path —
75
+ rate limiting hit the reverse-proxy IP instead of the real client IP,
76
+ and XFF/X-Real-IP headers were ignored regardless of the
77
+ `trustedProxies` config. Abuse unit tests were passing because they
78
+ call `determineSourceIp` directly with raw arrays, bypassing the
79
+ handler path. Fix: removed the pre-build in `createHandlers`;
80
+ `determineSourceIp` now receives `cfg.trustedProxies` directly. Closes
81
+ AF-28.
82
+
83
+ - **`validateSubject` allowed CR/LF, enabling header injection in
84
+ standalone callers (AF-29).** The ASCII regex `/^[\x00-\x7f]*$/`
85
+ matched CR (0x0D) and LF (0x0A). `validateSubject` is re-exported as a
86
+ public validator (AF-9.1 / v0.1.7) so callers using it as a standalone
87
+ gate were unprotected. `composeRaw` caught the injection downstream, but
88
+ the validator is the authoritative public guard. Fix: added an explicit
89
+ `/[\r\n]/` check to `validateSubject`, consistent with the guards already
90
+ present in `validateFromName` and `validateBodyOverride`. Closes AF-29.
91
+
92
+ - **Factory `subject` not validated at startup (AF-30).** The factory
93
+ `subject` option was never passed to `validateSubject` during
94
+ `createHandlers` startup, breaking the fail-fast pattern that
95
+ `bodyFooter` (`validateBodyFooter` in `index.js`) and `fromName`
96
+ (`validateFromName` in `createMailer`) already followed. A non-ASCII
97
+ subject or empty string silently passed config time and would only fail
98
+ at first `mailer.submit()` — potentially hours into production. Fix:
99
+ added `validateSubject(cfg.subject)` to the config-validation block in
100
+ `createHandlers`. Closes AF-30.
101
+
102
+ - **`validateBodyFooter` rejected 4-line footers with a trailing newline
103
+ (AF-31).** `footer.split('\n').length > 4` counted 5 split parts for
104
+ `"a\nb\nc\nd\n"` (4 logical lines, trailing newline as is conventional
105
+ for multi-line strings). Fix: strip a single trailing newline before
106
+ counting: `footer.replace(/\n$/, '').split('\n').length > 4`. Closes
107
+ AF-31.
108
+
109
+ ### Documentation
110
+
111
+ - **`runSendLink` JSDoc corrected: `handle` is null for both malformed
112
+ email and per-IP rate-limit short-circuit (AF-32).** The JSDoc stated
113
+ `handle` is null "only when the email failed to normalize." The per-IP
114
+ rate-limit early return also returns `handle: null` because `deriveHandle`
115
+ has not run at that point. No behavior change — documentation only. Closes
116
+ AF-32.
117
+
29
118
  ## [1.0.0] — 2026-04-29
30
119
 
31
120
  **Walk-away release.** No new API surface vs v0.2.3 — v1.0.0 is the
package/GUIDE.md CHANGED
@@ -106,6 +106,27 @@ nothing else*.
106
106
  - **Walks away at v1.0.0.** Maintenance mode (security patches +
107
107
  bug fixes) after that, by design.
108
108
 
109
+ ### Stability commitments under walk-away
110
+
111
+ v1.x will accept:
112
+ - Documentation corrections and clarifications.
113
+ - Helper exports that pull existing mechanism back into the library
114
+ (where adopters were re-implementing it inline).
115
+ - Bug fixes that don't change the API surface.
116
+ - Security fixes (CVEs in `nodemailer` or `node:sqlite` with
117
+ user-visible impact).
118
+
119
+ v1.x will NOT accept:
120
+ - New flows, new methods on the auth instance, new constructor options.
121
+ - Generalisations toward token-issuance frameworks (see
122
+ [`knowless.context.md`](knowless.context.md) § "What knowless is and
123
+ is not").
124
+ - Per-adopter ergonomic shortcuts that the host can implement in ≤30
125
+ LOC against the existing API.
126
+
127
+ The library being closed is a feature. Forks are encouraged for
128
+ requirements that don't fit.
129
+
109
130
  ## Walkthrough: library mode
110
131
 
111
132
  The shape: import `knowless`, configure it, mount the handlers on
@@ -574,6 +595,114 @@ boot for the wrong reason. Adopters who want fail-fast call
574
595
  `verifyTransport()` explicitly; everyone else gets eventually-consistent
575
596
  SMTP startup.
576
597
 
598
+ ## Custom mailer adapter (hosts with their own outbound stack)
599
+
600
+ The default mailer submits via nodemailer to localhost:25 (your Postfix).
601
+ If you already run outbound infrastructure with DKIM signing, a
602
+ transactional API, or a sendmail pipe, you can inject a mailer object:
603
+
604
+ ```js
605
+ import { knowless, dropShamRecipient } from 'knowless';
606
+
607
+ // Timing-equivalence self-equalisation for subprocess-based mailers.
608
+ // Pre-measure your P95 delivery time, pad real sends to match.
609
+ const TRANSPORT_FLOOR_MS = 8; // example — calibrate for your stack
610
+
611
+ async function padToTransportFloor(startMs) {
612
+ const elapsed = performance.now() - startMs;
613
+ if (elapsed < TRANSPORT_FLOOR_MS) {
614
+ await new Promise(r => setTimeout(r, TRANSPORT_FLOOR_MS - elapsed));
615
+ }
616
+ }
617
+
618
+ const mailer = {
619
+ async send(envelope, raw) {
620
+ const t0 = performance.now();
621
+
622
+ if (dropShamRecipient(envelope)) {
623
+ // FR-6: no wire bytes, but burn the same wall time real delivery
624
+ // would take so real-vs-sham timing stays within ≤1ms.
625
+ await padToTransportFloor(t0);
626
+ return;
627
+ }
628
+
629
+ // Example: pipe to sendmail (local MTA handles DKIM via opendkim milter)
630
+ await new Promise((resolve, reject) => {
631
+ const proc = spawn('sendmail', ['-t', '-oi'], { stdio: ['pipe', 'ignore', 'pipe'] });
632
+ let stderr = '';
633
+ proc.stderr.on('data', d => { stderr += d; });
634
+ proc.stdin.write(raw);
635
+ proc.stdin.end();
636
+ proc.on('close', code => {
637
+ if (code === 0) resolve();
638
+ else reject(new Error(`sendmail exited ${code}: ${stderr}`));
639
+ });
640
+ });
641
+ await padToTransportFloor(t0);
642
+ },
643
+
644
+ async verify() {
645
+ // Optional — called once at factory construction.
646
+ // Throw here to abort startup on misconfigured transport.
647
+ // Example: probe that sendmail is available.
648
+ await execFile('sendmail', ['-bv', 'root']).catch(err => {
649
+ throw new Error(`sendmail probe failed: ${err.message}`);
650
+ });
651
+ },
652
+
653
+ // close() is optional — call yourself on shutdown if you allocated resources.
654
+ };
655
+
656
+ const auth = knowless({ secret, baseUrl, from, mailer });
657
+ ```
658
+
659
+ ### Timing-equivalence CI smoke test
660
+
661
+ Add this to your integration tests to verify your adapter meets the
662
+ ≤1ms FR-6 bar:
663
+
664
+ ```js
665
+ // Run 200 warmup pairs, then 500 timed pairs.
666
+ // Assert |mean(real) - mean(sham)| < 1.0ms.
667
+ const WARMUP = 200, SAMPLES = 500;
668
+ const realTimes = [], shamTimes = [];
669
+
670
+ for (let i = 0; i < WARMUP + SAMPLES; i++) {
671
+ const t0 = performance.now();
672
+ await mailer.send({ to: 'alice@example.com' }, RAW_FIXTURE);
673
+ const tReal = performance.now() - t0;
674
+
675
+ const t1 = performance.now();
676
+ await mailer.send({ to: 'null@knowless.invalid' }, RAW_FIXTURE);
677
+ const tSham = performance.now() - t1;
678
+
679
+ if (i >= WARMUP) { realTimes.push(tReal); shamTimes.push(tSham); }
680
+ }
681
+
682
+ const mean = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
683
+ const delta = Math.abs(mean(realTimes) - mean(shamTimes));
684
+ assert(delta < 1.0, `timing delta ${delta.toFixed(3)}ms exceeds 1ms FR-6 bar`);
685
+ ```
686
+
687
+ ### Postfix transport_maps fallback
688
+
689
+ If you don't want to write a custom mailer, the default nodemailer
690
+ path still works even with opendkim: configure Postfix to sign on the
691
+ way out (MTA-level DKIM via `milter_default_action`). You only need a
692
+ custom mailer if you want to bypass localhost submission entirely.
693
+
694
+ Whichever path you take, ensure the `transport_maps` null-route is in
695
+ place:
696
+
697
+ ```
698
+ # /etc/postfix/transport
699
+ knowless.invalid discard:silently dropped by knowless null-route
700
+ ```
701
+
702
+ ```
703
+ postmap /etc/postfix/transport && systemctl reload postfix
704
+ ```
705
+
577
706
  ## Walkthrough: standalone server mode
578
707
 
579
708
  Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
@@ -761,11 +890,22 @@ once, back it up safely, never expose it.
761
890
 
762
891
  ### Can I customise the login HTML?
763
892
 
764
- No. The form is hardcoded. Operators wanting branding fork the
893
+ The built-in form is hardcoded. Operators wanting branding fork the
765
894
  project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
766
895
  is a slope ("let me put my logo" → "let me theme the page" →
767
- "let me embed a JS framework"). The hardcoded form refuses to
768
- drift.
896
+ "let me embed a JS framework"). The hardcoded form refuses to drift.
897
+
898
+ However, you can **skip mounting `loginForm`** entirely and serve your
899
+ own form, provided it satisfies the handler contract:
900
+
901
+ - POST to `loginPath` (default `/login`).
902
+ - Include an `email` field in the `application/x-www-form-urlencoded`
903
+ body.
904
+ - Do **not** pre-parse the body upstream — knowless reads the request
905
+ stream itself. A body-parser middleware mounted before `auth.login`
906
+ will silently steal the data (see gotcha #15 in
907
+ [`knowless.context.md`](knowless.context.md)).
908
+ - Optional: include a `next` field for the post-callback redirect URL.
769
909
 
770
910
  ### How do I add 2FA / WebAuthn / TOTP?
771
911
 
@@ -774,20 +914,51 @@ stop. WebAuthn after login is a different layer.
774
914
 
775
915
  ### What about CSRF on POST /login?
776
916
 
777
- The login form is unauthenticated, so traditional CSRF
778
- mitigations (anti-CSRF tokens) don't apply directly. SameSite=Lax
779
- on the session cookie covers the post-login risk. CSRF on the
780
- unauthenticated /login endpoint is on the v0.2 open-questions
781
- list (SPEC §15 Q-4) — Origin-header validation is the likely
782
- answer.
917
+ Knowless validates the `Origin` (or `Referer`) header against
918
+ `cookieDomain` on both `POST /login` and `POST /logout`.
919
+
920
+ - **Failure mode:** 403 with a plain-text body `"Forbidden"`. No
921
+ redirect, no JSON envelope.
922
+ - **Browser-absent callers** (curl, server-to-server): if neither
923
+ `Origin` nor `Referer` is present, the check passes — the
924
+ browser is the CSRF threat model, not API callers. Programmatic
925
+ callers that do send an `Origin` header must ensure it matches
926
+ `cookieDomain`.
927
+ - **The check is not disable-able.** Do not add an upstream
928
+ exception; the Origin check is the CSRF defence (SPEC §7.3 Step
929
+ 0). Do NOT add a separate CSRF token — it would duplicate a
930
+ defence already in place and complicate custom form integration.
931
+
932
+ ### What are the rate-limit defaults, and what does a rate-limited response look like?
933
+
934
+ Defaults (all configurable at construction, no live tuning):
935
+
936
+ | Option | Default | Scope |
937
+ |---|---|---|
938
+ | `maxLoginRequestsPerIpPerHour` | 30 | Per source IP |
939
+ | `maxNewHandlesPerIpPerHour` | 3 | Per source IP (open-registration only) |
940
+ | `maxActiveTokensPerHandle` | 5 | Per handle (unexpired, unused) |
941
+
942
+ A rate-limited submission returns **202** with the same HTML body as a
943
+ successful send — identical to the sham and real-send responses. This
944
+ is intentional: distinct status or body would let an attacker enumerate
945
+ which IP is being throttled. Aggregate counts are surfaced via the
946
+ `onSuppressionWindow` hook (v0.2.1); individual rate-limit hits are
947
+ not exposed per-event by design (see [`knowless.context.md`](knowless.context.md)
948
+ § "Why three hooks, not four").
783
949
 
784
950
  ### Can I run multiple instances behind a load balancer?
785
951
 
786
- Yes the SQLite store is shared across processes via the file
787
- system. Concurrent writes use SQLite's `BEGIN IMMEDIATE` for the
788
- token-issuance transaction (SPEC §4.7). For very high concurrency
789
- (>1000 logins/sec), implement the store interface against
790
- Postgres or Redis.
952
+ The default SQLite store is a single-process embedded engine. Knowless
953
+ is designed for single-process deployments. Contention shows up as
954
+ `SQLITE_BUSY` errors in logs; if you see them, your write rate exceeds
955
+ the single-process envelope. Benchmark in your own environment — the
956
+ answer depends on kernel, filesystem, SQLite version, and Node version,
957
+ and v1.x carries no numeric guarantee across upgrades.
958
+
959
+ For multi-process or multi-node deployments, implement the store
960
+ interface against Postgres, Redis, or any other backend. The interface
961
+ is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §13.
791
962
 
792
963
  ### How do I see what's in the store?
793
964
 
@@ -30,6 +30,23 @@ This document is the dense reference. For the why, see
30
30
  `docs/01-product/PRD.md`. For the wire formats, see
31
31
  `docs/02-design/SPEC.md`. For an adopter walkthrough, see `GUIDE.md`.
32
32
 
33
+ ## What knowless is and is not
34
+
35
+ Knowless is the substrate for **session-bearing logins**: prove control
36
+ of an email, get a session cookie, do work under that session. The mint
37
+ path is single-use, short-TTL, and exists to bootstrap the cookie — not
38
+ as a generic confirmation primitive.
39
+
40
+ If you need ad-hoc one-shot confirmation tokens with caller-chosen TTLs
41
+ (event activation that may sit in an inbox over a weekend, email-change
42
+ confirmation, magic-action links that aren't logins), **keep your own
43
+ token system for that step** and use knowless for the session that
44
+ follows. The walk-away release intentionally does not grow toward generic
45
+ token issuance — that's a different library with a different threat
46
+ model.
47
+
48
+ See § "What's NOT in knowless, and why" for the full lens.
49
+
33
50
  ## Which mode do I need?
34
51
 
35
52
  | Mode | What it does | When to use |
@@ -175,23 +192,41 @@ const auth = knowless({
175
192
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
176
193
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
177
194
 
195
+ ### Post-callback handle resolution
196
+
197
+ There is no callback hook fired by knowless on confirmation. The host's
198
+ `nextUrl` route is the seam. Read the handle there:
199
+
200
+ ```js
201
+ // In your post-callback landing route:
202
+ const handle = auth.handleFromRequest(req);
203
+ if (!handle) return res.writeHead(401).end();
204
+ // Now do host-side work keyed by handle.
205
+ ```
206
+
207
+ This is deliberate: the host knows what "successful login means for me";
208
+ knowless does not. Both `auth.login` (form) and `auth.startLogin`
209
+ (programmatic) land here after the link is clicked — the post-callback
210
+ route is the single seam for all login modes.
211
+
178
212
  Re-exports for advanced consumers:
179
213
 
180
214
  ```js
181
215
  import {
182
- knowless, // factory
183
- createStore, // direct store access (admin scripts)
184
- createMailer, // direct mailer access
185
- createHandlers, // bring your own factory wiring
186
- composeBody, // pure: build the mail body
187
- validateSubject, // pure: validate operator-supplied subject
188
- validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
216
+ knowless, // factory
217
+ dropShamRecipient, // pure: sham-address predicate for custom mailers
218
+ createStore, // direct store access (admin scripts)
219
+ createMailer, // direct mailer access
220
+ createHandlers, // bring your own factory wiring
221
+ composeBody, // pure: build the mail body
222
+ validateSubject, // pure: validate operator-supplied subject
223
+ validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
189
224
  validateBodyOverride, // pure: validate per-call body override (AF-26)
190
- validateFromName, // pure: validate operator-supplied From: display name (AF-27)
191
- renderLoginForm, // pure: HTML5 page rendering
192
- normalize, // pure: email normalization
193
- deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
194
- secretBytes, // pure: coerce hex string → 32-byte HMAC key
225
+ validateFromName, // pure: validate operator-supplied From: display name (AF-27)
226
+ renderLoginForm, // pure: HTML5 page rendering
227
+ normalize, // pure: email normalization
228
+ deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
229
+ secretBytes, // pure: coerce hex string → 32-byte HMAC key
195
230
  } from 'knowless';
196
231
  ```
197
232
 
@@ -448,6 +483,57 @@ which requires the operator secret.
448
483
  [Caddy verifies cookie via /verify, proxies to Uptime Kuma]
449
484
  ```
450
485
 
486
+ ## Custom mailer contract
487
+
488
+ When you inject `options.mailer`, knowless hands off five obligations.
489
+ The default localhost-SMTP mailer satisfies them all; custom mailers
490
+ must satisfy them deliberately.
491
+
492
+ **1. Sham-recipient handling.** Knowless addresses sham sends (FR-6
493
+ timing-equivalence sends for missed handles, rate-limit hits,
494
+ exempt-handle short-circuits) to `shamRecipient` (default
495
+ `null@knowless.invalid`). Custom mailers MUST drop these without
496
+ external delivery. Use the exported helper:
497
+
498
+ ```js
499
+ import { dropShamRecipient } from 'knowless';
500
+
501
+ async function send(envelope, raw) {
502
+ if (dropShamRecipient(envelope)) return; // no wire send, no error
503
+ // ...actually deliver raw
504
+ }
505
+ ```
506
+
507
+ Forgetting this lets sham mail bounce, distinguishing miss from hit on
508
+ the wire and reopening the enumeration oracle that FR-6 closes. If you
509
+ configured a custom `shamRecipient`, pass it as the second argument:
510
+ `dropShamRecipient(envelope, cfg.shamRecipient)`.
511
+
512
+ **2. Timing equivalence (≤1ms).** Real-vs-sham wire-time difference
513
+ must stay within ≤1ms, measured from the start of the mailer's
514
+ `send(envelope, raw)` call to its returned promise's resolution. The
515
+ host knows its transport's jitter; the library cannot equalise around an
516
+ opaque mailer. Mailers spawning a subprocess per send (e.g. `sendmail`
517
+ pipe) MUST self-equalise: pre-warm the subprocess,
518
+ parallel-spawn-then-discard, or measure-and-pad. See FR-6 above for
519
+ why the bar matters.
520
+
521
+ **3. RFC822 fidelity.** Ship the body knowless composes byte-for-byte.
522
+ No quoted-printable re-encoding, no header rewriting (other than
523
+ envelope routing your transport requires), no soft-break wrapping, no
524
+ charset transcoding. See gotcha #9.
525
+
526
+ **4. `verify()` semantics.** Optional. If present, knowless calls it
527
+ once at factory construction (synchronously awaited before `knowless()`
528
+ resolves). Throwing aborts startup; resolving with any value is success.
529
+ Knowless does not call `verify()` again on a per-send basis. Hosts
530
+ whose transports can fail-after-warm-up should monitor independently.
531
+
532
+ **5. `close()` lifecycle.** Optional. Knowless does not call it on
533
+ normal operation. Hosts that wire it up are responsible for calling it
534
+ on their own shutdown path. Knowless guarantees `close()` is safe to
535
+ call multiple times after the auth instance has stopped issuing sends.
536
+
451
537
  ## Architecture
452
538
 
453
539
  ```
@@ -568,11 +654,13 @@ rate-limits) belongs above the library.
568
654
  philosophy. If you can't run Postfix, knowless isn't your
569
655
  library.
570
656
 
571
- 3. **`shamRecipient` MUST be discarded by your MTA.** Default
572
- is `null@knowless.invalid`. If your MTA tries to deliver it,
573
- it'll bounce against an `.invalid` TLD that never resolves —
574
- noise in your mail logs, wasted DNS lookups. Add the
575
- `transport_maps` entry per Postfix snippet above.
657
+ 3. **`shamRecipient` MUST be discarded without external delivery.**
658
+ Default is `null@knowless.invalid`. With the default
659
+ localhost-SMTP mailer, add the `transport_maps` entry per Postfix
660
+ snippet above. With a custom injected mailer, call
661
+ `dropShamRecipient(envelope)` and no-op the send; forgetting this
662
+ bounces sham mail and reopens the enumeration oracle. See §
663
+ "Custom mailer contract".
576
664
 
577
665
  4. **Cookie domain defaults to baseUrl's hostname.** This is the
578
666
  *narrow* default; for SSO across subdomains (forward-auth
@@ -603,8 +691,10 @@ rate-limits) belongs above the library.
603
691
  default encoding picks base64 or QP for plain ASCII bodies,
604
692
  breaking the magic-link URL with QP soft-breaks (the v0.11
605
693
  POC finding). We sidestep by composing the message ourselves
606
- and using nodemailer only for SMTP. If you swap mailers, your
607
- replacement must handle this OR keep emitting raw RFC822.
694
+ and using nodemailer only for SMTP. If you inject a custom
695
+ mailer, ship the body byte-for-byte no QP re-encoding, no
696
+ soft-break wrapping, no charset transcoding. See § "Custom
697
+ mailer contract" obligation 3.
608
698
 
609
699
  10. **No JavaScript in any HTML page.** The login form, the
610
700
  confirmation page, error pages — all static HTML5. Works in
@@ -659,10 +749,11 @@ rate-limits) belongs above the library.
659
749
  who hold raw 32-byte keys.
660
750
 
661
751
  18. **`bodyFooter` constraints (AF-8.2).** ASCII only — `·` is NOT
662
- ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines, no `http(s)://`
663
- URLs (would conflict with the magic-link line). Validated at
664
- factory startup; fails fast. Goes after RFC 3676 `"-- "`
665
- delimiter so mail clients strip it from quoted replies.
752
+ ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines (a single trailing
753
+ newline is allowed and not counted as an extra line), no
754
+ `http(s)://` URLs (would conflict with the magic-link line).
755
+ Validated at factory startup; fails fast. Goes after RFC 3676
756
+ `"-- "` delimiter so mail clients strip it from quoted replies.
666
757
 
667
758
  19. **`startLogin` is silent at every layer (FR-6).** Returns
668
759
  `{handle, submitted: true}` for *every* branch — real send, sham,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
package/src/handlers.js CHANGED
@@ -5,7 +5,6 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
5
5
  import { composeBody, validateSubject, validateBodyOverride } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
- buildTrustedPeers,
9
8
  determineSourceIp,
10
9
  rateLimitExceeded,
11
10
  rateLimitIncrement,
@@ -191,9 +190,7 @@ export function createHandlers({ store, mailer, config, events }) {
191
190
  throw new Error('config.baseUrl invalid');
192
191
  }
193
192
  }
194
-
195
- // Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
196
- const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
193
+ validateSubject(cfg.subject);
197
194
 
198
195
  // AF-7.1: emit at most one warning per handler instance about an
199
196
  // upstream body parser swallowing the request body. Loud enough to
@@ -253,8 +250,9 @@ export function createHandlers({ store, mailer, config, events }) {
253
250
  *
254
251
  * @returns {Promise<{handle: string|null, isSham: boolean,
255
252
  * emailNorm: string, nextValidated: string|null}>}
256
- * handle is null only when the email failed to normalize (programmer
257
- * bug for startLogin; same-shape silent for /login).
253
+ * handle is null when the email failed to normalize (programmer bug
254
+ * for startLogin) OR when per-IP rate-limit short-circuited before
255
+ * handle derivation; same-shape silent for /login.
258
256
  */
259
257
  async function runSendLink({
260
258
  emailRaw,
@@ -469,7 +467,7 @@ export function createHandlers({ store, mailer, config, events }) {
469
467
  return;
470
468
  }
471
469
 
472
- const sourceIp = determineSourceIp(req, trustedProxies);
470
+ const sourceIp = determineSourceIp(req, cfg.trustedProxies);
473
471
  const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
474
472
  sameResponse(res, result.emailNorm, result.nextValidated ?? '');
475
473
  }
package/src/index.js CHANGED
@@ -279,6 +279,24 @@ export function knowless(options = {}) {
279
279
  };
280
280
  }
281
281
 
282
+ /**
283
+ * Returns true if `envelope.to` equals the sham null-route recipient
284
+ * that knowless uses for FR-6 timing-equivalence sends. Custom mailers
285
+ * MUST call this and no-op the wire send when it returns true. The
286
+ * library still records the sham token row; this helper only governs
287
+ * whether to put bytes on the wire.
288
+ *
289
+ * Pass the same `shamRecipient` you configured the auth instance with,
290
+ * or omit to use the default `null@knowless.invalid`.
291
+ *
292
+ * @param {object} envelope Object with a `to` field (the recipient address).
293
+ * @param {string} [shamRecipient] Defaults to `'null@knowless.invalid'`.
294
+ * @returns {boolean}
295
+ */
296
+ export function dropShamRecipient(envelope, shamRecipient = 'null@knowless.invalid') {
297
+ return envelope?.to === shamRecipient;
298
+ }
299
+
282
300
  export { createStore } from './store.js';
283
301
  export {
284
302
  createMailer,
package/src/mailer.js CHANGED
@@ -88,7 +88,7 @@ export function validateBodyFooter(footer) {
88
88
  if (footer.length > 240) throw new Error('bodyFooter must be ≤ 240 chars');
89
89
  if (!ASCII_RE.test(footer)) throw new Error('bodyFooter must be ASCII');
90
90
  if (footer.includes('\r')) throw new Error('bodyFooter must not contain CR');
91
- if (footer.split('\n').length > 4) {
91
+ if (footer.replace(/\n$/, '').split('\n').length > 4) {
92
92
  throw new Error('bodyFooter must be ≤ 4 lines');
93
93
  }
94
94
  if (/https?:\/\//i.test(footer)) {
@@ -269,6 +269,7 @@ export function validateSubject(subject) {
269
269
  }
270
270
  if (subject.length > 60) throw new Error('subject longer than 60 chars');
271
271
  if (!ASCII_RE.test(subject)) throw new Error('subject contains non-ASCII');
272
+ if (/[\r\n]/.test(subject)) throw new Error('subject must not contain CR/LF');
272
273
  const warnings = [];
273
274
  const triggers = ['!!', '$$', 'FREE', 'URGENT', 'WINNER'];
274
275
  for (const t of triggers) {