knowless 1.0.1 → 1.1.1

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
@@ -12,6 +12,12 @@ Versioning is [SemVer](https://semver.org/).
12
12
  auth+mail layer. ~1,150 LOC of bespoke auth/mail code removed,
13
13
  ~35 LOC of knowless wiring added (~33× reduction). Drove audit
14
14
  findings AF-7 → AF-17 across v0.1.5–v0.1.10.
15
+ - **2026-05-02 — Three adopters in production.** plato (Mode B,
16
+ forum) and gitdone (Mode A, multi-party email workflows) joined
17
+ addypin (Mode A, location sharing). gitdone's pre-merge review
18
+ surfaced the "wrong-shape integration" failure mode (parallel
19
+ tokens table + manual session minting alongside knowless instead
20
+ of `auth.startLogin`). Patched docs in v1.1.1; no API change.
15
21
 
16
22
  ## [Unreleased]
17
23
 
@@ -22,6 +28,70 @@ v1.0.0 are:
22
28
  user-visible impact)
23
29
  - Bug fixes that don't change the API surface
24
30
  - Documentation corrections
31
+ - Helper exports that pull existing mechanism back into the library
32
+
33
+ ## [1.1.1] — 2026-05-02
34
+
35
+ Documentation-only release. Adds the wrong-shape-integration
36
+ anti-pattern callout that gitdone's pre-merge review surfaced, and
37
+ records plato + gitdone as the second and third production adopters.
38
+ No code changes.
39
+
40
+ ### Documented
41
+
42
+ - `README.md` — replaced "Sibling projects" with an "Adopters"
43
+ section explicitly listing addypin (Mode A), plato (Mode B), and
44
+ gitdone (Mode A) with a pointer that addypin and gitdone are the
45
+ Mode A worked references.
46
+ - `GUIDE.md` § "Two adoption modes" — added an anti-pattern callout
47
+ at the top: if you're considering writing pending rows to a
48
+ custom tokens table or minting your own confirmation links, that's
49
+ Mode A and `auth.startLogin({ subjectOverride, bodyOverride })`
50
+ already does it. Includes a side-by-side wrong-shape vs Mode A
51
+ sketch. Surfaced by gitdone's pre-merge review where a parallel
52
+ activation system was built before the existing Mode A flow was
53
+ noticed.
54
+ - `GUIDE.md` Mode A/B sub-headings — appended the API entrypoint
55
+ (`auth.startLogin` for Mode A, `auth.login` for Mode B) so the
56
+ use-case leads and `startLogin` doesn't read as "login button
57
+ click."
58
+
59
+ ## [1.1.0] — 2026-05-02
60
+
61
+ Mailer-contract clarification release. Pulls FR-6 sham-routing
62
+ mechanism back into the library, makes the custom-mailer contract
63
+ explicit, and codifies walk-away scope in the docs.
64
+
65
+ ### Added
66
+
67
+ - `dropShamRecipient(envelope, shamRecipient?)` — exported pure
68
+ helper. Custom mailers call it to no-op sham sends without external
69
+ delivery. Exact-match predicate against the configured
70
+ `shamRecipient` (default `null@knowless.invalid`). No behaviour
71
+ change inside the library; ~10 LOC.
72
+
73
+ ### Documented
74
+
75
+ - `knowless.context.md` § "What knowless is and is not" — scope
76
+ doctrine: knowless is the substrate for session-bearing logins, not
77
+ generic confirmation tokens. Adopters with one-shot non-login flows
78
+ keep their own token system.
79
+ - `knowless.context.md` § "Custom mailer contract" — five explicit
80
+ obligations for injected mailers: sham-recipient handling, FR-6
81
+ timing equivalence ownership (≤1ms, mailer's responsibility),
82
+ RFC822 fidelity, `verify()` semantics, `close()` lifecycle.
83
+ - `knowless.context.md` Public API — "Post-callback handle resolution"
84
+ block: no callback hook, `nextUrl` route is the seam.
85
+ - `GUIDE.md` § "Stability commitments under walk-away" — what v1.x
86
+ will and will not accept.
87
+ - `GUIDE.md` § "Custom mailer adapter" — appendix with sendmail-pipe
88
+ skeleton, FR-6 CI smoke-test pattern, and Postfix fallback note.
89
+ - `GUIDE.md` FAQ — custom login form contract, rate-limit defaults
90
+ and response shape, Origin/Referer CSRF failure mode (accurate;
91
+ replaces stale v0.2-era note), SQLite single-process envelope
92
+ (qualitative; no numeric guarantee carried forward).
93
+
94
+ No breaking changes. No behaviour changes inside the library.
25
95
 
26
96
  Feature requests are deflected to PRD §14 NO-GO, to sibling projects,
27
97
  or to forking. The library being "done" is a feature.
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
@@ -265,7 +286,31 @@ both out of the box; pick per-action, not per-app — they coexist.
265
286
  The Mode A/B labels are used here and in the CHANGELOG so
266
287
  discussions across the docs stay unambiguous.
267
288
 
268
- **Mode B "sign in, then do the thing" (register-first, the default).**
289
+ > **Stop before you build a parallel activation system.** If
290
+ > you're considering writing pending rows to a custom tokens table,
291
+ > minting your own confirmation links, or calling into the sessions
292
+ > table directly to mark an account "activated by email" — that is
293
+ > Mode A, and it's already in this library. Use
294
+ > `auth.startLogin({ email, subjectOverride, bodyOverride })` and
295
+ > promote your pending resource in the callback handler. The
296
+ > wrong-shape integration is what every adopter has tried first; the
297
+ > right shape is the worked example below.
298
+
299
+ The wrong shape vs Mode A, side by side:
300
+
301
+ ```
302
+ WRONG SHAPE MODE A (use auth.startLogin)
303
+ ───────────────────────────── ─────────────────────────────
304
+ your_tokens table (none — knowless owns the token)
305
+ your custom email composer subjectOverride + bodyOverride
306
+ your /confirm/:token handler auth.callback (already mounted)
307
+ manual session insert handled by callback
308
+ your duplicate rate-limit code knowless rate-limit applies
309
+ sham-work + timing equivalence
310
+ preserved automatically
311
+ ```
312
+
313
+ **Mode B — "sign in, then do the thing" (register-first, the default, `auth.login`).**
269
314
  User must log in before performing the action. Wire `auth.login` /
270
315
  `auth.callback` as above; gate your action with
271
316
  `auth.handleFromRequest(req)`. Use when the action requires a session
@@ -280,7 +325,7 @@ app.post('/api/comments', (req, res) => {
280
325
  });
281
326
  ```
282
327
 
283
- **Mode A — "do the thing, confirm by email" (use-first, claim-later).**
328
+ **Mode A — "do the thing, confirm by email" (use-first, claim-later, `auth.startLogin`).**
284
329
  User performs the action without logging in; you capture their email
285
330
  and trigger a magic link. Clicking it opens a session and your
286
331
  callback handler "promotes" the deferred resource. Use for "drop a
@@ -574,6 +619,114 @@ boot for the wrong reason. Adopters who want fail-fast call
574
619
  `verifyTransport()` explicitly; everyone else gets eventually-consistent
575
620
  SMTP startup.
576
621
 
622
+ ## Custom mailer adapter (hosts with their own outbound stack)
623
+
624
+ The default mailer submits via nodemailer to localhost:25 (your Postfix).
625
+ If you already run outbound infrastructure with DKIM signing, a
626
+ transactional API, or a sendmail pipe, you can inject a mailer object:
627
+
628
+ ```js
629
+ import { knowless, dropShamRecipient } from 'knowless';
630
+
631
+ // Timing-equivalence self-equalisation for subprocess-based mailers.
632
+ // Pre-measure your P95 delivery time, pad real sends to match.
633
+ const TRANSPORT_FLOOR_MS = 8; // example — calibrate for your stack
634
+
635
+ async function padToTransportFloor(startMs) {
636
+ const elapsed = performance.now() - startMs;
637
+ if (elapsed < TRANSPORT_FLOOR_MS) {
638
+ await new Promise(r => setTimeout(r, TRANSPORT_FLOOR_MS - elapsed));
639
+ }
640
+ }
641
+
642
+ const mailer = {
643
+ async send(envelope, raw) {
644
+ const t0 = performance.now();
645
+
646
+ if (dropShamRecipient(envelope)) {
647
+ // FR-6: no wire bytes, but burn the same wall time real delivery
648
+ // would take so real-vs-sham timing stays within ≤1ms.
649
+ await padToTransportFloor(t0);
650
+ return;
651
+ }
652
+
653
+ // Example: pipe to sendmail (local MTA handles DKIM via opendkim milter)
654
+ await new Promise((resolve, reject) => {
655
+ const proc = spawn('sendmail', ['-t', '-oi'], { stdio: ['pipe', 'ignore', 'pipe'] });
656
+ let stderr = '';
657
+ proc.stderr.on('data', d => { stderr += d; });
658
+ proc.stdin.write(raw);
659
+ proc.stdin.end();
660
+ proc.on('close', code => {
661
+ if (code === 0) resolve();
662
+ else reject(new Error(`sendmail exited ${code}: ${stderr}`));
663
+ });
664
+ });
665
+ await padToTransportFloor(t0);
666
+ },
667
+
668
+ async verify() {
669
+ // Optional — called once at factory construction.
670
+ // Throw here to abort startup on misconfigured transport.
671
+ // Example: probe that sendmail is available.
672
+ await execFile('sendmail', ['-bv', 'root']).catch(err => {
673
+ throw new Error(`sendmail probe failed: ${err.message}`);
674
+ });
675
+ },
676
+
677
+ // close() is optional — call yourself on shutdown if you allocated resources.
678
+ };
679
+
680
+ const auth = knowless({ secret, baseUrl, from, mailer });
681
+ ```
682
+
683
+ ### Timing-equivalence CI smoke test
684
+
685
+ Add this to your integration tests to verify your adapter meets the
686
+ ≤1ms FR-6 bar:
687
+
688
+ ```js
689
+ // Run 200 warmup pairs, then 500 timed pairs.
690
+ // Assert |mean(real) - mean(sham)| < 1.0ms.
691
+ const WARMUP = 200, SAMPLES = 500;
692
+ const realTimes = [], shamTimes = [];
693
+
694
+ for (let i = 0; i < WARMUP + SAMPLES; i++) {
695
+ const t0 = performance.now();
696
+ await mailer.send({ to: 'alice@example.com' }, RAW_FIXTURE);
697
+ const tReal = performance.now() - t0;
698
+
699
+ const t1 = performance.now();
700
+ await mailer.send({ to: 'null@knowless.invalid' }, RAW_FIXTURE);
701
+ const tSham = performance.now() - t1;
702
+
703
+ if (i >= WARMUP) { realTimes.push(tReal); shamTimes.push(tSham); }
704
+ }
705
+
706
+ const mean = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
707
+ const delta = Math.abs(mean(realTimes) - mean(shamTimes));
708
+ assert(delta < 1.0, `timing delta ${delta.toFixed(3)}ms exceeds 1ms FR-6 bar`);
709
+ ```
710
+
711
+ ### Postfix transport_maps fallback
712
+
713
+ If you don't want to write a custom mailer, the default nodemailer
714
+ path still works even with opendkim: configure Postfix to sign on the
715
+ way out (MTA-level DKIM via `milter_default_action`). You only need a
716
+ custom mailer if you want to bypass localhost submission entirely.
717
+
718
+ Whichever path you take, ensure the `transport_maps` null-route is in
719
+ place:
720
+
721
+ ```
722
+ # /etc/postfix/transport
723
+ knowless.invalid discard:silently dropped by knowless null-route
724
+ ```
725
+
726
+ ```
727
+ postmap /etc/postfix/transport && systemctl reload postfix
728
+ ```
729
+
577
730
  ## Walkthrough: standalone server mode
578
731
 
579
732
  Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
@@ -761,11 +914,22 @@ once, back it up safely, never expose it.
761
914
 
762
915
  ### Can I customise the login HTML?
763
916
 
764
- No. The form is hardcoded. Operators wanting branding fork the
917
+ The built-in form is hardcoded. Operators wanting branding fork the
765
918
  project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
766
919
  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.
920
+ "let me embed a JS framework"). The hardcoded form refuses to drift.
921
+
922
+ However, you can **skip mounting `loginForm`** entirely and serve your
923
+ own form, provided it satisfies the handler contract:
924
+
925
+ - POST to `loginPath` (default `/login`).
926
+ - Include an `email` field in the `application/x-www-form-urlencoded`
927
+ body.
928
+ - Do **not** pre-parse the body upstream — knowless reads the request
929
+ stream itself. A body-parser middleware mounted before `auth.login`
930
+ will silently steal the data (see gotcha #15 in
931
+ [`knowless.context.md`](knowless.context.md)).
932
+ - Optional: include a `next` field for the post-callback redirect URL.
769
933
 
770
934
  ### How do I add 2FA / WebAuthn / TOTP?
771
935
 
@@ -774,20 +938,51 @@ stop. WebAuthn after login is a different layer.
774
938
 
775
939
  ### What about CSRF on POST /login?
776
940
 
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.
941
+ Knowless validates the `Origin` (or `Referer`) header against
942
+ `cookieDomain` on both `POST /login` and `POST /logout`.
943
+
944
+ - **Failure mode:** 403 with a plain-text body `"Forbidden"`. No
945
+ redirect, no JSON envelope.
946
+ - **Browser-absent callers** (curl, server-to-server): if neither
947
+ `Origin` nor `Referer` is present, the check passes — the
948
+ browser is the CSRF threat model, not API callers. Programmatic
949
+ callers that do send an `Origin` header must ensure it matches
950
+ `cookieDomain`.
951
+ - **The check is not disable-able.** Do not add an upstream
952
+ exception; the Origin check is the CSRF defence (SPEC §7.3 Step
953
+ 0). Do NOT add a separate CSRF token — it would duplicate a
954
+ defence already in place and complicate custom form integration.
955
+
956
+ ### What are the rate-limit defaults, and what does a rate-limited response look like?
957
+
958
+ Defaults (all configurable at construction, no live tuning):
959
+
960
+ | Option | Default | Scope |
961
+ |---|---|---|
962
+ | `maxLoginRequestsPerIpPerHour` | 30 | Per source IP |
963
+ | `maxNewHandlesPerIpPerHour` | 3 | Per source IP (open-registration only) |
964
+ | `maxActiveTokensPerHandle` | 5 | Per handle (unexpired, unused) |
965
+
966
+ A rate-limited submission returns **202** with the same HTML body as a
967
+ successful send — identical to the sham and real-send responses. This
968
+ is intentional: distinct status or body would let an attacker enumerate
969
+ which IP is being throttled. Aggregate counts are surfaced via the
970
+ `onSuppressionWindow` hook (v0.2.1); individual rate-limit hits are
971
+ not exposed per-event by design (see [`knowless.context.md`](knowless.context.md)
972
+ § "Why three hooks, not four").
783
973
 
784
974
  ### Can I run multiple instances behind a load balancer?
785
975
 
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.
976
+ The default SQLite store is a single-process embedded engine. Knowless
977
+ is designed for single-process deployments. Contention shows up as
978
+ `SQLITE_BUSY` errors in logs; if you see them, your write rate exceeds
979
+ the single-process envelope. Benchmark in your own environment — the
980
+ answer depends on kernel, filesystem, SQLite version, and Node version,
981
+ and v1.x carries no numeric guarantee across upgrades.
982
+
983
+ For multi-process or multi-node deployments, implement the store
984
+ interface against Postgres, Redis, or any other backend. The interface
985
+ is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §13.
791
986
 
792
987
  ### How do I see what's in the store?
793
988
 
package/README.md CHANGED
@@ -148,12 +148,21 @@ rate-limits) belong above the library — patterns in
148
148
  Full detail in [`knowless.context.md`](knowless.context.md) §
149
149
  "Threat model summary."
150
150
 
151
- ## Sibling projects
151
+ ## Adopters
152
152
 
153
- - [`addypin`](https://github.com/hamr0/addypin) location sharing,
154
- first knowless adopter
155
- - [`gitdone`](https://github.com/hamr0/gitdone) — verified email
156
- actions via DKIM/SPF inbound
153
+ Production users of knowless, in adoption order:
154
+
155
+ - [`addypin`](https://github.com/hamr0/addypin) — pin-drop location
156
+ sharing. First knowless adopter; Mode A (drop-pin-then-confirm).
157
+ - [`plato`](https://github.com/hamr0/plato) — forum (Reddit-shaped,
158
+ one fingerprint per site). Mode B (sign-in-then-do).
159
+ - [`gitdone`](https://github.com/hamr0/gitdone) — multi-party email
160
+ workflows verified via DKIM/SPF inbound. Mode A
161
+ (start-workflow-then-confirm).
162
+
163
+ If you're picking knowless up: the addypin and gitdone callsites are
164
+ both Mode A and good worked references for the use-first / claim-later
165
+ shape.
157
166
 
158
167
  ## License
159
168
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
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/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,