knowless 0.1.9 → 0.2.0

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,100 @@ 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. Targeted for v0.2.1.
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. Targeted for v0.2.1.
26
+
27
+ ## [0.2.0] — 2026-04-28
28
+
29
+ **No native compile. One production dep.** Drops `better-sqlite3`
30
+ in favour of `node:sqlite` (Node stdlib). Adopters on long-LTS
31
+ distros (RHEL 8/9, Alma, Rocky, Amazon Linux 2) no longer need a
32
+ C++20 toolchain to `npm install knowless`.
33
+
34
+ ### Breaking
35
+
36
+ - **Node floor bumped: `>=20.0.0` → `>=22.5.0`.** `node:sqlite`
37
+ requires Node 22.5+; unflagged stable on Node 24 LTS. Node 20
38
+ reaches EOL April 2026.
39
+ - **`better-sqlite3` removed from `dependencies`.** Down to one
40
+ production dep (`nodemailer`). Transitive package count goes
41
+ from ~40 to ~2. No `prebuild-install`, no `gcc`, no `make`,
42
+ no Python during install.
43
+ - **Storage internals changed**, public API unchanged. The
44
+ `createStore()` interface (SPEC §13) is byte-for-byte identical.
45
+ All 192 tests pass on first run after the swap.
46
+
47
+ ### Migration
48
+
49
+ - **For knowless library adopters:** ensure your runtime is
50
+ Node 22.5+. If you pinned `better-sqlite3` somewhere yourself
51
+ for unrelated reasons, that's now your call. Otherwise:
52
+ ```sh
53
+ npm install knowless@0.2.0
54
+ ```
55
+ No code changes on your side. Existing SQLite databases
56
+ continue to work — same schema, same WAL mode, same
57
+ prepared statements. Sessions and handles persist across
58
+ the upgrade.
59
+ - **For `knowless-server` operators:** ensure the host runs
60
+ Node 22.5+. If you ran `dnf install gcc-toolset-13` to get
61
+ v0.1.x to compile, you can remove it after the upgrade —
62
+ v0.2.0 doesn't need it. The systemd unit and env-var config
63
+ are unchanged.
64
+ - **You may see one `ExperimentalWarning` from `node:sqlite`
65
+ at first import** on Node 22.x. Suppress with `--no-warnings`
66
+ or run on Node 24 LTS where the API is fully stable.
67
+
68
+ ### Internal
69
+
70
+ - New `makeTransaction(db, fn)` adapter in `src/store.js`
71
+ replaces `better-sqlite3`'s `db.transaction()` wrapper. Uses
72
+ `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` directly — same
73
+ serialisation guarantee for the transactional cap-check
74
+ (SPEC §4.7) and account-deletion paths (FR-37a).
75
+ - Closes AF-18 (the addypin RHEL 8 deployment trap).
76
+
77
+ ## [0.1.10] — 2026-04-28
78
+
79
+ addypin manual smoke continued. Two DX docs improvements; no code
80
+ changes.
81
+
82
+ ### Documentation
83
+
84
+ - **GUIDE: "Local development setup" section (AF-16).** Covers the
85
+ five flags that turn knowless from "production-tuned, defensive"
86
+ to "developer-friendly, get-out-of-my-way" — `cookieSecure: false`,
87
+ `devLogMagicLinks: true`, `maxLoginRequestsPerIpPerHour: 0`,
88
+ `maxNewHandlesPerIpPerHour: 0`, `openRegistration: true`. Each
89
+ flag explained with what it solves and a sharp warning about
90
+ shipping it. Considered auto-disabling rate limits whenever
91
+ `devLogMagicLinks: true` to save typing, but rejected the
92
+ coupling — operators turning on `devLogMagicLinks` briefly to
93
+ debug a single email in prod should NOT have rate limits silently
94
+ dropped at the same time.
95
+ - **GUIDE: silent-miss debug line is now promoted as a feature
96
+ (AF-17).** The `[knowless dev:<from>] silent-miss: handle for
97
+ "X" does not exist (openRegistration=false)` stderr hint
98
+ introduced in AF-7.2 was buried in the CHANGELOG; it now leads
99
+ the dev-setup section. First-time closed-reg friction was costing
100
+ every adopter the same ~30 min; the hint cuts that to seconds
101
+ but only if you know it exists.
19
102
 
20
103
  ## [0.1.9] — 2026-04-28
21
104
 
package/GUIDE.md CHANGED
@@ -398,6 +398,59 @@ reverse proxy gates upstreams via `/verify` returning 200/401 +
398
398
  `handleFromRequest` — same answer, no sub-request round-trip, no
399
399
  header parsing.
400
400
 
401
+ ### Local development setup
402
+
403
+ Production defaults are tuned to bite bots, not to be friendly to a
404
+ developer hammering the same address from `127.0.0.1` for the
405
+ hundredth time. Use a dedicated dev config:
406
+
407
+ ```js
408
+ const auth = knowless({
409
+ // ...required fields
410
+ cookieSecure: false, // localhost-only HTTP origins (AF-4.4)
411
+ devLogMagicLinks: true, // print magic links to stderr when SMTP fails (AF-6.2)
412
+ maxLoginRequestsPerIpPerHour: 0, // disable per-IP login cap
413
+ maxNewHandlesPerIpPerHour: 0, // disable per-IP create cap
414
+ openRegistration: true, // skip the pre-seeding step in dev
415
+ });
416
+ ```
417
+
418
+ Why each flag matters in dev:
419
+
420
+ - **`cookieSecure: false`** — without it, `http://localhost` browsers
421
+ reject the session cookie silently. The library logs a stderr
422
+ warning at startup so you can't accidentally ship this to prod.
423
+ - **`devLogMagicLinks: true`** — when SMTP is unreachable (no local
424
+ Postfix yet), magic-link URLs print to stderr tagged
425
+ `[knowless dev:<from>] magic link: ...`. Click straight from the
426
+ terminal. **Bonus diagnostic** (AF-7.2): on a sham/silent-miss
427
+ path, you get `[knowless dev:<from>] silent-miss: handle for
428
+ "X" does not exist (openRegistration=false)` instead — surfaces
429
+ the closed-reg gotcha that costs everyone the same 30 minutes
430
+ the first time.
431
+ - **`maxLoginRequestsPerIpPerHour: 0` and `maxNewHandlesPerIpPerHour:
432
+ 0`** — disable per-IP rate caps. The defaults (30 / 3 per hour)
433
+ are sane for prod but shoot you in the foot during repeated test
434
+ runs. The counters **persist in the SQLite file** across process
435
+ restarts, so even rebooting the dev server doesn't clear them —
436
+ you'd have to delete the DB or wait an hour. Setting both to 0
437
+ in dev avoids the surprise.
438
+ - **`openRegistration: true`** — saves you from manually pre-seeding
439
+ every test email via `auth.deriveHandle` + your own store insert.
440
+
441
+ > **Don't ship this config.** Each of these flags weakens a specific
442
+ > defense. They are coupled to your environment, not to each other —
443
+ > intentionally. (We considered auto-disabling rate limits whenever
444
+ > `devLogMagicLinks` is true, but rejected: an operator turning on
445
+ > `devLogMagicLinks` to debug a single email in production should
446
+ > NOT have rate limits silently dropped at the same time.)
447
+
448
+ For end-to-end mail rendering checks (verify the `bodyFooter`,
449
+ inspect the magic-link line for QP soft-breaks, confirm the
450
+ right `subjectOverride` shipped), point dev knowless at MailHog
451
+ on `localhost:1025`. Setup walkthrough lives in
452
+ [`OPS.md` §11b](OPS.md).
453
+
401
454
  ### Step 7: GDPR right-to-erasure
402
455
 
403
456
  The store interface exposes `deleteHandle(handle)` — atomic delete
@@ -497,7 +550,7 @@ Full options table:
497
550
  | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
498
551
  | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
499
552
  | `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. |
500
- | `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
553
+ | `store` | no | (built-in `node:sqlite`) | Inject your own store implementation. |
501
554
  | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
502
555
 
503
556
  ## FAQ
@@ -606,15 +659,19 @@ mail account is later compromised.
606
659
 
607
660
  ## Constraints / install footprint
608
661
 
609
- - **Two direct dependencies.** `nodemailer` (SMTP submission) and
610
- `better-sqlite3` (storage). Both audited and pinned at major
611
- versions in `package.json`.
612
- - **~40 transitive packages** in a typical install. The bulk are
613
- `nodemailer`'s ecosystem deps (mostly idle in our usage) and
614
- `better-sqlite3`'s build-time prebuild fetcher. You may see one
615
- deprecation warning during install for `prebuild-install`
616
- build-chain noise, not runtime code.
617
- - **Node ≥ 20.** Uses `node:util parseArgs`, `node:net BlockList`
618
- for CIDR support, and `--env-file=` for the standalone server.
619
- - **No optional deps, no postinstall scripts** beyond `better-
620
- sqlite3`'s native binding fetch.
662
+ - **One direct dependency.** `nodemailer` (SMTP submission). Storage
663
+ is `node:sqlite` (Node stdlib, no native compile, no toolchain
664
+ required on the host).
665
+ - **~2 transitive packages** in a typical install (down from ~40 in
666
+ v0.1.x). No `prebuild-install`, no `gcc`, no `make`, no Python.
667
+ `npm ci` works on stock RHEL 8 / Alma / Rocky / Amazon Linux 2
668
+ with no extra packages. Self-hosters: `npm install knowless` is
669
+ done.
670
+ - **Node ≥ 22.5.** `node:sqlite` requires this floor (introduced
671
+ 22.5, unflagged in 22.13+, fully stable in 24 LTS). Drops Node
672
+ 20 about to EOL anyway.
673
+ - **No optional deps, no postinstall scripts.**
674
+
675
+ > `node:sqlite` may print one `ExperimentalWarning` to stderr on
676
+ > first import. Suppress with `--no-warnings` or by running on
677
+ > 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
 
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.9 | 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.0 | 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.9 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
4
+ > v0.2.0 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -333,7 +333,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
333
333
  -> handle.js (normalize ASCII-only, HMAC-SHA256)
334
334
  -> abuse.js (per-IP rate limit, per-handle token cap, honeypot)
335
335
  -> token.js (32 random bytes, base64url; SHA-256 at rest)
336
- -> store.js (better-sqlite3, transactional, prepared statements)
336
+ -> store.js (node:sqlite, transactional, prepared statements)
337
337
  -> mailer.js (raw RFC822 7bit; nodemailer for SMTP submission only)
338
338
  -> session.js (HMAC-signed cookie with "sess\\0" domain tag)
339
339
  -> form.js (hardcoded HTML5; no JS, no external resources)
@@ -344,7 +344,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
344
344
  |---|---|---|
345
345
  | `src/index.js` | ~140 | Public factory, sweeper, re-exports |
346
346
  | `src/handlers.js` | ~310 | login (sham), callback, verify, logout, loginForm, validateNextUrl |
347
- | `src/store.js` | ~210 | better-sqlite3 store; SPEC §13 interface |
347
+ | `src/store.js` | ~240 | node:sqlite store + transaction adapter; SPEC §13 interface |
348
348
  | `src/mailer.js` | ~120 | RFC822 raw composition + nodemailer SMTP submission |
349
349
  | `src/abuse.js` | ~95 | Source-IP determination, rate limits |
350
350
  | `src/handle.js` | ~50 | Email normalization, handle derivation |
@@ -433,7 +433,7 @@ rate-limits) belongs above the library.
433
433
  sweeper and closes the SQLite handle. Without it, your
434
434
  process won't exit cleanly. The sweeper timer is `unref()`d
435
435
  so it won't *prevent* exit, but the SQLite handle held by
436
- `better-sqlite3` will leave a finalizer warning.
436
+ `node:sqlite` will leave a finalizer warning.
437
437
 
438
438
  12. **CSRF defense is the Origin/Referer whitelist, not a token.**
439
439
  Modern browsers always emit `Origin` on cross-origin POSTs;
@@ -487,8 +487,9 @@ rate-limits) belongs above the library.
487
487
  - **Node 20+** -- targeting LTS; tested on Node 22
488
488
  - **Plain ES modules** -- no TypeScript source, no build step;
489
489
  ships JSDoc + (eventual) `.d.ts`
490
- - **Two production deps** -- `nodemailer` (SMTP submission) and
491
- `better-sqlite3` (storage). No third dep without revisiting
490
+ - **One production dep** -- `nodemailer` (SMTP submission). Storage
491
+ uses `node:sqlite` (stdlib, no native compile). No second runtime
492
+ dep without revisiting
492
493
  AGENT_RULES External Dependency Checklist.
493
494
  - **Localhost MTA only** -- no remote SMTP, no vendor SDKs.
494
495
  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.9",
3
+ "version": "0.2.0",
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",
package/src/store.js CHANGED
@@ -1,4 +1,32 @@
1
- import Database from 'better-sqlite3';
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ /**
4
+ * Wrap a function in a SQLite transaction. Mirrors better-sqlite3's
5
+ * `db.transaction(fn)` shape: returns a function that opens
6
+ * BEGIN IMMEDIATE, runs `fn`, COMMITs on success, ROLLBACKs on
7
+ * throw, and propagates the original error.
8
+ *
9
+ * BEGIN IMMEDIATE rather than BEGIN DEFERRED — knowless's
10
+ * transactional cap-check (SPEC §4.7) needs a write lock from the
11
+ * start to serialise concurrent issuance attempts.
12
+ *
13
+ * @param {DatabaseSync} db
14
+ * @param {Function} fn
15
+ */
16
+ function makeTransaction(db, fn) {
17
+ return (...args) => {
18
+ db.exec('BEGIN IMMEDIATE');
19
+ let result;
20
+ try {
21
+ result = fn(...args);
22
+ } catch (err) {
23
+ try { db.exec('ROLLBACK'); } catch { /* tolerate stack-unwind issues */ }
24
+ throw err;
25
+ }
26
+ db.exec('COMMIT');
27
+ return result;
28
+ };
29
+ }
2
30
 
3
31
  /**
4
32
  * Default token-sweeper grace: keep used tokens for 24h after redemption
@@ -76,11 +104,11 @@ const DDL = `
76
104
  * @returns {Store}
77
105
  */
78
106
  export function createStore(dbPath = ':memory:') {
79
- const db = new Database(dbPath);
80
- db.pragma('journal_mode = WAL');
81
- db.pragma('synchronous = NORMAL');
82
- db.pragma('foreign_keys = OFF');
83
- db.pragma('temp_store = MEMORY');
107
+ const db = new DatabaseSync(dbPath);
108
+ db.exec('PRAGMA journal_mode = WAL');
109
+ db.exec('PRAGMA synchronous = NORMAL');
110
+ db.exec('PRAGMA foreign_keys = OFF');
111
+ db.exec('PRAGMA temp_store = MEMORY');
84
112
  db.exec(DDL);
85
113
 
86
114
  const existing = db
@@ -166,7 +194,7 @@ export function createStore(dbPath = ':memory:') {
166
194
  };
167
195
 
168
196
  // Transactional cap-check + insert per SPEC §4.7.
169
- const insertTokenAtomic = db.transaction(
197
+ const insertTokenAtomic = makeTransaction(db,
170
198
  (tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
171
199
  if (maxActive > 0) {
172
200
  const { n: count } = stmt.countActiveTokens.get(handle, now);
@@ -181,7 +209,7 @@ export function createStore(dbPath = ':memory:') {
181
209
  );
182
210
 
183
211
  // Transactional account deletion per FR-37a.
184
- const deleteHandleAtomic = db.transaction((handle) => {
212
+ const deleteHandleAtomic = makeTransaction(db, (handle) => {
185
213
  stmt.deleteHandleSessions.run(handle);
186
214
  stmt.deleteHandleTokens.run(handle);
187
215
  stmt.deleteHandleRow.run(handle);