knowless 0.1.4 → 0.1.6

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
@@ -8,6 +8,62 @@ Versioning is [SemVer](https://semver.org/).
8
8
  ## [Unreleased]
9
9
 
10
10
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
11
+
12
+ ## [0.1.6] — 2026-04-28
13
+
14
+ addypin integration round 2 — one correctness fix (HMAC key handling)
15
+ and one common-want feature (operator footer on auth mail).
16
+
17
+ ### Breaking
18
+
19
+ - **The `secret` is now hex-decoded before being used as the HMAC
20
+ key.** Prior versions passed the 64-char hex string to
21
+ `crypto.createHmac` as ASCII bytes — same 256 bits of entropy, but
22
+ a different HMAC output than systems that hex-decode first. The
23
+ PRD already implied 32 bytes ("≥64 hex chars (32 bytes)"); the
24
+ implementation matched the spec on key length but used the wrong
25
+ key bytes. **Effect:** every handle and session signature changes
26
+ on upgrade. Existing sessions invalidate (users re-login); existing
27
+ pre-seeded handles must be re-derived. There are no production
28
+ knowless deployments yet (addypin and webrevival are both pre-prod),
29
+ so we lock the correct semantics in before v1.0 freezes. Closes
30
+ AF-8.1.
31
+ - The startup secret check now also validates that the secret is
32
+ 64-char lowercase hex (`/^[a-f0-9]{64,}$/i`). Mixed-case secrets
33
+ must be lowercased.
34
+ - `deriveHandle()` and `signSession()` accept `Buffer` directly for
35
+ adopters who already hold raw 32-byte keys.
36
+
37
+ ### Added
38
+
39
+ - **`bodyFooter: string`** config option — append a constant operator
40
+ footer to every magic-link email after the standard `"-- "` (RFC
41
+ 3676) signature delimiter. Constraints (deliberately strict to
42
+ preserve the URL-line invariant and 7bit body encoding):
43
+ - ASCII only (no unicode middle-dot — use `|` or `-`)
44
+ - ≤ 240 chars, ≤ 4 lines
45
+ - No CR
46
+ - No `http://` / `https://` substrings (would conflict with the
47
+ magic-link line and trigger MTA URL-rewriting heuristics)
48
+ Validated at factory startup; fails fast on misconfiguration.
49
+ Closes AF-8.2.
50
+ - `validateBodyFooter()` exported alongside `composeBody` for adopters
51
+ who want to validate operator-supplied footers themselves.
52
+ - `secretBytes()` exported from `./handle.js` (and via the package
53
+ root) for adopters who want to coerce a hex string to raw 32-byte
54
+ HMAC key on their own boundaries.
55
+
56
+ ### Migration from 0.1.5
57
+
58
+ - **Pre-seeded handles must be re-derived.** `deriveHandle('alice@x',
59
+ SECRET)` returns a different value in 0.1.6. If you stored handles
60
+ in any external system, recompute them. Closed-registration users
61
+ must re-seed.
62
+ - **Active sessions invalidate.** Users will need to log in again
63
+ after the upgrade. Plan for a single-shot user-visible logout.
64
+ - **Magic links in flight at upgrade time** become invalid (token
65
+ hashes are stored as HMAC outputs and the key changes). 15 min
66
+ TTL by default; a brief read-only window during deploy is enough.
11
67
  - `knowless-server --check-null-route`: CLI probe that submits a
12
68
  test message to `shamRecipient` and confirms the local MTA
13
69
  discarded it. Honest answer to "does the operator's null-route
@@ -15,6 +71,62 @@ Versioning is [SemVer](https://semver.org/).
15
71
  not what the MTA did, so this is the closest we can get.
16
72
  Targeted for v0.2.0.
17
73
 
74
+ ## [0.1.5] — 2026-04-28
75
+
76
+ addypin POC findings round. Adds programmatic magic-link entry
77
+ (unblocks "use first, claim later" UX patterns), one ergonomic
78
+ helper, two safety/diagnostic fixes, and three doc updates.
79
+
80
+ ### Added
81
+
82
+ - **`auth.startLogin({email, nextUrl?, sourceIp?})`** — programmatic
83
+ entry that runs the same 12-step sham-work flow as `POST /login`
84
+ but skips Origin/honeypot (no browser context). Returns
85
+ `{handle, submitted: true}` — same shape on rate-limit / sham /
86
+ real to preserve FR-6 timing equivalence. Throws only on
87
+ programmer error. SPEC §7.3a. Closes AF-7.3.
88
+ - **`auth.deriveHandle(email)`** — instance method that uses the
89
+ configured secret. Lets adopters compute owner-handles outside
90
+ HTTP context without spreading the secret across modules. Closes
91
+ AF-7.4.
92
+ - **GUIDE.md "Two adoption modes" section** — Mode B (register-
93
+ first, the form) and Mode A (use-first-claim-later, programmatic).
94
+ Both supported, pickable per-action.
95
+ - **GUIDE.md "Constraints / install footprint" section** — direct
96
+ deps, transitive count, deprecation-warning context. Closes AF-7.7.
97
+
98
+ ### Changed
99
+
100
+ - **`devLogMagicLinks` lines now tagged with `cfg.from`** —
101
+ `[knowless dev:auth@app.example.com] magic link: ...`. Disambiguates
102
+ multi-instance dev logs. Closes AF-7.6.
103
+ - **`devLogMagicLinks` + sham + SMTP-fail now prints a one-line
104
+ silent-miss hint** instead of staying silent. Surfaces the
105
+ closed-registration-is-on case that previously cost adopters
106
+ ~30min of debugging. Strictly opt-in dev mode. Closes AF-7.2.
107
+
108
+ ### Safety / diagnostics
109
+
110
+ - **`createMailer` validates `transportOverride` at startup.** A
111
+ malformed override (e.g. an options bag mistaken for a transport)
112
+ throws fast with a pointed error instead of failing at first
113
+ submission. Closes AF-7.5.
114
+ - **`POST /login` warns once on stderr when `Content-Length > 0`
115
+ but body is empty.** Catches the common non-Express trap of
116
+ mounting `express.urlencoded()` or similar body parsers ahead of
117
+ `auth.login` (which consumes the stream itself). One warning per
118
+ handler instance. Closes AF-7.1.
119
+
120
+ ### Documentation
121
+
122
+ - **SPEC §7.3a** specifies the programmatic entry's contract: which
123
+ steps it skips (Origin, honeypot), why FR-6 still holds, and
124
+ what programmer-error throws look like.
125
+ - **GUIDE.md** adds two traps for non-Express integrators (body-
126
+ parser conflict, Origin requirement) and a worked Mode-A example.
127
+ - **knowless.context.md** lists `startLogin` + `deriveHandle` in the
128
+ public API table and adds gotchas 15–16.
129
+
18
130
  ## [0.1.4] — 2026-04-28
19
131
 
20
132
  First real-world integration release. Bugs and ergonomics surfaced
package/GUIDE.md CHANGED
@@ -205,6 +205,95 @@ Fastify, Hono, `node:http` — all work. Each handler is a plain
205
205
  `(req, res) => Promise<void>` function. No framework hooks, no
206
206
  middleware injection.
207
207
 
208
+ #### Trap: do NOT pre-parse the body
209
+
210
+ knowless reads `POST /login`'s body itself. If a body parser
211
+ middleware runs ahead of `auth.login` and consumes the stream,
212
+ knowless sees an empty body and silently null-routes the request
213
+ (as if no email was submitted). Symptoms: form posts return 200,
214
+ no magic link arrives, no error logged.
215
+
216
+ Express works in the example above because `express.urlencoded()`
217
+ is mounted as application middleware but doesn't intercept the
218
+ specific path. **On Hono / Fastify-without-body-plugin / raw
219
+ node:http, mount `auth.login` directly with no body parser in
220
+ front.** Same goes for `auth.logout` (it doesn't read a body, but
221
+ keep the symmetry).
222
+
223
+ knowless will emit a one-time `console.warn(...)` if it sees an
224
+ empty body where `Content-Length > 0` — that's the canary for this
225
+ bug.
226
+
227
+ #### Trap: non-browser callers need an Origin header
228
+
229
+ `POST /login` runs an Origin/Referer whitelist (CSRF defense, see
230
+ the FAQ below). Browsers always send `Origin` on cross-origin
231
+ POSTs, so the form path is fine. **Curl / scripts / server-to-
232
+ server callers must send no Origin header at all** (knowless
233
+ allows browser-absent requests) **or** an Origin matching your
234
+ `cookieDomain`. If you set a foreign Origin, the request silently
235
+ falls through to a sham send. For programmatic callers, prefer
236
+ [`auth.startLogin()`](#mode-a-use-first-claim-later) over POSTing
237
+ the form.
238
+
239
+ ### Two adoption modes (Mode A vs Mode B)
240
+
241
+ knowless supports two UX flows out of the box. Pick per-action,
242
+ not per-app — both can coexist.
243
+
244
+ **Mode B — register-first (the default).** User must log in before
245
+ performing the action. Wire `auth.login` / `auth.callback` as
246
+ above; gate your action with `auth.handleFromRequest(req)`. Use
247
+ when the action requires a session (account settings, paid
248
+ features, anything you want tied to an identity at the moment of
249
+ the action).
250
+
251
+ ```js
252
+ app.post('/api/comments', (req, res) => {
253
+ const handle = auth.handleFromRequest(req);
254
+ if (!handle) return res.status(401).end();
255
+ // create comment owned by `handle`
256
+ });
257
+ ```
258
+
259
+ **Mode A — use-first, claim-later.** User performs the action
260
+ without logging in; you capture their email and trigger a magic
261
+ link. Clicking it opens a session and your callback handler
262
+ "promotes" the deferred resource. Use for "drop a pin," "post a
263
+ share link," "submit a paste" — patterns where forcing a login
264
+ *before* the action would harm the UX.
265
+
266
+ ```js
267
+ app.post('/api/pins', async (req, res) => {
268
+ const { email, lat, lng } = await readJsonBody(req);
269
+ const owner = auth.deriveHandle(email); // AF-7.4
270
+ await db.insertPendingPin({ owner, lat, lng }); // your code
271
+ await auth.startLogin({ // AF-7.3
272
+ email,
273
+ nextUrl: 'https://app.example.com/manage',
274
+ sourceIp: req.socket.remoteAddress,
275
+ });
276
+ res.status(202).end(); // "we'll email you the link"
277
+ });
278
+
279
+ // On callback, promote pending pins owned by the now-logged-in handle.
280
+ app.get('/manage', (req, res) => {
281
+ const owner = auth.handleFromRequest(req);
282
+ if (!owner) return res.redirect('/login');
283
+ // db.promotePendingPinsFor(owner)
284
+ });
285
+ ```
286
+
287
+ `startLogin` runs the same 12-step sham-work flow as the form
288
+ handler, so unknown emails, rate-limit hits, and real sends all
289
+ return identical shapes — the caller can't observe which happened.
290
+ This preserves FR-6 timing equivalence even for programmatic
291
+ callers. See SPEC §7.3a for the full contract.
292
+
293
+ `auth.deriveHandle(email)` returns the same opaque HMAC handle
294
+ that the form path uses, without you having to import the helper
295
+ or pass the secret around.
296
+
208
297
  ### Step 5: Pre-seed users (closed-registration mode, default)
209
298
 
210
299
  By default, knowless is closed: a handle must already exist before
@@ -465,3 +554,18 @@ than weakening the bar — see SPEC §14.5.
465
554
  Yes: `tokenTtlSeconds`. Don't set it absurdly high. Magic links
466
555
  that linger in inboxes are a phishing-amplification risk if the
467
556
  mail account is later compromised.
557
+
558
+ ## Constraints / install footprint
559
+
560
+ - **Two direct dependencies.** `nodemailer` (SMTP submission) and
561
+ `better-sqlite3` (storage). Both audited and pinned at major
562
+ versions in `package.json`.
563
+ - **~40 transitive packages** in a typical install. The bulk are
564
+ `nodemailer`'s ecosystem deps (mostly idle in our usage) and
565
+ `better-sqlite3`'s build-time prebuild fetcher. You may see one
566
+ deprecation warning during install for `prebuild-install` —
567
+ build-chain noise, not runtime code.
568
+ - **Node ≥ 20.** Uses `node:util parseArgs`, `node:net BlockList`
569
+ for CIDR support, and `--env-file=` for the standalone server.
570
+ - **No optional deps, no postinstall scripts** beyond `better-
571
+ sqlite3`'s native binding fetch.
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.1.0 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
10
+ > v0.1.6 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
11
11
 
12
12
  ## What this is
13
13
 
@@ -83,12 +83,12 @@ A short list (full table in [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
83
83
 
84
84
  | Mode | Status | When |
85
85
  |---|---|---|
86
- | **Library mode** | v0.1.0 | Mount handlers in your existing Node app |
87
- | **Standalone server** (forward-auth) | v0.2.0 | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. via Caddy or nginx |
86
+ | **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
87
+ | **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. via Caddy or nginx |
88
88
 
89
- This release ships library mode. Standalone server (`bin/knowless-server`)
90
- follows in 0.2.0 — see [`docs/03-tasks/TASKS.md`](docs/03-tasks/TASKS.md)
91
- Phase 6 for scope.
89
+ Library mode is the six-line example above. Standalone server is
90
+ `npx knowless-server` — see [`OPS.md`](OPS.md) for the full Postfix +
91
+ DNS + reverse-proxy walkthrough.
92
92
 
93
93
  ## Operator commitments
94
94
 
@@ -76,6 +76,7 @@ const auth = knowless({
76
76
  // --- Cookie / session ---
77
77
  cookieDomain: 'app.example.com', // default: hostname of baseUrl
78
78
  cookieName: 'knowless_session', // default 'knowless_session'
79
+ cookieSecure: true, // default true; "false" only for localhost dev
79
80
  sessionTtlSeconds: 30 * 86400, // 30 days
80
81
 
81
82
  // --- Token ---
@@ -97,17 +98,33 @@ const auth = knowless({
97
98
  // --- Behavior ---
98
99
  openRegistration: false, // first-email-wins handle creation
99
100
  includeLastLoginInEmail: true, // append "Last sign-in: <ISO ts>" line
100
- confirmationMessage: 'Thanks. If <strong>{email}</strong>...',
101
+ confirmationMessage: 'Thanks. If {email} is registered, a link is on its way.',
102
+ // ^ NOTE: the message is HTML-escaped before render (AF-6.5).
103
+ // {email} placeholder still works. For HTML, pre-render upstream.
104
+
105
+ // Operator footer on magic-link mail (AF-8.2). ASCII-only, ≤240
106
+ // chars, ≤4 lines, NO URLs (would conflict with the magic-link line).
107
+ // Validated at factory startup. Use | or - as separators (NOT · which
108
+ // is non-ASCII).
109
+ bodyFooter: 'feedback@example.com | privacy first',
101
110
 
102
111
  // --- Abuse defenses (FR-38..41) ---
103
112
  maxActiveTokensPerHandle: 5, // 0 to disable
104
113
  maxLoginRequestsPerIpPerHour: 30, // 0 to disable
105
114
  maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
106
115
  honeypotFieldName: 'website',
107
- trustedProxies: ['127.0.0.1', '::1'],
116
+ // Plain IPs and/or CIDR ranges (AF-6.3). Useful for k8s/docker/cgnat.
117
+ trustedProxies: ['127.0.0.1', '::1', '10.0.0.0/8'],
108
118
 
109
119
  // --- Lifecycle ---
110
120
  sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
121
+ onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
122
+
123
+ // --- Dev mode (AF-6.2) ---
124
+ // When SMTP submission fails AND this flag is true, the magic link
125
+ // is printed to stderr so a developer can click through. Off by
126
+ // default. Never fires for sham (silent-miss) submissions.
127
+ devLogMagicLinks: false,
111
128
 
112
129
  // --- Injection (tests / advanced) ---
113
130
  store: undefined, // bring your own store
@@ -127,7 +144,12 @@ const auth = knowless({
127
144
  | `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
128
145
  | `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
129
146
  | `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
147
+ | `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
130
148
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
149
+ | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
150
+ | `startLogin` | ({email, nextUrl?, sourceIp?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. SPEC §7.3a. AF-7.3. |
151
+ | `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
152
+ | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
131
153
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
132
154
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
133
155
 
@@ -141,9 +163,11 @@ import {
141
163
  createHandlers, // bring your own factory wiring
142
164
  composeBody, // pure: build the mail body
143
165
  validateSubject, // pure: validate operator-supplied subject
166
+ validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
144
167
  renderLoginForm, // pure: HTML5 page rendering
145
168
  normalize, // pure: email normalization
146
- deriveHandle, // pure: HMAC-SHA256(secret, email)
169
+ deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
170
+ secretBytes, // pure: coerce hex string → 32-byte HMAC key
147
171
  } from 'knowless';
148
172
  ```
149
173
 
@@ -372,11 +396,10 @@ rate-limits) belongs above the library.
372
396
  The library does NOT compute eTLD+1 automatically (would
373
397
  require a public-suffix-list dep).
374
398
 
375
- 5. **`Secure` cookie attribute is non-negotiable.** All session
376
- cookies set `Secure`. HTTP-only origins won't receive them.
377
- Use HTTPS in production. Localhost development: use
378
- `--insecure-localhost-cookies` (not implemented yet TASKS
379
- open question; works in Chrome with `--unsafely-treat-insecure-origin-as-secure`).
399
+ 5. **`Secure` cookie attribute toggles via `cookieSecure`.** Default
400
+ is `true`. Set `cookieSecure: false` ONLY for `http://localhost`
401
+ development; the library logs a stderr warning at startup (AF-4.4).
402
+ Production deployments MUST use HTTPS and leave `cookieSecure: true`.
380
403
 
381
404
  6. **Forward-auth needs the parent-domain cookie.** If your auth
382
405
  subdomain is `auth.example.com` and protected service is
@@ -410,6 +433,53 @@ rate-limits) belongs above the library.
410
433
  so it won't *prevent* exit, but the SQLite handle held by
411
434
  `better-sqlite3` will leave a finalizer warning.
412
435
 
436
+ 12. **CSRF defense is the Origin/Referer whitelist, not a token.**
437
+ Modern browsers always emit `Origin` on cross-origin POSTs;
438
+ knowless validates host against `cookieDomain` on POST /login
439
+ AND POST /logout. Browser-absent (curl / programmatic) is
440
+ allowed. **Do NOT add a CSRF token upstream** — the Origin
441
+ check is the defense. SPEC §7.3 Step 0.
442
+
443
+ 13. **`confirmationMessage` is plain text + `{email}` placeholder.**
444
+ The whole message is HTML-escaped before render (AF-6.5). If
445
+ you want bold/italic/links in the confirmation copy, pre-render
446
+ the HTML upstream and pass the escaped string — but understand
447
+ you're then responsible for not interpolating user data.
448
+
449
+ 14. **`devLogMagicLinks` is opt-in and dev-only.** When set true
450
+ AND SMTP submission fails, the magic link is printed to stderr
451
+ tagged `[knowless dev:<from>]`. Sham (silent-miss) submissions
452
+ print a `silent-miss: ...` hint instead of a link — opt-in dev
453
+ only, since this leaks closed-reg state. Don't enable in
454
+ production.
455
+
456
+ 15. **POST /login: don't pre-parse the body.** knowless reads the
457
+ request stream itself. Any framework body parser mounted in
458
+ front of `auth.login` will silently steal the form data and
459
+ null-route the request. knowless emits a one-time
460
+ `console.warn` if it sees `Content-Length > 0` with an empty
461
+ body. AF-7.1.
462
+
463
+ 16. **Two adoption modes — Mode B (register-first) and Mode A
464
+ (use-first claim-later).** Mode B is the form (`auth.login`).
465
+ Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
466
+ "drop a pin, claim by email click" patterns. Both run the
467
+ identical 12-step sham-work flow; same FR-6 guarantee. Pick
468
+ per-action, not per-app.
469
+
470
+ 17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
471
+ 64-char lowercase hex string; knowless decodes to 32 raw bytes
472
+ before HMAC. If you're upgrading from 0.1.5 or earlier, all
473
+ handles and session signatures change — re-seed handles, expect
474
+ one user-visible logout. `Buffer` accepted directly for adopters
475
+ who hold raw 32-byte keys.
476
+
477
+ 18. **`bodyFooter` constraints (AF-8.2).** ASCII only — `·` is NOT
478
+ ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines, no `http(s)://`
479
+ URLs (would conflict with the magic-link line). Validated at
480
+ factory startup; fails fast. Goes after RFC 3676 `"-- "`
481
+ delimiter so mail clients strip it from quoted replies.
482
+
413
483
  ## Constraints
414
484
 
415
485
  - **Node 20+** -- targeting LTS; tested on Node 22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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/handle.js CHANGED
@@ -26,6 +26,28 @@ export function normalize(input) {
26
26
  return lowered;
27
27
  }
28
28
 
29
+ /**
30
+ * Coerce an operator-supplied secret to the raw bytes used as HMAC key.
31
+ *
32
+ * AF-8.1: knowless requires `secret` to be a 64-char lowercase hex
33
+ * string (32 bytes). Prior versions passed it to `createHmac` as an
34
+ * ASCII string — same 256 bits of entropy, but a different HMAC
35
+ * output than systems that hex-decode first. That meant adopters
36
+ * with existing HMAC-keyed identifiers couldn't interoperate. The
37
+ * fix is to hex-decode at the boundary so HMAC uses 32 raw bytes.
38
+ *
39
+ * @param {Buffer|string} secret
40
+ * @returns {Buffer} 32 raw bytes
41
+ */
42
+ export function secretBytes(secret) {
43
+ if (Buffer.isBuffer(secret)) return secret;
44
+ if (typeof secret !== 'string') throw new Error('secret required');
45
+ if (!/^[a-f0-9]{64,}$/i.test(secret)) {
46
+ throw new Error('secret must be ≥64 hex chars (lowercase a-f, 0-9)');
47
+ }
48
+ return Buffer.from(secret, 'hex');
49
+ }
50
+
29
51
  /**
30
52
  * Derive the opaque handle for a normalized email using the operator secret.
31
53
  * HMAC-SHA256, lowercase hex output, 64 chars. See SPEC §3.
@@ -34,18 +56,15 @@ export function normalize(input) {
34
56
  * HMAC use of `secret` MUST add a tag prefix (see SPEC §3.4).
35
57
  *
36
58
  * @param {string} emailNormalized output of normalize()
37
- * @param {Buffer|string} secret operator HMAC secret
59
+ * @param {Buffer|string} secret operator HMAC secret (32+ raw bytes or ≥64 hex chars)
38
60
  * @returns {string} 64-char lowercase hex handle
39
61
  */
40
62
  export function deriveHandle(emailNormalized, secret) {
41
63
  if (typeof emailNormalized !== 'string' || emailNormalized.length === 0) {
42
64
  throw new Error('emailNormalized required');
43
65
  }
44
- if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
45
- throw new Error('secret required');
46
- }
47
66
  return crypto
48
- .createHmac('sha256', secret)
67
+ .createHmac('sha256', secretBytes(secret))
49
68
  .update(emailNormalized, 'utf8')
50
69
  .digest('hex');
51
70
  }
package/src/handlers.js CHANGED
@@ -182,6 +182,21 @@ export function createHandlers({ store, mailer, config }) {
182
182
  // Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
183
183
  const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
184
184
 
185
+ // AF-7.1: emit at most one warning per handler instance about an
186
+ // upstream body parser swallowing the request body. Loud enough to
187
+ // notice in dev, quiet enough not to spam.
188
+ let emptyBodyWarned = false;
189
+ function warnEmptyBodyOnce() {
190
+ if (emptyBodyWarned) return;
191
+ emptyBodyWarned = true;
192
+ console.warn(
193
+ '[knowless] POST /login received an empty body but Content-Length > 0. ' +
194
+ 'A body parser running ahead of this handler likely consumed the stream. ' +
195
+ 'knowless reads req itself; do not mount express.urlencoded() / express.json() / ' +
196
+ 'similar middleware in front of POST /login. (Warned once per instance.)',
197
+ );
198
+ }
199
+
185
200
  // SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
186
201
  // emitted by default and omitted only when cookieSecure: false (localhost
187
202
  // dev). HttpOnly + SameSite=Lax are always set.
@@ -208,56 +223,46 @@ export function createHandlers({ store, mailer, config }) {
208
223
  res.end();
209
224
  }
210
225
 
211
- async function login(req, res) {
212
- // Step 0 Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
213
- // CSRF defense: a malicious cross-origin page autosubmitting to /login
214
- // would otherwise trigger magic-link sends to known emails. Exempt
215
- // from FR-6 timing equivalence per SPEC §7.3.
216
- if (!validateOrigin(req, cfg.cookieDomain)) {
217
- sameResponse(res, '', '');
218
- return;
219
- }
220
-
221
- let raw;
222
- try {
223
- raw = await readBody(req);
224
- } catch {
225
- sameResponse(res, '', '');
226
- return;
227
- }
228
- const body = parseBody(raw, req.headers['content-type']);
229
- const emailRaw = typeof body.email === 'string' ? body.email : '';
230
- const honeypot = body[cfg.honeypotFieldName];
231
- const nextRaw = body.next;
232
-
226
+ /**
227
+ * The 12-step sham-work flow, reusable by both POST /login and the
228
+ * programmatic auth.startLogin() entry. Returns {handle, isSham} so
229
+ * the form handler can drive its same-response and the programmatic
230
+ * caller can return the handle to its caller. See SPEC §7.3 (form)
231
+ * and §7.3a (programmatic). AF-7.3.
232
+ *
233
+ * Steps skipped depending on the entry point:
234
+ * - Origin (§7.3 Step 0): form-only; programmatic callers are
235
+ * trusted server-side code.
236
+ * - Honeypot (§7.3 Step 2): form-only; no form context.
237
+ *
238
+ * Both entries run steps 1, 3, 4–12 identically — so the timing-
239
+ * equivalence guarantee (FR-6) holds for either.
240
+ *
241
+ * @returns {Promise<{handle: string|null, isSham: boolean,
242
+ * emailNorm: string, nextValidated: string|null}>}
243
+ * handle is null only when the email failed to normalize (programmer
244
+ * bug for startLogin; same-shape silent for /login).
245
+ */
246
+ async function runSendLink({ emailRaw, nextRaw, sourceIp }) {
233
247
  // Step 1: parse + normalize
234
248
  let emailNorm;
235
249
  try {
236
250
  emailNorm = normalize(emailRaw);
237
251
  } catch {
238
- sameResponse(res, emailRaw, nextRaw);
239
- return;
240
- }
241
-
242
- // Step 2: honeypot — exempt short-circuit (no sham work)
243
- if (typeof honeypot === 'string' && honeypot.length > 0) {
244
- sameResponse(res, emailNorm, nextRaw);
245
- return;
252
+ return { handle: null, isSham: false, emailNorm: emailRaw, nextValidated: null };
246
253
  }
247
254
 
248
255
  // Step 3: per-IP rate limit on /login — exempt short-circuit
249
- const ip = determineSourceIp(req, trustedProxies);
250
256
  if (
251
257
  rateLimitExceeded(
252
258
  store,
253
259
  'login_ip',
254
- ip,
260
+ sourceIp,
255
261
  cfg.maxLoginRequestsPerIpPerHour,
256
262
  HOUR_MS,
257
263
  )
258
264
  ) {
259
- sameResponse(res, emailNorm, nextRaw);
260
- return;
265
+ return { handle: null, isSham: false, emailNorm, nextValidated: null };
261
266
  }
262
267
 
263
268
  // ---- Equivalent-work region begins (SPEC §7.3 step 4) ----
@@ -271,7 +276,7 @@ export function createHandlers({ store, mailer, config }) {
271
276
  rateLimitExceeded(
272
277
  store,
273
278
  'create_ip',
274
- ip,
279
+ sourceIp,
275
280
  cfg.maxNewHandlesPerIpPerHour,
276
281
  HOUR_MS,
277
282
  )
@@ -314,6 +319,7 @@ export function createHandlers({ store, mailer, config }) {
314
319
  baseUrl: cfg.baseUrl,
315
320
  linkPath: cfg.linkPath,
316
321
  lastLoginAt,
322
+ bodyFooter: cfg.bodyFooter,
317
323
  });
318
324
 
319
325
  try {
@@ -323,18 +329,91 @@ export function createHandlers({ store, mailer, config }) {
323
329
  console.error('[knowless] mail submit failed:', err.message);
324
330
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
325
331
  // development the operator otherwise has no way to obtain the magic
326
- // link. Print it to stderr only when explicitly opted in. Sham
327
- // submissions are NOT logged (would leak silent-miss outcome).
328
- if (cfg.devLogMagicLinks && !isSham) {
329
- const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
330
- process.stderr.write(`[knowless dev] magic link: ${link}\n`);
332
+ // link. Print it to stderr only when explicitly opted in.
333
+ if (cfg.devLogMagicLinks) {
334
+ // AF-7.6: include `from` to disambiguate multi-instance logs.
335
+ const tag = `[knowless dev:${cfg.from}]`;
336
+ if (isSham) {
337
+ // AF-7.2 dev hint: silent-miss is by-design but in dev mode
338
+ // operators repeatedly debug "why no link?" — surface the
339
+ // reason. Only fires on opt-in dev mode + SMTP failure.
340
+ process.stderr.write(
341
+ `${tag} silent-miss: handle for "${emailNorm}" does not exist (openRegistration=${cfg.openRegistration})\n`,
342
+ );
343
+ } else {
344
+ const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
345
+ process.stderr.write(`${tag} magic link: ${link}\n`);
346
+ }
331
347
  }
332
348
  }
333
349
 
334
- rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
335
- if (isCreating) rateLimitIncrement(store, 'create_ip', ip, HOUR_MS);
350
+ rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
351
+ if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
336
352
 
337
- sameResponse(res, emailNorm, nextValidated ?? '');
353
+ return { handle, isSham, emailNorm, nextValidated };
354
+ }
355
+
356
+ async function login(req, res) {
357
+ // Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
358
+ if (!validateOrigin(req, cfg.cookieDomain)) {
359
+ sameResponse(res, '', '');
360
+ return;
361
+ }
362
+
363
+ let raw;
364
+ try {
365
+ raw = await readBody(req);
366
+ } catch {
367
+ sameResponse(res, '', '');
368
+ return;
369
+ }
370
+ // AF-7.1: warn when a body parser ahead of us has consumed the stream.
371
+ // POST /login with Content-Length > 0 but empty raw body is the
372
+ // signature; without this, the request silently null-routes and the
373
+ // adopter loses 30 minutes wondering why magic links never arrive.
374
+ if (raw.length === 0) {
375
+ const cl = Number(req.headers?.['content-length']);
376
+ if (Number.isFinite(cl) && cl > 0) {
377
+ warnEmptyBodyOnce();
378
+ }
379
+ }
380
+ const body = parseBody(raw, req.headers['content-type']);
381
+ const emailRaw = typeof body.email === 'string' ? body.email : '';
382
+ const honeypot = body[cfg.honeypotFieldName];
383
+ const nextRaw = body.next;
384
+
385
+ // Step 2: honeypot — exempt short-circuit (no sham work)
386
+ if (typeof honeypot === 'string' && honeypot.length > 0) {
387
+ sameResponse(res, emailRaw, nextRaw);
388
+ return;
389
+ }
390
+
391
+ const sourceIp = determineSourceIp(req, trustedProxies);
392
+ const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
393
+ sameResponse(res, result.emailNorm, result.nextValidated ?? '');
394
+ }
395
+
396
+ async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
397
+ // Programmer-error guards (AF-7.3). These DO throw; they're not
398
+ // silent-miss conditions, they're "you called the API wrong."
399
+ if (typeof email !== 'string' || email.length === 0) {
400
+ throw new Error('startLogin: email is required (string)');
401
+ }
402
+ if (nextUrl !== undefined && nextUrl !== null && typeof nextUrl !== 'string') {
403
+ throw new Error('startLogin: nextUrl must be a string when provided');
404
+ }
405
+ if (typeof sourceIp !== 'string') {
406
+ throw new Error('startLogin: sourceIp must be a string when provided');
407
+ }
408
+ const { handle } = await runSendLink({
409
+ emailRaw: email,
410
+ nextRaw: nextUrl ?? null,
411
+ sourceIp,
412
+ });
413
+ // Same-shape return: rate-limit / sham / real all collapse here.
414
+ // `handle` is the HMAC of the normalized email (or null if email
415
+ // was malformed). It leaks nothing about existence per FR-6.
416
+ return { handle, submitted: true };
338
417
  }
339
418
 
340
419
  async function callback(req, res) {
@@ -453,6 +532,7 @@ export function createHandlers({ store, mailer, config }) {
453
532
  logout,
454
533
  loginForm,
455
534
  handleFromRequest,
535
+ startLogin,
456
536
  validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
457
537
  // exposed for tests
458
538
  _config: cfg,
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createStore } from './store.js';
2
- import { createMailer } from './mailer.js';
2
+ import { createMailer, validateBodyFooter } from './mailer.js';
3
3
  import { createHandlers } from './handlers.js';
4
+ import { deriveHandle as deriveHandleRaw } from './handle.js';
4
5
 
5
6
  /** Default sweeper tick: 5 minutes. Per FR-13. */
6
7
  const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
@@ -72,8 +73,12 @@ export function knowless(options = {}) {
72
73
  for (const f of REQUIRED_FIELDS) {
73
74
  if (!options[f]) throw new Error(`knowless: ${f} is required`);
74
75
  }
75
- if (typeof options.secret !== 'string' || options.secret.length < 64) {
76
- throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
76
+ if (typeof options.secret !== 'string' || !/^[a-f0-9]{64,}$/i.test(options.secret)) {
77
+ throw new Error('knowless: secret must be at least 64 hex chars (32 bytes, lowercase a-f, 0-9)');
78
+ }
79
+ // Validate operator-supplied body footer at startup (AF-8.2).
80
+ if (options.bodyFooter !== undefined && options.bodyFooter !== null) {
81
+ validateBodyFooter(options.bodyFooter);
77
82
  }
78
83
 
79
84
  // SPEC §5.4: cookieSecure: false is allowed only for localhost dev.
@@ -141,6 +146,16 @@ export function knowless(options = {}) {
141
146
  * "Log out everywhere." Returns the number of sessions removed.
142
147
  * AF-6.1. */
143
148
  revokeSessions: (handle) => store.revokeSessions(handle),
149
+ /** Programmatic magic-link send. Use this for "use first, claim
150
+ * later" UX flows (drop a pin, post a comment, then confirm via
151
+ * email). Returns `{handle, submitted: true}` — same shape on
152
+ * rate-limit / sham / real to preserve FR-6 timing equivalence.
153
+ * See SPEC §7.3a. AF-7.3. */
154
+ startLogin: handlers.startLogin,
155
+ /** Derive the opaque handle for an email using the configured
156
+ * secret. Lets adopters compute owner-handles outside HTTP context
157
+ * without spreading the secret across modules. AF-7.4. */
158
+ deriveHandle: (email) => deriveHandleRaw(email, options.secret),
144
159
  /** Effective config (with defaults applied), useful for routing. */
145
160
  config: handlers._config,
146
161
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
@@ -158,7 +173,7 @@ export function knowless(options = {}) {
158
173
  }
159
174
 
160
175
  export { createStore } from './store.js';
161
- export { createMailer, composeBody, validateSubject } from './mailer.js';
176
+ export { createMailer, composeBody, validateSubject, validateBodyFooter } from './mailer.js';
162
177
  export { createHandlers } from './handlers.js';
163
178
  export { renderLoginForm } from './form.js';
164
- export { normalize, deriveHandle } from './handle.js';
179
+ export { normalize, deriveHandle, secretBytes } from './handle.js';
package/src/mailer.js CHANGED
@@ -52,6 +52,39 @@ function composeRaw({ from, to, subject, body }) {
52
52
  return `${headers}\r\n\r\n${normalized}`;
53
53
  }
54
54
 
55
+ /**
56
+ * Validate an operator-supplied body footer per AF-8.2.
57
+ *
58
+ * Constraints (deliberately strict to preserve the URL-line invariant
59
+ * and 7bit body encoding from the v0.11 POC finding):
60
+ * - ASCII only
61
+ * - ≤ 240 chars
62
+ * - No CR (LF allowed; line count ≤ 4)
63
+ * - No `http://` / `https://` substring (avoids URL-line confusion
64
+ * and avoids triggering MTA URL-rewriting heuristics)
65
+ *
66
+ * Throws on any violation. Returns the (already-trimmed) footer.
67
+ *
68
+ * @param {unknown} footer
69
+ * @returns {string|null}
70
+ */
71
+ export function validateBodyFooter(footer) {
72
+ if (footer == null || footer === '') return null;
73
+ if (typeof footer !== 'string') {
74
+ throw new Error('bodyFooter must be a string');
75
+ }
76
+ if (footer.length > 240) throw new Error('bodyFooter must be ≤ 240 chars');
77
+ if (!ASCII_RE.test(footer)) throw new Error('bodyFooter must be ASCII');
78
+ if (footer.includes('\r')) throw new Error('bodyFooter must not contain CR');
79
+ if (footer.split('\n').length > 4) {
80
+ throw new Error('bodyFooter must be ≤ 4 lines');
81
+ }
82
+ if (/https?:\/\//i.test(footer)) {
83
+ throw new Error('bodyFooter must not contain URLs (would conflict with the magic-link line)');
84
+ }
85
+ return footer;
86
+ }
87
+
55
88
  /**
56
89
  * Compose the plain-text body of the magic-link email per SPEC §12.2.
57
90
  *
@@ -68,6 +101,11 @@ function composeRaw({ from, to, subject, body }) {
68
101
  * Last sign-in: <ISO 8601 UTC timestamp>.
69
102
  * If that wasn't you, do not click the link above.
70
103
  *
104
+ * Plus, when bodyFooter is provided (AF-8.2):
105
+ *
106
+ * --
107
+ * <footer text>
108
+ *
71
109
  * The URL appears on its own line. Body is ASCII-only.
72
110
  *
73
111
  * @param {object} args
@@ -75,9 +113,10 @@ function composeRaw({ from, to, subject, body }) {
75
113
  * @param {string} args.baseUrl e.g. 'https://app.example.com'
76
114
  * @param {string} args.linkPath e.g. '/auth/callback'
77
115
  * @param {number|null} [args.lastLoginAt] Unix ms; null/undefined to omit
116
+ * @param {string|null} [args.bodyFooter] operator footer; pre-validated
78
117
  * @returns {string} the body text (ASCII)
79
118
  */
80
- export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
119
+ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFooter }) {
81
120
  const url = `${baseUrl}${linkPath}?t=${tokenRaw}`;
82
121
  let body =
83
122
  'Click to sign in:\n\n' +
@@ -89,6 +128,11 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
89
128
  body +=
90
129
  `\nLast sign-in: ${iso}.\n` + 'If that wasn\'t you, do not click the link above.\n';
91
130
  }
131
+ if (bodyFooter) {
132
+ // Standard email signature delimiter: "-- " (dash-dash-space) on
133
+ // its own line. Mail clients strip this section from quoted replies.
134
+ body += `\n-- \n${bodyFooter}\n`;
135
+ }
92
136
  if (!ASCII_RE.test(body)) {
93
137
  throw new Error('mail body contains non-ASCII');
94
138
  }
@@ -151,6 +195,18 @@ export function createMailer(cfg) {
151
195
  auth: undefined,
152
196
  });
153
197
 
198
+ // AF-7.5: validate the resolved transport at startup. Without this,
199
+ // a malformed transportOverride (or a bare options bag mistaken for a
200
+ // transport) silently constructs something that throws "sendMail is
201
+ // not a function" only at first submission — which in production may
202
+ // be hours later. Fail fast at factory time instead.
203
+ if (typeof transport.sendMail !== 'function') {
204
+ throw new Error(
205
+ 'mailer: transport has no sendMail() — if you passed transportOverride, ' +
206
+ 'pass the result of nodemailer.createTransport(opts), not an opts bag.',
207
+ );
208
+ }
209
+
154
210
  return {
155
211
  async submit({ to, subject, body }) {
156
212
  if (typeof to !== 'string' || !ASCII_RE.test(to)) {
package/src/session.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import { secretBytes } from './handle.js';
2
3
 
3
4
  /**
4
5
  * Domain-separation tag for session signatures. See SPEC §3.4 / §5.2.
@@ -22,7 +23,7 @@ export function newSid() {
22
23
  */
23
24
  function signature(sidB64u, secret) {
24
25
  return crypto
25
- .createHmac('sha256', secret)
26
+ .createHmac('sha256', secretBytes(secret))
26
27
  .update(SESS_TAG)
27
28
  .update(sidB64u, 'utf8')
28
29
  .digest('hex');
@@ -40,9 +41,6 @@ export function signSession(sidB64u, secret) {
40
41
  if (typeof sidB64u !== 'string' || !/^[A-Za-z0-9_-]+$/.test(sidB64u)) {
41
42
  throw new Error('invalid sid');
42
43
  }
43
- if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
44
- throw new Error('secret required');
45
- }
46
44
  return `${sidB64u}.${signature(sidB64u, secret)}`;
47
45
  }
48
46