knowless 0.1.10 → 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
@@ -5,17 +5,160 @@ All notable changes to `knowless` are recorded here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  Versioning is [SemVer](https://semver.org/).
7
7
 
8
+ ## Milestones
9
+
10
+ - **2026-04-28 — First customer integration shipped.** addypin
11
+ merged its `try/knowless` branch and runs knowless as its
12
+ auth+mail layer. ~1,150 LOC of bespoke auth/mail code removed,
13
+ ~35 LOC of knowless wiring added (~33× reduction). Drove audit
14
+ findings AF-7 → AF-17 across v0.1.5–v0.1.10.
15
+
8
16
  ## [Unreleased]
9
17
 
10
18
  - **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
11
- bundling Postfix + null-route + the binary so a self-hoster
12
- runs `docker compose up` and has a working auth gateway in
13
- one step. Material UX win for the PRD §4.2 self-hoster
14
- audience. Targeted for v0.2.0.
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.
15
22
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
16
23
  - `knowless-server --check-null-route`: CLI probe that submits a
17
24
  test message to `shamRecipient` and confirms the local MTA
18
- discarded it. Targeted for v0.2.0.
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.
112
+
113
+ ## [0.2.0] — 2026-04-28
114
+
115
+ **No native compile. One production dep.** Drops `better-sqlite3`
116
+ in favour of `node:sqlite` (Node stdlib). Adopters on long-LTS
117
+ distros (RHEL 8/9, Alma, Rocky, Amazon Linux 2) no longer need a
118
+ C++20 toolchain to `npm install knowless`.
119
+
120
+ ### Breaking
121
+
122
+ - **Node floor bumped: `>=20.0.0` → `>=22.5.0`.** `node:sqlite`
123
+ requires Node 22.5+; unflagged stable on Node 24 LTS. Node 20
124
+ reaches EOL April 2026.
125
+ - **`better-sqlite3` removed from `dependencies`.** Down to one
126
+ production dep (`nodemailer`). Transitive package count goes
127
+ from ~40 to ~2. No `prebuild-install`, no `gcc`, no `make`,
128
+ no Python during install.
129
+ - **Storage internals changed**, public API unchanged. The
130
+ `createStore()` interface (SPEC §13) is byte-for-byte identical.
131
+ All 192 tests pass on first run after the swap.
132
+
133
+ ### Migration
134
+
135
+ - **For knowless library adopters:** ensure your runtime is
136
+ Node 22.5+. If you pinned `better-sqlite3` somewhere yourself
137
+ for unrelated reasons, that's now your call. Otherwise:
138
+ ```sh
139
+ npm install knowless@0.2.0
140
+ ```
141
+ No code changes on your side. Existing SQLite databases
142
+ continue to work — same schema, same WAL mode, same
143
+ prepared statements. Sessions and handles persist across
144
+ the upgrade.
145
+ - **For `knowless-server` operators:** ensure the host runs
146
+ Node 22.5+. If you ran `dnf install gcc-toolset-13` to get
147
+ v0.1.x to compile, you can remove it after the upgrade —
148
+ v0.2.0 doesn't need it. The systemd unit and env-var config
149
+ are unchanged.
150
+ - **You may see one `ExperimentalWarning` from `node:sqlite`
151
+ at first import** on Node 22.x. Suppress with `--no-warnings`
152
+ or run on Node 24 LTS where the API is fully stable.
153
+
154
+ ### Internal
155
+
156
+ - New `makeTransaction(db, fn)` adapter in `src/store.js`
157
+ replaces `better-sqlite3`'s `db.transaction()` wrapper. Uses
158
+ `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` directly — same
159
+ serialisation guarantee for the transactional cap-check
160
+ (SPEC §4.7) and account-deletion paths (FR-37a).
161
+ - Closes AF-18 (the addypin RHEL 8 deployment trap).
19
162
 
20
163
  ## [0.1.10] — 2026-04-28
21
164
 
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
@@ -550,11 +625,67 @@ Full options table:
550
625
  | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
551
626
  | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
552
627
  | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. **Mode-A adopters:** if you don't mount `loginForm`, set this to a route you actually serve (e.g. `/`) — otherwise expired/replayed magic-link clicks 302 to a 404. |
553
- | `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
628
+ | `store` | no | (built-in `node:sqlite`) | Inject your own store implementation. |
554
629
  | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
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
@@ -659,15 +790,19 @@ mail account is later compromised.
659
790
 
660
791
  ## Constraints / install footprint
661
792
 
662
- - **Two direct dependencies.** `nodemailer` (SMTP submission) and
663
- `better-sqlite3` (storage). Both audited and pinned at major
664
- versions in `package.json`.
665
- - **~40 transitive packages** in a typical install. The bulk are
666
- `nodemailer`'s ecosystem deps (mostly idle in our usage) and
667
- `better-sqlite3`'s build-time prebuild fetcher. You may see one
668
- deprecation warning during install for `prebuild-install`
669
- build-chain noise, not runtime code.
670
- - **Node ≥ 20.** Uses `node:util parseArgs`, `node:net BlockList`
671
- for CIDR support, and `--env-file=` for the standalone server.
672
- - **No optional deps, no postinstall scripts** beyond `better-
673
- sqlite3`'s native binding fetch.
793
+ - **One direct dependency.** `nodemailer` (SMTP submission). Storage
794
+ is `node:sqlite` (Node stdlib, no native compile, no toolchain
795
+ required on the host).
796
+ - **~2 transitive packages** in a typical install (down from ~40 in
797
+ v0.1.x). No `prebuild-install`, no `gcc`, no `make`, no Python.
798
+ `npm ci` works on stock RHEL 8 / Alma / Rocky / Amazon Linux 2
799
+ with no extra packages. Self-hosters: `npm install knowless` is
800
+ done.
801
+ - **Node ≥ 22.5.** `node:sqlite` requires this floor (introduced
802
+ 22.5, unflagged in 22.13+, fully stable in 24 LTS). Drops Node
803
+ 20 about to EOL anyway.
804
+ - **No optional deps, no postinstall scripts.**
805
+
806
+ > `node:sqlite` may print one `ExperimentalWarning` to stderr on
807
+ > first import. Suppress with `--no-warnings` or by running on
808
+ > Node 24 LTS where the API is fully stable.
package/OPS.md CHANGED
@@ -548,13 +548,14 @@ CLI invoked by Postfix's `pipe` transport, or a web server plus a
548
548
  cron worker handling 48h reminders. Each process instantiates
549
549
  `knowless({...})` against the same `dbPath`.
550
550
 
551
- **Why this works.** `better-sqlite3` opens the database in WAL mode
552
- by default (knowless explicitly sets `journal_mode=WAL` at startup).
553
- WAL allows multiple readers and one writer concurrently, with the
554
- OS-level locking semantics needed for cross-process safety. Every
555
- write goes through a prepared statement under a SQLite transaction,
556
- so two processes inserting tokens or sessions at the same time can't
557
- corrupt the table.
551
+ **Why this works.** knowless opens the database in WAL mode at
552
+ startup (`PRAGMA journal_mode = WAL`). WAL allows multiple readers
553
+ and one writer concurrently, with the OS-level locking semantics
554
+ needed for cross-process safety. Every write goes through a prepared
555
+ statement under a SQLite transaction, so two processes inserting
556
+ tokens or sessions at the same time can't corrupt the table. Since
557
+ v0.2.0 the storage backend is `node:sqlite` (Node stdlib) — no
558
+ native compile, no `gcc` toolchain on the host.
558
559
 
559
560
  **What to know about each subsystem under multi-process:**
560
561