knowless 0.2.1 → 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,14 +15,143 @@ Versioning is [SemVer](https://semver.org/).
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
- - **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
19
- bundling Postfix + null-route + the binary. Now meaningfully
20
- smaller and faster to build because v0.2.0 dropped the native
21
- compile dep.
22
- - Caddy forward-auth Docker integration test (TASKS.md 6.8).
23
- - `knowless-server --check-null-route`: CLI probe that submits a
24
- test message to `shamRecipient` and confirms the local MTA
25
- discarded it.
18
+ **v0.2.3 is feature-complete.** v1.0.0 is the planned next release —
19
+ walk-away promotion, no API changes.
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
+
89
+ ## [0.2.2] — 2026-04-29
90
+
91
+ **One feature add at the end of v0.2.x: per-call body customization
92
+ for `auth.startLogin` (AF-26).** Closes the last addypin gap before
93
+ v1.0.0 promotion — adopters with multiple Mode-A flows (pin
94
+ confirmation, login, expiry warning) can now phrase the email body
95
+ to match per-call subjects without re-implementing token mint /
96
+ sham-work / SMTP submit.
97
+
98
+ ### Added
99
+
100
+ - **`bodyOverride: ({url}) => string` arg on `auth.startLogin`
101
+ (AF-26).** A template function called per-call after the magic-
102
+ link URL is composed. knowless still owns URL composition (so the
103
+ v0.11 POC 7bit URL-line invariant stays in knowless's control) and
104
+ validates the rendered output. `bodyFooter` continues to append
105
+ after the override; the `lastLogin` line does NOT auto-append on
106
+ overridden bodies — the template owns content end-to-end.
107
+
108
+ ```js
109
+ await auth.startLogin({
110
+ email,
111
+ subjectOverride: `Confirm your pin: ${shortcode}`,
112
+ bodyOverride: ({ url }) =>
113
+ `Confirm your pin "${shortcode}":\n\n${url}\n\n` +
114
+ `Link expires in 15 minutes.\n`,
115
+ });
116
+ ```
117
+
118
+ - **`validateBodyOverride(body, url)` re-export.** Pure validator
119
+ exposed alongside the other `validate*` re-exports. Same validation
120
+ knowless runs internally on the override return value:
121
+ - Non-empty string, ≤ 2048 chars
122
+ - ASCII only
123
+ - No CR (header-injection defense)
124
+ - URL appears exactly once
125
+ - URL is on its own line (preserves the v0.11 POC 7bit URL-line
126
+ invariant — QP soft-breaks would break the link)
127
+
128
+ Throws on any violation. Adopters generally don't need to call this
129
+ directly — knowless validates the function's return value at
130
+ compose time — but it's exported for ahead-of-time tests.
131
+
132
+ ### Why this is in scope (and not a 'forum-style' rejected addition)
133
+
134
+ The body has to be composed *after* the token is minted (the URL
135
+ contains the token), so the caller can't just "send their own email"
136
+ without re-implementing most of knowless. Bypassing knowless to send
137
+ a custom body would mean rebuilding token mint + token-store insert
138
+ + sham-work timing + SMTP submit — that's most of the library. The
139
+ ASCII / URL-line / 7bit constraints are the right place to keep
140
+ validating, and those live in knowless. Identity-layer concern,
141
+ mechanism stays where the policy is.
142
+
143
+ Contrast with the rejected items in the v0.2.1 backlog cull
144
+ (disposable-domain, account-age, hashcash, Docker image): each of
145
+ those passed the "could the adopter do this themselves?" test.
146
+ Per-call body customization fails it.
147
+
148
+ ### Internal
149
+
150
+ - 16 new tests in `test/integration/body-override.test.js` covering
151
+ happy path on real submissions, sham-branch parity (FR-6),
152
+ bodyFooter append behavior, lastLogin non-auto-append, every
153
+ validation error path, undefined/null pass-through, and the
154
+ re-exported `validateBodyOverride` helper. Test count: 207 → 223.
26
155
 
27
156
  ## [0.2.1] — 2026-04-29
28
157
 
@@ -110,6 +239,36 @@ accessor, hashcash, `lookupMessageId()`, `onShamHit`).
110
239
  containment, and `verifyTransport()` resolve/reject paths.
111
240
  Test count: 192 → 207.
112
241
 
242
+ ### Cut from v0.2.x backlog (kept here for the record)
243
+
244
+ Three items previously listed under Unreleased were stress-tested
245
+ against walk-away-at-v1.0.0 and cut. Rationale per item, so future
246
+ contributors see why these aren't being re-proposed:
247
+
248
+ - **`knowless-server --check-null-route` CLI probe — CUT.** Operator
249
+ setup-correctness check, not identity layer. The same probe is
250
+ three commands of `swaks` + `tail /var/log/maillog`; documented in
251
+ GUIDE.md Step 3. Adding a knowless CLI feature for it would carry
252
+ maintenance burden into walk-away for something an operator can
253
+ already do with a one-line shell command.
254
+ - **Caddy forward-auth Docker integration test (TASKS 6.8) — CUT.**
255
+ The contract under test is two HTTP responses and one header
256
+ (`/verify` → 200+`X-User-Handle` or 401). Every hop is already
257
+ covered by `forward-auth-next.test.js` + `cli.test.js`. addypin
258
+ runs knowless behind Caddy in production — that is the integration
259
+ test, with adopter signal stronger than any docker-compose CI
260
+ could provide. Removed as a v1.0.0 graduation criterion;
261
+ PRD §6.1 updated.
262
+ - **Turnkey Docker image (`knowless/knowless-server:0.2.x`) — CUT.**
263
+ Doesn't actually solve the operator problem: SPF / DKIM / PTR /
264
+ outbound-port-25 work is still the operator's, image only saves
265
+ ~5 minutes of `apt install postfix && postmap`. Cost-side is
266
+ permanent: Postfix is on a CVE drumbeat, and a walk-away library
267
+ shipping a Postfix image would commit to forever-rebuilds —
268
+ exactly the opposite of walk-away discipline. If a self-hoster
269
+ builds a community Dockerfile, OPS.md will link to it; knowless
270
+ itself doesn't ship one.
271
+
113
272
  ## [0.2.0] — 2026-04-28
114
273
 
115
274
  **No native compile. One production dep.** Drops `better-sqlite3`
package/GUIDE.md CHANGED
@@ -162,6 +162,25 @@ sudo postmap /etc/postfix/transport
162
162
  sudo systemctl reload postfix
163
163
  ```
164
164
 
165
+ **Verify the null-route is catching mail.** A misconfigured
166
+ null-route doesn't surface until the first sham submission — by
167
+ which point you're debugging a silent-202 from a real form post.
168
+ One-line check, no knowless code needed:
169
+
170
+ ```
171
+ sudo apt install swaks # one-time, if not present
172
+ swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
173
+ sudo journalctl -u postfix --since '1 minute ago' | grep -i 'discard'
174
+ ```
175
+
176
+ A `discard:` line in the postfix log confirms `transport_maps` is
177
+ applied. If you see `relay=` / `delivered` / a queue ID with no
178
+ discard, re-run `postmap` + `systemctl reload postfix` and try
179
+ again. (knowless deliberately does NOT ship a `--check-null-route`
180
+ CLI for this — operator MTA validation is operator-side, and adding
181
+ a wrapper for a one-line `swaks` invocation would carry maintenance
182
+ burden into the v1.0.0 walk-away window for no real value.)
183
+
165
184
  Then the DNS records — set on your sending domain, **not** your
166
185
  app's primary domain (typical setup: `auth.example.com` is the
167
186
  sending domain):
@@ -240,15 +259,18 @@ the form.
240
259
 
241
260
  ### Two adoption modes (Mode A vs Mode B)
242
261
 
243
- knowless supports two UX flows out of the box. Pick per-action,
244
- not per-app both can coexist.
262
+ In plain English: **"sign in, then do the thing"** (Mode B) and
263
+ **"do the thing, confirm by email"** (Mode A). knowless supports
264
+ both out of the box; pick per-action, not per-app — they coexist.
265
+ The Mode A/B labels are used here and in the CHANGELOG so
266
+ discussions across the docs stay unambiguous.
245
267
 
246
- **Mode B — register-first (the default).** User must log in before
247
- performing the action. Wire `auth.login` / `auth.callback` as
248
- above; gate your action with `auth.handleFromRequest(req)`. Use
249
- when the action requires a session (account settings, paid
250
- features, anything you want tied to an identity at the moment of
251
- the action).
268
+ **Mode B — "sign in, then do the thing" (register-first, the default).**
269
+ User must log in before performing the action. Wire `auth.login` /
270
+ `auth.callback` as above; gate your action with
271
+ `auth.handleFromRequest(req)`. Use when the action requires a session
272
+ (account settings, paid features, anything you want tied to an
273
+ identity at the moment of the action).
252
274
 
253
275
  ```js
254
276
  app.post('/api/comments', (req, res) => {
@@ -258,12 +280,12 @@ app.post('/api/comments', (req, res) => {
258
280
  });
259
281
  ```
260
282
 
261
- **Mode A — use-first, claim-later.** User performs the action
262
- without logging in; you capture their email and trigger a magic
263
- link. Clicking it opens a session and your callback handler
264
- "promotes" the deferred resource. Use for "drop a pin," "post a
265
- share link," "submit a paste" — patterns where forcing a login
266
- *before* the action would harm the UX.
283
+ **Mode A — "do the thing, confirm by email" (use-first, claim-later).**
284
+ User performs the action without logging in; you capture their email
285
+ and trigger a magic link. Clicking it opens a session and your
286
+ callback handler "promotes" the deferred resource. Use for "drop a
287
+ pin," "post a share link," "submit a paste" — patterns where forcing
288
+ a login *before* the action would harm the UX.
267
289
 
268
290
  ```js
269
291
  app.post('/api/pins', async (req, res) => {
@@ -277,6 +299,14 @@ app.post('/api/pins', async (req, res) => {
277
299
  // Per-call subject so the user can tell at a glance this is a
278
300
  // pin-confirmation, not a routine login. AF-9.
279
301
  subjectOverride: `Confirm your pin: ${shortcode}`,
302
+ // Per-call body so subject and body agree. AF-26 (v0.2.2).
303
+ // knowless still composes the URL and validates the rendered
304
+ // output (ASCII / URL on its own line / ≤2048 chars). bodyFooter
305
+ // still appends; the lastLogin line does NOT auto-append on
306
+ // overridden bodies — the template owns the content.
307
+ bodyOverride: ({ url }) =>
308
+ `Confirm your pin "${shortcode}":\n\n${url}\n\n` +
309
+ `This link expires in 15 minutes. If you didn't request it, ignore.\n`,
280
310
  });
281
311
  res.status(202).end(); // "we'll email you the link"
282
312
  });
@@ -601,7 +631,8 @@ Full options table:
601
631
  |---|---|---|---|
602
632
  | `secret` | yes | — | HMAC key, ≥64 hex chars (32 bytes). FR-47, FR-48. |
603
633
  | `baseUrl` | yes | — | Base URL for magic-link construction. |
604
- | `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. |
605
636
  | `dbPath` | no | `./knowless.db` | SQLite file path. |
606
637
  | `cookieDomain` | no | (eTLD+1 of `baseUrl`) | Session cookie scope. |
607
638
  | `cookieName` | no | `knowless_session` | Session cookie name. |
@@ -630,6 +661,24 @@ Full options table:
630
661
 
631
662
  ## FAQ
632
663
 
664
+ ### Is there an official knowless Docker image?
665
+
666
+ No. knowless does not ship a turnkey image with Postfix + null-route
667
+ + the binary pre-baked. The reasoning: a Docker image bundling
668
+ Postfix wouldn't actually save the operator from the work that
669
+ matters (SPF / DKIM / PTR records on your sending domain, outbound
670
+ port 25 unblocked at your hosting provider, reverse DNS pointed at
671
+ your sending hostname — all done outside the container regardless),
672
+ and shipping a Postfix image would commit a walk-away library to a
673
+ permanent CVE-rebuild cadence. The OPS.md walkthrough is the
674
+ canonical install path; a fresh VPS to working forward-auth takes
675
+ 30–60 minutes of one-time setup, and then it stays put.
676
+
677
+ If a community Dockerfile emerges (open invitation — knowless is
678
+ Apache-2.0), OPS.md will link to it. Until then, run
679
+ `knowless-server` as a systemd unit alongside Postfix as the OPS
680
+ walkthrough lays out.
681
+
633
682
  ### Why doesn't knowless block disposable email domains?
634
683
 
635
684
  Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
package/README.md CHANGED
@@ -7,198 +7,146 @@ 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.1 | 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
- ## Why this exists
12
+ ## Where to go next
13
13
 
14
- Most auth libraries (Auth0, Clerk, Magic, Firebase Auth) default to
15
- maximum identity collection: full email stored in plaintext, profile
16
- fields, recovery email, federation. Even nominally privacy-focused
17
- options store enough that a breach is materially harmful.
14
+ Two docs live alongside this README. They serve different readers; pick
15
+ the one that matches yours.
18
16
 
19
- knowless is the simpler answer that always worked: **magic link in,
20
- session cookie out, nothing else stored.** Email is HMAC-hashed at the
17
+ | You are | Read this | What's there |
18
+ |---|---|---|
19
+ | **A human integrating for the first time** | [`GUIDE.md`](GUIDE.md) | Step-by-step walkthrough — install, generate the secret, set up Postfix, mount handlers, both modes worked end-to-end. Configuration reference, FAQ, troubleshooting. |
20
+ | **An AI agent, or reading in a hurry** | [`knowless.context.md`](knowless.context.md) | Dense single-file reference. Public API table, every option with defaults, 19 gotchas, lifecycle diagrams, the sham-work pattern, threat model, "what's NOT in knowless and why." Designed to fit one context window. |
21
+ | **Deploying to a real server** | [`OPS.md`](OPS.md) | Postfix install, SPF/DKIM/PTR/DMARC, null-route, systemd, Caddy/nginx/Traefik forward-auth, MailHog dev, fail2ban, multi-process. |
22
+ | **Tracking what changed** | [`CHANGELOG.md`](CHANGELOG.md) | Version history. |
23
+
24
+ ## What it does
25
+
26
+ The simpler answer that always worked: **magic link in, session
27
+ cookie out, nothing else stored.** Email is HMAC-hashed at the
21
28
  boundary and discarded. The library refuses, by API shape, to send
22
29
  anything but the sign-in link or store anything identifying.
23
30
 
31
+ Most auth libraries default to maximum identity collection: full email
32
+ in plaintext, profile fields, recovery email, federation. Even
33
+ nominally privacy-focused options store enough that a breach is
34
+ materially harmful. knowless inverts the default.
35
+
24
36
  The thesis: most services have ten layers of auth tooling where they
25
37
  need two.
26
38
 
27
- ## Two modes — pick per action
28
-
29
- Same library, two flows. They coexist in one app; choose per-endpoint
30
- based on whether forcing a login *before* the action would harm UX.
39
+ ## How it works
31
40
 
32
- ### Mode B — register-first (the form)
41
+ ```
42
+ email → HMAC-SHA256(secret, normalize(email)) → opaque handle
43
+ | |
44
+ v v
45
+ magic-link token (256-bit, single-use) sessions, tokens
46
+ | |
47
+ v v
48
+ submitted via localhost SMTP stored as SHA-256 hashes
49
+ |
50
+ v
51
+ user clicks → handle resolved → signed cookie set
52
+ ```
33
53
 
34
- User must log in before performing the action. Standard "sign in to
35
- continue" flow.
54
+ - **Plaintext email is never persisted.** Only the salted hash
55
+ (`HMAC-SHA256(secret, normalized_email)`).
56
+ - **Only the magic link is ever sent.** No welcome, no digest, no
57
+ notification. There is no API to send anything else.
58
+ - **All outbound mail goes via your localhost MTA.** No vendor SDKs,
59
+ no API tokens.
60
+ - **Tokens are SHA-256 at rest, single-use, 15-min TTL.** Raw token
61
+ never persisted.
62
+ - **Session cookies are HMAC-signed.** No JWT, no algorithm confusion.
63
+ - **Sham work on every miss.** Unknown emails do the same work as
64
+ registered ones (compose, submit, log) but the SMTP recipient is a
65
+ null-route. Times equivalent within 1ms — measured in CI.
36
66
 
37
- - User hits `/login`, types email
38
- - Magic link arrives, click → session cookie
39
- - Your protected endpoints call `auth.handleFromRequest(req)` to gate
40
- access
67
+ ## Two modes
41
68
 
42
- Use for: account settings, paid features, anything that requires an
43
- identified user at the moment of the action.
69
+ Same library, two flows. They coexist in one app — pick per action.
44
70
 
45
- ### Mode Ause-first, claim-later (programmatic)
71
+ - **"Sign in, then do the thing"** a normal login.
72
+ - **"Do the thing, confirm by email"** — drop a pin, post a comment,
73
+ share a link without an account, and the email confirmation creates
74
+ the account in the background.
46
75
 
47
- User performs the action *without* being logged in. You capture their
48
- email along with the action, fire a magic link via
49
- `auth.startLogin({email, nextUrl, ...})`, and clicking it opens a
50
- session and "promotes" the deferred resource.
76
+ The same sham-work flow runs underneath either mode, so unknown
77
+ emails, rate-limit hits, and real sends look identical to an external
78
+ observer.
51
79
 
52
- Use for: drop-a-pin / submit-a-paste / share-a-link / disposable
53
- resources / anywhere logging in first kills the UX.
80
+ Worked code for both in [`GUIDE.md`](GUIDE.md).
54
81
 
55
- The same 12-step sham-work flow runs underneath either mode, so
56
- unknown emails, rate-limit hits, and real sends look identical to an
57
- external observer (the FR-6 timing-equivalence guarantee). Pick per
58
- action; the two coexist.
82
+ ## Two deployment shapes
59
83
 
60
- Worked code for both modes is in [`GUIDE.md`](GUIDE.md). The dense
61
- API reference is [`knowless.context.md`](knowless.context.md).
84
+ | Shape | When |
85
+ |---|---|
86
+ | **Library mode** | Mount the five handlers (`login`, `callback`, `verify`, `logout`, `loginForm`) in your existing Node app. |
87
+ | **Standalone server** (`npx knowless-server`) | Forward-auth gateway behind Caddy / nginx / Traefik for self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / Jellyfin / etc. One auth subdomain, SSO across services via the parent-domain cookie. |
62
88
 
63
89
  ## What's opinionated (locked by design)
64
90
 
65
- These are deliberate trade-offs, documented as `NO-GO` in
66
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §14.
67
- The library refuses, by API shape, to grow into them.
91
+ Deliberate trade-offs. The library refuses, by API shape, to grow
92
+ into them.
68
93
 
69
- - **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend. The
70
- operator runs Postfix (or another MTA) on the same host, in
71
- outbound-only mode.
72
- - **One mail purpose: the sign-in link.** No welcome message, no
73
- digest, no notification. There is no `sendNotification()` to be
74
- tempted by.
94
+ - **Localhost SMTP only.** No Mailgun / Postmark / SES / Resend.
95
+ - **One mail purpose: the sign-in link.** No `sendNotification()` to
96
+ be tempted by.
75
97
  - **Plain-text 7-bit email.** No HTML, no tracking pixels, no
76
98
  click-rewriting, no read-receipts.
77
99
  - **No OAuth / OIDC / SAML.** Different audience.
78
100
  - **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
79
101
  library if you need them.
80
102
  - **No admin UI.** `sqlite3 knowless.db` is the admin UI.
81
- - **Hardcoded login form.** No template overrides; fork or live
82
- with it.
83
- - **No telemetry, analytics, or error reporting.** Self-hostable end
84
- to end. No phone-home of any kind.
103
+ - **Hardcoded login form.** No template overrides; fork or live with
104
+ it.
105
+ - **No telemetry, analytics, or error reporting.** No phone-home of
106
+ any kind.
85
107
  - **Walks away at v1.0.0.** Maintenance mode after that — only
86
108
  security fixes.
87
109
 
88
- ## What's swappable
110
+ If any of those break your case, knowless isn't the right tool. Look
111
+ at [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
112
+ or commercial offerings.
89
113
 
90
- Everything that *isn't* identity-shape or threat-model essential is
91
- config or injection.
92
-
93
- | Knob | Default | Common reasons to change |
94
- |---|---|---|
95
- | `dbPath` | `./knowless.db` | Move to `/var/lib/knowless/...` for systemd; share across processes |
96
- | `smtpHost`, `smtpPort` | `localhost`, `25` | Point at MailHog (`localhost:1025`) for dev mail inspection |
97
- | `cookieDomain` | hostname of `baseUrl` | Set to your eTLD+1 for SSO across subdomains |
98
- | `cookieSecure` | `true` | `false` only for `http://localhost` dev (logs a warning) |
99
- | `tokenTtlSeconds`, `sessionTtlSeconds` | `900`, `2592000` | Tighten for high-security uses; loosen at your peril |
100
- | `openRegistration` | `false` | `true` to let any new email auto-register on first link |
101
- | `subject` | `Sign in` | Match your brand; per-call override on `startLogin` (`subjectOverride`) |
102
- | `bodyFooter` | none | Append a constant brand/legal/feedback line to every magic-link mail |
103
- | `confirmationMessage` | (default copy) | Replace the post-submit "we'll email you" text |
104
- | `maxLoginRequestsPerIpPerHour`, `maxNewHandlesPerIpPerHour` | `30`, `3` | Raise for genuinely shared NATs; `0` to disable in dev |
105
- | `trustedProxies` | `[127.0.0.1, ::1]` | Plain IPs **and** CIDRs (`10.0.0.0/8`) for k8s/docker/cgnat |
106
- | `bypassRateLimit` (per-call) | `false` | Trusted CLI/cron callers via `auth.startLogin` |
107
- | `store` | built-in `node:sqlite` | Inject your own store (Postgres, etc.) |
108
- | `mailer` | built-in nodemailer | Inject your own mailer |
109
- | `transportOverride` | none | Pass a custom `nodemailer.createTransport` |
110
- | `onSweepError(err)` | none | Operator alerting hook for sweeper failures |
111
- | `devLogMagicLinks` | `false` | `true` in dev: print magic-link URLs (or silent-miss hints) to stderr when SMTP fails |
112
-
113
- Full table with defaults, types, and validation rules:
114
- [`GUIDE.md`](GUIDE.md) → "Configuration reference."
115
-
116
- ## Two deployment shapes (one codebase)
117
-
118
- | Mode | Status | When |
119
- |---|---|---|
120
- | **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
121
- | **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. behind Caddy / nginx / Traefik |
122
-
123
- Library mode is the six-line example in [`GUIDE.md`](GUIDE.md).
124
- Standalone server is `npx knowless-server` — full Postfix + DNS +
125
- reverse-proxy walkthrough in [`OPS.md`](OPS.md).
114
+ ## Operator commitments
126
115
 
127
- ## First customer: addypin
116
+ By choosing knowless, you commit to running:
128
117
 
129
- [`addypin`](https://github.com/hamr0/addypin) location-sharing
130
- service in the same hermit-architecture lineage adopted knowless
131
- as its auth+mail layer. The integration delta:
118
+ - **Postfix** (or another MTA) on the same host, outbound-only
119
+ - **SPF, DKIM, PTR** records for your sending domain
120
+ - **Outbound port 25** open (some clouds block it)
121
+ - A **null-route** for the configured `shamRecipient` so silent-miss
122
+ sham mail drops, not bounces
132
123
 
133
- - **~1,150 lines of bespoke auth/mail code removed** (custom mailer,
134
- inbound CLI, login plumbing, pin-confirmation state machine, email
135
- fingerprinting helpers, the matching test files)
136
- - **~35 lines of knowless wiring added**
137
- - **~33× reduction** on the auth/mail surface
138
- - **One production dep** (`nodemailer` only; v0.2.0 dropped
139
- `better-sqlite3` for `node:sqlite`, the stdlib SQLite driver — no
140
- C++ toolchain, no native compile, ~40 transitive packages → 2)
124
+ Step-by-step in [`OPS.md`](OPS.md).
141
125
 
142
- The integration round produced the audit findings AF-7 through AF-17
143
- that drove v0.1.5 → v0.1.10. See [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
144
- §17 for the full backlog.
126
+ ## Threat model one paragraph
145
127
 
146
- ## Operator commitments
128
+ **Defends well:** DB-only leaks (handles are HMAC-salted),
129
+ plaintext-email exfiltration (none persisted), password reuse (no
130
+ passwords), silent email enumeration via the login form (timing-
131
+ equivalent + same response shape), email-bombing a target (per-handle
132
+ token cap), naive bots (honeypot), account-creation spam (per-IP
133
+ caps), replay attacks (atomic mark-token-used), open redirects
134
+ (`next_url` whitelist), CSRF on POST endpoints (Origin/Referer
135
+ whitelist).
147
136
 
148
- By choosing knowless, you commit to:
149
-
150
- - Running your own server with **Postfix** (or another MTA) installed
151
- for outbound-only mail
152
- - Setting up **SPF, DKIM, and PTR** for your sending domain
153
- - Verifying **outbound port 25** is open (some clouds block it)
154
- - A **null-route entry** for the configured `shamRecipient` so
155
- silent-miss sham mail is dropped, not bounced
156
- - Accepting that the magic link is the **only email** your service
157
- ever sends
158
-
159
- Step-by-step in [`OPS.md`](OPS.md): Postfix install, null-route,
160
- SPF/DKIM/PTR/DMARC, systemd unit, Caddy / nginx / Traefik
161
- forward-auth examples, Tailscale pattern, reverse-proxy rate
162
- limiting, fail2ban / Turnstile, multi-process deployments, MailHog
163
- dev workflow, backups.
164
-
165
- ## Documentation
166
-
167
- - [**`GUIDE.md`**](GUIDE.md) — start here. Adopter walkthrough,
168
- install, six-line example, both modes worked end-to-end,
169
- configuration reference, FAQ, troubleshooting.
170
- - [**`knowless.context.md`**](knowless.context.md) — dense reference
171
- for AI agents and humans-in-a-hurry. Public API table, all options,
172
- 18 gotchas, lifecycles, the sham-work pattern, threat model
173
- summary.
174
- - [`OPS.md`](OPS.md) — operator setup, fresh VPS to working forward-auth.
175
- - [`CHANGELOG.md`](CHANGELOG.md) — version history.
176
- - [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
177
- requirements, threat model, decisions log, NO-GO table, audit
178
- findings backlog.
179
- - [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
180
- algorithms, byte layouts (reimplementation-grade).
181
-
182
- ## Threat model (one-paragraph)
183
-
184
- Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
185
-
186
- **Defends well:** DB-only leaks, plaintext-email exfiltration, password
187
- reuse / credential stuffing, silent email enumeration (timing-
188
- equivalent within 1ms locally), email-bombing a target, naive bot
189
- traffic, account-creation spam, replay attacks, open redirects, CSRF
190
- on `POST /login` / `POST /logout` (Origin/Referer whitelist).
191
-
192
- **Defends partially:** HMAC-secret-only leak (allows targeted
193
- existence checks but not session forgery), phishing (no password to
194
- type into a fake site, but a phished mailbox still receives links).
137
+ **Partially:** HMAC-secret-only leak (allows targeted existence
138
+ checks but not session forgery), phishing (no password to type into a
139
+ fake site, but a phished mailbox still receives links).
195
140
 
196
141
  **Does NOT defend against:** sophisticated bots that bypass the
197
142
  honeypot, distributed floods from many IPs, full server compromise,
198
- compromised email accounts, social engineering, insider threat at
199
- the operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
200
- rate-limits) belong above the library; [`OPS.md`](OPS.md) §9–§10
201
- covers the patterns.
143
+ compromised email accounts, social engineering, insider threat at the
144
+ operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
145
+ rate-limits) belong above the library patterns in
146
+ [`OPS.md`](OPS.md).
147
+
148
+ Full detail in [`knowless.context.md`](knowless.context.md) §
149
+ "Threat model summary."
202
150
 
203
151
  ## Sibling projects
204
152
 
@@ -207,14 +155,6 @@ covers the patterns.
207
155
  - [`gitdone`](https://github.com/hamr0/gitdone) — verified email
208
156
  actions via DKIM/SPF inbound
209
157
 
210
- ## Contributing
211
-
212
- Issues and PRs welcome at <https://github.com/hamr0/knowless>.
213
-
214
- Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
215
- v1.0.0 ships will be deflected to the [§14 NO-GO table](docs/01-product/PRD.md)
216
- or to sibling projects. The library being "done" is a feature.
217
-
218
158
  ## License
219
159
 
220
160
  [Apache 2.0](LICENSE) with [`NOTICE`](NOTICE) preservation. Forks
@@ -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.1 | 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
@@ -160,7 +168,7 @@ const auth = knowless({
160
168
  | `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
161
169
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
162
170
  | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
163
- | `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
171
+ | `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bodyOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bodyOverride` (AF-26, v0.2.2) is a `({url}) => string` template fn that replaces the default body — knowless still composes the URL and validates the rendered output (ASCII, URL on its own line, ≤2048 chars); `bodyFooter` still appends. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
164
172
  | `deriveHandle` | (email: string) | string | `HMAC-SHA256(secret, normalize(email))` using the configured secret. Normalizes input (lowercase + trim) so `Alice@X.com` and `alice@x.com` produce the same handle. Match what `startLogin` and `POST /login` compute. AF-7.4 / AF-13. |
165
173
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
166
174
  | `verifyTransport` | -- | Promise\<true\> | Probe the configured SMTP transport (v0.2.1). Resolves true on success, rejects with the underlying error. Adopters call this explicitly when they want fail-fast on misconfigured SMTP at boot — no auto-on-boot variant by design (k8s readiness probes / docker-compose ordering would fail boot for the wrong reason). AF-20. |
@@ -178,6 +186,8 @@ import {
178
186
  composeBody, // pure: build the mail body
179
187
  validateSubject, // pure: validate operator-supplied subject
180
188
  validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
189
+ validateBodyOverride, // pure: validate per-call body override (AF-26)
190
+ validateFromName, // pure: validate operator-supplied From: display name (AF-27)
181
191
  renderLoginForm, // pure: HTML5 page rendering
182
192
  normalize, // pure: email normalization
183
193
  deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
@@ -363,6 +373,21 @@ transport_maps = hash:/etc/postfix/transport
363
373
  postmap /etc/postfix/transport && systemctl reload postfix
364
374
  ```
365
375
 
376
+ Verify the null-route is actually discarding (one-line operator
377
+ check, no knowless code involved):
378
+
379
+ ```
380
+ swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
381
+ journalctl -u postfix --since '1 minute ago' | grep 'discard'
382
+ ```
383
+
384
+ A `discard:` line in the postfix log confirms the null-route caught
385
+ the message. If you see `relay=` or `delivered`, the
386
+ `transport_maps` entry isn't being applied — re-run `postmap` and
387
+ `systemctl reload postfix`. (No `--check-null-route` CLI in
388
+ knowless: an operator's MTA validation lives with the MTA, not
389
+ inside a walk-away library.)
390
+
366
391
  ## FR-6: timing equivalence (the testable property)
367
392
 
368
393
  The library ships a CI test (`test/integration/timing.test.js`)
@@ -619,12 +644,12 @@ rate-limits) belongs above the library.
619
644
  `console.warn` if it sees `Content-Length > 0` with an empty
620
645
  body. AF-7.1.
621
646
 
622
- 16. **Two adoption modes — Mode B (register-first) and Mode A
623
- (use-first claim-later).** Mode B is the form (`auth.login`).
624
- Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
625
- "drop a pin, claim by email click" patterns. Both run the
626
- identical 12-step sham-work flow; same FR-6 guarantee. Pick
627
- per-action, not per-app.
647
+ 16. **Two adoption modes — "sign in, then do" (Mode B) and "do
648
+ then confirm by email" (Mode A).** Mode B is the form
649
+ (`auth.login`). Mode A is `auth.startLogin({email, nextUrl,
650
+ sourceIp})` for "drop a pin, claim by email click" patterns.
651
+ Both run the identical 12-step sham-work flow; same FR-6
652
+ guarantee. Pick per-action, not per-app.
628
653
 
629
654
  17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
630
655
  64-char lowercase hex string; knowless decodes to 32 raw bytes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.2.1",
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/handlers.js CHANGED
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { normalize, deriveHandle } from './handle.js';
3
3
  import { issueToken, hashToken } from './token.js';
4
4
  import { newSid, signSession, verifySessionSignature } from './session.js';
5
- import { composeBody, validateSubject } from './mailer.js';
5
+ import { composeBody, validateSubject, validateBodyOverride } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
8
  buildTrustedPeers,
@@ -256,7 +256,14 @@ export function createHandlers({ store, mailer, config, events }) {
256
256
  * handle is null only when the email failed to normalize (programmer
257
257
  * bug for startLogin; same-shape silent for /login).
258
258
  */
259
- async function runSendLink({ emailRaw, nextRaw, sourceIp, subject, bypassRateLimit = false }) {
259
+ async function runSendLink({
260
+ emailRaw,
261
+ nextRaw,
262
+ sourceIp,
263
+ subject,
264
+ bodyOverride,
265
+ bypassRateLimit = false,
266
+ }) {
260
267
  // Step 1: parse + normalize
261
268
  let emailNorm;
262
269
  try {
@@ -343,13 +350,31 @@ export function createHandlers({ store, mailer, config, events }) {
343
350
  // hammering of a single handle without per-event identity leakage.
344
351
  if (evicted > 0) ev.rateLimitHit();
345
352
 
346
- const mailBody = composeBody({
347
- tokenRaw: token.raw,
348
- baseUrl: cfg.baseUrl,
349
- linkPath: cfg.linkPath,
350
- lastLoginAt,
351
- bodyFooter: cfg.bodyFooter,
352
- });
353
+ // AF-26: per-call body override for startLogin (Mode A). When
354
+ // provided, the adopter's template function receives the composed
355
+ // magic-link URL and returns the full body text. Same submit path,
356
+ // same sham work, same FR-6 timing equivalence — just lets adopters
357
+ // phrase the body to match per-call subjects (pin confirmation,
358
+ // login, expiry warning). bodyFooter still appends; lastLogin line
359
+ // does not (override is full-content replacement).
360
+ let mailBody;
361
+ if (typeof bodyOverride === 'function') {
362
+ const url = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
363
+ const rendered = bodyOverride({ url });
364
+ validateBodyOverride(rendered, url); // throws on invalid
365
+ mailBody = rendered;
366
+ if (cfg.bodyFooter) {
367
+ mailBody += `\n-- \n${cfg.bodyFooter}\n`;
368
+ }
369
+ } else {
370
+ mailBody = composeBody({
371
+ tokenRaw: token.raw,
372
+ baseUrl: cfg.baseUrl,
373
+ linkPath: cfg.linkPath,
374
+ lastLoginAt,
375
+ bodyFooter: cfg.bodyFooter,
376
+ });
377
+ }
353
378
 
354
379
  // AF-9: programmatic callers may override the subject per call
355
380
  // (addypin sends confirmation / login / expiry-warning all via
@@ -454,6 +479,7 @@ export function createHandlers({ store, mailer, config, events }) {
454
479
  nextUrl,
455
480
  sourceIp = '',
456
481
  subjectOverride,
482
+ bodyOverride,
457
483
  bypassRateLimit = false,
458
484
  } = {}) {
459
485
  // Programmer-error guards (AF-7.3). These DO throw; they're not
@@ -480,11 +506,20 @@ export function createHandlers({ store, mailer, config, events }) {
480
506
  validateSubject(subjectOverride); // throws on invalid
481
507
  subject = subjectOverride;
482
508
  }
509
+ // AF-26: per-call body override. The function is called inside
510
+ // runSendLink with the composed magic-link URL; its return value
511
+ // is validated by validateBodyOverride(). The arg-type check
512
+ // happens here at the API edge so a non-function bodyOverride
513
+ // fails fast, before any token is minted.
514
+ if (bodyOverride !== undefined && bodyOverride !== null && typeof bodyOverride !== 'function') {
515
+ throw new Error('startLogin: bodyOverride must be a function when provided');
516
+ }
483
517
  const { handle } = await runSendLink({
484
518
  emailRaw: email,
485
519
  nextRaw: nextUrl ?? null,
486
520
  sourceIp,
487
521
  subject,
522
+ bodyOverride,
488
523
  bypassRateLimit,
489
524
  });
490
525
  // Same-shape return: rate-limit / sham / real all collapse here.
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,
@@ -273,7 +280,14 @@ export function knowless(options = {}) {
273
280
  }
274
281
 
275
282
  export { createStore } from './store.js';
276
- export { createMailer, composeBody, validateSubject, validateBodyFooter } from './mailer.js';
283
+ export {
284
+ createMailer,
285
+ composeBody,
286
+ validateSubject,
287
+ validateBodyFooter,
288
+ validateBodyOverride,
289
+ validateFromName,
290
+ } from './mailer.js';
277
291
  export { createHandlers } from './handlers.js';
278
292
  export { renderLoginForm } from './form.js';
279
293
  export { normalize, deriveHandle, secretBytes } from './handle.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}`,
@@ -139,6 +151,111 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFoot
139
151
  return body;
140
152
  }
141
153
 
154
+ /**
155
+ * Validate a body produced by `startLogin`'s `bodyOverride` template
156
+ * function (AF-26). The override lets adopters phrase the email body
157
+ * to match per-call subjects (pin confirmation, login, etc.) without
158
+ * losing knowless's URL-composition / sham-work / 7bit invariants.
159
+ *
160
+ * Constraints (deliberately strict to preserve the v0.11 POC URL-line
161
+ * invariant — QP soft-breaks WILL break the magic link):
162
+ * - non-empty string
163
+ * - ≤ 2048 chars (operator-side overflow guard)
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.
173
+ * - no CR (LF allowed; defense-in-depth header-injection guard)
174
+ * - the magic-link URL appears EXACTLY ONCE
175
+ * - that occurrence is on its own line (no leading or trailing
176
+ * non-newline characters on the same line)
177
+ *
178
+ * Throws on any violation. Adopter is responsible for the rest of
179
+ * the body content (security advice, expiry hint, etc.); knowless
180
+ * does not enforce semantic content.
181
+ *
182
+ * @param {unknown} body
183
+ * @param {string} url the magic-link URL knowless composed
184
+ * @returns {void} throws on invalid
185
+ */
186
+ export function validateBodyOverride(body, url) {
187
+ if (typeof body !== 'string' || body.length === 0) {
188
+ throw new Error('bodyOverride must return a non-empty string');
189
+ }
190
+ if (body.length > 2048) {
191
+ throw new Error('bodyOverride must return ≤ 2048 chars');
192
+ }
193
+ if (!ASCII_RE.test(body)) {
194
+ throw new Error('bodyOverride must return ASCII');
195
+ }
196
+ if (body.includes('\r')) {
197
+ throw new Error('bodyOverride must not contain CR (header-injection defense)');
198
+ }
199
+ const occurrences = body.split(url).length - 1;
200
+ if (occurrences === 0) {
201
+ throw new Error('bodyOverride must include the magic-link URL exactly once');
202
+ }
203
+ if (occurrences > 1) {
204
+ throw new Error('bodyOverride must include the magic-link URL exactly once');
205
+ }
206
+ const lines = body.split('\n');
207
+ const ownLineCount = lines.filter((l) => l === url).length;
208
+ if (ownLineCount !== 1) {
209
+ throw new Error(
210
+ 'bodyOverride must place the magic-link URL on its own line ' +
211
+ '(preserves the 7bit URL-line invariant; QP soft-breaks would break the link)',
212
+ );
213
+ }
214
+ }
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
+
142
259
  /**
143
260
  * Validate operator-overridden subject per SPEC §12.5.
144
261
  * Throws on invalid; warns (returns warnings array) on suspicious-but-allowed.
@@ -171,18 +288,24 @@ export function validateSubject(subject) {
171
288
  * with streamTransport:true) to capture the raw bytes without an MTA.
172
289
  *
173
290
  * @param {object} cfg
174
- * @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.
175
296
  * @param {string} [cfg.smtpHost='localhost']
176
297
  * @param {number} [cfg.smtpPort=25]
177
298
  * @param {object} [cfg.transportOverride] for tests
178
- * @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 }}
179
300
  */
180
301
  export function createMailer(cfg) {
181
- const { from, smtpHost = 'localhost', smtpPort = 25, transportOverride } = cfg;
302
+ const { from, fromName, smtpHost = 'localhost', smtpPort = 25, transportOverride } = cfg;
182
303
  if (typeof from !== 'string' || from.length === 0) {
183
304
  throw new Error('mailer: from is required');
184
305
  }
185
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);
186
309
 
187
310
  const transport =
188
311
  transportOverride ??
@@ -215,7 +338,9 @@ export function createMailer(cfg) {
215
338
  if (!ASCII_RE.test(body)) {
216
339
  throw new Error('mailer: body must be ASCII');
217
340
  }
218
- 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 });
219
344
  return transport.sendMail({
220
345
  envelope: { from, to: [to] },
221
346
  raw,