knowless 0.2.2 → 0.2.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
@@ -15,9 +15,77 @@ 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 —
18
+ **v0.2.3 is feature-complete.** v1.0.0 is the planned next release —
19
19
  walk-away promotion, no API changes.
20
20
 
21
+ ## [0.2.3] — 2026-04-29
22
+
23
+ **Last cosmetic gap before walk-away: From: header display name
24
+ (AF-27).** addypin's recipients saw `From: noreply@addypin.com`
25
+ instead of `From: addypin <noreply@addypin.com>` — most clients
26
+ render the local-part as the sender name in inbox previews, so the
27
+ brand "addypin" was hidden behind "noreply." The library was
28
+ conflating the bare RFC 5321 envelope sender (MAIL FROM, no display
29
+ name allowed) with the RFC 5322 From: header (display name allowed),
30
+ preventing adopters from working around without forking the mailer.
31
+
32
+ Also: the `bodyOverride` JSDoc (AF-26) gets an extra paragraph
33
+ calling out typographic-punctuation traps after addypin hit the
34
+ em-dash on a live send. Pure documentation, no API change.
35
+
36
+ ### Added
37
+
38
+ - **`fromName` factory option (AF-27).** Optional. When set, the
39
+ `From:` header is rendered as `${fromName} <${from}>`; envelope
40
+ sender stays bare. Validated at factory startup via the new
41
+ `validateFromName()` helper:
42
+ - ≤ 60 chars
43
+ - ASCII only (excludes em/en dashes, smart quotes, ellipses,
44
+ middle dots — same trap surface as `bodyOverride`)
45
+ - No CR / LF (header-injection defense, matches existing
46
+ composeRaw invariant)
47
+ - No `<` / `>` / `"` (would break `name <addr>` quoting)
48
+
49
+ ```js
50
+ knowless({
51
+ secret, baseUrl,
52
+ from: 'noreply@addypin.com',
53
+ fromName: 'addypin', // recipient sees: From: addypin <noreply@addypin.com>
54
+ });
55
+ ```
56
+
57
+ Adopters who don't pass `fromName` get the existing behavior (bare
58
+ address in From:). No call-site changes required.
59
+
60
+ - **`validateFromName(name)` re-export.** Pure validator alongside
61
+ the other `validate*` helpers, for ahead-of-time tests.
62
+
63
+ ### Changed
64
+
65
+ - **`composeRaw` accepts `fromName` arg.** Internal mailer-interface
66
+ change: composeRaw now takes an optional `fromName` parameter
67
+ threaded through from `createMailer`. Adopters with a custom
68
+ `mailer` injection (rare) should plumb `fromName` accordingly if
69
+ they want the display-name behavior; otherwise the field is
70
+ ignored and bare-address behavior is preserved.
71
+
72
+ ### Documentation
73
+
74
+ - **`bodyOverride` JSDoc (AF-26)** — added a typographic-punctuation
75
+ paragraph listing the four common traps (em/en dashes, smart
76
+ quotes, ellipses, middle dots) and their ASCII alternatives.
77
+ Adopters writing email copy reach for these out of habit; the
78
+ validator error message was clear, but a heads-up before the first
79
+ live send saves a debugging cycle. Same paragraph applies to the
80
+ `fromName` validator docstring (AF-27).
81
+
82
+ ### Internal
83
+
84
+ - 12 new tests in `test/integration/from-name.test.js` covering
85
+ the happy path (real + sham branches), envelope stays bare,
86
+ whitespace passthrough, all five validation error paths, and the
87
+ re-exported `validateFromName` helper. Test count: 223 → 235.
88
+
21
89
  ## [0.2.2] — 2026-04-29
22
90
 
23
91
  **One feature add at the end of v0.2.x: per-call body customization
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
+ > v0.2.3 | 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
+ > v0.2.3 | 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": "0.2.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",
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,