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/README.md CHANGED
@@ -7,35 +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.10 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
11
-
12
- ## What this is
13
-
14
- Magic-link auth + session cookie + nothing else. Six lines of
15
- operator code:
16
-
17
- ```js
18
- import express from 'express';
19
- import { knowless } from 'knowless';
20
-
21
- const app = express();
22
- const auth = knowless({
23
- secret: process.env.KNOWLESS_SECRET, // 64-char hex (32 bytes)
24
- baseUrl: 'https://app.example.com',
25
- from: 'auth@app.example.com',
26
- });
27
-
28
- app.use(express.urlencoded({ extended: false }));
29
- app.get('/login', auth.loginForm);
30
- app.post('/login', auth.login);
31
- app.get('/auth/callback', auth.callback);
32
- app.get('/verify', auth.verify);
33
- app.post('/logout', auth.logout);
34
- ```
35
-
36
- That's the entire integration. Users hit `/login`, type their email,
37
- click the magic link in their inbox, and are logged in for 30 days
38
- via a signed session cookie.
10
+ > v0.2.1 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
39
11
 
40
12
  ## Why this exists
41
13
 
@@ -44,114 +16,194 @@ maximum identity collection: full email stored in plaintext, profile
44
16
  fields, recovery email, federation. Even nominally privacy-focused
45
17
  options store enough that a breach is materially harmful.
46
18
 
47
- `knowless` is the simpler answer that always worked: magic link in,
48
- session cookie out, nothing else stored. The library refuses, by API
49
- shape, to send anything but the sign-in link or store anything
50
- identifying.
19
+ knowless is the simpler answer that always worked: **magic link in,
20
+ session cookie out, nothing else stored.** Email is HMAC-hashed at the
21
+ boundary and discarded. The library refuses, by API shape, to send
22
+ anything but the sign-in link or store anything identifying.
51
23
 
52
24
  The thesis: most services have ten layers of auth tooling where they
53
25
  need two.
54
26
 
55
- ## What it commits to
56
-
57
- - **Stores no plaintext email, ever.** Email is salted-hashed on the
58
- way in (`HMAC-SHA256(secret, normalized_email)`) and discarded.
59
- - **Sends no email except the magic link.** Not a welcome message,
60
- not a digest, not a notification. By API shape there is no
61
- `sendNotification()` method to be tempted by.
62
- - **Self-hostable end to end.** No vendor relationships. No
63
- telemetry. No phone-home of any kind.
64
- - **Walks away at v1.0.0.** Maintenance mode after that.
65
-
66
- ## What it deliberately doesn't do
67
-
68
- A short list (full table in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §14):
69
-
70
- - No remote SMTP / mail vendor support localhost Postfix is the
71
- only transport
72
- - No HTML email, no tracking pixels, no click-rewriting
73
- - No OAuth / OIDC / SAML — different audience
74
- - No 2FA / WebAuthn / TOTP — compose with a separate library if
75
- needed
76
- - No admin UI for handles or sessions `sqlite3 knowless.db` is
77
- the admin UI
78
- - No customisable login form templates — the page is hardcoded;
79
- fork or live with it
80
- - No telemetry, analytics, or error reporting
27
+ ## Two modes pick per action
28
+
29
+ Same library, two flows. They coexist in one app; choose per-endpoint
30
+ based on whether forcing a login *before* the action would harm UX.
31
+
32
+ ### Mode Bregister-first (the form)
33
+
34
+ User must log in before performing the action. Standard "sign in to
35
+ continue" flow.
36
+
37
+ - User hits `/login`, types email
38
+ - Magic link arrives, click → session cookie
39
+ - Your protected endpoints call `auth.handleFromRequest(req)` to gate
40
+ access
41
+
42
+ Use for: account settings, paid features, anything that requires an
43
+ identified user at the moment of the action.
44
+
45
+ ### Mode A use-first, claim-later (programmatic)
46
+
47
+ User performs the action *without* being logged in. You capture their
48
+ email along with the action, fire a magic link via
49
+ `auth.startLogin({email, nextUrl, ...})`, and clicking it opens a
50
+ session and "promotes" the deferred resource.
51
+
52
+ Use for: drop-a-pin / submit-a-paste / share-a-link / disposable
53
+ resources / anywhere logging in first kills the UX.
54
+
55
+ The same 12-step sham-work flow runs underneath either mode, so
56
+ unknown emails, rate-limit hits, and real sends look identical to an
57
+ external observer (the FR-6 timing-equivalence guarantee). Pick per
58
+ action; the two coexist.
59
+
60
+ Worked code for both modes is in [`GUIDE.md`](GUIDE.md). The dense
61
+ API reference is [`knowless.context.md`](knowless.context.md).
62
+
63
+ ## What's opinionated (locked by design)
64
+
65
+ These are deliberate trade-offs, documented as `NO-GO` in
66
+ [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §14.
67
+ The library refuses, by API shape, to grow into them.
68
+
69
+ - **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend. The
70
+ operator runs Postfix (or another MTA) on the same host, in
71
+ outbound-only mode.
72
+ - **One mail purpose: the sign-in link.** No welcome message, no
73
+ digest, no notification. There is no `sendNotification()` to be
74
+ tempted by.
75
+ - **Plain-text 7-bit email.** No HTML, no tracking pixels, no
76
+ click-rewriting, no read-receipts.
77
+ - **No OAuth / OIDC / SAML.** Different audience.
78
+ - **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
79
+ library if you need them.
80
+ - **No admin UI.** `sqlite3 knowless.db` is the admin UI.
81
+ - **Hardcoded login form.** No template overrides; fork or live
82
+ with it.
83
+ - **No telemetry, analytics, or error reporting.** Self-hostable end
84
+ to end. No phone-home of any kind.
85
+ - **Walks away at v1.0.0.** Maintenance mode after that — only
86
+ security fixes.
87
+
88
+ ## What's swappable
89
+
90
+ Everything that *isn't* identity-shape or threat-model essential is
91
+ config or injection.
92
+
93
+ | Knob | Default | Common reasons to change |
94
+ |---|---|---|
95
+ | `dbPath` | `./knowless.db` | Move to `/var/lib/knowless/...` for systemd; share across processes |
96
+ | `smtpHost`, `smtpPort` | `localhost`, `25` | Point at MailHog (`localhost:1025`) for dev mail inspection |
97
+ | `cookieDomain` | hostname of `baseUrl` | Set to your eTLD+1 for SSO across subdomains |
98
+ | `cookieSecure` | `true` | `false` only for `http://localhost` dev (logs a warning) |
99
+ | `tokenTtlSeconds`, `sessionTtlSeconds` | `900`, `2592000` | Tighten for high-security uses; loosen at your peril |
100
+ | `openRegistration` | `false` | `true` to let any new email auto-register on first link |
101
+ | `subject` | `Sign in` | Match your brand; per-call override on `startLogin` (`subjectOverride`) |
102
+ | `bodyFooter` | none | Append a constant brand/legal/feedback line to every magic-link mail |
103
+ | `confirmationMessage` | (default copy) | Replace the post-submit "we'll email you" text |
104
+ | `maxLoginRequestsPerIpPerHour`, `maxNewHandlesPerIpPerHour` | `30`, `3` | Raise for genuinely shared NATs; `0` to disable in dev |
105
+ | `trustedProxies` | `[127.0.0.1, ::1]` | Plain IPs **and** CIDRs (`10.0.0.0/8`) for k8s/docker/cgnat |
106
+ | `bypassRateLimit` (per-call) | `false` | Trusted CLI/cron callers via `auth.startLogin` |
107
+ | `store` | built-in `node:sqlite` | Inject your own store (Postgres, etc.) |
108
+ | `mailer` | built-in nodemailer | Inject your own mailer |
109
+ | `transportOverride` | none | Pass a custom `nodemailer.createTransport` |
110
+ | `onSweepError(err)` | none | Operator alerting hook for sweeper failures |
111
+ | `devLogMagicLinks` | `false` | `true` in dev: print magic-link URLs (or silent-miss hints) to stderr when SMTP fails |
112
+
113
+ Full table with defaults, types, and validation rules:
114
+ [`GUIDE.md`](GUIDE.md) → "Configuration reference."
81
115
 
82
116
  ## Two deployment shapes (one codebase)
83
117
 
84
118
  | Mode | Status | When |
85
119
  |---|---|---|
86
120
  | **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 |
121
+ | **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. behind Caddy / nginx / Traefik |
122
+
123
+ Library mode is the six-line example in [`GUIDE.md`](GUIDE.md).
124
+ Standalone server is `npx knowless-server` — full Postfix + DNS +
125
+ reverse-proxy walkthrough in [`OPS.md`](OPS.md).
126
+
127
+ ## First customer: addypin
128
+
129
+ [`addypin`](https://github.com/hamr0/addypin) — location-sharing
130
+ service in the same hermit-architecture lineage — adopted knowless
131
+ as its auth+mail layer. The integration delta:
132
+
133
+ - **~1,150 lines of bespoke auth/mail code removed** (custom mailer,
134
+ inbound CLI, login plumbing, pin-confirmation state machine, email
135
+ fingerprinting helpers, the matching test files)
136
+ - **~35 lines of knowless wiring added**
137
+ - **~33× reduction** on the auth/mail surface
138
+ - **One production dep** (`nodemailer` only; v0.2.0 dropped
139
+ `better-sqlite3` for `node:sqlite`, the stdlib SQLite driver — no
140
+ C++ toolchain, no native compile, ~40 transitive packages → 2)
88
141
 
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.
142
+ The integration round produced the audit findings AF-7 through AF-17
143
+ that drove v0.1.5 v0.1.10. See [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
144
+ §17 for the full backlog.
92
145
 
93
146
  ## Operator commitments
94
147
 
95
148
  By choosing knowless, you commit to:
96
149
 
97
- - Running your own server with **Postfix installed and configured**
98
- for outbound-only mail (or another localhost MTA)
99
- - Setting up **SPF, DKIM, and PTR records** for your sending domain
100
- (one-time DNS setup)
150
+ - Running your own server with **Postfix** (or another MTA) installed
151
+ for outbound-only mail
152
+ - Setting up **SPF, DKIM, and PTR** for your sending domain
101
153
  - Verifying **outbound port 25** is open (some clouds block it)
102
- - A **null-route entry** in your MTA's `transport_maps` for the
103
- configured `shamRecipient` (default `null@knowless.invalid`) so
104
- silent-miss sham mail is dropped, not delivered to NXDOMAIN
105
- - Accepting that this is the **only email** your service ever sends
154
+ - A **null-route entry** for the configured `shamRecipient` so
155
+ silent-miss sham mail is dropped, not bounced
156
+ - Accepting that the magic link is the **only email** your service
157
+ ever sends
106
158
 
107
- These are documented in [`OPS.md`](OPS.md): Postfix install,
108
- null-route, SPF/DKIM/PTR, systemd unit, Caddy / nginx / Traefik
109
- forward-auth examples, Tailscale pattern, reverse-proxy rate limiting,
110
- and fail2ban / Turnstile references.
159
+ Step-by-step in [`OPS.md`](OPS.md): Postfix install, null-route,
160
+ SPF/DKIM/PTR/DMARC, systemd unit, Caddy / nginx / Traefik
161
+ forward-auth examples, Tailscale pattern, reverse-proxy rate
162
+ limiting, fail2ban / Turnstile, multi-process deployments, MailHog
163
+ dev workflow, backups.
111
164
 
112
165
  ## Documentation
113
166
 
114
- - [`README.md`](README.md) (this file) project pitch, six-line example
115
- - [`GUIDE.md`](GUIDE.md) adopter walkthrough: who it's for, who it
116
- isn't, how to integrate, configuration reference, FAQ
117
- - [`OPS.md`](OPS.md) — operator setup: Postfix, null-route, DNS,
118
- systemd, reverse-proxy forward-auth examples
119
- - [`knowless.context.md`](knowless.context.md) dense AI-agent
120
- integration guide (tables, gotchas, public API at a glance)
167
+ - [**`GUIDE.md`**](GUIDE.md) start here. Adopter walkthrough,
168
+ install, six-line example, both modes worked end-to-end,
169
+ configuration reference, FAQ, troubleshooting.
170
+ - [**`knowless.context.md`**](knowless.context.md) — dense reference
171
+ for AI agents and humans-in-a-hurry. Public API table, all options,
172
+ 18 gotchas, lifecycles, the sham-work pattern, threat model
173
+ summary.
174
+ - [`OPS.md`](OPS.md) — operator setup, fresh VPS to working forward-auth.
175
+ - [`CHANGELOG.md`](CHANGELOG.md) — version history.
121
176
  - [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
122
- requirements: scope, threat model, decisions log, NO-GO table
177
+ requirements, threat model, decisions log, NO-GO table, audit
178
+ findings backlog.
123
179
  - [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
124
- byte layouts, algorithms (reimplementation-grade)
125
- - [`docs/03-tasks/TASKS.md`](docs/03-tasks/TASKS.md) — implementation
126
- task list and phase plan
127
- - [`CHANGELOG.md`](CHANGELOG.md) — version history
180
+ algorithms, byte layouts (reimplementation-grade).
128
181
 
129
- ## Threat model — what this defends and what it doesn't
182
+ ## Threat model (one-paragraph)
130
183
 
131
- Honest version (full detail in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §12):
184
+ Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
132
185
 
133
- **Defends well:** database-only leaks, plaintext email exfiltration,
134
- password reuse / credential stuffing, silent email enumeration
135
- (timing-equivalent within 1ms locally), email-bombing a target,
136
- naive bot traffic, account-creation spam, replay attacks, open
137
- redirects.
186
+ **Defends well:** DB-only leaks, plaintext-email exfiltration, password
187
+ reuse / credential stuffing, silent email enumeration (timing-
188
+ equivalent within 1ms locally), email-bombing a target, naive bot
189
+ traffic, account-creation spam, replay attacks, open redirects, CSRF
190
+ on `POST /login` / `POST /logout` (Origin/Referer whitelist).
138
191
 
139
192
  **Defends partially:** HMAC-secret-only leak (allows targeted
140
- existence-checks but not session forgery), phishing (no password
141
- to type into a fake site, but a phished mailbox can still receive
142
- links).
193
+ existence checks but not session forgery), phishing (no password to
194
+ type into a fake site, but a phished mailbox still receives links).
143
195
 
144
196
  **Does NOT defend against:** sophisticated bots that bypass the
145
197
  honeypot, distributed floods from many IPs, full server compromise,
146
198
  compromised email accounts, social engineering, insider threat at
147
199
  the operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
148
- rate-limits) belong above the library; OPS.md will document the
149
- common patterns.
200
+ rate-limits) belong above the library; [`OPS.md`](OPS.md) §9–§10
201
+ covers the patterns.
150
202
 
151
203
  ## Sibling projects
152
204
 
153
- - [`addypin`](https://github.com/hamr0/addypin) — location sharing
154
- with the same hermit philosophy
205
+ - [`addypin`](https://github.com/hamr0/addypin) — location sharing,
206
+ first knowless adopter
155
207
  - [`gitdone`](https://github.com/hamr0/gitdone) — verified email
156
208
  actions via DKIM/SPF inbound
157
209
 
@@ -160,8 +212,8 @@ common patterns.
160
212
  Issues and PRs welcome at <https://github.com/hamr0/knowless>.
161
213
 
162
214
  Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
163
- v1.0.0 ships will be deflected to the §14 NO-GO table or to sibling
164
- projects. The library being "done" is a feature.
215
+ v1.0.0 ships will be deflected to the [§14 NO-GO table](docs/01-product/PRD.md)
216
+ or to sibling projects. The library being "done" is a feature.
165
217
 
166
218
  ## License
167
219
 
@@ -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.1.10 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | 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
  ```
@@ -333,7 +430,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
333
430
  -> handle.js (normalize ASCII-only, HMAC-SHA256)
334
431
  -> abuse.js (per-IP rate limit, per-handle token cap, honeypot)
335
432
  -> token.js (32 random bytes, base64url; SHA-256 at rest)
336
- -> store.js (better-sqlite3, transactional, prepared statements)
433
+ -> store.js (node:sqlite, transactional, prepared statements)
337
434
  -> mailer.js (raw RFC822 7bit; nodemailer for SMTP submission only)
338
435
  -> session.js (HMAC-signed cookie with "sess\\0" domain tag)
339
436
  -> form.js (hardcoded HTML5; no JS, no external resources)
@@ -344,7 +441,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
344
441
  |---|---|---|
345
442
  | `src/index.js` | ~140 | Public factory, sweeper, re-exports |
346
443
  | `src/handlers.js` | ~310 | login (sham), callback, verify, logout, loginForm, validateNextUrl |
347
- | `src/store.js` | ~210 | better-sqlite3 store; SPEC §13 interface |
444
+ | `src/store.js` | ~240 | node:sqlite store + transaction adapter; SPEC §13 interface |
348
445
  | `src/mailer.js` | ~120 | RFC822 raw composition + nodemailer SMTP submission |
349
446
  | `src/abuse.js` | ~95 | Source-IP determination, rate limits |
350
447
  | `src/handle.js` | ~50 | Email normalization, handle derivation |
@@ -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),
@@ -433,7 +590,7 @@ rate-limits) belongs above the library.
433
590
  sweeper and closes the SQLite handle. Without it, your
434
591
  process won't exit cleanly. The sweeper timer is `unref()`d
435
592
  so it won't *prevent* exit, but the SQLite handle held by
436
- `better-sqlite3` will leave a finalizer warning.
593
+ `node:sqlite` will leave a finalizer warning.
437
594
 
438
595
  12. **CSRF defense is the Origin/Referer whitelist, not a token.**
439
596
  Modern browsers always emit `Origin` on cross-origin POSTs;
@@ -482,13 +639,23 @@ 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
488
654
  - **Plain ES modules** -- no TypeScript source, no build step;
489
655
  ships JSDoc + (eventual) `.d.ts`
490
- - **Two production deps** -- `nodemailer` (SMTP submission) and
491
- `better-sqlite3` (storage). No third dep without revisiting
656
+ - **One production dep** -- `nodemailer` (SMTP submission). Storage
657
+ uses `node:sqlite` (stdlib, no native compile). No second runtime
658
+ dep without revisiting
492
659
  AGENT_RULES External Dependency Checklist.
493
660
  - **Localhost MTA only** -- no remote SMTP, no vendor SDKs.
494
661
  Operators run their own Postfix / OpenSMTPD / Exim.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.10",
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",
@@ -23,14 +23,13 @@
23
23
  "knowless.context.md"
24
24
  ],
25
25
  "engines": {
26
- "node": ">=20.0.0"
26
+ "node": ">=22.5.0"
27
27
  },
28
28
  "scripts": {
29
29
  "test": "node --test 'test/**/*.test.js'",
30
30
  "lint": "find src bin -type f \\( -name '*.js' -o -name 'knowless-server' \\) -exec node --check {} \\;"
31
31
  },
32
32
  "dependencies": {
33
- "better-sqlite3": "^11.0.0",
34
33
  "nodemailer": "^8.0.7"
35
34
  },
36
35
  "license": "Apache-2.0",