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 +69 -1
- package/GUIDE.md +2 -1
- package/README.md +1 -1
- package/knowless.context.md +11 -2
- package/package.json +1 -1
- package/src/index.js +9 -1
- package/src/mailer.js +79 -8
package/CHANGELOG.md
CHANGED
|
@@ -15,9 +15,77 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
15
15
|
|
|
16
16
|
## [Unreleased]
|
|
17
17
|
|
|
18
|
-
**v0.2.
|
|
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 | — |
|
|
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.
|
|
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
|
|
package/knowless.context.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
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,
|