knowless 0.2.0 → 0.2.2
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 +185 -8
- package/GUIDE.md +193 -14
- package/README.md +99 -159
- package/knowless.context.md +190 -8
- package/package.json +1 -1
- package/src/handlers.js +91 -13
- package/src/index.js +105 -2
- package/src/mailer.js +75 -0
- package/src/store.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -15,14 +15,191 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
15
15
|
|
|
16
16
|
## [Unreleased]
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
**v0.2.2 is feature-complete.** v1.0.0 is the planned next release —
|
|
19
|
+
walk-away promotion, no API changes.
|
|
20
|
+
|
|
21
|
+
## [0.2.2] — 2026-04-29
|
|
22
|
+
|
|
23
|
+
**One feature add at the end of v0.2.x: per-call body customization
|
|
24
|
+
for `auth.startLogin` (AF-26).** Closes the last addypin gap before
|
|
25
|
+
v1.0.0 promotion — adopters with multiple Mode-A flows (pin
|
|
26
|
+
confirmation, login, expiry warning) can now phrase the email body
|
|
27
|
+
to match per-call subjects without re-implementing token mint /
|
|
28
|
+
sham-work / SMTP submit.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`bodyOverride: ({url}) => string` arg on `auth.startLogin`
|
|
33
|
+
(AF-26).** A template function called per-call after the magic-
|
|
34
|
+
link URL is composed. knowless still owns URL composition (so the
|
|
35
|
+
v0.11 POC 7bit URL-line invariant stays in knowless's control) and
|
|
36
|
+
validates the rendered output. `bodyFooter` continues to append
|
|
37
|
+
after the override; the `lastLogin` line does NOT auto-append on
|
|
38
|
+
overridden bodies — the template owns content end-to-end.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
await auth.startLogin({
|
|
42
|
+
email,
|
|
43
|
+
subjectOverride: `Confirm your pin: ${shortcode}`,
|
|
44
|
+
bodyOverride: ({ url }) =>
|
|
45
|
+
`Confirm your pin "${shortcode}":\n\n${url}\n\n` +
|
|
46
|
+
`Link expires in 15 minutes.\n`,
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **`validateBodyOverride(body, url)` re-export.** Pure validator
|
|
51
|
+
exposed alongside the other `validate*` re-exports. Same validation
|
|
52
|
+
knowless runs internally on the override return value:
|
|
53
|
+
- Non-empty string, ≤ 2048 chars
|
|
54
|
+
- ASCII only
|
|
55
|
+
- No CR (header-injection defense)
|
|
56
|
+
- URL appears exactly once
|
|
57
|
+
- URL is on its own line (preserves the v0.11 POC 7bit URL-line
|
|
58
|
+
invariant — QP soft-breaks would break the link)
|
|
59
|
+
|
|
60
|
+
Throws on any violation. Adopters generally don't need to call this
|
|
61
|
+
directly — knowless validates the function's return value at
|
|
62
|
+
compose time — but it's exported for ahead-of-time tests.
|
|
63
|
+
|
|
64
|
+
### Why this is in scope (and not a 'forum-style' rejected addition)
|
|
65
|
+
|
|
66
|
+
The body has to be composed *after* the token is minted (the URL
|
|
67
|
+
contains the token), so the caller can't just "send their own email"
|
|
68
|
+
without re-implementing most of knowless. Bypassing knowless to send
|
|
69
|
+
a custom body would mean rebuilding token mint + token-store insert
|
|
70
|
+
+ sham-work timing + SMTP submit — that's most of the library. The
|
|
71
|
+
ASCII / URL-line / 7bit constraints are the right place to keep
|
|
72
|
+
validating, and those live in knowless. Identity-layer concern,
|
|
73
|
+
mechanism stays where the policy is.
|
|
74
|
+
|
|
75
|
+
Contrast with the rejected items in the v0.2.1 backlog cull
|
|
76
|
+
(disposable-domain, account-age, hashcash, Docker image): each of
|
|
77
|
+
those passed the "could the adopter do this themselves?" test.
|
|
78
|
+
Per-call body customization fails it.
|
|
79
|
+
|
|
80
|
+
### Internal
|
|
81
|
+
|
|
82
|
+
- 16 new tests in `test/integration/body-override.test.js` covering
|
|
83
|
+
happy path on real submissions, sham-branch parity (FR-6),
|
|
84
|
+
bodyFooter append behavior, lastLogin non-auto-append, every
|
|
85
|
+
validation error path, undefined/null pass-through, and the
|
|
86
|
+
re-exported `validateBodyOverride` helper. Test count: 207 → 223.
|
|
87
|
+
|
|
88
|
+
## [0.2.1] — 2026-04-29
|
|
89
|
+
|
|
90
|
+
**Operator visibility, opt-in.** Three event hooks + one method,
|
|
91
|
+
the full surface forum + addypin asked for during the v0.2.0
|
|
92
|
+
integration spike. The shape was negotiated against
|
|
93
|
+
`walk-away-at-v1.0.0` (PRD §6.3): every "obvious" addition was
|
|
94
|
+
deliberately rejected if it could be done in adopter or perimeter
|
|
95
|
+
code. See `knowless.context.md` § "What's NOT in knowless, and why"
|
|
96
|
+
for the rejected-by-design list (disposable-domain check, account-age
|
|
97
|
+
accessor, hashcash, `lookupMessageId()`, `onShamHit`).
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- **`onMailerSubmit({messageId, handle, timestamp})` (AF-19).**
|
|
102
|
+
Per-event hook fired on successful SMTP submission for *real*
|
|
103
|
+
(non-sham) sends only. Adopters log it, build msg_id ↔ handle
|
|
104
|
+
correlation maps, or pipe to structured logging. Knowless never
|
|
105
|
+
stores the mapping. Sham branches deliberately do NOT fire this
|
|
106
|
+
hook — that's the load-bearing NFR-10 invariant (would let a
|
|
107
|
+
careless adopter log per-handle data and reopen the enumeration
|
|
108
|
+
oracle that sham-work was designed to prevent).
|
|
109
|
+
- **`onTransportFailure({error, timestamp}) (AF-19).** Per-event
|
|
110
|
+
hook fired on SMTP errors. No identity data — safe per-event,
|
|
111
|
+
safe to alert on.
|
|
112
|
+
- **`onSuppressionWindow({sham, rateLimited, windowMs})` (AF-19).**
|
|
113
|
+
Heartbeat hook fired every `suppressionWindowMs` (default 60s)
|
|
114
|
+
with aggregate counters across all silent-202 branches: sham
|
|
115
|
+
hits, `login_ip` cap, `create_ip` cap (counted both as sham and
|
|
116
|
+
rate-limited when fall-through happens), and per-handle token-cap
|
|
117
|
+
rotation. Heartbeats fire even when both counters are zero — a
|
|
118
|
+
missing emission is itself diagnostic. Replaces a per-event
|
|
119
|
+
`onShamHit` / `onRateLimitHit` design that would have leaked
|
|
120
|
+
per-handle data through log lines; the windowed aggregate
|
|
121
|
+
preserves the spike signal without per-call distinguishability.
|
|
122
|
+
- **`auth.verifyTransport()` method (AF-20).** Wraps
|
|
123
|
+
`transport.verify()`. Resolves `Promise<true>` on non-rejection,
|
|
124
|
+
rejects with the underlying error. Adopters call this explicitly
|
|
125
|
+
when they want fail-fast on misconfigured SMTP at boot. **No
|
|
126
|
+
auto-on-boot variant by design (AF-21).** Deployments where
|
|
127
|
+
knowless starts before Postfix (docker-compose ordering, k8s
|
|
128
|
+
readiness probes) would fail boot for the wrong reason.
|
|
129
|
+
- **`startLogin` silent-202 documented (AF-22).** New gotcha #19 in
|
|
130
|
+
`knowless.context.md` and a Mode-A pointer in GUIDE.md make
|
|
131
|
+
explicit that `startLogin` returns `{handle, submitted: true}`
|
|
132
|
+
for every branch (real, sham, rate-limited, missing handle) by
|
|
133
|
+
design. Operators who need branch visibility wire the v0.2.1
|
|
134
|
+
hooks; the per-call return shape never reveals which branch ran.
|
|
135
|
+
|
|
136
|
+
### Changed
|
|
137
|
+
|
|
138
|
+
- **`store.insertToken` returns the eviction count.** Internal
|
|
139
|
+
store-interface change: `insertToken` now returns the number of
|
|
140
|
+
tokens evicted to make room for the new one (always `0` when
|
|
141
|
+
`maxActive` is `0`). Used by `runSendLink` to count per-handle
|
|
142
|
+
cap rotation events into the `rateLimited` counter. Adopters
|
|
143
|
+
with custom stores implementing the SPEC §13 interface should
|
|
144
|
+
update accordingly; the change is forward-compatible (returning
|
|
145
|
+
`undefined` is treated as zero evictions).
|
|
146
|
+
|
|
147
|
+
### Documentation (forum + addypin negotiation outcome)
|
|
148
|
+
|
|
149
|
+
- **knowless.context.md § "What's NOT in knowless, and why"** —
|
|
150
|
+
permanent record of three rejected-by-design additions
|
|
151
|
+
(disposable-domain check, account-age accessor, per-IP hashcash)
|
|
152
|
+
with the seam argument and walk-away-at-v1.0.0 framing. Future
|
|
153
|
+
contributors evaluating "should X go in knowless?" run two tests
|
|
154
|
+
before answering yes: identity layer vs behavior layer; mechanism
|
|
155
|
+
living with policy.
|
|
156
|
+
- **GUIDE.md FAQ** — "Why doesn't knowless block disposable email
|
|
157
|
+
domains?" + "How do I check how old a user is?" with adopter-side
|
|
158
|
+
code patterns for both. Closes the most likely "but-can-it" requests.
|
|
159
|
+
|
|
160
|
+
### Internal
|
|
161
|
+
|
|
162
|
+
- Hook errors are caught and swallowed via a single `safeHook()`
|
|
163
|
+
wrapper, matching the existing `onSweepError` contract. Knowless
|
|
164
|
+
never crashes because an operator's observability sink threw.
|
|
165
|
+
- Suppression-window timer is `unref()`'d and only started when
|
|
166
|
+
`onSuppressionWindow` is wired — adopters not using the hook
|
|
167
|
+
spend zero `setInterval` slots on it.
|
|
168
|
+
- 16 new tests in `test/integration/v021-hooks.test.js` covering
|
|
169
|
+
payload shapes, the sham-no-fire invariant, aggregation
|
|
170
|
+
semantics, heartbeat behavior, counter reset, hook-error
|
|
171
|
+
containment, and `verifyTransport()` resolve/reject paths.
|
|
172
|
+
Test count: 192 → 207.
|
|
173
|
+
|
|
174
|
+
### Cut from v0.2.x backlog (kept here for the record)
|
|
175
|
+
|
|
176
|
+
Three items previously listed under Unreleased were stress-tested
|
|
177
|
+
against walk-away-at-v1.0.0 and cut. Rationale per item, so future
|
|
178
|
+
contributors see why these aren't being re-proposed:
|
|
179
|
+
|
|
180
|
+
- **`knowless-server --check-null-route` CLI probe — CUT.** Operator
|
|
181
|
+
setup-correctness check, not identity layer. The same probe is
|
|
182
|
+
three commands of `swaks` + `tail /var/log/maillog`; documented in
|
|
183
|
+
GUIDE.md Step 3. Adding a knowless CLI feature for it would carry
|
|
184
|
+
maintenance burden into walk-away for something an operator can
|
|
185
|
+
already do with a one-line shell command.
|
|
186
|
+
- **Caddy forward-auth Docker integration test (TASKS 6.8) — CUT.**
|
|
187
|
+
The contract under test is two HTTP responses and one header
|
|
188
|
+
(`/verify` → 200+`X-User-Handle` or 401). Every hop is already
|
|
189
|
+
covered by `forward-auth-next.test.js` + `cli.test.js`. addypin
|
|
190
|
+
runs knowless behind Caddy in production — that is the integration
|
|
191
|
+
test, with adopter signal stronger than any docker-compose CI
|
|
192
|
+
could provide. Removed as a v1.0.0 graduation criterion;
|
|
193
|
+
PRD §6.1 updated.
|
|
194
|
+
- **Turnkey Docker image (`knowless/knowless-server:0.2.x`) — CUT.**
|
|
195
|
+
Doesn't actually solve the operator problem: SPF / DKIM / PTR /
|
|
196
|
+
outbound-port-25 work is still the operator's, image only saves
|
|
197
|
+
~5 minutes of `apt install postfix && postmap`. Cost-side is
|
|
198
|
+
permanent: Postfix is on a CVE drumbeat, and a walk-away library
|
|
199
|
+
shipping a Postfix image would commit to forever-rebuilds —
|
|
200
|
+
exactly the opposite of walk-away discipline. If a self-hoster
|
|
201
|
+
builds a community Dockerfile, OPS.md will link to it; knowless
|
|
202
|
+
itself doesn't ship one.
|
|
26
203
|
|
|
27
204
|
## [0.2.0] — 2026-04-28
|
|
28
205
|
|
package/GUIDE.md
CHANGED
|
@@ -162,6 +162,25 @@ sudo postmap /etc/postfix/transport
|
|
|
162
162
|
sudo systemctl reload postfix
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
**Verify the null-route is catching mail.** A misconfigured
|
|
166
|
+
null-route doesn't surface until the first sham submission — by
|
|
167
|
+
which point you're debugging a silent-202 from a real form post.
|
|
168
|
+
One-line check, no knowless code needed:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
sudo apt install swaks # one-time, if not present
|
|
172
|
+
swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
|
|
173
|
+
sudo journalctl -u postfix --since '1 minute ago' | grep -i 'discard'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
A `discard:` line in the postfix log confirms `transport_maps` is
|
|
177
|
+
applied. If you see `relay=` / `delivered` / a queue ID with no
|
|
178
|
+
discard, re-run `postmap` + `systemctl reload postfix` and try
|
|
179
|
+
again. (knowless deliberately does NOT ship a `--check-null-route`
|
|
180
|
+
CLI for this — operator MTA validation is operator-side, and adding
|
|
181
|
+
a wrapper for a one-line `swaks` invocation would carry maintenance
|
|
182
|
+
burden into the v1.0.0 walk-away window for no real value.)
|
|
183
|
+
|
|
165
184
|
Then the DNS records — set on your sending domain, **not** your
|
|
166
185
|
app's primary domain (typical setup: `auth.example.com` is the
|
|
167
186
|
sending domain):
|
|
@@ -240,15 +259,18 @@ the form.
|
|
|
240
259
|
|
|
241
260
|
### Two adoption modes (Mode A vs Mode B)
|
|
242
261
|
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
In plain English: **"sign in, then do the thing"** (Mode B) and
|
|
263
|
+
**"do the thing, confirm by email"** (Mode A). knowless supports
|
|
264
|
+
both out of the box; pick per-action, not per-app — they coexist.
|
|
265
|
+
The Mode A/B labels are used here and in the CHANGELOG so
|
|
266
|
+
discussions across the docs stay unambiguous.
|
|
245
267
|
|
|
246
|
-
**Mode B — register-first
|
|
247
|
-
performing the action. Wire `auth.login` /
|
|
248
|
-
above; gate your action with
|
|
249
|
-
when the action requires a session
|
|
250
|
-
features, anything you want tied to an
|
|
251
|
-
the action).
|
|
268
|
+
**Mode B — "sign in, then do the thing" (register-first, the default).**
|
|
269
|
+
User must log in before performing the action. Wire `auth.login` /
|
|
270
|
+
`auth.callback` as above; gate your action with
|
|
271
|
+
`auth.handleFromRequest(req)`. Use when the action requires a session
|
|
272
|
+
(account settings, paid features, anything you want tied to an
|
|
273
|
+
identity at the moment of the action).
|
|
252
274
|
|
|
253
275
|
```js
|
|
254
276
|
app.post('/api/comments', (req, res) => {
|
|
@@ -258,12 +280,12 @@ app.post('/api/comments', (req, res) => {
|
|
|
258
280
|
});
|
|
259
281
|
```
|
|
260
282
|
|
|
261
|
-
**Mode A — use-first, claim-later.**
|
|
262
|
-
without logging in; you capture their email
|
|
263
|
-
link. Clicking it opens a session and your
|
|
264
|
-
"promotes" the deferred resource. Use for "drop a
|
|
265
|
-
share link," "submit a paste" — patterns where forcing
|
|
266
|
-
*before* the action would harm the UX.
|
|
283
|
+
**Mode A — "do the thing, confirm by email" (use-first, claim-later).**
|
|
284
|
+
User performs the action without logging in; you capture their email
|
|
285
|
+
and trigger a magic link. Clicking it opens a session and your
|
|
286
|
+
callback handler "promotes" the deferred resource. Use for "drop a
|
|
287
|
+
pin," "post a share link," "submit a paste" — patterns where forcing
|
|
288
|
+
a login *before* the action would harm the UX.
|
|
267
289
|
|
|
268
290
|
```js
|
|
269
291
|
app.post('/api/pins', async (req, res) => {
|
|
@@ -277,6 +299,14 @@ app.post('/api/pins', async (req, res) => {
|
|
|
277
299
|
// Per-call subject so the user can tell at a glance this is a
|
|
278
300
|
// pin-confirmation, not a routine login. AF-9.
|
|
279
301
|
subjectOverride: `Confirm your pin: ${shortcode}`,
|
|
302
|
+
// Per-call body so subject and body agree. AF-26 (v0.2.2).
|
|
303
|
+
// knowless still composes the URL and validates the rendered
|
|
304
|
+
// output (ASCII / URL on its own line / ≤2048 chars). bodyFooter
|
|
305
|
+
// still appends; the lastLogin line does NOT auto-append on
|
|
306
|
+
// overridden bodies — the template owns the content.
|
|
307
|
+
bodyOverride: ({ url }) =>
|
|
308
|
+
`Confirm your pin "${shortcode}":\n\n${url}\n\n` +
|
|
309
|
+
`This link expires in 15 minutes. If you didn't request it, ignore.\n`,
|
|
280
310
|
});
|
|
281
311
|
res.status(202).end(); // "we'll email you the link"
|
|
282
312
|
});
|
|
@@ -295,6 +325,11 @@ return identical shapes — the caller can't observe which happened.
|
|
|
295
325
|
This preserves FR-6 timing equivalence even for programmatic
|
|
296
326
|
callers. See SPEC §7.3a for the full contract.
|
|
297
327
|
|
|
328
|
+
If you need *operator* visibility (not per-call: aggregate counts and
|
|
329
|
+
real-send confirmation), wire the v0.2.1 hooks documented in
|
|
330
|
+
[Step 8](#step-8-optional-operator-monitoring-via-event-hooks-v021)
|
|
331
|
+
below — they emit without breaking the per-call silent-202 contract.
|
|
332
|
+
|
|
298
333
|
`auth.deriveHandle(email)` returns the same opaque HMAC handle
|
|
299
334
|
that the form path uses, without you having to import the helper
|
|
300
335
|
or pass the secret around. The instance method **normalizes the
|
|
@@ -469,6 +504,76 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
|
|
|
469
504
|
chooses the UX (admin CLI, in-app self-service, ticket-driven
|
|
470
505
|
support).
|
|
471
506
|
|
|
507
|
+
### Step 8 (optional): Operator monitoring via event hooks (v0.2.1+)
|
|
508
|
+
|
|
509
|
+
Three event hooks + one opt-in method. All optional, all opt-in. None
|
|
510
|
+
are required for correct operation; the library is fully functional
|
|
511
|
+
with zero hooks wired. They exist so an operator can wire knowless to
|
|
512
|
+
their existing observability stack (Prometheus, statsd, structured
|
|
513
|
+
logs, on-call paging) without knowless curating its own metrics shape.
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
const auth = knowless({
|
|
517
|
+
// ...required + existing options...
|
|
518
|
+
|
|
519
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => {
|
|
520
|
+
log.info('knowless.dispatch', { messageId, handle, ts: timestamp });
|
|
521
|
+
},
|
|
522
|
+
onTransportFailure: ({error, timestamp}) => {
|
|
523
|
+
log.error('knowless.smtp_failed', { err: error.message, ts: timestamp });
|
|
524
|
+
pager.notify('SMTP transport failed');
|
|
525
|
+
},
|
|
526
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => {
|
|
527
|
+
metrics.gauge('knowless.sham_count', sham);
|
|
528
|
+
metrics.gauge('knowless.rate_limited', rateLimited);
|
|
529
|
+
if (sham > BASELINE * 10) pager.notify('possible enumeration attack');
|
|
530
|
+
},
|
|
531
|
+
// suppressionWindowMs: 60_000, // default; configurable
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Optional: explicit transport probe at boot. No auto-probe by design.
|
|
535
|
+
try {
|
|
536
|
+
await auth.verifyTransport();
|
|
537
|
+
} catch (err) {
|
|
538
|
+
console.error('SMTP unreachable at boot:', err);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
#### Why three hooks, not four
|
|
544
|
+
|
|
545
|
+
The two silent-202 branches — sham hits (handle does not exist) and
|
|
546
|
+
rate-limit hits (any of the three caps) — are bundled into one *windowed
|
|
547
|
+
aggregate* (`onSuppressionWindow`) rather than per-event hooks. Per-event
|
|
548
|
+
hooks would let a careless adopter log per-handle data, which is the
|
|
549
|
+
enumeration oracle that sham-work exists to prevent. The HTTP response
|
|
550
|
+
is silent on these branches; the log file must be silent too.
|
|
551
|
+
|
|
552
|
+
Operators still get the spike signal — a 10× jump in `sham` count over
|
|
553
|
+
the window is the enumeration-attack alarm. They don't get per-call
|
|
554
|
+
correlation to a specific handle, and they shouldn't have it.
|
|
555
|
+
|
|
556
|
+
`onMailerSubmit` is per-event because it fires *only* on real
|
|
557
|
+
submissions, where the handle was already disclosed by the form
|
|
558
|
+
input. `onTransportFailure` is per-event because it carries no
|
|
559
|
+
identity data.
|
|
560
|
+
|
|
561
|
+
> **Don't log `onSuppressionWindow` payloads in a way that distinguishes
|
|
562
|
+
> them from `onMailerSubmit` at the log-line level.** The aggregate
|
|
563
|
+
> count is fine; the line itself should be cleanly labeled as a periodic
|
|
564
|
+
> counter emission, not "a sham just happened." If your log shipper or
|
|
565
|
+
> dashboard groups them differently, you've put back the per-event
|
|
566
|
+
> distinguishability the bundling was meant to remove.
|
|
567
|
+
|
|
568
|
+
#### Why `verifyTransport()` is opt-in
|
|
569
|
+
|
|
570
|
+
No auto-on-boot variant exists by design. Deployments where knowless
|
|
571
|
+
starts before Postfix (docker-compose ordering, k8s readiness probes
|
|
572
|
+
that run knowless before the SMTP container is healthy) would fail
|
|
573
|
+
boot for the wrong reason. Adopters who want fail-fast call
|
|
574
|
+
`verifyTransport()` explicitly; everyone else gets eventually-consistent
|
|
575
|
+
SMTP startup.
|
|
576
|
+
|
|
472
577
|
## Walkthrough: standalone server mode
|
|
473
578
|
|
|
474
579
|
Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
|
|
@@ -555,6 +660,80 @@ Full options table:
|
|
|
555
660
|
|
|
556
661
|
## FAQ
|
|
557
662
|
|
|
663
|
+
### Is there an official knowless Docker image?
|
|
664
|
+
|
|
665
|
+
No. knowless does not ship a turnkey image with Postfix + null-route
|
|
666
|
+
+ the binary pre-baked. The reasoning: a Docker image bundling
|
|
667
|
+
Postfix wouldn't actually save the operator from the work that
|
|
668
|
+
matters (SPF / DKIM / PTR records on your sending domain, outbound
|
|
669
|
+
port 25 unblocked at your hosting provider, reverse DNS pointed at
|
|
670
|
+
your sending hostname — all done outside the container regardless),
|
|
671
|
+
and shipping a Postfix image would commit a walk-away library to a
|
|
672
|
+
permanent CVE-rebuild cadence. The OPS.md walkthrough is the
|
|
673
|
+
canonical install path; a fresh VPS to working forward-auth takes
|
|
674
|
+
30–60 minutes of one-time setup, and then it stays put.
|
|
675
|
+
|
|
676
|
+
If a community Dockerfile emerges (open invitation — knowless is
|
|
677
|
+
Apache-2.0), OPS.md will link to it. Until then, run
|
|
678
|
+
`knowless-server` as a systemd unit alongside Postfix as the OPS
|
|
679
|
+
walkthrough lays out.
|
|
680
|
+
|
|
681
|
+
### Why doesn't knowless block disposable email domains?
|
|
682
|
+
|
|
683
|
+
Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
|
|
684
|
+
adopter policy, not identity layer. The blocklist is a public GitHub
|
|
685
|
+
repo, the override list is operator-specific, and the cron to refresh
|
|
686
|
+
it lives in your ops layer. Putting the *mechanism* in knowless while
|
|
687
|
+
the *list curation* and *overrides* live in the adopter is the wrong
|
|
688
|
+
seam — both stay together in your form handler.
|
|
689
|
+
|
|
690
|
+
```js
|
|
691
|
+
// In your /login form handler, before calling auth.login:
|
|
692
|
+
import { DISPOSABLE_DOMAINS, ADOPTER_OVERRIDES } from './disposable-domains.js';
|
|
693
|
+
|
|
694
|
+
app.post('/login', async (req, res) => {
|
|
695
|
+
const email = /* parse from body */;
|
|
696
|
+
const domain = email.split('@')[1]?.toLowerCase();
|
|
697
|
+
if (domain && DISPOSABLE_DOMAINS.has(domain) && !ADOPTER_OVERRIDES.has(domain)) {
|
|
698
|
+
// Reply with the same shape as auth.login's success/sham response
|
|
699
|
+
// to preserve FR-6-equivalent timing at your layer too. Match
|
|
700
|
+
// status, headers, and body that auth.login would emit.
|
|
701
|
+
return res.status(200).type('html').send(/* same confirmation HTML */);
|
|
702
|
+
}
|
|
703
|
+
return auth.login(req, res);
|
|
704
|
+
});
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
The same argument applies to per-IP hashcash / proof-of-work: if
|
|
708
|
+
`maxNewHandlesPerIpPerHour: 3` isn't enough for your threat model,
|
|
709
|
+
run hashcash at Caddy or your edge layer — knowless's login form
|
|
710
|
+
deliberately stays zero-JS.
|
|
711
|
+
|
|
712
|
+
### How do I check how old a handle / user is?
|
|
713
|
+
|
|
714
|
+
knowless deliberately doesn't expose handle creation dates. The reason:
|
|
715
|
+
"first time this email hit knowless" is rarely the trust signal you
|
|
716
|
+
actually want — you want "first time this user did something meaningful
|
|
717
|
+
in *my app*." A six-month-old knowless handle that has never posted
|
|
718
|
+
has zero application tenure.
|
|
719
|
+
|
|
720
|
+
Pattern: track `(handle, first_seen_at)` in your own table the first
|
|
721
|
+
time a handle performs the action you care about (first post, first
|
|
722
|
+
purchase, first non-trivial API call). Bucket by your tenure, not
|
|
723
|
+
knowless's.
|
|
724
|
+
|
|
725
|
+
```js
|
|
726
|
+
// On the action you care about:
|
|
727
|
+
const handle = auth.handleFromRequest(req);
|
|
728
|
+
db.recordFirstSeen(handle, Date.now()); // INSERT OR IGNORE
|
|
729
|
+
const age = db.ageBucketFor(handle); // 'new' | '1mo' | '1y' | '5y+'
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
This is also safer: returning a `Date | null` keyed by handle is itself
|
|
733
|
+
an enumeration oracle (null leaks "this handle doesn't exist"). Bucket
|
|
734
|
+
on your side from a table that only knows about handles that have
|
|
735
|
+
already acted.
|
|
736
|
+
|
|
558
737
|
### My users say magic links land in spam.
|
|
559
738
|
|
|
560
739
|
This is operator infrastructure, not the library. The library
|