knowless 0.1.2 → 0.1.4

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
@@ -7,13 +7,88 @@ Versioning is [SemVer](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- - Standalone server (`bin/knowless-server`): env-var-driven CLI with
11
- `--print-config` and `--config-check`, forward-auth deployment shape
12
- for self-hosters gating no-auth services. (Tracked in TASKS.md
13
- Phase 6.)
14
- - `OPS.md`: full operator setup walkthrough (Postfix, null-route for
15
- sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
16
- Traefik). (Tracked in TASKS.md Phase 7.)
10
+ - Caddy forward-auth Docker integration test (TASKS.md 6.8).
11
+ - `knowless-server --check-null-route`: CLI probe that submits a
12
+ test message to `shamRecipient` and confirms the local MTA
13
+ discarded it. Honest answer to "does the operator's null-route
14
+ actually work?" the library can know what it submitted but
15
+ not what the MTA did, so this is the closest we can get.
16
+ Targeted for v0.2.0.
17
+
18
+ ## [0.1.4] — 2026-04-28
19
+
20
+ First real-world integration release. Bugs and ergonomics surfaced
21
+ by the addypin team's spike on v0.1.3, plus two minor security
22
+ hardenings that fell out of the audit.
23
+
24
+ ### Added
25
+
26
+ - **`auth.revokeSessions(handle)`** — log out everywhere without
27
+ deleting the account. Returns the number of sessions removed.
28
+ Closes AF-6.1.
29
+ - **`devLogMagicLinks: true`** opt-in — when SMTP fails AND this
30
+ flag is set, prints the magic link to stderr so a developer can
31
+ click through. Off by default; never fires for sham (silent-miss)
32
+ submissions; never replaces real SMTP delivery on success. Closes
33
+ AF-6.2.
34
+ - **CIDR support in `trustedProxies`** — accept `10.0.0.0/8`,
35
+ `fd00::/8`, etc. in addition to plain IPs. Uses `node:net`
36
+ `BlockList`, no new dep. Closes AF-6.3.
37
+
38
+ ### Security
39
+
40
+ - **CSRF on `POST /logout`.** Origin/Referer validation now mirrors
41
+ `POST /login` (AF-4.3). Without this, a malicious page could
42
+ force-logout an authenticated victim. Closes AF-6.4.
43
+ - **`confirmationMessage` is HTML-escaped before rendering.** The
44
+ message is operator-config (not user input), but a careless
45
+ operator interpolating user data into it would have produced an
46
+ XSS. The whole message is now escaped before `{email}` substitution
47
+ (which was already escaped). Operators who want HTML in the
48
+ confirmation message must pre-render upstream. Closes AF-6.5.
49
+
50
+ ### Documentation
51
+
52
+ - **SPEC §10.2** documents the new logout Origin check.
53
+ - **SPEC §7.3 Step 0** adds an explicit "do NOT add a CSRF token
54
+ upstream — the Origin/Referer whitelist IS the CSRF defense"
55
+ note for adopters. Closes AF-6.6.
56
+ - **GUIDE.md** front-matter now leads with the v1.0.0 walks-away
57
+ commitment. Procurement signal: a library that has explicitly
58
+ committed to *not growing* is a different risk profile from a
59
+ typical OSS package. Closes AF-6.7.
60
+
61
+ ## [0.1.3] — 2026-04-28
62
+
63
+ Standalone-deployment release. The library could already be embedded
64
+ in a Node service since v0.1.0; v0.1.3 closes the operator-side story
65
+ so a self-hoster can `npx knowless-server` and have a working
66
+ forward-auth gate in front of arbitrary services.
67
+
68
+ ### Added
69
+
70
+ - **Standalone server** — `bin/knowless-server` ships a self-contained
71
+ HTTP server for forward-auth deployments. Configuration is via
72
+ `KNOWLESS_*` env vars (PRD FR-49 to FR-56); CLI flags are inspection-
73
+ only:
74
+ - `--help` lists every env var with default and purpose
75
+ - `--version` prints the package version
76
+ - `--print-config` prints effective config with secrets redacted as
77
+ `<set>` / `<unset>`
78
+ - `--config-check` validates required vars are present, the secret
79
+ is ≥64 hex chars, the SMTP host is reachable, and the DB path is
80
+ writable. Suitable for systemd `ExecStartPre`.
81
+ - `config.example.env` — documented sample env file at repo root.
82
+ Operators copy this and load via `node --env-file=...` or systemd
83
+ `EnvironmentFile=`. Library does not auto-load it (FR-56).
84
+ - Startup log block (FR-54) with effective config, SMTP check result,
85
+ and listening address.
86
+ - **`OPS.md`** — full operator setup walkthrough: Postfix
87
+ outbound-only install, **required** null-route for sham mail,
88
+ SPF / DKIM / PTR / DMARC, port-25 verification, hardened systemd
89
+ unit, Caddy / nginx / Traefik forward-auth examples, Tailscale
90
+ pattern, reverse-proxy rate limiting, fail2ban / Turnstile
91
+ references, backup guidance.
17
92
 
18
93
  ## [0.1.2] — 2026-04-28
19
94
 
package/GUIDE.md CHANGED
@@ -5,6 +5,32 @@
5
5
  > For the product philosophy, see
6
6
  > [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
7
7
 
8
+ ## Read this first: knowless walks away at v1.0.0
9
+
10
+ knowless commits to a small, audit-able surface and a *closed* feature
11
+ list. v1.0.0 is the **terminal release** for new functionality: only
12
+ security fixes ship after that. There will be no v2.0 with sessions+,
13
+ no plugin system, no second mailer, no SaaS counterpart.
14
+
15
+ What this means for you as an adopter:
16
+
17
+ - **You own integration breadth.** If knowless's defaults don't fit
18
+ exactly, you patch around it (the API is small enough to do this) or
19
+ fork it (Apache 2.0). We won't add a config knob to absorb your
20
+ case.
21
+ - **You can pin and forget.** v1.0.0 will work the same way three
22
+ years later. Security patches will land in v1.x.
23
+ - **Procurement signal.** A library that has explicitly committed to
24
+ *not growing* is a different risk profile from a typical OSS
25
+ package. Most reviews assume "still actively developed" is good;
26
+ for an auth dependency, "still actively developed" is also "still
27
+ changing in ways you'll have to track." knowless inverts that.
28
+
29
+ If you need a kitchen-sink auth library with active feature
30
+ development, this isn't the right tool. See
31
+ [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
32
+ or commercial offerings.
33
+
8
34
  ## Who this is for
9
35
 
10
36
  Three audiences, in order of fit:
package/OPS.md ADDED
@@ -0,0 +1,573 @@
1
+ # OPS — knowless operator setup
2
+
3
+ This guide takes a fresh Ubuntu/Debian VPS to "knowless is delivering
4
+ magic-link mail to your users' inbox." Every step is a manual decision
5
+ point for the operator. There are no automation scripts; ops is your
6
+ job and we provide the checklist.
7
+
8
+ If any step here surprises you — outbound port 25, DKIM, PTR records,
9
+ the null-route requirement — stop and read PRD §11 first. knowless
10
+ trades operator effort for a small, auditable surface. If you'd rather
11
+ delegate email to a SaaS, knowless is the wrong tool.
12
+
13
+ ---
14
+
15
+ ## 1. Prerequisites
16
+
17
+ - Ubuntu 22.04 / 24.04 or Debian 12, on a host that can:
18
+ - bind a public DNS name (e.g. `auth.example.com`)
19
+ - send outbound TCP/25 (verify before going further — see §3)
20
+ - have a working PTR record for its public IPv4 (and IPv6 if used)
21
+ - A domain with control of its DNS records
22
+ - Node.js ≥ 20 installed
23
+ - A reverse proxy in front of HTTP (Caddy, nginx, or Traefik)
24
+
25
+ knowless does not handle TLS termination. Your reverse proxy does.
26
+
27
+ ---
28
+
29
+ ## 2. Install Postfix (outbound-only)
30
+
31
+ knowless submits mail to a localhost MTA over plain SMTP on port 25
32
+ without auth. The MTA does the actual delivery. Postfix is the
33
+ recommended choice; any MTA that accepts unauthenticated localhost
34
+ submission works.
35
+
36
+ ```sh
37
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y postfix mailutils
38
+ # When the installer asks: choose "Internet Site"
39
+ # System mail name: your sending domain (e.g. example.com)
40
+ ```
41
+
42
+ Minimal `/etc/postfix/main.cf` for outbound-only:
43
+
44
+ ```
45
+ myhostname = mail.example.com
46
+ mydomain = example.com
47
+ myorigin = $mydomain
48
+ inet_interfaces = loopback-only
49
+ inet_protocols = ipv4
50
+ mydestination =
51
+ relayhost =
52
+ smtp_tls_security_level = may
53
+ smtp_tls_loglevel = 1
54
+ ```
55
+
56
+ `inet_interfaces = loopback-only` means Postfix accepts submission
57
+ only from `127.0.0.1`. knowless connects there. Restart:
58
+
59
+ ```sh
60
+ sudo systemctl restart postfix
61
+ sudo systemctl enable postfix
62
+ ```
63
+
64
+ Verify the localhost submission works:
65
+
66
+ ```sh
67
+ echo "Subject: test" | sendmail -v you@somewhere-you-control.com
68
+ sudo tail /var/log/mail.log
69
+ ```
70
+
71
+ If you see `status=sent` you are done with §2.
72
+
73
+ ---
74
+
75
+ ## 3. Verify outbound port 25
76
+
77
+ Many cloud providers (AWS, GCP, Azure, Oracle, DigitalOcean droplets,
78
+ Hetzner cloud, …) block outbound TCP/25 by default to limit spam from
79
+ compromised instances. **Test before troubleshooting anything else.**
80
+
81
+ ```sh
82
+ nc -zv gmail-smtp-in.l.google.com 25
83
+ # Expected: succeeded / Connection to ... 25 port [tcp/smtp] succeeded
84
+ ```
85
+
86
+ If this hangs or fails:
87
+ - AWS: open a "Request to remove email sending limitations" ticket.
88
+ - GCP: port 25 is permanently blocked. Use a relay (see below) or
89
+ move to a provider that allows it.
90
+ - Hetzner: port 25 is blocked on new accounts; ask support.
91
+ - Most VPS hosts (Vultr, OVH, Linode, your own metal): open by default.
92
+
93
+ **Alternative if port 25 is permanently blocked:** configure Postfix
94
+ to relay through a transactional provider's submission endpoint
95
+ (587/465 with auth). knowless still talks to localhost; Postfix is
96
+ what relays out. That's a Postfix concern, not a knowless concern.
97
+
98
+ ---
99
+
100
+ ## 4. Null-route for sham mail (REQUIRED)
101
+
102
+ knowless's silent-miss design (PRD FR-2 to FR-6) submits a real-shaped
103
+ SMTP message on every login attempt — including ones where the email
104
+ doesn't map to any registered handle. The "sham" submissions are
105
+ addressed to `null@knowless.invalid` by default. Without a null-route,
106
+ Postfix will queue these forever trying to resolve a nonexistent
107
+ domain. **Configure the null-route. SPEC §7.4.**
108
+
109
+ ```sh
110
+ # /etc/postfix/transport
111
+ knowless.invalid discard:silently dropped by knowless null-route
112
+ ```
113
+
114
+ Then:
115
+
116
+ ```sh
117
+ sudo postmap /etc/postfix/transport
118
+ ```
119
+
120
+ Add to `/etc/postfix/main.cf`:
121
+
122
+ ```
123
+ transport_maps = hash:/etc/postfix/transport
124
+ ```
125
+
126
+ Reload:
127
+
128
+ ```sh
129
+ sudo systemctl reload postfix
130
+ ```
131
+
132
+ Verify discard works:
133
+
134
+ ```sh
135
+ echo "test" | mail -s "should be dropped" null@knowless.invalid
136
+ sudo tail /var/log/mail.log | grep knowless.invalid
137
+ # Expected: status=sent (silently dropped by knowless null-route)
138
+ ```
139
+
140
+ If you customized `shamRecipient` in your knowless config to point
141
+ elsewhere, change the transport entry's domain to match.
142
+
143
+ ---
144
+
145
+ ## 5. SPF, DKIM, PTR
146
+
147
+ Without these your magic-link mail goes to spam. There is no
148
+ shortcut here.
149
+
150
+ ### 5.1 SPF
151
+
152
+ Add a TXT record at the apex of your sending domain:
153
+
154
+ ```
155
+ example.com. TXT "v=spf1 mx a ~all"
156
+ ```
157
+
158
+ If your knowless server is *not* an MX for the domain, replace
159
+ `mx` with `ip4:1.2.3.4` listing the server's public IP.
160
+
161
+ ### 5.2 DKIM
162
+
163
+ Install OpenDKIM:
164
+
165
+ ```sh
166
+ sudo apt-get install -y opendkim opendkim-tools
167
+ sudo opendkim-genkey -D /etc/opendkim/keys -d example.com -s mail
168
+ sudo chown opendkim:opendkim /etc/opendkim/keys/mail.private
169
+ sudo chmod 600 /etc/opendkim/keys/mail.private
170
+ ```
171
+
172
+ `/etc/opendkim/keys/mail.txt` now contains the DNS record. Publish it
173
+ as `mail._domainkey.example.com` in your DNS.
174
+
175
+ Wire OpenDKIM into Postfix — add to `/etc/postfix/main.cf`:
176
+
177
+ ```
178
+ milter_default_action = accept
179
+ smtpd_milters = inet:localhost:8891
180
+ non_smtpd_milters = $smtpd_milters
181
+ ```
182
+
183
+ Configure `/etc/opendkim.conf`:
184
+
185
+ ```
186
+ Domain example.com
187
+ KeyFile /etc/opendkim/keys/mail.private
188
+ Selector mail
189
+ Socket inet:8891@localhost
190
+ ```
191
+
192
+ Restart both:
193
+
194
+ ```sh
195
+ sudo systemctl restart opendkim postfix
196
+ ```
197
+
198
+ Verify with `mail-tester.com`: send a test, expect 9–10/10.
199
+
200
+ ### 5.3 PTR (reverse DNS)
201
+
202
+ Your server's public IP must reverse-resolve to a hostname. **Most
203
+ providers expose this in their control panel** (Hetzner: "rDNS";
204
+ DigitalOcean: name your droplet `mail.example.com`; OVH: "Reverse
205
+ DNS" tab). Set the PTR to the same hostname you put in
206
+ `myhostname` (§2). Verify:
207
+
208
+ ```sh
209
+ dig -x 1.2.3.4 +short
210
+ # Expected: mail.example.com.
211
+ ```
212
+
213
+ A missing or generic PTR (`ec2-...`, `static.cloud-provider.tld`)
214
+ is the single most common reason mail lands in spam.
215
+
216
+ ### 5.4 Optional: DMARC
217
+
218
+ ```
219
+ _dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"
220
+ ```
221
+
222
+ Start with `p=none` to monitor. Move to `p=quarantine` later if you
223
+ want stricter handling.
224
+
225
+ ---
226
+
227
+ ## 6. Run knowless-server under systemd
228
+
229
+ ```sh
230
+ sudo useradd --system --home /var/lib/knowless --create-home knowless
231
+ sudo install -d -o knowless -g knowless -m 0750 /var/lib/knowless /etc/knowless
232
+ ```
233
+
234
+ Generate a secret and create `/etc/knowless/knowless.env`:
235
+
236
+ ```sh
237
+ sudo install -m 0600 -o knowless -g knowless /dev/null /etc/knowless/knowless.env
238
+ sudo tee /etc/knowless/knowless.env > /dev/null <<EOF
239
+ KNOWLESS_SECRET=$(openssl rand -hex 32)
240
+ KNOWLESS_BASE_URL=https://auth.example.com
241
+ KNOWLESS_FROM=auth@example.com
242
+ KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
243
+ KNOWLESS_COOKIE_DOMAIN=example.com
244
+ KNOWLESS_COOKIE_SECURE=true
245
+ KNOWLESS_HOST=127.0.0.1
246
+ KNOWLESS_PORT=8080
247
+ EOF
248
+ sudo chmod 0600 /etc/knowless/knowless.env
249
+ ```
250
+
251
+ `/etc/systemd/system/knowless.service`:
252
+
253
+ ```ini
254
+ [Unit]
255
+ Description=knowless passwordless auth server
256
+ Wants=network-online.target postfix.service
257
+ After=network-online.target postfix.service
258
+
259
+ [Service]
260
+ Type=simple
261
+ User=knowless
262
+ Group=knowless
263
+ EnvironmentFile=/etc/knowless/knowless.env
264
+ ExecStartPre=/usr/bin/npx --yes knowless-server --config-check
265
+ ExecStart=/usr/bin/npx --yes knowless-server
266
+ Restart=on-failure
267
+ RestartSec=2
268
+
269
+ # Hardening
270
+ NoNewPrivileges=true
271
+ ProtectSystem=strict
272
+ ProtectHome=true
273
+ ReadWritePaths=/var/lib/knowless
274
+ PrivateTmp=true
275
+ PrivateDevices=true
276
+ ProtectKernelTunables=true
277
+ ProtectKernelModules=true
278
+ ProtectControlGroups=true
279
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
280
+ LockPersonality=true
281
+ SystemCallArchitectures=native
282
+
283
+ [Install]
284
+ WantedBy=multi-user.target
285
+ ```
286
+
287
+ Enable:
288
+
289
+ ```sh
290
+ sudo systemctl daemon-reload
291
+ sudo systemctl enable --now knowless
292
+ sudo systemctl status knowless
293
+ sudo journalctl -u knowless -f
294
+ ```
295
+
296
+ `ExecStartPre=knowless-server --config-check` ensures a misconfigured
297
+ deploy fails to start instead of silently breaking auth.
298
+
299
+ ---
300
+
301
+ ## 7. Reverse proxy — pick one
302
+
303
+ knowless does not terminate TLS. Your proxy fronts it on `:443`,
304
+ forwards `Host` and `X-Forwarded-For`, and (for forward-auth
305
+ deployments) routes protected upstreams through `/verify`.
306
+
307
+ ### 7.1 Caddy
308
+
309
+ ```caddy
310
+ auth.example.com {
311
+ reverse_proxy 127.0.0.1:8080
312
+ }
313
+
314
+ # Protect Uptime Kuma with knowless forward-auth
315
+ kuma.example.com {
316
+ forward_auth 127.0.0.1:8080 {
317
+ uri /verify
318
+ copy_headers X-Knowless-Handle
319
+ }
320
+ reverse_proxy 127.0.0.1:3001
321
+ }
322
+ ```
323
+
324
+ Caddy auto-issues TLS via Let's Encrypt and trusts itself as a proxy
325
+ on the loopback. knowless's default `KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1`
326
+ covers this.
327
+
328
+ ### 7.2 nginx
329
+
330
+ ```nginx
331
+ # /etc/nginx/sites-available/auth.example.com
332
+ server {
333
+ listen 443 ssl http2;
334
+ server_name auth.example.com;
335
+ # ssl_certificate / ssl_certificate_key managed by certbot
336
+
337
+ location / {
338
+ proxy_pass http://127.0.0.1:8080;
339
+ proxy_set_header Host $host;
340
+ proxy_set_header X-Forwarded-For $remote_addr;
341
+ proxy_set_header X-Forwarded-Proto $scheme;
342
+ }
343
+ }
344
+
345
+ # Protect a service with auth_request
346
+ server {
347
+ listen 443 ssl http2;
348
+ server_name kuma.example.com;
349
+
350
+ location = /_knowless_verify {
351
+ internal;
352
+ proxy_pass http://127.0.0.1:8080/verify;
353
+ proxy_pass_request_body off;
354
+ proxy_set_header Content-Length "";
355
+ proxy_set_header X-Original-URI $request_uri;
356
+ proxy_set_header Cookie $http_cookie;
357
+ }
358
+
359
+ error_page 401 = @knowless_login;
360
+ location @knowless_login {
361
+ return 302 https://auth.example.com/login?next=https://$host$request_uri;
362
+ }
363
+
364
+ location / {
365
+ auth_request /_knowless_verify;
366
+ auth_request_set $handle $upstream_http_x_knowless_handle;
367
+ proxy_set_header X-Knowless-Handle $handle;
368
+ proxy_pass http://127.0.0.1:3001;
369
+ }
370
+ }
371
+ ```
372
+
373
+ ### 7.3 Traefik
374
+
375
+ ```yaml
376
+ # dynamic.yml
377
+ http:
378
+ middlewares:
379
+ knowless:
380
+ forwardAuth:
381
+ address: "http://127.0.0.1:8080/verify"
382
+ authResponseHeaders: [ "X-Knowless-Handle" ]
383
+
384
+ routers:
385
+ auth:
386
+ rule: "Host(`auth.example.com`)"
387
+ service: knowless
388
+ tls: { certResolver: le }
389
+ kuma:
390
+ rule: "Host(`kuma.example.com`)"
391
+ service: kuma
392
+ middlewares: [ knowless ]
393
+ tls: { certResolver: le }
394
+
395
+ services:
396
+ knowless:
397
+ loadBalancer:
398
+ servers: [{ url: "http://127.0.0.1:8080" }]
399
+ kuma:
400
+ loadBalancer:
401
+ servers: [{ url: "http://127.0.0.1:3001" }]
402
+ ```
403
+
404
+ ---
405
+
406
+ ## 8. Tailscale / WireGuard pattern
407
+
408
+ A common deployment: knowless lives on a public VPS (port 25 open,
409
+ PTR set), but the services it protects live on a home server.
410
+ Connect them with a mesh VPN.
411
+
412
+ ```
413
+ public VPS home server
414
+ +----------------+ +-------------------+
415
+ | Caddy :443 | | Uptime Kuma :3001 |
416
+ | knowless :8080 | | Vaultwarden :8222 |
417
+ | tailscale0 |--------------| tailscale0 |
418
+ +----------------+ +-------------------+
419
+ ```
420
+
421
+ In Caddy on the VPS:
422
+
423
+ ```caddy
424
+ kuma.example.com {
425
+ forward_auth 127.0.0.1:8080 {
426
+ uri /verify
427
+ }
428
+ reverse_proxy 100.64.0.5:3001 # tailscale IP of home server
429
+ }
430
+ ```
431
+
432
+ Home server exposes nothing publicly. The VPS terminates TLS, runs
433
+ auth, and proxies through the mesh.
434
+
435
+ ---
436
+
437
+ ## 9. Reverse-proxy rate limiting (defence in depth)
438
+
439
+ knowless ships modest in-process limits as a baseline (FR-39).
440
+ Operators expecting elevated abuse should layer stricter limits at
441
+ the proxy. The proxy sees traffic earlier and can drop it without
442
+ even hitting Node.
443
+
444
+ ### 9.1 Caddy
445
+
446
+ ```caddy
447
+ auth.example.com {
448
+ rate_limit {
449
+ zone login_ip {
450
+ key {client_ip}
451
+ window 1m
452
+ events 10
453
+ }
454
+ match path /login
455
+ }
456
+ reverse_proxy 127.0.0.1:8080
457
+ }
458
+ ```
459
+
460
+ (Requires the `caddy-ratelimit` plugin; build with
461
+ `xcaddy build --with github.com/mholt/caddy-ratelimit`.)
462
+
463
+ ### 9.2 nginx
464
+
465
+ ```nginx
466
+ limit_req_zone $binary_remote_addr zone=knowless_login:10m rate=10r/m;
467
+
468
+ server {
469
+ location = /login {
470
+ limit_req zone=knowless_login burst=5 nodelay;
471
+ proxy_pass http://127.0.0.1:8080;
472
+ }
473
+ }
474
+ ```
475
+
476
+ ---
477
+
478
+ ## 10. fail2ban / Cloudflare Turnstile (heavier abuse profiles)
479
+
480
+ Optional; both have trade-offs.
481
+
482
+ **fail2ban** can tail `journalctl -u knowless` and block IPs that
483
+ trip many rate-limit responses:
484
+
485
+ ```ini
486
+ # /etc/fail2ban/filter.d/knowless.conf
487
+ [Definition]
488
+ failregex = ^.*\s+\[knowless\]\s+rate-limited\s+ip=<HOST>
489
+ ignoreregex =
490
+ ```
491
+
492
+ ```ini
493
+ # /etc/fail2ban/jail.d/knowless.conf
494
+ [knowless]
495
+ enabled = true
496
+ filter = knowless
497
+ backend = systemd
498
+ journalmatch = _SYSTEMD_UNIT=knowless.service
499
+ maxretry = 20
500
+ findtime = 600
501
+ bantime = 3600
502
+ action = iptables[name=knowless, port=https, protocol=tcp]
503
+ ```
504
+
505
+ (knowless does not log structured rate-limit lines today; if you want
506
+ this you may need to wrap it. See Issue tracker.)
507
+
508
+ **Cloudflare Turnstile** is the lowest-friction CAPTCHA but
509
+ introduces a third-party dependency on Cloudflare for every login
510
+ form load. knowless doesn't integrate it natively; if you need it,
511
+ embed the widget in your own login page (use the library mode
512
+ `renderLoginForm` as a starting template).
513
+
514
+ Both are last-resort knobs. Most operators won't need them.
515
+
516
+ ---
517
+
518
+ ## 11. Operational checks
519
+
520
+ Once running:
521
+
522
+ ```sh
523
+ # Validate config
524
+ sudo -u knowless npx --yes knowless-server --config-check
525
+
526
+ # Print effective config (secrets redacted)
527
+ sudo -u knowless npx --yes knowless-server --print-config
528
+
529
+ # Watch logs
530
+ sudo journalctl -u knowless -f
531
+
532
+ # Send yourself a magic link end-to-end
533
+ curl -i -X POST https://auth.example.com/login \
534
+ -d email=you@somewhere-you-control.com
535
+ # Then check your inbox (NOT spam) for the link.
536
+ ```
537
+
538
+ If the email lands in spam, work through §5 (SPF, DKIM, PTR, DMARC)
539
+ in that order — most spam-folder verdicts trace to one of those four.
540
+
541
+ ---
542
+
543
+ ## 12. Backup and recovery
544
+
545
+ The only stateful file is the SQLite database (`KNOWLESS_DB_PATH`,
546
+ default `/var/lib/knowless/knowless.db`). It contains:
547
+
548
+ - Handles (HMAC outputs of email addresses)
549
+ - Active token hashes (short-lived, 15 min default)
550
+ - Session ID hashes (30 day default)
551
+ - Rate-limit counters
552
+
553
+ Loss is recoverable: users sign in again and get a new session. There
554
+ is no irreplaceable data here. A weekly `sqlite3 knowless.db .backup`
555
+ is sufficient.
556
+
557
+ The HMAC secret in `/etc/knowless/knowless.env` is the actual asset
558
+ to protect. Losing it logs every user out and changes every handle
559
+ (emails will be re-derived to new opaque values on next login).
560
+ **Back up the secret separately, out-of-band, encrypted.** Never
561
+ commit it.
562
+
563
+ ---
564
+
565
+ ## 13. Where things are documented
566
+
567
+ - **PRD** (`docs/01-product/PRD.md`) — what the library does and
568
+ doesn't do, threat model, NO-GO list
569
+ - **SPEC** (`docs/02-design/SPEC.md`) — wire formats, exact flows,
570
+ schema
571
+ - **GUIDE.md** — application-developer integration in library mode
572
+ - **README.md** — short orientation
573
+ - **OPS.md** (this document) — operator-side setup
package/README.md CHANGED
@@ -104,16 +104,18 @@ By choosing knowless, you commit to:
104
104
  silent-miss sham mail is dropped, not delivered to NXDOMAIN
105
105
  - Accepting that this is the **only email** your service ever sends
106
106
 
107
- These are documented in `OPS.md` (forthcoming with 0.2.0). Until
108
- then, see `GUIDE.md` for the high-level path and the
109
- [Postfix outbound-only setup guide](https://www.postfix.org/STANDARD_CONFIGURATION_README.html#stand_alone)
110
- upstream.
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.
111
111
 
112
112
  ## Documentation
113
113
 
114
114
  - [`README.md`](README.md) (this file) — project pitch, six-line example
115
115
  - [`GUIDE.md`](GUIDE.md) — adopter walkthrough: who it's for, who it
116
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
117
119
  - [`knowless.context.md`](knowless.context.md) — dense AI-agent
118
120
  integration guide (tables, gotchas, public API at a glance)
119
121
  - [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
package/bin/.gitkeep ADDED
File without changes
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ // knowless-server — standalone forward-auth HTTP server.
3
+ // All configuration via KNOWLESS_* env vars. PRD §7.12 (FR-49 to FR-56).
4
+
5
+ import { parseArgs } from 'node:util';
6
+ import { createServer } from 'node:http';
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+ import net from 'node:net';
11
+ import fs from 'node:fs';
12
+ import { knowless } from '../src/index.js';
13
+
14
+ const PKG = JSON.parse(
15
+ readFileSync(
16
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
17
+ 'utf8',
18
+ ),
19
+ );
20
+
21
+ // [optionKey, envVar, default (null = required), purpose, secret?]
22
+ const SPEC = [
23
+ ['secret', 'KNOWLESS_SECRET', null, 'HMAC secret, ≥64 hex chars (32 bytes). REQUIRED.', true],
24
+ ['baseUrl', 'KNOWLESS_BASE_URL', null, 'Public base URL for magic links. REQUIRED.'],
25
+ ['from', 'KNOWLESS_FROM', null, 'Sender email address. REQUIRED.'],
26
+ ['dbPath', 'KNOWLESS_DB_PATH', './knowless.db', 'SQLite database path.'],
27
+ ['cookieDomain', 'KNOWLESS_COOKIE_DOMAIN', '', 'Session cookie domain. Defaults to baseUrl hostname.'],
28
+ ['cookieSecure', 'KNOWLESS_COOKIE_SECURE', 'true', 'Set Secure flag on cookies. "false" only for localhost dev.'],
29
+ ['tokenTtlSeconds', 'KNOWLESS_TOKEN_TTL_SECONDS', '900', 'Magic-link token lifetime (seconds).'],
30
+ ['sessionTtlSeconds', 'KNOWLESS_SESSION_TTL_SECONDS', '2592000', 'Session lifetime (seconds, default 30d).'],
31
+ ['linkPath', 'KNOWLESS_LINK_PATH', '/auth/callback', 'Magic-link callback path.'],
32
+ ['loginPath', 'KNOWLESS_LOGIN_PATH', '/login', 'Login form path.'],
33
+ ['verifyPath', 'KNOWLESS_VERIFY_PATH', '/verify', 'Forward-auth check path.'],
34
+ ['logoutPath', 'KNOWLESS_LOGOUT_PATH', '/logout', 'Logout path.'],
35
+ ['smtpHost', 'KNOWLESS_SMTP_HOST', 'localhost', 'SMTP host for outgoing magic-link mail.'],
36
+ ['smtpPort', 'KNOWLESS_SMTP_PORT', '25', 'SMTP port.'],
37
+ ['openRegistration', 'KNOWLESS_OPEN_REGISTRATION', 'false', 'Allow new-handle creation on first email.'],
38
+ ['subject', 'KNOWLESS_SUBJECT', 'Sign in', 'Email subject line.'],
39
+ ['includeLastLoginInEmail', 'KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL', 'true', 'Append last-sign-in note to magic-link email.'],
40
+ ['maxActiveTokensPerHandle', 'KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE', '5', 'Cap on concurrent magic links per handle. 0 disables.'],
41
+ ['maxLoginRequestsPerIpPerHour', 'KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR', '30', 'Per-IP login submission cap. 0 disables.'],
42
+ ['maxNewHandlesPerIpPerHour', 'KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR', '3', 'Per-IP account-creation cap (openRegistration only). 0 disables.'],
43
+ ['honeypotFieldName', 'KNOWLESS_HONEYPOT_FIELD_NAME', 'website', 'Name of the honeypot field.'],
44
+ ['trustedProxies', 'KNOWLESS_TRUSTED_PROXIES', '127.0.0.1,::1', 'Comma-separated IPs trusted for X-Forwarded-For.'],
45
+ ];
46
+
47
+ // PORT is a runtime-only knob; not part of the library options object.
48
+ const SERVER_SPEC = [
49
+ ['port', 'KNOWLESS_PORT', '8080', 'TCP port the HTTP server binds.'],
50
+ ['host', 'KNOWLESS_HOST', '0.0.0.0', 'Address the HTTP server binds.'],
51
+ ];
52
+
53
+ const NUMERIC = new Set([
54
+ 'tokenTtlSeconds',
55
+ 'sessionTtlSeconds',
56
+ 'smtpPort',
57
+ 'maxActiveTokensPerHandle',
58
+ 'maxLoginRequestsPerIpPerHour',
59
+ 'maxNewHandlesPerIpPerHour',
60
+ ]);
61
+ const BOOLEAN = new Set([
62
+ 'cookieSecure',
63
+ 'openRegistration',
64
+ 'includeLastLoginInEmail',
65
+ ]);
66
+ const LIST = new Set(['trustedProxies']);
67
+
68
+ function loadFromEnv(env = process.env) {
69
+ const opts = {};
70
+ for (const [key, envVar, def] of SPEC) {
71
+ const raw = env[envVar];
72
+ const value = raw !== undefined && raw !== '' ? raw : def;
73
+ if (value === null) continue; // required, missing — leave unset
74
+ if (value === '') continue; // optional with empty default — skip
75
+ if (NUMERIC.has(key)) opts[key] = Number(value);
76
+ else if (BOOLEAN.has(key)) opts[key] = value === 'true' || value === '1';
77
+ else if (LIST.has(key)) opts[key] = value.split(',').map((s) => s.trim()).filter(Boolean);
78
+ else opts[key] = value;
79
+ }
80
+ const serverConfig = {};
81
+ for (const [key, envVar, def] of SERVER_SPEC) {
82
+ const raw = env[envVar];
83
+ const value = raw !== undefined && raw !== '' ? raw : def;
84
+ serverConfig[key] = key === 'port' ? Number(value) : value;
85
+ }
86
+ return { opts, serverConfig };
87
+ }
88
+
89
+ function printHelp() {
90
+ process.stdout.write(`knowless-server v${PKG.version}
91
+
92
+ Usage: knowless-server [--help | --version | --print-config | --config-check]
93
+
94
+ Configuration is via environment variables. CLI flags do NOT set config.
95
+
96
+ Required env vars:
97
+ `);
98
+ for (const [, envVar, def, purpose] of SPEC) {
99
+ if (def !== null) continue;
100
+ process.stdout.write(` ${envVar}\n ${purpose}\n`);
101
+ }
102
+ process.stdout.write('\nOptional env vars (with defaults):\n');
103
+ for (const [, envVar, def, purpose] of SPEC) {
104
+ if (def === null) continue;
105
+ const shown = def === '' ? '<derived>' : def;
106
+ process.stdout.write(` ${envVar}=${shown}\n ${purpose}\n`);
107
+ }
108
+ for (const [, envVar, def, purpose] of SERVER_SPEC) {
109
+ process.stdout.write(` ${envVar}=${def}\n ${purpose}\n`);
110
+ }
111
+ process.stdout.write(
112
+ '\nLoad a .env file with: node --env-file=knowless.env $(which knowless-server)\n',
113
+ );
114
+ }
115
+
116
+ function formatConfig({ opts, serverConfig }) {
117
+ const lines = [];
118
+ for (const [key, envVar, , , isSecret] of SPEC) {
119
+ if (!(key in opts)) {
120
+ lines.push(`${envVar}=<unset>`);
121
+ continue;
122
+ }
123
+ let val = opts[key];
124
+ if (isSecret) val = '<set>';
125
+ else if (Array.isArray(val)) val = val.join(',');
126
+ lines.push(`${envVar}=${val}`);
127
+ }
128
+ for (const [key, envVar] of SERVER_SPEC) {
129
+ lines.push(`${envVar}=${serverConfig[key]}`);
130
+ }
131
+ return lines.join('\n');
132
+ }
133
+
134
+ function checkRequired(opts) {
135
+ const errors = [];
136
+ for (const [key, envVar, def] of SPEC) {
137
+ if (def !== null) continue;
138
+ if (!(key in opts)) errors.push(`${envVar} is missing`);
139
+ }
140
+ if (opts.secret && opts.secret.length < 64) {
141
+ errors.push('KNOWLESS_SECRET must be at least 64 hex chars (32 bytes)');
142
+ }
143
+ return errors;
144
+ }
145
+
146
+ function checkSmtpReachable(host, port, timeoutMs = 2000) {
147
+ return new Promise((resolve) => {
148
+ const sock = net.createConnection({ host, port });
149
+ let done = false;
150
+ const finish = (ok, err) => {
151
+ if (done) return;
152
+ done = true;
153
+ try { sock.destroy(); } catch { /* tolerate */ }
154
+ resolve({ ok, err });
155
+ };
156
+ sock.setTimeout(timeoutMs);
157
+ sock.once('connect', () => finish(true));
158
+ sock.once('timeout', () => finish(false, new Error('connection timed out')));
159
+ sock.once('error', (e) => finish(false, e));
160
+ });
161
+ }
162
+
163
+ function checkDbWritable(dbPath) {
164
+ if (dbPath === ':memory:') return { ok: true };
165
+ try {
166
+ const dir = dirname(dbPath);
167
+ fs.accessSync(dir, fs.constants.W_OK);
168
+ return { ok: true };
169
+ } catch (err) {
170
+ return { ok: false, err };
171
+ }
172
+ }
173
+
174
+ async function runConfigCheck({ opts, serverConfig }, { print } = { print: true }) {
175
+ if (print) process.stdout.write(formatConfig({ opts, serverConfig }) + '\n');
176
+ const errors = checkRequired(opts);
177
+ if (errors.length) {
178
+ for (const e of errors) process.stderr.write(`config error: ${e}\n`);
179
+ return 1;
180
+ }
181
+ const smtp = await checkSmtpReachable(opts.smtpHost, opts.smtpPort);
182
+ if (!smtp.ok) {
183
+ process.stderr.write(
184
+ `config error: SMTP ${opts.smtpHost}:${opts.smtpPort} not reachable: ${smtp.err.message}\n`,
185
+ );
186
+ return 1;
187
+ }
188
+ process.stdout.write(`SMTP ${opts.smtpHost}:${opts.smtpPort} reachable: OK\n`);
189
+ const db = checkDbWritable(opts.dbPath);
190
+ if (!db.ok) {
191
+ process.stderr.write(
192
+ `config error: DB path ${opts.dbPath} not writable: ${db.err.message}\n`,
193
+ );
194
+ return 1;
195
+ }
196
+ process.stdout.write(`DB path ${opts.dbPath} writable: OK\n`);
197
+ return 0;
198
+ }
199
+
200
+ function buildRouter(auth, cfg) {
201
+ const map = new Map([
202
+ [`POST ${cfg.loginPath}`, auth.login],
203
+ [`GET ${cfg.loginPath}`, auth.loginForm],
204
+ [`GET ${cfg.linkPath}`, auth.callback],
205
+ [`GET ${cfg.verifyPath}`, auth.verify],
206
+ [`POST ${cfg.logoutPath}`, auth.logout],
207
+ ]);
208
+ return (req, res) => {
209
+ const url = new URL(req.url, 'http://placeholder.invalid');
210
+ const handler = map.get(`${req.method} ${url.pathname}`);
211
+ if (!handler) {
212
+ res.statusCode = 404;
213
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
214
+ res.end('not found\n');
215
+ return;
216
+ }
217
+ Promise.resolve()
218
+ .then(() => handler(req, res))
219
+ .catch((err) => {
220
+ process.stderr.write(`[knowless] handler error: ${err.stack || err.message}\n`);
221
+ if (!res.headersSent) {
222
+ res.statusCode = 500;
223
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
224
+ res.end('internal error\n');
225
+ }
226
+ });
227
+ };
228
+ }
229
+
230
+ async function runServer({ opts, serverConfig }) {
231
+ const errors = checkRequired(opts);
232
+ if (errors.length) {
233
+ for (const e of errors) process.stderr.write(`config error: ${e}\n`);
234
+ return 1;
235
+ }
236
+ const auth = knowless(opts);
237
+ const router = buildRouter(auth, auth.config);
238
+ const server = createServer(router);
239
+
240
+ await new Promise((resolve, reject) => {
241
+ server.once('error', reject);
242
+ server.listen(serverConfig.port, serverConfig.host, () => {
243
+ server.off('error', reject);
244
+ resolve();
245
+ });
246
+ });
247
+
248
+ // FR-54: single startup log block.
249
+ const block =
250
+ '--- knowless-server started ---\n' +
251
+ formatConfig({ opts, serverConfig }) +
252
+ `\nlistening: http://${serverConfig.host}:${serverConfig.port}\n` +
253
+ '-------------------------------\n';
254
+ process.stdout.write(block);
255
+
256
+ const shutdown = (sig) => {
257
+ process.stdout.write(`[knowless] ${sig}, shutting down\n`);
258
+ server.close(() => {
259
+ try { auth.close(); } catch { /* tolerate */ }
260
+ process.exit(0);
261
+ });
262
+ setTimeout(() => process.exit(1), 5000).unref();
263
+ };
264
+ process.on('SIGINT', () => shutdown('SIGINT'));
265
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
266
+ return null;
267
+ }
268
+
269
+ async function main(argv = process.argv.slice(2)) {
270
+ let parsed;
271
+ try {
272
+ parsed = parseArgs({
273
+ args: argv,
274
+ options: {
275
+ help: { type: 'boolean' },
276
+ version: { type: 'boolean' },
277
+ 'print-config': { type: 'boolean' },
278
+ 'config-check': { type: 'boolean' },
279
+ },
280
+ strict: true,
281
+ allowPositionals: false,
282
+ });
283
+ } catch (err) {
284
+ process.stderr.write(`error: ${err.message}\nrun with --help for usage\n`);
285
+ return 2;
286
+ }
287
+ const { values } = parsed;
288
+ if (values.help) { printHelp(); return 0; }
289
+ if (values.version) { process.stdout.write(PKG.version + '\n'); return 0; }
290
+
291
+ const cfg = loadFromEnv();
292
+ if (values['print-config']) {
293
+ process.stdout.write(formatConfig(cfg) + '\n');
294
+ return 0;
295
+ }
296
+ if (values['config-check']) {
297
+ return runConfigCheck(cfg);
298
+ }
299
+ const code = await runServer(cfg);
300
+ return code === null ? undefined : code;
301
+ }
302
+
303
+ const isEntrypoint = import.meta.url === `file://${process.argv[1]}`;
304
+ if (isEntrypoint) {
305
+ main().then((code) => {
306
+ if (code !== undefined) process.exit(code);
307
+ }).catch((err) => {
308
+ process.stderr.write(`fatal: ${err.stack || err.message}\n`);
309
+ process.exit(1);
310
+ });
311
+ }
312
+
313
+ export { loadFromEnv, formatConfig, checkRequired, SPEC, SERVER_SPEC, main };
@@ -0,0 +1,74 @@
1
+ # knowless-server — example configuration. PRD FR-56.
2
+ #
3
+ # Copy to e.g. /etc/knowless/knowless.env (mode 0600) and load via:
4
+ # node --env-file=/etc/knowless/knowless.env $(which knowless-server)
5
+ # or systemd:
6
+ # EnvironmentFile=/etc/knowless/knowless.env
7
+ #
8
+ # This file is documentation, NOT loaded automatically by the library.
9
+
10
+ # --- REQUIRED ---
11
+
12
+ # HMAC secret for handle derivation and session signing.
13
+ # Generate with: openssl rand -hex 32
14
+ KNOWLESS_SECRET=
15
+
16
+ # Public base URL of this auth server.
17
+ KNOWLESS_BASE_URL=https://auth.example.com
18
+
19
+ # Sender address for outgoing magic-link mail.
20
+ KNOWLESS_FROM=auth@example.com
21
+
22
+ # --- COMMON ---
23
+
24
+ # SQLite database path. Parent dir must be writable.
25
+ KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
26
+
27
+ # Cookie domain. Defaults to baseUrl hostname; usually set to your eTLD+1
28
+ # (e.g. example.com) so the session cookie is shared across subdomains.
29
+ # KNOWLESS_COOKIE_DOMAIN=example.com
30
+
31
+ # Set "false" only for http://localhost development. Library logs a warning.
32
+ KNOWLESS_COOKIE_SECURE=true
33
+
34
+ # Allow new-handle creation on first email. Default false (closed-reg).
35
+ KNOWLESS_OPEN_REGISTRATION=false
36
+
37
+ # --- SERVER ---
38
+
39
+ KNOWLESS_HOST=0.0.0.0
40
+ KNOWLESS_PORT=8080
41
+
42
+ # --- SMTP ---
43
+ # Default assumes a local Postfix on the same host (recommended).
44
+
45
+ KNOWLESS_SMTP_HOST=localhost
46
+ KNOWLESS_SMTP_PORT=25
47
+
48
+ # --- TIMINGS (seconds) ---
49
+
50
+ KNOWLESS_TOKEN_TTL_SECONDS=900
51
+ KNOWLESS_SESSION_TTL_SECONDS=2592000
52
+
53
+ # --- PATHS ---
54
+
55
+ KNOWLESS_LOGIN_PATH=/login
56
+ KNOWLESS_LINK_PATH=/auth/callback
57
+ KNOWLESS_VERIFY_PATH=/verify
58
+ KNOWLESS_LOGOUT_PATH=/logout
59
+
60
+ # --- ABUSE LIMITS (0 disables) ---
61
+
62
+ KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE=5
63
+ KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR=30
64
+ KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR=3
65
+ KNOWLESS_HONEYPOT_FIELD_NAME=website
66
+
67
+ # Comma-separated. Reverse proxy must be in this list for X-Forwarded-For
68
+ # to be honored.
69
+ KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1
70
+
71
+ # --- EMAIL CONTENT ---
72
+
73
+ KNOWLESS_SUBJECT=Sign in
74
+ KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL=true
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
7
7
  "exports": {
8
8
  ".": "./src/index.js"
9
9
  },
10
+ "bin": {
11
+ "knowless-server": "./bin/knowless-server"
12
+ },
10
13
  "files": [
11
14
  "src/",
15
+ "bin/",
16
+ "config.example.env",
12
17
  "LICENSE",
13
18
  "NOTICE",
14
19
  "README.md",
15
20
  "CHANGELOG.md",
16
21
  "GUIDE.md",
22
+ "OPS.md",
17
23
  "knowless.context.md"
18
24
  ],
19
25
  "engines": {
package/src/abuse.js CHANGED
@@ -1,3 +1,62 @@
1
+ import net from 'node:net';
2
+
3
+ /**
4
+ * Build a peer-IP matcher from a list of plain IPs and/or CIDR ranges.
5
+ * AF-6.3 — CIDR support so docker / k8s / cgnat ranges can be trusted
6
+ * without enumerating every address.
7
+ *
8
+ * Accepts:
9
+ * - bare IPv4/IPv6 ("127.0.0.1", "::1")
10
+ * - CIDR ("10.0.0.0/8", "fd00::/8")
11
+ * - a Set or array of either
12
+ * - a `node:net` BlockList (passed through)
13
+ *
14
+ * @param {Set<string>|string[]|net.BlockList} trustedProxies
15
+ * @returns {{ has: (ip: string) => boolean }}
16
+ */
17
+ export function buildTrustedPeers(trustedProxies) {
18
+ if (trustedProxies && typeof trustedProxies.check === 'function') {
19
+ return { has: (ip) => safeBlockListCheck(trustedProxies, ip) };
20
+ }
21
+ const list = Array.isArray(trustedProxies)
22
+ ? trustedProxies
23
+ : trustedProxies instanceof Set
24
+ ? [...trustedProxies]
25
+ : [];
26
+ const exact = new Set();
27
+ const block = new net.BlockList();
28
+ for (const entry of list) {
29
+ if (typeof entry !== 'string' || !entry) continue;
30
+ const slash = entry.indexOf('/');
31
+ if (slash >= 0) {
32
+ const addr = entry.slice(0, slash);
33
+ const prefix = Number(entry.slice(slash + 1));
34
+ const family = net.isIPv6(addr) ? 'ipv6' : 'ipv4';
35
+ try {
36
+ block.addSubnet(addr, prefix, family);
37
+ } catch {
38
+ /* skip malformed CIDR rather than crash */
39
+ }
40
+ } else {
41
+ exact.add(entry);
42
+ }
43
+ }
44
+ return {
45
+ has: (ip) => exact.has(ip) || safeBlockListCheck(block, ip),
46
+ };
47
+ }
48
+
49
+ function safeBlockListCheck(block, ip) {
50
+ if (typeof ip !== 'string' || ip.length === 0) return false;
51
+ const family = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null;
52
+ if (!family) return false;
53
+ try {
54
+ return block.check(ip, family);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
1
60
  /**
2
61
  * Determine the source IP of a request per FR-42 and SPEC §7.6.
3
62
  *
@@ -6,19 +65,20 @@
6
65
  * fall back to the connection's remote address. This prevents IP
7
66
  * spoofing from clients while supporting forward-auth deployments.
8
67
  *
68
+ * `trustedProxies` accepts plain IPs and CIDR ranges (AF-6.3).
69
+ *
9
70
  * @param {{
10
71
  * socket?: { remoteAddress?: string },
11
72
  * connection?: { remoteAddress?: string },
12
73
  * headers?: Record<string, string|string[]|undefined>
13
74
  * }} req a node:http request (or shape-compatible)
14
- * @param {Set<string>|string[]} trustedProxies set or array of trusted peer IPs
75
+ * @param {Set<string>|string[]|net.BlockList} trustedProxies trusted peer IPs / CIDRs
15
76
  * @returns {string} the determined IP, or '' if undeterminable
16
77
  */
17
78
  export function determineSourceIp(req, trustedProxies) {
18
79
  const peer =
19
80
  req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
20
- const trusted =
21
- trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
81
+ const trusted = buildTrustedPeers(trustedProxies);
22
82
  if (!trusted.has(peer)) {
23
83
  return peer;
24
84
  }
package/src/form.js CHANGED
@@ -56,10 +56,15 @@ export function renderLoginForm(args) {
56
56
  next,
57
57
  } = args;
58
58
 
59
+ // confirmationMessage is operator-supplied config, not user input — but
60
+ // operators may naively interpolate user data into it. Escape the whole
61
+ // message before substituting {email} (which is itself escaped). The
62
+ // contract is "confirmationMessage is plain text + {email} placeholder";
63
+ // operators who want HTML can pre-render upstream. Closes AF-6.5.
59
64
  const messageBlock =
60
65
  confirmationMessage != null
61
66
  ? `<div class="msg" role="status">${
62
- confirmationMessage.replace(
67
+ htmlEscape(confirmationMessage).replace(
63
68
  /\{email\}/g,
64
69
  htmlEscape(echoedEmail ?? ''),
65
70
  )
package/src/handlers.js CHANGED
@@ -5,6 +5,7 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
5
5
  import { composeBody } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
+ buildTrustedPeers,
8
9
  determineSourceIp,
9
10
  rateLimitExceeded,
10
11
  rateLimitIncrement,
@@ -178,7 +179,8 @@ export function createHandlers({ store, mailer, config }) {
178
179
  }
179
180
  }
180
181
 
181
- const trustedProxies = new Set(cfg.trustedProxies);
182
+ // Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
183
+ const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
182
184
 
183
185
  // SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
184
186
  // emitted by default and omitted only when cookieSecure: false (localhost
@@ -319,6 +321,14 @@ export function createHandlers({ store, mailer, config }) {
319
321
  } catch (err) {
320
322
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
321
323
  console.error('[knowless] mail submit failed:', err.message);
324
+ // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
325
+ // development the operator otherwise has no way to obtain the magic
326
+ // link. Print it to stderr only when explicitly opted in. Sham
327
+ // submissions are NOT logged (would leak silent-miss outcome).
328
+ if (cfg.devLogMagicLinks && !isSham) {
329
+ const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
330
+ process.stderr.write(`[knowless dev] magic link: ${link}\n`);
331
+ }
322
332
  }
323
333
 
324
334
  rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
@@ -400,6 +410,15 @@ export function createHandlers({ store, mailer, config }) {
400
410
  }
401
411
 
402
412
  async function logout(req, res) {
413
+ // CSRF defense — same Origin/Referer check as POST /login (AF-4.3).
414
+ // Without this, a third-party page can force-logout a victim. Closes
415
+ // AF-6.4. Browser-absent (curl/programmatic) is allowed.
416
+ if (!validateOrigin(req, cfg.cookieDomain)) {
417
+ res.statusCode = 403;
418
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
419
+ res.end('forbidden\n');
420
+ return;
421
+ }
403
422
  const cookie = getCookie(req, cfg.cookieName);
404
423
  if (cookie) {
405
424
  const sid = verifySessionSignature(cookie, cfg.secret);
package/src/index.js CHANGED
@@ -137,6 +137,10 @@ export function knowless(options = {}) {
137
137
  handleFromRequest: handlers.handleFromRequest,
138
138
  /** Delete a handle + all tokens + all sessions atomically (FR-37a). */
139
139
  deleteHandle: (handle) => store.deleteHandle(handle),
140
+ /** Revoke every session for `handle` without deleting the handle.
141
+ * "Log out everywhere." Returns the number of sessions removed.
142
+ * AF-6.1. */
143
+ revokeSessions: (handle) => store.revokeSessions(handle),
140
144
  /** Effective config (with defaults applied), useful for routing. */
141
145
  config: handlers._config,
142
146
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
package/src/store.js CHANGED
@@ -278,6 +278,11 @@ export function createStore(dbPath = ':memory:') {
278
278
  assertHexHash(sidHash, 'sidHash');
279
279
  return stmt.deleteSession.run(sidHash).changes > 0;
280
280
  },
281
+ /** Delete every session for `handle`. Returns rows-deleted. AF-6.1. */
282
+ revokeSessions(handle) {
283
+ assertHexHash(handle, 'handle');
284
+ return stmt.deleteHandleSessions.run(handle).changes;
285
+ },
281
286
  sweepSessions(now = Date.now()) {
282
287
  return stmt.sweepSessions.run(now).changes;
283
288
  },