knowless 0.2.0 → 0.2.1

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
@@ -18,11 +18,97 @@ Versioning is [SemVer](https://semver.org/).
18
18
  - **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
19
19
  bundling Postfix + null-route + the binary. Now meaningfully
20
20
  smaller and faster to build because v0.2.0 dropped the native
21
- compile dep. Targeted for v0.2.1.
21
+ compile dep.
22
22
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
23
23
  - `knowless-server --check-null-route`: CLI probe that submits a
24
24
  test message to `shamRecipient` and confirms the local MTA
25
- discarded it. Targeted for v0.2.1.
25
+ discarded it.
26
+
27
+ ## [0.2.1] — 2026-04-29
28
+
29
+ **Operator visibility, opt-in.** Three event hooks + one method,
30
+ the full surface forum + addypin asked for during the v0.2.0
31
+ integration spike. The shape was negotiated against
32
+ `walk-away-at-v1.0.0` (PRD §6.3): every "obvious" addition was
33
+ deliberately rejected if it could be done in adopter or perimeter
34
+ code. See `knowless.context.md` § "What's NOT in knowless, and why"
35
+ for the rejected-by-design list (disposable-domain check, account-age
36
+ accessor, hashcash, `lookupMessageId()`, `onShamHit`).
37
+
38
+ ### Added
39
+
40
+ - **`onMailerSubmit({messageId, handle, timestamp})` (AF-19).**
41
+ Per-event hook fired on successful SMTP submission for *real*
42
+ (non-sham) sends only. Adopters log it, build msg_id ↔ handle
43
+ correlation maps, or pipe to structured logging. Knowless never
44
+ stores the mapping. Sham branches deliberately do NOT fire this
45
+ hook — that's the load-bearing NFR-10 invariant (would let a
46
+ careless adopter log per-handle data and reopen the enumeration
47
+ oracle that sham-work was designed to prevent).
48
+ - **`onTransportFailure({error, timestamp}) (AF-19).** Per-event
49
+ hook fired on SMTP errors. No identity data — safe per-event,
50
+ safe to alert on.
51
+ - **`onSuppressionWindow({sham, rateLimited, windowMs})` (AF-19).**
52
+ Heartbeat hook fired every `suppressionWindowMs` (default 60s)
53
+ with aggregate counters across all silent-202 branches: sham
54
+ hits, `login_ip` cap, `create_ip` cap (counted both as sham and
55
+ rate-limited when fall-through happens), and per-handle token-cap
56
+ rotation. Heartbeats fire even when both counters are zero — a
57
+ missing emission is itself diagnostic. Replaces a per-event
58
+ `onShamHit` / `onRateLimitHit` design that would have leaked
59
+ per-handle data through log lines; the windowed aggregate
60
+ preserves the spike signal without per-call distinguishability.
61
+ - **`auth.verifyTransport()` method (AF-20).** Wraps
62
+ `transport.verify()`. Resolves `Promise<true>` on non-rejection,
63
+ rejects with the underlying error. Adopters call this explicitly
64
+ when they want fail-fast on misconfigured SMTP at boot. **No
65
+ auto-on-boot variant by design (AF-21).** Deployments where
66
+ knowless starts before Postfix (docker-compose ordering, k8s
67
+ readiness probes) would fail boot for the wrong reason.
68
+ - **`startLogin` silent-202 documented (AF-22).** New gotcha #19 in
69
+ `knowless.context.md` and a Mode-A pointer in GUIDE.md make
70
+ explicit that `startLogin` returns `{handle, submitted: true}`
71
+ for every branch (real, sham, rate-limited, missing handle) by
72
+ design. Operators who need branch visibility wire the v0.2.1
73
+ hooks; the per-call return shape never reveals which branch ran.
74
+
75
+ ### Changed
76
+
77
+ - **`store.insertToken` returns the eviction count.** Internal
78
+ store-interface change: `insertToken` now returns the number of
79
+ tokens evicted to make room for the new one (always `0` when
80
+ `maxActive` is `0`). Used by `runSendLink` to count per-handle
81
+ cap rotation events into the `rateLimited` counter. Adopters
82
+ with custom stores implementing the SPEC §13 interface should
83
+ update accordingly; the change is forward-compatible (returning
84
+ `undefined` is treated as zero evictions).
85
+
86
+ ### Documentation (forum + addypin negotiation outcome)
87
+
88
+ - **knowless.context.md § "What's NOT in knowless, and why"** —
89
+ permanent record of three rejected-by-design additions
90
+ (disposable-domain check, account-age accessor, per-IP hashcash)
91
+ with the seam argument and walk-away-at-v1.0.0 framing. Future
92
+ contributors evaluating "should X go in knowless?" run two tests
93
+ before answering yes: identity layer vs behavior layer; mechanism
94
+ living with policy.
95
+ - **GUIDE.md FAQ** — "Why doesn't knowless block disposable email
96
+ domains?" + "How do I check how old a user is?" with adopter-side
97
+ code patterns for both. Closes the most likely "but-can-it" requests.
98
+
99
+ ### Internal
100
+
101
+ - Hook errors are caught and swallowed via a single `safeHook()`
102
+ wrapper, matching the existing `onSweepError` contract. Knowless
103
+ never crashes because an operator's observability sink threw.
104
+ - Suppression-window timer is `unref()`'d and only started when
105
+ `onSuppressionWindow` is wired — adopters not using the hook
106
+ spend zero `setInterval` slots on it.
107
+ - 16 new tests in `test/integration/v021-hooks.test.js` covering
108
+ payload shapes, the sham-no-fire invariant, aggregation
109
+ semantics, heartbeat behavior, counter reset, hook-error
110
+ containment, and `verifyTransport()` resolve/reject paths.
111
+ Test count: 192 → 207.
26
112
 
27
113
  ## [0.2.0] — 2026-04-28
28
114
 
package/GUIDE.md CHANGED
@@ -295,6 +295,11 @@ return identical shapes — the caller can't observe which happened.
295
295
  This preserves FR-6 timing equivalence even for programmatic
296
296
  callers. See SPEC §7.3a for the full contract.
297
297
 
298
+ If you need *operator* visibility (not per-call: aggregate counts and
299
+ real-send confirmation), wire the v0.2.1 hooks documented in
300
+ [Step 8](#step-8-optional-operator-monitoring-via-event-hooks-v021)
301
+ below — they emit without breaking the per-call silent-202 contract.
302
+
298
303
  `auth.deriveHandle(email)` returns the same opaque HMAC handle
299
304
  that the form path uses, without you having to import the helper
300
305
  or pass the secret around. The instance method **normalizes the
@@ -469,6 +474,76 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
469
474
  chooses the UX (admin CLI, in-app self-service, ticket-driven
470
475
  support).
471
476
 
477
+ ### Step 8 (optional): Operator monitoring via event hooks (v0.2.1+)
478
+
479
+ Three event hooks + one opt-in method. All optional, all opt-in. None
480
+ are required for correct operation; the library is fully functional
481
+ with zero hooks wired. They exist so an operator can wire knowless to
482
+ their existing observability stack (Prometheus, statsd, structured
483
+ logs, on-call paging) without knowless curating its own metrics shape.
484
+
485
+ ```js
486
+ const auth = knowless({
487
+ // ...required + existing options...
488
+
489
+ onMailerSubmit: ({messageId, handle, timestamp}) => {
490
+ log.info('knowless.dispatch', { messageId, handle, ts: timestamp });
491
+ },
492
+ onTransportFailure: ({error, timestamp}) => {
493
+ log.error('knowless.smtp_failed', { err: error.message, ts: timestamp });
494
+ pager.notify('SMTP transport failed');
495
+ },
496
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => {
497
+ metrics.gauge('knowless.sham_count', sham);
498
+ metrics.gauge('knowless.rate_limited', rateLimited);
499
+ if (sham > BASELINE * 10) pager.notify('possible enumeration attack');
500
+ },
501
+ // suppressionWindowMs: 60_000, // default; configurable
502
+ });
503
+
504
+ // Optional: explicit transport probe at boot. No auto-probe by design.
505
+ try {
506
+ await auth.verifyTransport();
507
+ } catch (err) {
508
+ console.error('SMTP unreachable at boot:', err);
509
+ process.exit(1);
510
+ }
511
+ ```
512
+
513
+ #### Why three hooks, not four
514
+
515
+ The two silent-202 branches — sham hits (handle does not exist) and
516
+ rate-limit hits (any of the three caps) — are bundled into one *windowed
517
+ aggregate* (`onSuppressionWindow`) rather than per-event hooks. Per-event
518
+ hooks would let a careless adopter log per-handle data, which is the
519
+ enumeration oracle that sham-work exists to prevent. The HTTP response
520
+ is silent on these branches; the log file must be silent too.
521
+
522
+ Operators still get the spike signal — a 10× jump in `sham` count over
523
+ the window is the enumeration-attack alarm. They don't get per-call
524
+ correlation to a specific handle, and they shouldn't have it.
525
+
526
+ `onMailerSubmit` is per-event because it fires *only* on real
527
+ submissions, where the handle was already disclosed by the form
528
+ input. `onTransportFailure` is per-event because it carries no
529
+ identity data.
530
+
531
+ > **Don't log `onSuppressionWindow` payloads in a way that distinguishes
532
+ > them from `onMailerSubmit` at the log-line level.** The aggregate
533
+ > count is fine; the line itself should be cleanly labeled as a periodic
534
+ > counter emission, not "a sham just happened." If your log shipper or
535
+ > dashboard groups them differently, you've put back the per-event
536
+ > distinguishability the bundling was meant to remove.
537
+
538
+ #### Why `verifyTransport()` is opt-in
539
+
540
+ No auto-on-boot variant exists by design. Deployments where knowless
541
+ starts before Postfix (docker-compose ordering, k8s readiness probes
542
+ that run knowless before the SMTP container is healthy) would fail
543
+ boot for the wrong reason. Adopters who want fail-fast call
544
+ `verifyTransport()` explicitly; everyone else gets eventually-consistent
545
+ SMTP startup.
546
+
472
547
  ## Walkthrough: standalone server mode
473
548
 
474
549
  Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
@@ -555,6 +630,62 @@ Full options table:
555
630
 
556
631
  ## FAQ
557
632
 
633
+ ### Why doesn't knowless block disposable email domains?
634
+
635
+ Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
636
+ adopter policy, not identity layer. The blocklist is a public GitHub
637
+ repo, the override list is operator-specific, and the cron to refresh
638
+ it lives in your ops layer. Putting the *mechanism* in knowless while
639
+ the *list curation* and *overrides* live in the adopter is the wrong
640
+ seam — both stay together in your form handler.
641
+
642
+ ```js
643
+ // In your /login form handler, before calling auth.login:
644
+ import { DISPOSABLE_DOMAINS, ADOPTER_OVERRIDES } from './disposable-domains.js';
645
+
646
+ app.post('/login', async (req, res) => {
647
+ const email = /* parse from body */;
648
+ const domain = email.split('@')[1]?.toLowerCase();
649
+ if (domain && DISPOSABLE_DOMAINS.has(domain) && !ADOPTER_OVERRIDES.has(domain)) {
650
+ // Reply with the same shape as auth.login's success/sham response
651
+ // to preserve FR-6-equivalent timing at your layer too. Match
652
+ // status, headers, and body that auth.login would emit.
653
+ return res.status(200).type('html').send(/* same confirmation HTML */);
654
+ }
655
+ return auth.login(req, res);
656
+ });
657
+ ```
658
+
659
+ The same argument applies to per-IP hashcash / proof-of-work: if
660
+ `maxNewHandlesPerIpPerHour: 3` isn't enough for your threat model,
661
+ run hashcash at Caddy or your edge layer — knowless's login form
662
+ deliberately stays zero-JS.
663
+
664
+ ### How do I check how old a handle / user is?
665
+
666
+ knowless deliberately doesn't expose handle creation dates. The reason:
667
+ "first time this email hit knowless" is rarely the trust signal you
668
+ actually want — you want "first time this user did something meaningful
669
+ in *my app*." A six-month-old knowless handle that has never posted
670
+ has zero application tenure.
671
+
672
+ Pattern: track `(handle, first_seen_at)` in your own table the first
673
+ time a handle performs the action you care about (first post, first
674
+ purchase, first non-trivial API call). Bucket by your tenure, not
675
+ knowless's.
676
+
677
+ ```js
678
+ // On the action you care about:
679
+ const handle = auth.handleFromRequest(req);
680
+ db.recordFirstSeen(handle, Date.now()); // INSERT OR IGNORE
681
+ const age = db.ageBucketFor(handle); // 'new' | '1mo' | '1y' | '5y+'
682
+ ```
683
+
684
+ This is also safer: returning a `Date | null` keyed by handle is itself
685
+ an enumeration oracle (null leaks "this handle doesn't exist"). Bucket
686
+ on your side from a table that only knows about handles that have
687
+ already acted.
688
+
558
689
  ### My users say magic links land in spam.
559
690
 
560
691
  This is operator infrastructure, not the library. The library
package/README.md CHANGED
@@ -7,7 +7,7 @@ that don't need to email their users for anything but the sign-in link.
7
7
  npm install knowless
8
8
  ```
9
9
 
10
- > v0.2.0 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
10
+ > v0.2.1 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
11
11
 
12
12
  ## Why this exists
13
13
 
@@ -1,7 +1,7 @@
1
1
  # knowless -- Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring knowless into a project.
4
- > v0.2.0 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
4
+ > v0.2.1 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -122,6 +122,17 @@ const auth = knowless({
122
122
  sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
123
123
  onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
124
124
 
125
+ // --- Operator visibility (v0.2.1, all opt-in) ---
126
+ // Per-event hooks. Errors swallowed (matches onSweepError contract).
127
+ onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
128
+ onTransportFailure: ({error, timestamp}) => { /* */ },
129
+ // Heartbeat aggregate. Default 60s; emits even when both counters
130
+ // are zero. See "Operator visibility" section for the threat-model
131
+ // reasoning behind aggregating sham + rate-limit branches here
132
+ // rather than emitting per-event.
133
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
134
+ suppressionWindowMs: 60_000,
135
+
125
136
  // --- Dev mode (AF-6.2) ---
126
137
  // When SMTP submission fails AND this flag is true, the magic link
127
138
  // is printed to stderr so a developer can click through. Off by
@@ -152,6 +163,7 @@ const auth = knowless({
152
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. |
153
164
  | `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. |
154
165
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
166
+ | `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. |
155
167
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
156
168
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
157
169
 
@@ -173,6 +185,91 @@ import {
173
185
  } from 'knowless';
174
186
  ```
175
187
 
188
+ ## Operator visibility (v0.2.1)
189
+
190
+ Three event hooks + one opt-in method, shipped in v0.2.1. Future
191
+ contributors reading this section before extending the surface: do not
192
+ add a per-event `onShamHit`, do not add a per-handle `onRateLimitHit`,
193
+ do not add an auto-on-boot probe, do not add a `lookupMessageId()`
194
+ endpoint. Each was considered and deliberately rejected during the
195
+ forum + addypin negotiation that produced this surface (PRD §17.3,
196
+ v0.2.1) — see "What's NOT in knowless" below for the reasoning.
197
+
198
+ ### Three hooks (factory options)
199
+
200
+ ```js
201
+ const auth = knowless({
202
+ // ...required + existing options...
203
+
204
+ // Per-event, safe to log per-call.
205
+ onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
206
+ onTransportFailure: ({error, timestamp}) => { /* */ },
207
+
208
+ // Batched aggregate. Fires every windowMs regardless of count
209
+ // (heartbeat). Default cadence 60s.
210
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
211
+ suppressionWindowMs: 60_000,
212
+ });
213
+ ```
214
+
215
+ Field types:
216
+ - `messageId`: string — SMTP `Message-ID` returned by nodemailer
217
+ - `handle`: string — 64-char hex; only emitted on real (non-sham) submits
218
+ - `timestamp`: number — epoch ms
219
+ - `error`: Error
220
+ - `sham`, `rateLimited`: integer counters, count within the window
221
+ - `windowMs`: integer — the configured window length, echoed in the payload
222
+
223
+ Errors thrown from hooks are caught and swallowed (matches the existing
224
+ `onSweepError` contract); knowless does not depend on hook delivery for
225
+ correctness.
226
+
227
+ ### Method
228
+
229
+ `auth.verifyTransport()` — wraps `transport.verify()` on the configured
230
+ SMTP transport. Returns `Promise<true>` on success, rejects with the
231
+ underlying error. Adopters call this explicitly when they want fail-fast
232
+ on misconfigured SMTP at boot. **No auto-on-boot variant** by design:
233
+ deployments where knowless starts before Postfix (docker-compose
234
+ ordering, k8s readiness probes) would fail boot for the wrong reason.
235
+
236
+ ### Threat-model justification (the durable part)
237
+
238
+ The two silent-202 branches — sham (handle does not exist) and rate-limit
239
+ (any of the three caps) — are aggregated rather than per-event because
240
+ **NFR-10 timing equivalence applies at the log layer too**, not just the
241
+ HTTP response. A per-event `onShamHit({handle})` lets a careless adopter
242
+ log "sham detected for X" and the log file becomes an enumeration oracle
243
+ — the exact thing sham-work was designed to prevent. The response is
244
+ silent; the log must be silent too.
245
+
246
+ Knowless has three rate limits, and one of them is identity-tied:
247
+ - `maxLoginRequestsPerIpPerHour` — IP-keyed
248
+ - `maxNewHandlesPerIpPerHour` — IP-keyed
249
+ - `maxActiveTokensPerHandle` — **handle-keyed; per-event hits leak
250
+ "this handle exists and has hit a token cap"**
251
+
252
+ Splitting per-event-IP from per-event-handle works in theory and fails
253
+ in practice — future contributor sees the asymmetry and adds the missing
254
+ handle variant for symmetry. Bundling all three into the windowed
255
+ aggregate forecloses that drift.
256
+
257
+ `onMailerSubmit` carries `handle` per-event because it fires *only on
258
+ real submissions*, where the handle was already disclosed to knowless
259
+ by the form input. Emitting it back to the adopter is not a new leak.
260
+ `onTransportFailure` carries no identity data, per-event safe.
261
+
262
+ ### Why no `lookupMessageId()` endpoint
263
+
264
+ An earlier proposal added an authenticated `auth.lookupMessageId(id)`
265
+ behind an operator secret so operators could correlate maillog entries
266
+ to handles. Rejected: the same capability is achievable by the adopter
267
+ maintaining their own `(messageId → handle)` map, populated from
268
+ `onMailerSubmit`. Knowless never stores the mapping, never exposes a
269
+ new authenticated surface, never carries operator-secret rotation
270
+ burden. The hook is the mechanism; the correlation map is adopter
271
+ choice.
272
+
176
273
  ## Handle / token / session lifecycles
177
274
 
178
275
  ```
@@ -352,6 +449,66 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
352
449
  | `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
353
450
  | `src/form.js` | ~110 | Hardcoded login HTML |
354
451
 
452
+ ## What's NOT in knowless, and why
453
+
454
+ Three capabilities that look like they belong here but don't, listed
455
+ because the "why not" needs to outlast walk-away-at-v1.0.0. When future
456
+ contributors propose adding any of these back, point them here.
457
+
458
+ ### Disposable-domain blocking — adopter / form handler
459
+
460
+ Reject `mailinator.com` etc. before knowless sees the submission.
461
+ Mechanism + list + override + weekly cron all live in the adopter's
462
+ form handler.
463
+
464
+ The argument for putting this in knowless was timing equivalence: if
465
+ the adopter rejects fast, an attacker times the response and learns
466
+ "this domain is on a public blocklist." Counter: the blocklist is a
467
+ public GitHub repo (`disposable-email-domains/disposable-email-domains`).
468
+ Anyone can fetch it directly. Timing-equivalence here protects information
469
+ that isn't secret. Knowless's sham-work protects against email
470
+ *enumeration* (is `alice@x.com` registered?), not domain *classification*
471
+ (is `x.com` on a public list?). Different threat, different defense.
472
+
473
+ Splitting mechanism (knowless) from policy + list curation (adopter) is
474
+ the wrong seam. Both stay in the adopter's form handler.
475
+
476
+ ### App-tenure / account-age — adopter / first-seen tracking
477
+
478
+ Knowless's "handle creation date" is when this email first hit knowless.
479
+ The adopter's interesting question is "how long has this user been
480
+ participating in *my app*" — a different number, and the adopter's
481
+ number is the one that should drive trust decisions.
482
+
483
+ Concrete failure mode: a handle registered with knowless six months ago
484
+ but never posted has zero app-tenure. If the adopter reads knowless's
485
+ age, a brand-new spammer with an old handle gets unearned credibility.
486
+
487
+ Pattern: adopter stores `(handle, first_seen_at)` the first time it sees
488
+ a handle perform a meaningful action. App-tenure is app-derived. Knowless
489
+ doesn't expose age data — and wouldn't even if it could, because
490
+ returning `Date | null` keyed by handle is itself an enumeration leak.
491
+
492
+ ### Per-IP hashcash / proof-of-work — Caddy / perimeter layer
493
+
494
+ `maxNewHandlesPerIpPerHour: 3` already covers the ground hashcash would
495
+ cover. A botnet that can't get past three signups per IP per hour needs
496
+ IP rotation regardless; once rotated, a 2s hashcash is rounding error
497
+ at botnet economics. Costs are real: breaks Lynx/w3m (gotcha #10),
498
+ requires JS in the login form (the only zero-JS exception we'd carry),
499
+ ~2s UX delay for legit users on weak devices. If a deployment observes
500
+ per-IP signup actually saturating the cap, Caddy (or another perimeter
501
+ layer) can run hashcash off-the-shelf without making knowless carry it.
502
+
503
+ ### The deciding lens
504
+
505
+ knowless walks away at v1.0.0 (PRD §6.3). Every config option carried
506
+ into v1.0.0 is something v1.x has to keep stable through the
507
+ maintenance window. The test for any proposed addition: does this
508
+ belong in the **identity layer** (who they are) or the **behavior
509
+ layer** (what they did)? Identity layer is in scope. Behavior layer is
510
+ out. When unsure, default out — less surface, less carrying cost.
511
+
355
512
  ## Threat model summary
356
513
 
357
514
  **Defends well:** DB-only leaks (handles are HMAC-salted),
@@ -482,6 +639,15 @@ rate-limits) belongs above the library.
482
639
  factory startup; fails fast. Goes after RFC 3676 `"-- "`
483
640
  delimiter so mail clients strip it from quoted replies.
484
641
 
642
+ 19. **`startLogin` is silent at every layer (FR-6).** Returns
643
+ `{handle, submitted: true}` for *every* branch — real send, sham,
644
+ rate-limited, missing-handle-with-`openRegistration:false`. Adopters
645
+ cannot derive the branch from the return value, by design.
646
+ Operator visibility comes from the v0.2.1 hooks (`onMailerSubmit`
647
+ per-event, `onSuppressionWindow` aggregated) — *not* from the
648
+ return shape. Don't wrap `startLogin` in something that surfaces
649
+ the branch to the caller; that re-opens the enumeration oracle.
650
+
485
651
  ## Constraints
486
652
 
487
653
  - **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.2.0",
3
+ "version": "0.2.1",
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
@@ -163,7 +163,20 @@ function sidHashOf(sid) {
163
163
  * validateNextUrl: (raw:string)=>string|null
164
164
  * }}
165
165
  */
166
- export function createHandlers({ store, mailer, config }) {
166
+ export function createHandlers({ store, mailer, config, events }) {
167
+ // v0.2.1 operator-visibility hooks. All optional; treat missing as
168
+ // no-ops so the handler hot path is identical for adopters who don't
169
+ // wire them. The factory passes a fully-populated `events` object;
170
+ // direct callers of createHandlers (tests / advanced wiring) may omit
171
+ // it entirely.
172
+ const noop = () => {};
173
+ const ev = {
174
+ shamHit: events?.shamHit ?? noop,
175
+ rateLimitHit: events?.rateLimitHit ?? noop,
176
+ onMailerSubmit: events?.onMailerSubmit ?? noop,
177
+ onTransportFailure: events?.onTransportFailure ?? noop,
178
+ };
179
+
167
180
  const cfg = { ...DEFAULTS, ...config };
168
181
  if (!cfg.secret) throw new Error('config.secret required');
169
182
  if (typeof cfg.secret !== 'string' || cfg.secret.length < 64) {
@@ -267,6 +280,7 @@ export function createHandlers({ store, mailer, config }) {
267
280
  HOUR_MS,
268
281
  )
269
282
  ) {
283
+ ev.rateLimitHit();
270
284
  return { handle: null, isSham: false, emailNorm, nextValidated: null };
271
285
  }
272
286
 
@@ -287,6 +301,11 @@ export function createHandlers({ store, mailer, config }) {
287
301
  )
288
302
  ) {
289
303
  // Cap exceeded — fall through to sham, do NOT short-circuit.
304
+ // The fall-through becomes a sham-hit too; both counters
305
+ // increment because they're independent dimensions (operator
306
+ // can correlate from `rateLimited` jumping in lockstep with
307
+ // `sham`).
308
+ ev.rateLimitHit();
290
309
  isCreating = false;
291
310
  }
292
311
  }
@@ -308,9 +327,10 @@ export function createHandlers({ store, mailer, config }) {
308
327
  } else {
309
328
  isSham = true;
310
329
  toAddress = cfg.shamRecipient;
330
+ ev.shamHit();
311
331
  }
312
332
 
313
- store.insertToken({
333
+ const evicted = store.insertToken({
314
334
  tokenHash: token.hash,
315
335
  handle,
316
336
  expiresAt,
@@ -318,6 +338,10 @@ export function createHandlers({ store, mailer, config }) {
318
338
  isSham,
319
339
  maxActive: cfg.maxActiveTokensPerHandle,
320
340
  });
341
+ // Per-handle token cap rotation is the third rate limit. Counted
342
+ // here in the aggregate `rateLimited` window so operators see
343
+ // hammering of a single handle without per-event identity leakage.
344
+ if (evicted > 0) ev.rateLimitHit();
321
345
 
322
346
  const mailBody = composeBody({
323
347
  tokenRaw: token.raw,
@@ -334,10 +358,29 @@ export function createHandlers({ store, mailer, config }) {
334
358
  // so timing equivalence is preserved.
335
359
  const effectiveSubject = subject ?? cfg.subject;
336
360
  try {
337
- await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
361
+ const info = await mailer.submit({
362
+ to: toAddress,
363
+ subject: effectiveSubject,
364
+ body: mailBody,
365
+ });
366
+ // v0.2.1: per-event hook on real (non-sham) submissions only.
367
+ // Sham branches go through the windowed aggregate; emitting them
368
+ // per-event here would let a careless adopter log per-handle
369
+ // data and reopen the enumeration oracle that sham-work exists
370
+ // to prevent.
371
+ if (!isSham) {
372
+ ev.onMailerSubmit({
373
+ messageId: info?.messageId ?? null,
374
+ handle,
375
+ timestamp: Date.now(),
376
+ });
377
+ }
338
378
  } catch (err) {
339
379
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
340
380
  console.error('[knowless] mail submit failed:', err.message);
381
+ // v0.2.1: per-event hook for SMTP failures. Carries no identity
382
+ // data, safe per-event. Operator wires this to alerting.
383
+ ev.onTransportFailure({ error: err, timestamp: Date.now() });
341
384
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
342
385
  // development the operator otherwise has no way to obtain the magic
343
386
  // link. Print it to stderr only when explicitly opted in.
package/src/index.js CHANGED
@@ -9,8 +9,27 @@ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
9
9
  /** Default rate-limit retention: 24 hours past window-start. */
10
10
  const DEFAULT_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000;
11
11
 
12
+ /** Default suppression-window cadence: 60 seconds. v0.2.1. */
13
+ const DEFAULT_SUPPRESSION_WINDOW_MS = 60 * 1000;
14
+
12
15
  const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
13
16
 
17
+ /**
18
+ * Wrap a user-supplied hook so its errors are caught and swallowed.
19
+ * Matches the `onSweepError` contract: knowless never crashes because
20
+ * an operator's observability sink threw.
21
+ */
22
+ function safeHook(fn, label) {
23
+ if (typeof fn !== 'function') return () => {};
24
+ return (arg) => {
25
+ try {
26
+ fn(arg);
27
+ } catch (err) {
28
+ console.error(`[knowless] ${label} hook threw:`, err?.message ?? err);
29
+ }
30
+ };
31
+ }
32
+
14
33
  /**
15
34
  * @typedef {Object} KnowlessOptions
16
35
  * @property {string} secret HMAC secret, ≥64 hex chars (32 bytes). FR-47, FR-48.
@@ -37,6 +56,21 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
37
56
  * @property {string[]} [trustedProxies]
38
57
  * @property {string} [shamRecipient='null@knowless.invalid'] See SPEC §7.4.
39
58
  * @property {number} [sweepIntervalMs] Sweeper tick; defaults to 5 minutes.
59
+ * @property {function} [onSweepError] Optional alerting hook for sweep failures.
60
+ * @property {function} [onMailerSubmit] v0.2.1. Per-event hook fired on
61
+ * successful mail submission for *real* (non-sham) sends only. Payload:
62
+ * `{messageId, handle, timestamp}`. Errors are caught and swallowed.
63
+ * @property {function} [onTransportFailure] v0.2.1. Per-event hook fired
64
+ * on SMTP errors. Payload: `{error, timestamp}`. Errors swallowed.
65
+ * @property {function} [onSuppressionWindow] v0.2.1. Heartbeat hook fired
66
+ * every `suppressionWindowMs` with aggregate counters. Payload:
67
+ * `{sham, rateLimited, windowMs}`. Aggregates the silent-202 branches
68
+ * (sham + rate-limit hits) without per-event identity disclosure;
69
+ * see knowless.context.md § "v0.2.1 design" for the threat-model
70
+ * reasoning. Fires even when both counters are zero (heartbeat).
71
+ * Errors swallowed.
72
+ * @property {number} [suppressionWindowMs=60000] v0.2.1. Cadence of
73
+ * `onSuppressionWindow` emissions. Default 60 seconds.
40
74
  * @property {object} [store] Inject your own store implementation.
41
75
  * @property {object} [mailer] Inject your own mailer.
42
76
  * @property {object} [transportOverride] Pass to nodemailer.createTransport (tests).
@@ -64,7 +98,12 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
64
98
  * verify: Function,
65
99
  * logout: Function,
66
100
  * loginForm: Function,
101
+ * handleFromRequest: (req: any) => string | null,
67
102
  * deleteHandle: (handle: string) => void,
103
+ * revokeSessions: (handle: string) => number,
104
+ * startLogin: (args: object) => Promise<{handle: string|null, submitted: true}>,
105
+ * deriveHandle: (email: string) => string,
106
+ * verifyTransport: () => Promise<true>,
68
107
  * config: object,
69
108
  * close: () => void,
70
109
  * }}
@@ -102,7 +141,58 @@ export function knowless(options = {}) {
102
141
  transportOverride: options.transportOverride,
103
142
  });
104
143
 
105
- const handlers = createHandlers({ store, mailer, config: options });
144
+ // v0.2.1 operator-visibility hooks. All optional. Validate types up
145
+ // front so a typo is caught at startup, not on the first hit.
146
+ for (const k of ['onMailerSubmit', 'onTransportFailure', 'onSuppressionWindow']) {
147
+ if (options[k] !== undefined && typeof options[k] !== 'function') {
148
+ throw new Error(`knowless: ${k} must be a function when provided`);
149
+ }
150
+ }
151
+ const suppressionWindowMs =
152
+ options.suppressionWindowMs ?? DEFAULT_SUPPRESSION_WINDOW_MS;
153
+ if (
154
+ typeof suppressionWindowMs !== 'number' ||
155
+ !Number.isFinite(suppressionWindowMs) ||
156
+ suppressionWindowMs <= 0
157
+ ) {
158
+ throw new Error('knowless: suppressionWindowMs must be a positive number');
159
+ }
160
+
161
+ // Counters reset every windowMs. Aggregating sham + rate-limit
162
+ // branches behind a windowed counter (rather than per-event hooks)
163
+ // is the deliberate design — see knowless.context.md § "Why three
164
+ // hooks, not four" for the threat-model justification.
165
+ let shamCount = 0;
166
+ let rateLimitedCount = 0;
167
+ const onMailerSubmit = safeHook(options.onMailerSubmit, 'onMailerSubmit');
168
+ const onTransportFailure = safeHook(options.onTransportFailure, 'onTransportFailure');
169
+ const onSuppressionWindow = safeHook(options.onSuppressionWindow, 'onSuppressionWindow');
170
+
171
+ const events = {
172
+ shamHit: () => { shamCount++; },
173
+ rateLimitHit: () => { rateLimitedCount++; },
174
+ onMailerSubmit,
175
+ onTransportFailure,
176
+ };
177
+
178
+ const handlers = createHandlers({ store, mailer, config: options, events });
179
+
180
+ // The window timer fires every windowMs as a heartbeat — emits even
181
+ // when both counters are zero. Operators rely on the heartbeat as a
182
+ // liveness signal ("knowless is processing"); a missing emission is
183
+ // itself diagnostic. Only run the timer when the hook is wired so we
184
+ // don't spend a setInterval slot on adopters who don't use it.
185
+ let suppressionTimer = null;
186
+ if (typeof options.onSuppressionWindow === 'function') {
187
+ suppressionTimer = setInterval(() => {
188
+ const sham = shamCount;
189
+ const rateLimited = rateLimitedCount;
190
+ shamCount = 0;
191
+ rateLimitedCount = 0;
192
+ onSuppressionWindow({ sham, rateLimited, windowMs: suppressionWindowMs });
193
+ }, suppressionWindowMs);
194
+ if (typeof suppressionTimer.unref === 'function') suppressionTimer.unref();
195
+ }
106
196
 
107
197
  const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
108
198
  const onSweepError = options.onSweepError;
@@ -163,8 +253,15 @@ export function knowless(options = {}) {
163
253
  config: handlers._config,
164
254
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
165
255
  _sweep: runSweep,
256
+ /** Probe the configured SMTP transport (v0.2.1). Resolves true on
257
+ * success, rejects with the underlying error. Opt-in fail-fast for
258
+ * adopters who want to validate SMTP at boot; no auto-on-boot
259
+ * variant by design — k8s readiness probes / docker-compose
260
+ * ordering would fail boot for the wrong reason. */
261
+ verifyTransport: () => mailer.verify(),
166
262
  close() {
167
263
  clearInterval(sweepTimer);
264
+ if (suppressionTimer !== null) clearInterval(suppressionTimer);
168
265
  try {
169
266
  mailer.close?.();
170
267
  } catch {
package/src/mailer.js CHANGED
@@ -221,6 +221,27 @@ export function createMailer(cfg) {
221
221
  raw,
222
222
  });
223
223
  },
224
+ /**
225
+ * Probe the underlying SMTP transport. Resolves to true on success,
226
+ * rejects with the underlying error otherwise. Adopters call this
227
+ * explicitly when they want fail-fast on misconfigured SMTP at boot.
228
+ * No auto-on-boot variant: deployments where knowless starts before
229
+ * Postfix (docker-compose ordering, k8s readiness probes) would
230
+ * fail boot for the wrong reason. v0.2.1.
231
+ *
232
+ * Contract: non-rejection means success. The underlying nodemailer
233
+ * transport may return a truthy value, falsy value, or throw —
234
+ * non-throwing is treated as success and normalized to `true`.
235
+ * Tests using `streamTransport` exercise this normalization
236
+ * (streamTransport's verify() returns false even on healthy probes).
237
+ */
238
+ async verify() {
239
+ if (typeof transport.verify !== 'function') {
240
+ return true;
241
+ }
242
+ await transport.verify();
243
+ return true;
244
+ },
224
245
  close() {
225
246
  if (typeof transport.close === 'function') transport.close();
226
247
  },
package/src/store.js CHANGED
@@ -194,17 +194,23 @@ export function createStore(dbPath = ':memory:') {
194
194
  };
195
195
 
196
196
  // Transactional cap-check + insert per SPEC §4.7.
197
+ // Returns the number of tokens evicted to make room for the new one
198
+ // (always 0 when maxActive is 0). Callers use this to count
199
+ // per-handle-cap rotation events for operator monitoring (v0.2.1).
197
200
  const insertTokenAtomic = makeTransaction(db,
198
201
  (tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
202
+ let evicted = 0;
199
203
  if (maxActive > 0) {
200
204
  const { n: count } = stmt.countActiveTokens.get(handle, now);
201
205
  let toEvict = count - maxActive + 1;
202
206
  while (toEvict > 0) {
203
207
  stmt.evictOldestActive.run(handle, now);
204
208
  toEvict--;
209
+ evicted++;
205
210
  }
206
211
  }
207
212
  stmt.insertToken.run(tokenHash, handle, expiresAt, nextUrl, isSham);
213
+ return evicted;
208
214
  },
209
215
  );
210
216
 
@@ -231,6 +237,13 @@ export function createStore(dbPath = ':memory:') {
231
237
  },
232
238
 
233
239
  // --- Token ---
240
+ /**
241
+ * Insert a token, evicting oldest active tokens for the handle when
242
+ * the per-handle cap (maxActive) would be exceeded.
243
+ * @returns {number} count of tokens evicted to make room (0 when no
244
+ * rotation occurred). Used for operator monitoring (v0.2.1
245
+ * `onSuppressionWindow.rateLimited` counter).
246
+ */
234
247
  insertToken(args) {
235
248
  const {
236
249
  tokenHash,
@@ -243,7 +256,7 @@ export function createStore(dbPath = ':memory:') {
243
256
  } = args;
244
257
  assertHexHash(tokenHash, 'tokenHash');
245
258
  assertHexHash(handle, 'handle');
246
- insertTokenAtomic(
259
+ return insertTokenAtomic(
247
260
  tokenHash,
248
261
  handle,
249
262
  expiresAt,