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 +88 -5
- package/GUIDE.md +70 -13
- package/OPS.md +8 -7
- package/README.md +155 -103
- package/knowless.context.md +7 -6
- package/package.json +2 -3
- package/src/store.js +36 -8
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
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
610
|
-
`
|
|
611
|
-
|
|
612
|
-
- **~
|
|
613
|
-
|
|
614
|
-
`
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
- **Node ≥
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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.**
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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.
|
|
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
|
-
|
|
48
|
-
session cookie out, nothing else stored
|
|
49
|
-
|
|
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
|
-
##
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
-
|
|
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 B — register-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.
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
98
|
-
for outbound-only mail
|
|
99
|
-
- Setting up **SPF, DKIM, and PTR
|
|
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**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
forward-auth examples, Tailscale pattern, reverse-proxy rate
|
|
110
|
-
|
|
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
|
-
- [
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
- [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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
|
|
182
|
+
## Threat model (one-paragraph)
|
|
130
183
|
|
|
131
|
-
Honest version (full detail in [
|
|
184
|
+
Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
|
|
132
185
|
|
|
133
|
-
**Defends well:**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
141
|
-
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/knowless.context.md
CHANGED
|
@@ -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.
|
|
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 (
|
|
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` | ~
|
|
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
|
-
`
|
|
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
|
-
- **
|
|
491
|
-
`
|
|
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.
|
|
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": ">=
|
|
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
|
|
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
|
|
80
|
-
db.
|
|
81
|
-
db.
|
|
82
|
-
db.
|
|
83
|
-
db.
|
|
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
|
|
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
|
|
212
|
+
const deleteHandleAtomic = makeTransaction(db, (handle) => {
|
|
185
213
|
stmt.deleteHandleSessions.run(handle);
|
|
186
214
|
stmt.deleteHandleTokens.run(handle);
|
|
187
215
|
stmt.deleteHandleRow.run(handle);
|