knowless 0.2.3 → 1.0.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
@@ -15,8 +15,131 @@ Versioning is [SemVer](https://semver.org/).
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
- **v0.2.3 is feature-complete.** v1.0.0 is the planned next release
19
- walk-away promotion, no API changes.
18
+ Walk-away is active. Per PRD §6.3, the only changes that ship after
19
+ v1.0.0 are:
20
+
21
+ - Security fixes (CVEs in `nodemailer` or `node:sqlite` with
22
+ user-visible impact)
23
+ - Bug fixes that don't change the API surface
24
+ - Documentation corrections
25
+
26
+ Feature requests are deflected to PRD §14 NO-GO, to sibling projects,
27
+ or to forking. The library being "done" is a feature.
28
+
29
+ ### Fixed
30
+
31
+ - **XFF/X-Real-IP never honored through handler path (AF-28).**
32
+ `createHandlers` pre-built `trustedProxies` into a `{ has }` object
33
+ and passed it to `determineSourceIp`, which re-called
34
+ `buildTrustedPeers` on it. The pre-built object is not a `BlockList`,
35
+ array, or `Set`, so the peer list fell through to `[]`. Net effect:
36
+ trusted-proxy matching was silently empty in the handler code path —
37
+ rate limiting hit the reverse-proxy IP instead of the real client IP,
38
+ and XFF/X-Real-IP headers were ignored regardless of the
39
+ `trustedProxies` config. Abuse unit tests were passing because they
40
+ call `determineSourceIp` directly with raw arrays, bypassing the
41
+ handler path. Fix: removed the pre-build in `createHandlers`;
42
+ `determineSourceIp` now receives `cfg.trustedProxies` directly. Closes
43
+ AF-28.
44
+
45
+ - **`validateSubject` allowed CR/LF, enabling header injection in
46
+ standalone callers (AF-29).** The ASCII regex `/^[\x00-\x7f]*$/`
47
+ matched CR (0x0D) and LF (0x0A). `validateSubject` is re-exported as a
48
+ public validator (AF-9.1 / v0.1.7) so callers using it as a standalone
49
+ gate were unprotected. `composeRaw` caught the injection downstream, but
50
+ the validator is the authoritative public guard. Fix: added an explicit
51
+ `/[\r\n]/` check to `validateSubject`, consistent with the guards already
52
+ present in `validateFromName` and `validateBodyOverride`. Closes AF-29.
53
+
54
+ - **Factory `subject` not validated at startup (AF-30).** The factory
55
+ `subject` option was never passed to `validateSubject` during
56
+ `createHandlers` startup, breaking the fail-fast pattern that
57
+ `bodyFooter` (`validateBodyFooter` in `index.js`) and `fromName`
58
+ (`validateFromName` in `createMailer`) already followed. A non-ASCII
59
+ subject or empty string silently passed config time and would only fail
60
+ at first `mailer.submit()` — potentially hours into production. Fix:
61
+ added `validateSubject(cfg.subject)` to the config-validation block in
62
+ `createHandlers`. Closes AF-30.
63
+
64
+ - **`validateBodyFooter` rejected 4-line footers with a trailing newline
65
+ (AF-31).** `footer.split('\n').length > 4` counted 5 split parts for
66
+ `"a\nb\nc\nd\n"` (4 logical lines, trailing newline as is conventional
67
+ for multi-line strings). Fix: strip a single trailing newline before
68
+ counting: `footer.replace(/\n$/, '').split('\n').length > 4`. Closes
69
+ AF-31.
70
+
71
+ ### Documentation
72
+
73
+ - **`runSendLink` JSDoc corrected: `handle` is null for both malformed
74
+ email and per-IP rate-limit short-circuit (AF-32).** The JSDoc stated
75
+ `handle` is null "only when the email failed to normalize." The per-IP
76
+ rate-limit early return also returns `handle: null` because `deriveHandle`
77
+ has not run at that point. No behavior change — documentation only. Closes
78
+ AF-32.
79
+
80
+ ## [1.0.0] — 2026-04-29
81
+
82
+ **Walk-away release.** No new API surface vs v0.2.3 — v1.0.0 is the
83
+ *promotion* tag, marking the library as feature-complete and the
84
+ maintenance mode (security + bug fixes only) as active.
85
+
86
+ This is the terminal feature release by intent (PRD §6.3). The
87
+ discipline that produced it: every proposed addition during the
88
+ v0.1.x → v0.2.x cycle was stress-tested against two questions —
89
+ *is this identity layer or behavior layer?* and *does the mechanism
90
+ live with the policy?* Items that failed either test were cut to
91
+ adopter / perimeter / operator code. The result is a library small
92
+ enough to audit in an afternoon, with one production dep, and a
93
+ closed feature list.
94
+
95
+ ### Why v1.0.0 now
96
+
97
+ All PRD §6.1 graduation criteria are met (12/12 after the 2026-04-29
98
+ scope cull). The library is production-validated end-to-end:
99
+
100
+ - **One real adopter shipped on it.** addypin merged its
101
+ `try/knowless` branch and runs knowless as its auth+mail layer in
102
+ production. ~1,150 LOC of bespoke auth/mail removed; ~35 LOC of
103
+ knowless wiring added; ~33× reduction.
104
+ - **The full v0.2.x hardening cycle was driven by adopter signal.**
105
+ Eleven audit findings (AF-7 through AF-25) shipped or were
106
+ recorded as deliberate cuts. Final cycle (AF-19/20/21 operator
107
+ visibility, AF-26 body override, AF-27 From: display name) all
108
+ validated by addypin in production:
109
+ - v0.2.2 + AF-26: bodyOverride wired into pin-confirmation,
110
+ login, and resend@ flows; subject and body agree end-to-end.
111
+ - v0.2.3 + AF-27: fromName wired in both factories (web +
112
+ inbound CLI); inbox preview shows the brand name, not the
113
+ local-part. Validated by use, not by spec.
114
+ - **Test count: 235** (192 in v0.2.0 → 207 in v0.2.1 → 223 in
115
+ v0.2.2 → 235 in v0.2.3 → 235 in v1.0.0).
116
+ - **One production dep** (`nodemailer`). Storage uses `node:sqlite`
117
+ from the Node stdlib. No native compile, no toolchain.
118
+ - **`Δ_mean` for the FR-6 timing test: 0.002ms locally** — 500× under
119
+ the 1ms practical-effect bar.
120
+
121
+ ### What walk-away means in practice
122
+
123
+ - **Pin and forget.** v1.0.0 will work the same way three years
124
+ later. Security patches will land in v1.x.
125
+ - **No v2.0.** No sessions+, no plugin system, no second mailer, no
126
+ SaaS counterpart. The API closes here.
127
+ - **No additive v1.x.** v1.1.0, v1.2.0, etc. are reserved for
128
+ security and bug fixes only. Feature requests are deflected.
129
+ This is the discipline the AF-23/24/25 cuts and the
130
+ AF-26/AF-27-as-v0.2.x decisions both protect: walk-away has to
131
+ *mean* walk-away, otherwise the promise is empty.
132
+ - **Procurement signal.** A library that has explicitly committed
133
+ to *not growing* is a different risk profile from a typical OSS
134
+ package. Most reviews read "still actively developed" as good —
135
+ but for an auth dependency, "still actively developed" is also
136
+ "still changing in ways you'll have to track." knowless inverts
137
+ that.
138
+
139
+ ### Migration from v0.2.3
140
+
141
+ None. v1.0.0 is byte-equivalent to v0.2.3 source. `npm install
142
+ knowless@1.0.0` is a drop-in.
20
143
 
21
144
  ## [0.2.3] — 2026-04-29
22
145
 
package/README.md CHANGED
@@ -7,7 +7,7 @@ that don't need to email their users for anything but the sign-in link.
7
7
  npm install knowless
8
8
  ```
9
9
 
10
- > v0.2.3 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
10
+ > v1.0.0 (walk-away release) | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
11
11
 
12
12
  ## Where to go next
13
13
 
@@ -1,7 +1,7 @@
1
1
  # knowless -- Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring knowless into a project.
4
- > v0.2.3 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
4
+ > v1.0.0 (walk-away release) | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -659,10 +659,11 @@ rate-limits) belongs above the library.
659
659
  who hold raw 32-byte keys.
660
660
 
661
661
  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.
662
+ ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines (a single trailing
663
+ newline is allowed and not counted as an extra line), no
664
+ `http(s)://` URLs (would conflict with the magic-link line).
665
+ Validated at factory startup; fails fast. Goes after RFC 3676
666
+ `"-- "` delimiter so mail clients strip it from quoted replies.
666
667
 
667
668
  19. **`startLogin` is silent at every layer (FR-6).** Returns
668
669
  `{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": "0.2.3",
3
+ "version": "1.0.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/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/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) {