knowless 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,14 +15,191 @@ Versioning is [SemVer](https://semver.org/).
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
- - **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
19
- bundling Postfix + null-route + the binary. Now meaningfully
20
- smaller and faster to build because v0.2.0 dropped the native
21
- compile dep. Targeted for v0.2.1.
22
- - Caddy forward-auth Docker integration test (TASKS.md 6.8).
23
- - `knowless-server --check-null-route`: CLI probe that submits a
24
- test message to `shamRecipient` and confirms the local MTA
25
- discarded it. Targeted for v0.2.1.
18
+ **v0.2.2 is feature-complete.** v1.0.0 is the planned next release —
19
+ walk-away promotion, no API changes.
20
+
21
+ ## [0.2.2] 2026-04-29
22
+
23
+ **One feature add at the end of v0.2.x: per-call body customization
24
+ for `auth.startLogin` (AF-26).** Closes the last addypin gap before
25
+ v1.0.0 promotion adopters with multiple Mode-A flows (pin
26
+ confirmation, login, expiry warning) can now phrase the email body
27
+ to match per-call subjects without re-implementing token mint /
28
+ sham-work / SMTP submit.
29
+
30
+ ### Added
31
+
32
+ - **`bodyOverride: ({url}) => string` arg on `auth.startLogin`
33
+ (AF-26).** A template function called per-call after the magic-
34
+ link URL is composed. knowless still owns URL composition (so the
35
+ v0.11 POC 7bit URL-line invariant stays in knowless's control) and
36
+ validates the rendered output. `bodyFooter` continues to append
37
+ after the override; the `lastLogin` line does NOT auto-append on
38
+ overridden bodies — the template owns content end-to-end.
39
+
40
+ ```js
41
+ await auth.startLogin({
42
+ email,
43
+ subjectOverride: `Confirm your pin: ${shortcode}`,
44
+ bodyOverride: ({ url }) =>
45
+ `Confirm your pin "${shortcode}":\n\n${url}\n\n` +
46
+ `Link expires in 15 minutes.\n`,
47
+ });
48
+ ```
49
+
50
+ - **`validateBodyOverride(body, url)` re-export.** Pure validator
51
+ exposed alongside the other `validate*` re-exports. Same validation
52
+ knowless runs internally on the override return value:
53
+ - Non-empty string, ≤ 2048 chars
54
+ - ASCII only
55
+ - No CR (header-injection defense)
56
+ - URL appears exactly once
57
+ - URL is on its own line (preserves the v0.11 POC 7bit URL-line
58
+ invariant — QP soft-breaks would break the link)
59
+
60
+ Throws on any violation. Adopters generally don't need to call this
61
+ directly — knowless validates the function's return value at
62
+ compose time — but it's exported for ahead-of-time tests.
63
+
64
+ ### Why this is in scope (and not a 'forum-style' rejected addition)
65
+
66
+ The body has to be composed *after* the token is minted (the URL
67
+ contains the token), so the caller can't just "send their own email"
68
+ without re-implementing most of knowless. Bypassing knowless to send
69
+ a custom body would mean rebuilding token mint + token-store insert
70
+ + sham-work timing + SMTP submit — that's most of the library. The
71
+ ASCII / URL-line / 7bit constraints are the right place to keep
72
+ validating, and those live in knowless. Identity-layer concern,
73
+ mechanism stays where the policy is.
74
+
75
+ Contrast with the rejected items in the v0.2.1 backlog cull
76
+ (disposable-domain, account-age, hashcash, Docker image): each of
77
+ those passed the "could the adopter do this themselves?" test.
78
+ Per-call body customization fails it.
79
+
80
+ ### Internal
81
+
82
+ - 16 new tests in `test/integration/body-override.test.js` covering
83
+ happy path on real submissions, sham-branch parity (FR-6),
84
+ bodyFooter append behavior, lastLogin non-auto-append, every
85
+ validation error path, undefined/null pass-through, and the
86
+ re-exported `validateBodyOverride` helper. Test count: 207 → 223.
87
+
88
+ ## [0.2.1] — 2026-04-29
89
+
90
+ **Operator visibility, opt-in.** Three event hooks + one method,
91
+ the full surface forum + addypin asked for during the v0.2.0
92
+ integration spike. The shape was negotiated against
93
+ `walk-away-at-v1.0.0` (PRD §6.3): every "obvious" addition was
94
+ deliberately rejected if it could be done in adopter or perimeter
95
+ code. See `knowless.context.md` § "What's NOT in knowless, and why"
96
+ for the rejected-by-design list (disposable-domain check, account-age
97
+ accessor, hashcash, `lookupMessageId()`, `onShamHit`).
98
+
99
+ ### Added
100
+
101
+ - **`onMailerSubmit({messageId, handle, timestamp})` (AF-19).**
102
+ Per-event hook fired on successful SMTP submission for *real*
103
+ (non-sham) sends only. Adopters log it, build msg_id ↔ handle
104
+ correlation maps, or pipe to structured logging. Knowless never
105
+ stores the mapping. Sham branches deliberately do NOT fire this
106
+ hook — that's the load-bearing NFR-10 invariant (would let a
107
+ careless adopter log per-handle data and reopen the enumeration
108
+ oracle that sham-work was designed to prevent).
109
+ - **`onTransportFailure({error, timestamp}) (AF-19).** Per-event
110
+ hook fired on SMTP errors. No identity data — safe per-event,
111
+ safe to alert on.
112
+ - **`onSuppressionWindow({sham, rateLimited, windowMs})` (AF-19).**
113
+ Heartbeat hook fired every `suppressionWindowMs` (default 60s)
114
+ with aggregate counters across all silent-202 branches: sham
115
+ hits, `login_ip` cap, `create_ip` cap (counted both as sham and
116
+ rate-limited when fall-through happens), and per-handle token-cap
117
+ rotation. Heartbeats fire even when both counters are zero — a
118
+ missing emission is itself diagnostic. Replaces a per-event
119
+ `onShamHit` / `onRateLimitHit` design that would have leaked
120
+ per-handle data through log lines; the windowed aggregate
121
+ preserves the spike signal without per-call distinguishability.
122
+ - **`auth.verifyTransport()` method (AF-20).** Wraps
123
+ `transport.verify()`. Resolves `Promise<true>` on non-rejection,
124
+ rejects with the underlying error. Adopters call this explicitly
125
+ when they want fail-fast on misconfigured SMTP at boot. **No
126
+ auto-on-boot variant by design (AF-21).** Deployments where
127
+ knowless starts before Postfix (docker-compose ordering, k8s
128
+ readiness probes) would fail boot for the wrong reason.
129
+ - **`startLogin` silent-202 documented (AF-22).** New gotcha #19 in
130
+ `knowless.context.md` and a Mode-A pointer in GUIDE.md make
131
+ explicit that `startLogin` returns `{handle, submitted: true}`
132
+ for every branch (real, sham, rate-limited, missing handle) by
133
+ design. Operators who need branch visibility wire the v0.2.1
134
+ hooks; the per-call return shape never reveals which branch ran.
135
+
136
+ ### Changed
137
+
138
+ - **`store.insertToken` returns the eviction count.** Internal
139
+ store-interface change: `insertToken` now returns the number of
140
+ tokens evicted to make room for the new one (always `0` when
141
+ `maxActive` is `0`). Used by `runSendLink` to count per-handle
142
+ cap rotation events into the `rateLimited` counter. Adopters
143
+ with custom stores implementing the SPEC §13 interface should
144
+ update accordingly; the change is forward-compatible (returning
145
+ `undefined` is treated as zero evictions).
146
+
147
+ ### Documentation (forum + addypin negotiation outcome)
148
+
149
+ - **knowless.context.md § "What's NOT in knowless, and why"** —
150
+ permanent record of three rejected-by-design additions
151
+ (disposable-domain check, account-age accessor, per-IP hashcash)
152
+ with the seam argument and walk-away-at-v1.0.0 framing. Future
153
+ contributors evaluating "should X go in knowless?" run two tests
154
+ before answering yes: identity layer vs behavior layer; mechanism
155
+ living with policy.
156
+ - **GUIDE.md FAQ** — "Why doesn't knowless block disposable email
157
+ domains?" + "How do I check how old a user is?" with adopter-side
158
+ code patterns for both. Closes the most likely "but-can-it" requests.
159
+
160
+ ### Internal
161
+
162
+ - Hook errors are caught and swallowed via a single `safeHook()`
163
+ wrapper, matching the existing `onSweepError` contract. Knowless
164
+ never crashes because an operator's observability sink threw.
165
+ - Suppression-window timer is `unref()`'d and only started when
166
+ `onSuppressionWindow` is wired — adopters not using the hook
167
+ spend zero `setInterval` slots on it.
168
+ - 16 new tests in `test/integration/v021-hooks.test.js` covering
169
+ payload shapes, the sham-no-fire invariant, aggregation
170
+ semantics, heartbeat behavior, counter reset, hook-error
171
+ containment, and `verifyTransport()` resolve/reject paths.
172
+ Test count: 192 → 207.
173
+
174
+ ### Cut from v0.2.x backlog (kept here for the record)
175
+
176
+ Three items previously listed under Unreleased were stress-tested
177
+ against walk-away-at-v1.0.0 and cut. Rationale per item, so future
178
+ contributors see why these aren't being re-proposed:
179
+
180
+ - **`knowless-server --check-null-route` CLI probe — CUT.** Operator
181
+ setup-correctness check, not identity layer. The same probe is
182
+ three commands of `swaks` + `tail /var/log/maillog`; documented in
183
+ GUIDE.md Step 3. Adding a knowless CLI feature for it would carry
184
+ maintenance burden into walk-away for something an operator can
185
+ already do with a one-line shell command.
186
+ - **Caddy forward-auth Docker integration test (TASKS 6.8) — CUT.**
187
+ The contract under test is two HTTP responses and one header
188
+ (`/verify` → 200+`X-User-Handle` or 401). Every hop is already
189
+ covered by `forward-auth-next.test.js` + `cli.test.js`. addypin
190
+ runs knowless behind Caddy in production — that is the integration
191
+ test, with adopter signal stronger than any docker-compose CI
192
+ could provide. Removed as a v1.0.0 graduation criterion;
193
+ PRD §6.1 updated.
194
+ - **Turnkey Docker image (`knowless/knowless-server:0.2.x`) — CUT.**
195
+ Doesn't actually solve the operator problem: SPF / DKIM / PTR /
196
+ outbound-port-25 work is still the operator's, image only saves
197
+ ~5 minutes of `apt install postfix && postmap`. Cost-side is
198
+ permanent: Postfix is on a CVE drumbeat, and a walk-away library
199
+ shipping a Postfix image would commit to forever-rebuilds —
200
+ exactly the opposite of walk-away discipline. If a self-hoster
201
+ builds a community Dockerfile, OPS.md will link to it; knowless
202
+ itself doesn't ship one.
26
203
 
27
204
  ## [0.2.0] — 2026-04-28
28
205
 
package/GUIDE.md CHANGED
@@ -162,6 +162,25 @@ sudo postmap /etc/postfix/transport
162
162
  sudo systemctl reload postfix
163
163
  ```
164
164
 
165
+ **Verify the null-route is catching mail.** A misconfigured
166
+ null-route doesn't surface until the first sham submission — by
167
+ which point you're debugging a silent-202 from a real form post.
168
+ One-line check, no knowless code needed:
169
+
170
+ ```
171
+ sudo apt install swaks # one-time, if not present
172
+ swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
173
+ sudo journalctl -u postfix --since '1 minute ago' | grep -i 'discard'
174
+ ```
175
+
176
+ A `discard:` line in the postfix log confirms `transport_maps` is
177
+ applied. If you see `relay=` / `delivered` / a queue ID with no
178
+ discard, re-run `postmap` + `systemctl reload postfix` and try
179
+ again. (knowless deliberately does NOT ship a `--check-null-route`
180
+ CLI for this — operator MTA validation is operator-side, and adding
181
+ a wrapper for a one-line `swaks` invocation would carry maintenance
182
+ burden into the v1.0.0 walk-away window for no real value.)
183
+
165
184
  Then the DNS records — set on your sending domain, **not** your
166
185
  app's primary domain (typical setup: `auth.example.com` is the
167
186
  sending domain):
@@ -240,15 +259,18 @@ the form.
240
259
 
241
260
  ### Two adoption modes (Mode A vs Mode B)
242
261
 
243
- knowless supports two UX flows out of the box. Pick per-action,
244
- not per-app both can coexist.
262
+ In plain English: **"sign in, then do the thing"** (Mode B) and
263
+ **"do the thing, confirm by email"** (Mode A). knowless supports
264
+ both out of the box; pick per-action, not per-app — they coexist.
265
+ The Mode A/B labels are used here and in the CHANGELOG so
266
+ discussions across the docs stay unambiguous.
245
267
 
246
- **Mode B — register-first (the default).** User must log in before
247
- performing the action. Wire `auth.login` / `auth.callback` as
248
- above; gate your action with `auth.handleFromRequest(req)`. Use
249
- when the action requires a session (account settings, paid
250
- features, anything you want tied to an identity at the moment of
251
- the action).
268
+ **Mode B — "sign in, then do the thing" (register-first, the default).**
269
+ User must log in before performing the action. Wire `auth.login` /
270
+ `auth.callback` as above; gate your action with
271
+ `auth.handleFromRequest(req)`. Use when the action requires a session
272
+ (account settings, paid features, anything you want tied to an
273
+ identity at the moment of the action).
252
274
 
253
275
  ```js
254
276
  app.post('/api/comments', (req, res) => {
@@ -258,12 +280,12 @@ app.post('/api/comments', (req, res) => {
258
280
  });
259
281
  ```
260
282
 
261
- **Mode A — use-first, claim-later.** User performs the action
262
- without logging in; you capture their email and trigger a magic
263
- link. Clicking it opens a session and your callback handler
264
- "promotes" the deferred resource. Use for "drop a pin," "post a
265
- share link," "submit a paste" — patterns where forcing a login
266
- *before* the action would harm the UX.
283
+ **Mode A — "do the thing, confirm by email" (use-first, claim-later).**
284
+ User performs the action without logging in; you capture their email
285
+ and trigger a magic link. Clicking it opens a session and your
286
+ callback handler "promotes" the deferred resource. Use for "drop a
287
+ pin," "post a share link," "submit a paste" — patterns where forcing
288
+ a login *before* the action would harm the UX.
267
289
 
268
290
  ```js
269
291
  app.post('/api/pins', async (req, res) => {
@@ -277,6 +299,14 @@ app.post('/api/pins', async (req, res) => {
277
299
  // Per-call subject so the user can tell at a glance this is a
278
300
  // pin-confirmation, not a routine login. AF-9.
279
301
  subjectOverride: `Confirm your pin: ${shortcode}`,
302
+ // Per-call body so subject and body agree. AF-26 (v0.2.2).
303
+ // knowless still composes the URL and validates the rendered
304
+ // output (ASCII / URL on its own line / ≤2048 chars). bodyFooter
305
+ // still appends; the lastLogin line does NOT auto-append on
306
+ // overridden bodies — the template owns the content.
307
+ bodyOverride: ({ url }) =>
308
+ `Confirm your pin "${shortcode}":\n\n${url}\n\n` +
309
+ `This link expires in 15 minutes. If you didn't request it, ignore.\n`,
280
310
  });
281
311
  res.status(202).end(); // "we'll email you the link"
282
312
  });
@@ -295,6 +325,11 @@ return identical shapes — the caller can't observe which happened.
295
325
  This preserves FR-6 timing equivalence even for programmatic
296
326
  callers. See SPEC §7.3a for the full contract.
297
327
 
328
+ If you need *operator* visibility (not per-call: aggregate counts and
329
+ real-send confirmation), wire the v0.2.1 hooks documented in
330
+ [Step 8](#step-8-optional-operator-monitoring-via-event-hooks-v021)
331
+ below — they emit without breaking the per-call silent-202 contract.
332
+
298
333
  `auth.deriveHandle(email)` returns the same opaque HMAC handle
299
334
  that the form path uses, without you having to import the helper
300
335
  or pass the secret around. The instance method **normalizes the
@@ -469,6 +504,76 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
469
504
  chooses the UX (admin CLI, in-app self-service, ticket-driven
470
505
  support).
471
506
 
507
+ ### Step 8 (optional): Operator monitoring via event hooks (v0.2.1+)
508
+
509
+ Three event hooks + one opt-in method. All optional, all opt-in. None
510
+ are required for correct operation; the library is fully functional
511
+ with zero hooks wired. They exist so an operator can wire knowless to
512
+ their existing observability stack (Prometheus, statsd, structured
513
+ logs, on-call paging) without knowless curating its own metrics shape.
514
+
515
+ ```js
516
+ const auth = knowless({
517
+ // ...required + existing options...
518
+
519
+ onMailerSubmit: ({messageId, handle, timestamp}) => {
520
+ log.info('knowless.dispatch', { messageId, handle, ts: timestamp });
521
+ },
522
+ onTransportFailure: ({error, timestamp}) => {
523
+ log.error('knowless.smtp_failed', { err: error.message, ts: timestamp });
524
+ pager.notify('SMTP transport failed');
525
+ },
526
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => {
527
+ metrics.gauge('knowless.sham_count', sham);
528
+ metrics.gauge('knowless.rate_limited', rateLimited);
529
+ if (sham > BASELINE * 10) pager.notify('possible enumeration attack');
530
+ },
531
+ // suppressionWindowMs: 60_000, // default; configurable
532
+ });
533
+
534
+ // Optional: explicit transport probe at boot. No auto-probe by design.
535
+ try {
536
+ await auth.verifyTransport();
537
+ } catch (err) {
538
+ console.error('SMTP unreachable at boot:', err);
539
+ process.exit(1);
540
+ }
541
+ ```
542
+
543
+ #### Why three hooks, not four
544
+
545
+ The two silent-202 branches — sham hits (handle does not exist) and
546
+ rate-limit hits (any of the three caps) — are bundled into one *windowed
547
+ aggregate* (`onSuppressionWindow`) rather than per-event hooks. Per-event
548
+ hooks would let a careless adopter log per-handle data, which is the
549
+ enumeration oracle that sham-work exists to prevent. The HTTP response
550
+ is silent on these branches; the log file must be silent too.
551
+
552
+ Operators still get the spike signal — a 10× jump in `sham` count over
553
+ the window is the enumeration-attack alarm. They don't get per-call
554
+ correlation to a specific handle, and they shouldn't have it.
555
+
556
+ `onMailerSubmit` is per-event because it fires *only* on real
557
+ submissions, where the handle was already disclosed by the form
558
+ input. `onTransportFailure` is per-event because it carries no
559
+ identity data.
560
+
561
+ > **Don't log `onSuppressionWindow` payloads in a way that distinguishes
562
+ > them from `onMailerSubmit` at the log-line level.** The aggregate
563
+ > count is fine; the line itself should be cleanly labeled as a periodic
564
+ > counter emission, not "a sham just happened." If your log shipper or
565
+ > dashboard groups them differently, you've put back the per-event
566
+ > distinguishability the bundling was meant to remove.
567
+
568
+ #### Why `verifyTransport()` is opt-in
569
+
570
+ No auto-on-boot variant exists by design. Deployments where knowless
571
+ starts before Postfix (docker-compose ordering, k8s readiness probes
572
+ that run knowless before the SMTP container is healthy) would fail
573
+ boot for the wrong reason. Adopters who want fail-fast call
574
+ `verifyTransport()` explicitly; everyone else gets eventually-consistent
575
+ SMTP startup.
576
+
472
577
  ## Walkthrough: standalone server mode
473
578
 
474
579
  Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
@@ -555,6 +660,80 @@ Full options table:
555
660
 
556
661
  ## FAQ
557
662
 
663
+ ### Is there an official knowless Docker image?
664
+
665
+ No. knowless does not ship a turnkey image with Postfix + null-route
666
+ + the binary pre-baked. The reasoning: a Docker image bundling
667
+ Postfix wouldn't actually save the operator from the work that
668
+ matters (SPF / DKIM / PTR records on your sending domain, outbound
669
+ port 25 unblocked at your hosting provider, reverse DNS pointed at
670
+ your sending hostname — all done outside the container regardless),
671
+ and shipping a Postfix image would commit a walk-away library to a
672
+ permanent CVE-rebuild cadence. The OPS.md walkthrough is the
673
+ canonical install path; a fresh VPS to working forward-auth takes
674
+ 30–60 minutes of one-time setup, and then it stays put.
675
+
676
+ If a community Dockerfile emerges (open invitation — knowless is
677
+ Apache-2.0), OPS.md will link to it. Until then, run
678
+ `knowless-server` as a systemd unit alongside Postfix as the OPS
679
+ walkthrough lays out.
680
+
681
+ ### Why doesn't knowless block disposable email domains?
682
+
683
+ Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
684
+ adopter policy, not identity layer. The blocklist is a public GitHub
685
+ repo, the override list is operator-specific, and the cron to refresh
686
+ it lives in your ops layer. Putting the *mechanism* in knowless while
687
+ the *list curation* and *overrides* live in the adopter is the wrong
688
+ seam — both stay together in your form handler.
689
+
690
+ ```js
691
+ // In your /login form handler, before calling auth.login:
692
+ import { DISPOSABLE_DOMAINS, ADOPTER_OVERRIDES } from './disposable-domains.js';
693
+
694
+ app.post('/login', async (req, res) => {
695
+ const email = /* parse from body */;
696
+ const domain = email.split('@')[1]?.toLowerCase();
697
+ if (domain && DISPOSABLE_DOMAINS.has(domain) && !ADOPTER_OVERRIDES.has(domain)) {
698
+ // Reply with the same shape as auth.login's success/sham response
699
+ // to preserve FR-6-equivalent timing at your layer too. Match
700
+ // status, headers, and body that auth.login would emit.
701
+ return res.status(200).type('html').send(/* same confirmation HTML */);
702
+ }
703
+ return auth.login(req, res);
704
+ });
705
+ ```
706
+
707
+ The same argument applies to per-IP hashcash / proof-of-work: if
708
+ `maxNewHandlesPerIpPerHour: 3` isn't enough for your threat model,
709
+ run hashcash at Caddy or your edge layer — knowless's login form
710
+ deliberately stays zero-JS.
711
+
712
+ ### How do I check how old a handle / user is?
713
+
714
+ knowless deliberately doesn't expose handle creation dates. The reason:
715
+ "first time this email hit knowless" is rarely the trust signal you
716
+ actually want — you want "first time this user did something meaningful
717
+ in *my app*." A six-month-old knowless handle that has never posted
718
+ has zero application tenure.
719
+
720
+ Pattern: track `(handle, first_seen_at)` in your own table the first
721
+ time a handle performs the action you care about (first post, first
722
+ purchase, first non-trivial API call). Bucket by your tenure, not
723
+ knowless's.
724
+
725
+ ```js
726
+ // On the action you care about:
727
+ const handle = auth.handleFromRequest(req);
728
+ db.recordFirstSeen(handle, Date.now()); // INSERT OR IGNORE
729
+ const age = db.ageBucketFor(handle); // 'new' | '1mo' | '1y' | '5y+'
730
+ ```
731
+
732
+ This is also safer: returning a `Date | null` keyed by handle is itself
733
+ an enumeration oracle (null leaks "this handle doesn't exist"). Bucket
734
+ on your side from a table that only knows about handles that have
735
+ already acted.
736
+
558
737
  ### My users say magic links land in spam.
559
738
 
560
739
  This is operator infrastructure, not the library. The library