knowless 0.1.4 → 0.1.5

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,6 +15,62 @@ Versioning is [SemVer](https://semver.org/).
15
15
  not what the MTA did, so this is the closest we can get.
16
16
  Targeted for v0.2.0.
17
17
 
18
+ ## [0.1.5] — 2026-04-28
19
+
20
+ addypin POC findings round. Adds programmatic magic-link entry
21
+ (unblocks "use first, claim later" UX patterns), one ergonomic
22
+ helper, two safety/diagnostic fixes, and three doc updates.
23
+
24
+ ### Added
25
+
26
+ - **`auth.startLogin({email, nextUrl?, sourceIp?})`** — programmatic
27
+ entry that runs the same 12-step sham-work flow as `POST /login`
28
+ but skips Origin/honeypot (no browser context). Returns
29
+ `{handle, submitted: true}` — same shape on rate-limit / sham /
30
+ real to preserve FR-6 timing equivalence. Throws only on
31
+ programmer error. SPEC §7.3a. Closes AF-7.3.
32
+ - **`auth.deriveHandle(email)`** — instance method that uses the
33
+ configured secret. Lets adopters compute owner-handles outside
34
+ HTTP context without spreading the secret across modules. Closes
35
+ AF-7.4.
36
+ - **GUIDE.md "Two adoption modes" section** — Mode B (register-
37
+ first, the form) and Mode A (use-first-claim-later, programmatic).
38
+ Both supported, pickable per-action.
39
+ - **GUIDE.md "Constraints / install footprint" section** — direct
40
+ deps, transitive count, deprecation-warning context. Closes AF-7.7.
41
+
42
+ ### Changed
43
+
44
+ - **`devLogMagicLinks` lines now tagged with `cfg.from`** —
45
+ `[knowless dev:auth@app.example.com] magic link: ...`. Disambiguates
46
+ multi-instance dev logs. Closes AF-7.6.
47
+ - **`devLogMagicLinks` + sham + SMTP-fail now prints a one-line
48
+ silent-miss hint** instead of staying silent. Surfaces the
49
+ closed-registration-is-on case that previously cost adopters
50
+ ~30min of debugging. Strictly opt-in dev mode. Closes AF-7.2.
51
+
52
+ ### Safety / diagnostics
53
+
54
+ - **`createMailer` validates `transportOverride` at startup.** A
55
+ malformed override (e.g. an options bag mistaken for a transport)
56
+ throws fast with a pointed error instead of failing at first
57
+ submission. Closes AF-7.5.
58
+ - **`POST /login` warns once on stderr when `Content-Length > 0`
59
+ but body is empty.** Catches the common non-Express trap of
60
+ mounting `express.urlencoded()` or similar body parsers ahead of
61
+ `auth.login` (which consumes the stream itself). One warning per
62
+ handler instance. Closes AF-7.1.
63
+
64
+ ### Documentation
65
+
66
+ - **SPEC §7.3a** specifies the programmatic entry's contract: which
67
+ steps it skips (Origin, honeypot), why FR-6 still holds, and
68
+ what programmer-error throws look like.
69
+ - **GUIDE.md** adds two traps for non-Express integrators (body-
70
+ parser conflict, Origin requirement) and a worked Mode-A example.
71
+ - **knowless.context.md** lists `startLogin` + `deriveHandle` in the
72
+ public API table and adds gotchas 15–16.
73
+
18
74
  ## [0.1.4] — 2026-04-28
19
75
 
20
76
  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.4 | 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,27 @@ 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.
101
104
 
102
105
  // --- Abuse defenses (FR-38..41) ---
103
106
  maxActiveTokensPerHandle: 5, // 0 to disable
104
107
  maxLoginRequestsPerIpPerHour: 30, // 0 to disable
105
108
  maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
106
109
  honeypotFieldName: 'website',
107
- trustedProxies: ['127.0.0.1', '::1'],
110
+ // Plain IPs and/or CIDR ranges (AF-6.3). Useful for k8s/docker/cgnat.
111
+ trustedProxies: ['127.0.0.1', '::1', '10.0.0.0/8'],
108
112
 
109
113
  // --- Lifecycle ---
110
114
  sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
115
+ onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
116
+
117
+ // --- Dev mode (AF-6.2) ---
118
+ // When SMTP submission fails AND this flag is true, the magic link
119
+ // is printed to stderr so a developer can click through. Off by
120
+ // default. Never fires for sham (silent-miss) submissions.
121
+ devLogMagicLinks: false,
111
122
 
112
123
  // --- Injection (tests / advanced) ---
113
124
  store: undefined, // bring your own store
@@ -127,7 +138,12 @@ const auth = knowless({
127
138
  | `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
128
139
  | `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
129
140
  | `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
141
+ | `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
142
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
143
+ | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
144
+ | `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. |
145
+ | `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
146
+ | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
131
147
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
132
148
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
133
149
 
@@ -372,11 +388,10 @@ rate-limits) belongs above the library.
372
388
  The library does NOT compute eTLD+1 automatically (would
373
389
  require a public-suffix-list dep).
374
390
 
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`).
391
+ 5. **`Secure` cookie attribute toggles via `cookieSecure`.** Default
392
+ is `true`. Set `cookieSecure: false` ONLY for `http://localhost`
393
+ development; the library logs a stderr warning at startup (AF-4.4).
394
+ Production deployments MUST use HTTPS and leave `cookieSecure: true`.
380
395
 
381
396
  6. **Forward-auth needs the parent-domain cookie.** If your auth
382
397
  subdomain is `auth.example.com` and protected service is
@@ -410,6 +425,40 @@ rate-limits) belongs above the library.
410
425
  so it won't *prevent* exit, but the SQLite handle held by
411
426
  `better-sqlite3` will leave a finalizer warning.
412
427
 
428
+ 12. **CSRF defense is the Origin/Referer whitelist, not a token.**
429
+ Modern browsers always emit `Origin` on cross-origin POSTs;
430
+ knowless validates host against `cookieDomain` on POST /login
431
+ AND POST /logout. Browser-absent (curl / programmatic) is
432
+ allowed. **Do NOT add a CSRF token upstream** — the Origin
433
+ check is the defense. SPEC §7.3 Step 0.
434
+
435
+ 13. **`confirmationMessage` is plain text + `{email}` placeholder.**
436
+ The whole message is HTML-escaped before render (AF-6.5). If
437
+ you want bold/italic/links in the confirmation copy, pre-render
438
+ the HTML upstream and pass the escaped string — but understand
439
+ you're then responsible for not interpolating user data.
440
+
441
+ 14. **`devLogMagicLinks` is opt-in and dev-only.** When set true
442
+ AND SMTP submission fails, the magic link is printed to stderr
443
+ tagged `[knowless dev:<from>]`. Sham (silent-miss) submissions
444
+ print a `silent-miss: ...` hint instead of a link — opt-in dev
445
+ only, since this leaks closed-reg state. Don't enable in
446
+ production.
447
+
448
+ 15. **POST /login: don't pre-parse the body.** knowless reads the
449
+ request stream itself. Any framework body parser mounted in
450
+ front of `auth.login` will silently steal the form data and
451
+ null-route the request. knowless emits a one-time
452
+ `console.warn` if it sees `Content-Length > 0` with an empty
453
+ body. AF-7.1.
454
+
455
+ 16. **Two adoption modes — Mode B (register-first) and Mode A
456
+ (use-first claim-later).** Mode B is the form (`auth.login`).
457
+ Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
458
+ "drop a pin, claim by email click" patterns. Both run the
459
+ identical 12-step sham-work flow; same FR-6 guarantee. Pick
460
+ per-action, not per-app.
461
+
413
462
  ## Constraints
414
463
 
415
464
  - **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.5",
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
@@ -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
  )
@@ -323,18 +328,91 @@ export function createHandlers({ store, mailer, config }) {
323
328
  console.error('[knowless] mail submit failed:', err.message);
324
329
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
325
330
  // 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`);
331
+ // link. Print it to stderr only when explicitly opted in.
332
+ if (cfg.devLogMagicLinks) {
333
+ // AF-7.6: include `from` to disambiguate multi-instance logs.
334
+ const tag = `[knowless dev:${cfg.from}]`;
335
+ if (isSham) {
336
+ // AF-7.2 dev hint: silent-miss is by-design but in dev mode
337
+ // operators repeatedly debug "why no link?" — surface the
338
+ // reason. Only fires on opt-in dev mode + SMTP failure.
339
+ process.stderr.write(
340
+ `${tag} silent-miss: handle for "${emailNorm}" does not exist (openRegistration=${cfg.openRegistration})\n`,
341
+ );
342
+ } else {
343
+ const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
344
+ process.stderr.write(`${tag} magic link: ${link}\n`);
345
+ }
331
346
  }
332
347
  }
333
348
 
334
- rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
335
- if (isCreating) rateLimitIncrement(store, 'create_ip', ip, HOUR_MS);
349
+ rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
350
+ if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
336
351
 
337
- sameResponse(res, emailNorm, nextValidated ?? '');
352
+ return { handle, isSham, emailNorm, nextValidated };
353
+ }
354
+
355
+ async function login(req, res) {
356
+ // Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
357
+ if (!validateOrigin(req, cfg.cookieDomain)) {
358
+ sameResponse(res, '', '');
359
+ return;
360
+ }
361
+
362
+ let raw;
363
+ try {
364
+ raw = await readBody(req);
365
+ } catch {
366
+ sameResponse(res, '', '');
367
+ return;
368
+ }
369
+ // AF-7.1: warn when a body parser ahead of us has consumed the stream.
370
+ // POST /login with Content-Length > 0 but empty raw body is the
371
+ // signature; without this, the request silently null-routes and the
372
+ // adopter loses 30 minutes wondering why magic links never arrive.
373
+ if (raw.length === 0) {
374
+ const cl = Number(req.headers?.['content-length']);
375
+ if (Number.isFinite(cl) && cl > 0) {
376
+ warnEmptyBodyOnce();
377
+ }
378
+ }
379
+ const body = parseBody(raw, req.headers['content-type']);
380
+ const emailRaw = typeof body.email === 'string' ? body.email : '';
381
+ const honeypot = body[cfg.honeypotFieldName];
382
+ const nextRaw = body.next;
383
+
384
+ // Step 2: honeypot — exempt short-circuit (no sham work)
385
+ if (typeof honeypot === 'string' && honeypot.length > 0) {
386
+ sameResponse(res, emailRaw, nextRaw);
387
+ return;
388
+ }
389
+
390
+ const sourceIp = determineSourceIp(req, trustedProxies);
391
+ const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
392
+ sameResponse(res, result.emailNorm, result.nextValidated ?? '');
393
+ }
394
+
395
+ async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
396
+ // Programmer-error guards (AF-7.3). These DO throw; they're not
397
+ // silent-miss conditions, they're "you called the API wrong."
398
+ if (typeof email !== 'string' || email.length === 0) {
399
+ throw new Error('startLogin: email is required (string)');
400
+ }
401
+ if (nextUrl !== undefined && nextUrl !== null && typeof nextUrl !== 'string') {
402
+ throw new Error('startLogin: nextUrl must be a string when provided');
403
+ }
404
+ if (typeof sourceIp !== 'string') {
405
+ throw new Error('startLogin: sourceIp must be a string when provided');
406
+ }
407
+ const { handle } = await runSendLink({
408
+ emailRaw: email,
409
+ nextRaw: nextUrl ?? null,
410
+ sourceIp,
411
+ });
412
+ // Same-shape return: rate-limit / sham / real all collapse here.
413
+ // `handle` is the HMAC of the normalized email (or null if email
414
+ // was malformed). It leaks nothing about existence per FR-6.
415
+ return { handle, submitted: true };
338
416
  }
339
417
 
340
418
  async function callback(req, res) {
@@ -453,6 +531,7 @@ export function createHandlers({ store, mailer, config }) {
453
531
  logout,
454
532
  loginForm,
455
533
  handleFromRequest,
534
+ startLogin,
456
535
  validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
457
536
  // exposed for tests
458
537
  _config: cfg,
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createStore } from './store.js';
2
2
  import { createMailer } 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;
@@ -141,6 +142,16 @@ export function knowless(options = {}) {
141
142
  * "Log out everywhere." Returns the number of sessions removed.
142
143
  * AF-6.1. */
143
144
  revokeSessions: (handle) => store.revokeSessions(handle),
145
+ /** Programmatic magic-link send. Use this for "use first, claim
146
+ * later" UX flows (drop a pin, post a comment, then confirm via
147
+ * email). Returns `{handle, submitted: true}` — same shape on
148
+ * rate-limit / sham / real to preserve FR-6 timing equivalence.
149
+ * See SPEC §7.3a. AF-7.3. */
150
+ startLogin: handlers.startLogin,
151
+ /** Derive the opaque handle for an email using the configured
152
+ * secret. Lets adopters compute owner-handles outside HTTP context
153
+ * without spreading the secret across modules. AF-7.4. */
154
+ deriveHandle: (email) => deriveHandleRaw(email, options.secret),
144
155
  /** Effective config (with defaults applied), useful for routing. */
145
156
  config: handlers._config,
146
157
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
package/src/mailer.js CHANGED
@@ -151,6 +151,18 @@ export function createMailer(cfg) {
151
151
  auth: undefined,
152
152
  });
153
153
 
154
+ // AF-7.5: validate the resolved transport at startup. Without this,
155
+ // a malformed transportOverride (or a bare options bag mistaken for a
156
+ // transport) silently constructs something that throws "sendMail is
157
+ // not a function" only at first submission — which in production may
158
+ // be hours later. Fail fast at factory time instead.
159
+ if (typeof transport.sendMail !== 'function') {
160
+ throw new Error(
161
+ 'mailer: transport has no sendMail() — if you passed transportOverride, ' +
162
+ 'pass the result of nodemailer.createTransport(opts), not an opts bag.',
163
+ );
164
+ }
165
+
154
166
  return {
155
167
  async submit({ to, subject, body }) {
156
168
  if (typeof to !== 'string' || !ASCII_RE.test(to)) {