knowless 0.2.2 → 1.0.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
@@ -15,8 +15,148 @@ Versioning is [SemVer](https://semver.org/).
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
- **v0.2.2 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
+ ## [1.0.0] — 2026-04-29
30
+
31
+ **Walk-away release.** No new API surface vs v0.2.3 — v1.0.0 is the
32
+ *promotion* tag, marking the library as feature-complete and the
33
+ maintenance mode (security + bug fixes only) as active.
34
+
35
+ This is the terminal feature release by intent (PRD §6.3). The
36
+ discipline that produced it: every proposed addition during the
37
+ v0.1.x → v0.2.x cycle was stress-tested against two questions —
38
+ *is this identity layer or behavior layer?* and *does the mechanism
39
+ live with the policy?* Items that failed either test were cut to
40
+ adopter / perimeter / operator code. The result is a library small
41
+ enough to audit in an afternoon, with one production dep, and a
42
+ closed feature list.
43
+
44
+ ### Why v1.0.0 now
45
+
46
+ All PRD §6.1 graduation criteria are met (12/12 after the 2026-04-29
47
+ scope cull). The library is production-validated end-to-end:
48
+
49
+ - **One real adopter shipped on it.** addypin merged its
50
+ `try/knowless` branch and runs knowless as its auth+mail layer in
51
+ production. ~1,150 LOC of bespoke auth/mail removed; ~35 LOC of
52
+ knowless wiring added; ~33× reduction.
53
+ - **The full v0.2.x hardening cycle was driven by adopter signal.**
54
+ Eleven audit findings (AF-7 through AF-25) shipped or were
55
+ recorded as deliberate cuts. Final cycle (AF-19/20/21 operator
56
+ visibility, AF-26 body override, AF-27 From: display name) all
57
+ validated by addypin in production:
58
+ - v0.2.2 + AF-26: bodyOverride wired into pin-confirmation,
59
+ login, and resend@ flows; subject and body agree end-to-end.
60
+ - v0.2.3 + AF-27: fromName wired in both factories (web +
61
+ inbound CLI); inbox preview shows the brand name, not the
62
+ local-part. Validated by use, not by spec.
63
+ - **Test count: 235** (192 in v0.2.0 → 207 in v0.2.1 → 223 in
64
+ v0.2.2 → 235 in v0.2.3 → 235 in v1.0.0).
65
+ - **One production dep** (`nodemailer`). Storage uses `node:sqlite`
66
+ from the Node stdlib. No native compile, no toolchain.
67
+ - **`Δ_mean` for the FR-6 timing test: 0.002ms locally** — 500× under
68
+ the 1ms practical-effect bar.
69
+
70
+ ### What walk-away means in practice
71
+
72
+ - **Pin and forget.** v1.0.0 will work the same way three years
73
+ later. Security patches will land in v1.x.
74
+ - **No v2.0.** No sessions+, no plugin system, no second mailer, no
75
+ SaaS counterpart. The API closes here.
76
+ - **No additive v1.x.** v1.1.0, v1.2.0, etc. are reserved for
77
+ security and bug fixes only. Feature requests are deflected.
78
+ This is the discipline the AF-23/24/25 cuts and the
79
+ AF-26/AF-27-as-v0.2.x decisions both protect: walk-away has to
80
+ *mean* walk-away, otherwise the promise is empty.
81
+ - **Procurement signal.** A library that has explicitly committed
82
+ to *not growing* is a different risk profile from a typical OSS
83
+ package. Most reviews read "still actively developed" as good —
84
+ but for an auth dependency, "still actively developed" is also
85
+ "still changing in ways you'll have to track." knowless inverts
86
+ that.
87
+
88
+ ### Migration from v0.2.3
89
+
90
+ None. v1.0.0 is byte-equivalent to v0.2.3 source. `npm install
91
+ knowless@1.0.0` is a drop-in.
92
+
93
+ ## [0.2.3] — 2026-04-29
94
+
95
+ **Last cosmetic gap before walk-away: From: header display name
96
+ (AF-27).** addypin's recipients saw `From: noreply@addypin.com`
97
+ instead of `From: addypin <noreply@addypin.com>` — most clients
98
+ render the local-part as the sender name in inbox previews, so the
99
+ brand "addypin" was hidden behind "noreply." The library was
100
+ conflating the bare RFC 5321 envelope sender (MAIL FROM, no display
101
+ name allowed) with the RFC 5322 From: header (display name allowed),
102
+ preventing adopters from working around without forking the mailer.
103
+
104
+ Also: the `bodyOverride` JSDoc (AF-26) gets an extra paragraph
105
+ calling out typographic-punctuation traps after addypin hit the
106
+ em-dash on a live send. Pure documentation, no API change.
107
+
108
+ ### Added
109
+
110
+ - **`fromName` factory option (AF-27).** Optional. When set, the
111
+ `From:` header is rendered as `${fromName} <${from}>`; envelope
112
+ sender stays bare. Validated at factory startup via the new
113
+ `validateFromName()` helper:
114
+ - ≤ 60 chars
115
+ - ASCII only (excludes em/en dashes, smart quotes, ellipses,
116
+ middle dots — same trap surface as `bodyOverride`)
117
+ - No CR / LF (header-injection defense, matches existing
118
+ composeRaw invariant)
119
+ - No `<` / `>` / `"` (would break `name <addr>` quoting)
120
+
121
+ ```js
122
+ knowless({
123
+ secret, baseUrl,
124
+ from: 'noreply@addypin.com',
125
+ fromName: 'addypin', // recipient sees: From: addypin <noreply@addypin.com>
126
+ });
127
+ ```
128
+
129
+ Adopters who don't pass `fromName` get the existing behavior (bare
130
+ address in From:). No call-site changes required.
131
+
132
+ - **`validateFromName(name)` re-export.** Pure validator alongside
133
+ the other `validate*` helpers, for ahead-of-time tests.
134
+
135
+ ### Changed
136
+
137
+ - **`composeRaw` accepts `fromName` arg.** Internal mailer-interface
138
+ change: composeRaw now takes an optional `fromName` parameter
139
+ threaded through from `createMailer`. Adopters with a custom
140
+ `mailer` injection (rare) should plumb `fromName` accordingly if
141
+ they want the display-name behavior; otherwise the field is
142
+ ignored and bare-address behavior is preserved.
143
+
144
+ ### Documentation
145
+
146
+ - **`bodyOverride` JSDoc (AF-26)** — added a typographic-punctuation
147
+ paragraph listing the four common traps (em/en dashes, smart
148
+ quotes, ellipses, middle dots) and their ASCII alternatives.
149
+ Adopters writing email copy reach for these out of habit; the
150
+ validator error message was clear, but a heads-up before the first
151
+ live send saves a debugging cycle. Same paragraph applies to the
152
+ `fromName` validator docstring (AF-27).
153
+
154
+ ### Internal
155
+
156
+ - 12 new tests in `test/integration/from-name.test.js` covering
157
+ the happy path (real + sham branches), envelope stays bare,
158
+ whitespace passthrough, all five validation error paths, and the
159
+ re-exported `validateFromName` helper. Test count: 223 → 235.
20
160
 
21
161
  ## [0.2.2] — 2026-04-29
22
162
 
package/GUIDE.md CHANGED
@@ -631,7 +631,8 @@ Full options table:
631
631
  |---|---|---|---|
632
632
  | `secret` | yes | — | HMAC key, ≥64 hex chars (32 bytes). FR-47, FR-48. |
633
633
  | `baseUrl` | yes | — | Base URL for magic-link construction. |
634
- | `from` | yes | — | Sender email address. |
634
+ | `from` | yes | — | Bare RFC 5321 sender (envelope MAIL FROM AND default From: header). |
635
+ | `fromName` | no | — | Optional RFC 5322 display name for the From: header (AF-27, v0.2.3+). When set, recipients see `addypin <noreply@addypin.com>` instead of bare `noreply@addypin.com` — most clients display the local-part as the sender name otherwise. ASCII, ≤60 chars, no CR/LF/<>". envelope.from stays bare always. |
635
636
  | `dbPath` | no | `./knowless.db` | SQLite file path. |
636
637
  | `cookieDomain` | no | (eTLD+1 of `baseUrl`) | Session cookie scope. |
637
638
  | `cookieName` | no | `knowless_session` | Session cookie name. |
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.2 | 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.2 | 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
 
@@ -70,7 +70,15 @@ const auth = knowless({
70
70
  // --- Required ---
71
71
  secret: '...', // 64-char hex; HMAC + cookie sig key
72
72
  baseUrl: 'https://app.example.com', // base for magic-link URL construction
73
- from: 'auth@app.example.com', // sender address
73
+ from: 'auth@app.example.com', // bare RFC 5321 sender (envelope MAIL FROM
74
+ // AND default From: header value)
75
+
76
+ // --- Optional sender display name (AF-27, v0.2.3) ---
77
+ fromName: 'addypin', // optional. When set, From: header is
78
+ // `<fromName> <from>` (e.g. `addypin
79
+ // <noreply@addypin.com>`); envelope.from
80
+ // stays bare. Validated at startup:
81
+ // ASCII, ≤60 chars, no CR/LF, no <>".
74
82
 
75
83
  // --- Storage ---
76
84
  dbPath: './knowless.db', // SQLite file; ':memory:' for tests
@@ -179,6 +187,7 @@ import {
179
187
  validateSubject, // pure: validate operator-supplied subject
180
188
  validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
181
189
  validateBodyOverride, // pure: validate per-call body override (AF-26)
190
+ validateFromName, // pure: validate operator-supplied From: display name (AF-27)
182
191
  renderLoginForm, // pure: HTML5 page rendering
183
192
  normalize, // pure: email normalization
184
193
  deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.2.2",
3
+ "version": "1.0.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/index.js CHANGED
@@ -34,7 +34,13 @@ function safeHook(fn, label) {
34
34
  * @typedef {Object} KnowlessOptions
35
35
  * @property {string} secret HMAC secret, ≥64 hex chars (32 bytes). FR-47, FR-48.
36
36
  * @property {string} baseUrl Public base URL for magic links.
37
- * @property {string} from Sender email address.
37
+ * @property {string} from Sender email address (bare RFC 5321
38
+ * MAIL FROM and default From: header value).
39
+ * @property {string} [fromName] Optional RFC 5322 display name for
40
+ * the From: header (AF-27, v0.2.3). When set, recipients see
41
+ * `<fromName> <from>` in the From: header (e.g. `addypin
42
+ * <noreply@addypin.com>`); SMTP envelope sender stays bare.
43
+ * Validated at factory startup (ASCII, ≤60 chars, no CR/LF/<>).
38
44
  * @property {string} [dbPath='./knowless.db']
39
45
  * @property {string} [cookieDomain] Defaults to baseUrl's hostname.
40
46
  * @property {number} [tokenTtlSeconds=900]
@@ -136,6 +142,7 @@ export function knowless(options = {}) {
136
142
  options.mailer ??
137
143
  createMailer({
138
144
  from: options.from,
145
+ fromName: options.fromName,
139
146
  smtpHost: options.smtpHost,
140
147
  smtpPort: options.smtpPort,
141
148
  transportOverride: options.transportOverride,
@@ -279,6 +286,7 @@ export {
279
286
  validateSubject,
280
287
  validateBodyFooter,
281
288
  validateBodyOverride,
289
+ validateFromName,
282
290
  } from './mailer.js';
283
291
  export { createHandlers } from './handlers.js';
284
292
  export { renderLoginForm } from './form.js';
package/src/mailer.js CHANGED
@@ -12,13 +12,17 @@ const ASCII_RE = /^[\x00-\x7f]*$/;
12
12
  * the SMTP submission transport.
13
13
  *
14
14
  * @param {object} args
15
- * @param {string} args.from
15
+ * @param {string} args.from bare RFC 5321 MAIL FROM address
16
+ * @param {string} [args.fromName] optional RFC 5322 display name
17
+ * (AF-27). When set, the From: header is `name <addr>`; when null/
18
+ * undefined, the From: header is the bare `addr`. envelope.from
19
+ * (caller-side) always uses the bare address.
16
20
  * @param {string} args.to
17
21
  * @param {string} args.subject
18
22
  * @param {string} args.body ASCII-only plain text
19
23
  * @returns {string} RFC822 message with CRLF line endings
20
24
  */
21
- function composeRaw({ from, to, subject, body }) {
25
+ function composeRaw({ from, fromName, to, subject, body }) {
22
26
  // AF-2.1: header-injection defense in depth. normalize() upstream
23
27
  // already rejects \r and \n in email addresses, but the mailer
24
28
  // shouldn't trust its callers — this is the layer that emits the
@@ -35,11 +39,19 @@ function composeRaw({ from, to, subject, body }) {
35
39
  throw new Error(`mailer: ${name} contains CR/LF — header injection blocked`);
36
40
  }
37
41
  }
42
+ // AF-27: defensive re-check on fromName (createMailer already validated
43
+ // at startup, but composeRaw owns the wire-format invariant).
44
+ if (fromName != null && fromName !== '') {
45
+ if (typeof fromName !== 'string' || /[\r\n<>"]/.test(fromName)) {
46
+ throw new Error('mailer: fromName contains forbidden characters');
47
+ }
48
+ }
38
49
  const fromDomain = from.includes('@') ? from.split('@').pop() : 'localhost';
39
50
  const messageId = `<${crypto.randomUUID()}@${fromDomain}>`;
40
51
  const date = new Date().toUTCString();
52
+ const fromHeader = fromName ? `${fromName} <${from}>` : from;
41
53
  const headers = [
42
- `From: ${from}`,
54
+ `From: ${fromHeader}`,
43
55
  `To: ${to}`,
44
56
  `Subject: ${subject}`,
45
57
  `Date: ${date}`,
@@ -149,7 +161,15 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFoot
149
161
  * invariant — QP soft-breaks WILL break the magic link):
150
162
  * - non-empty string
151
163
  * - ≤ 2048 chars (operator-side overflow guard)
152
- * - ASCII only
164
+ * - ASCII only (0x00–0x7F). This excludes typographic punctuation
165
+ * that adopters reach for out of habit:
166
+ * em/en dashes (— –) → use - or --
167
+ * smart quotes (" " ' ') → use " and '
168
+ * ellipses (…) → use ...
169
+ * middle dots (·) → use | or -
170
+ * The constraint preserves 7bit transfer encoding; non-ASCII
171
+ * would force quoted-printable, which can soft-break the URL
172
+ * line and break the link.
153
173
  * - no CR (LF allowed; defense-in-depth header-injection guard)
154
174
  * - the magic-link URL appears EXACTLY ONCE
155
175
  * - that occurrence is on its own line (no leading or trailing
@@ -193,6 +213,49 @@ export function validateBodyOverride(body, url) {
193
213
  }
194
214
  }
195
215
 
216
+ /**
217
+ * Validate the operator-supplied display name for the `From:` header
218
+ * (AF-27, v0.2.3). knowless splits the bare envelope sender (RFC 5321
219
+ * MAIL FROM) from the RFC 5322 `From:` header, allowing operators to
220
+ * brand the inbox preview as `addypin <noreply@addypin.com>` rather
221
+ * than the bare `noreply@addypin.com` (which most clients display as
222
+ * the local-part "noreply").
223
+ *
224
+ * Constraints (deliberately strict — same trap as bodyOverride for
225
+ * typographic punctuation):
226
+ * - ≤ 60 chars (same ballpark as Subject)
227
+ * - ASCII only (0x00–0x7F). Excludes em/en dashes, smart quotes,
228
+ * ellipses, middle dots. Use plain ASCII equivalents.
229
+ * - No CR / LF (header-injection defense; same invariant as
230
+ * composeRaw enforces on `from` / `to` / `subject`)
231
+ * - No `<` / `>` / `"` (would break the `name <addr>` quoting)
232
+ *
233
+ * Returns the validated string (or `null` for null/empty input, so
234
+ * callers can pass through). Throws on violation.
235
+ *
236
+ * @param {unknown} name
237
+ * @returns {string|null}
238
+ */
239
+ export function validateFromName(name) {
240
+ if (name == null || name === '') return null;
241
+ if (typeof name !== 'string') {
242
+ throw new Error('fromName must be a string when provided');
243
+ }
244
+ if (name.length > 60) {
245
+ throw new Error('fromName must be ≤ 60 chars');
246
+ }
247
+ if (!ASCII_RE.test(name)) {
248
+ throw new Error('fromName must be ASCII (no em-dashes, smart quotes, ellipses, etc.)');
249
+ }
250
+ if (/[\r\n]/.test(name)) {
251
+ throw new Error('fromName must not contain CR/LF (header-injection defense)');
252
+ }
253
+ if (/[<>"]/.test(name)) {
254
+ throw new Error('fromName must not contain < > or " (would break From: header quoting)');
255
+ }
256
+ return name;
257
+ }
258
+
196
259
  /**
197
260
  * Validate operator-overridden subject per SPEC §12.5.
198
261
  * Throws on invalid; warns (returns warnings array) on suspicious-but-allowed.
@@ -225,18 +288,24 @@ export function validateSubject(subject) {
225
288
  * with streamTransport:true) to capture the raw bytes without an MTA.
226
289
  *
227
290
  * @param {object} cfg
228
- * @param {string} cfg.from sender, e.g. 'auth@app.example.com'
291
+ * @param {string} cfg.from bare RFC 5321 sender address (envelope
292
+ * MAIL FROM AND default From: header value when fromName is unset)
293
+ * @param {string} [cfg.fromName] AF-27 (v0.2.3). Optional RFC 5322
294
+ * display name. When set, the From: header is `name <addr>`; envelope
295
+ * sender stays bare. Validated by validateFromName() at startup.
229
296
  * @param {string} [cfg.smtpHost='localhost']
230
297
  * @param {number} [cfg.smtpPort=25]
231
298
  * @param {object} [cfg.transportOverride] for tests
232
- * @returns {{ submit(args: {to:string, subject:string, body:string}): Promise<any>, close(): void }}
299
+ * @returns {{ submit(args: {to:string, subject:string, body:string}): Promise<any>, verify(): Promise<true>, close(): void }}
233
300
  */
234
301
  export function createMailer(cfg) {
235
- const { from, smtpHost = 'localhost', smtpPort = 25, transportOverride } = cfg;
302
+ const { from, fromName, smtpHost = 'localhost', smtpPort = 25, transportOverride } = cfg;
236
303
  if (typeof from !== 'string' || from.length === 0) {
237
304
  throw new Error('mailer: from is required');
238
305
  }
239
306
  if (!ASCII_RE.test(from)) throw new Error('mailer: from must be ASCII');
307
+ // AF-27: validate display name at startup; fail-fast.
308
+ const validatedFromName = validateFromName(fromName);
240
309
 
241
310
  const transport =
242
311
  transportOverride ??
@@ -269,7 +338,9 @@ export function createMailer(cfg) {
269
338
  if (!ASCII_RE.test(body)) {
270
339
  throw new Error('mailer: body must be ASCII');
271
340
  }
272
- const raw = composeRaw({ from, to, subject, body });
341
+ // AF-27: From: header may include display name; envelope.from
342
+ // stays bare (RFC 5321 MAIL FROM doesn't allow display names).
343
+ const raw = composeRaw({ from, fromName: validatedFromName, to, subject, body });
273
344
  return transport.sendMail({
274
345
  envelope: { from, to: [to] },
275
346
  raw,