knowless 0.2.1 → 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 +99 -8
- package/GUIDE.md +62 -14
- package/README.md +99 -159
- package/knowless.context.md +24 -8
- package/package.json +1 -1
- package/src/handlers.js +44 -9
- package/src/index.js +7 -1
- package/src/mailer.js +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,14 +15,75 @@ 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.
|
|
26
87
|
|
|
27
88
|
## [0.2.1] — 2026-04-29
|
|
28
89
|
|
|
@@ -110,6 +171,36 @@ accessor, hashcash, `lookupMessageId()`, `onShamHit`).
|
|
|
110
171
|
containment, and `verifyTransport()` resolve/reject paths.
|
|
111
172
|
Test count: 192 → 207.
|
|
112
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.
|
|
203
|
+
|
|
113
204
|
## [0.2.0] — 2026-04-28
|
|
114
205
|
|
|
115
206
|
**No native compile. One production dep.** Drops `better-sqlite3`
|
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
|
});
|
|
@@ -630,6 +660,24 @@ Full options table:
|
|
|
630
660
|
|
|
631
661
|
## FAQ
|
|
632
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
|
+
|
|
633
681
|
### Why doesn't knowless block disposable email domains?
|
|
634
682
|
|
|
635
683
|
Disposable-domain blocking (mailinator.com, throwaway.email, etc.) is
|
package/README.md
CHANGED
|
@@ -7,198 +7,146 @@ 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.2.
|
|
10
|
+
> v0.2.2 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Where to go next
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
fields, recovery email, federation. Even nominally privacy-focused
|
|
17
|
-
options store enough that a breach is materially harmful.
|
|
14
|
+
Two docs live alongside this README. They serve different readers; pick
|
|
15
|
+
the one that matches yours.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
| You are | Read this | What's there |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **A human integrating for the first time** | [`GUIDE.md`](GUIDE.md) | Step-by-step walkthrough — install, generate the secret, set up Postfix, mount handlers, both modes worked end-to-end. Configuration reference, FAQ, troubleshooting. |
|
|
20
|
+
| **An AI agent, or reading in a hurry** | [`knowless.context.md`](knowless.context.md) | Dense single-file reference. Public API table, every option with defaults, 19 gotchas, lifecycle diagrams, the sham-work pattern, threat model, "what's NOT in knowless and why." Designed to fit one context window. |
|
|
21
|
+
| **Deploying to a real server** | [`OPS.md`](OPS.md) | Postfix install, SPF/DKIM/PTR/DMARC, null-route, systemd, Caddy/nginx/Traefik forward-auth, MailHog dev, fail2ban, multi-process. |
|
|
22
|
+
| **Tracking what changed** | [`CHANGELOG.md`](CHANGELOG.md) | Version history. |
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
The simpler answer that always worked: **magic link in, session
|
|
27
|
+
cookie out, nothing else stored.** Email is HMAC-hashed at the
|
|
21
28
|
boundary and discarded. The library refuses, by API shape, to send
|
|
22
29
|
anything but the sign-in link or store anything identifying.
|
|
23
30
|
|
|
31
|
+
Most auth libraries default to maximum identity collection: full email
|
|
32
|
+
in plaintext, profile fields, recovery email, federation. Even
|
|
33
|
+
nominally privacy-focused options store enough that a breach is
|
|
34
|
+
materially harmful. knowless inverts the default.
|
|
35
|
+
|
|
24
36
|
The thesis: most services have ten layers of auth tooling where they
|
|
25
37
|
need two.
|
|
26
38
|
|
|
27
|
-
##
|
|
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.
|
|
39
|
+
## How it works
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
```
|
|
42
|
+
email → HMAC-SHA256(secret, normalize(email)) → opaque handle
|
|
43
|
+
| |
|
|
44
|
+
v v
|
|
45
|
+
magic-link token (256-bit, single-use) sessions, tokens
|
|
46
|
+
| |
|
|
47
|
+
v v
|
|
48
|
+
submitted via localhost SMTP stored as SHA-256 hashes
|
|
49
|
+
|
|
|
50
|
+
v
|
|
51
|
+
user clicks → handle resolved → signed cookie set
|
|
52
|
+
```
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
- **Plaintext email is never persisted.** Only the salted hash
|
|
55
|
+
(`HMAC-SHA256(secret, normalized_email)`).
|
|
56
|
+
- **Only the magic link is ever sent.** No welcome, no digest, no
|
|
57
|
+
notification. There is no API to send anything else.
|
|
58
|
+
- **All outbound mail goes via your localhost MTA.** No vendor SDKs,
|
|
59
|
+
no API tokens.
|
|
60
|
+
- **Tokens are SHA-256 at rest, single-use, 15-min TTL.** Raw token
|
|
61
|
+
never persisted.
|
|
62
|
+
- **Session cookies are HMAC-signed.** No JWT, no algorithm confusion.
|
|
63
|
+
- **Sham work on every miss.** Unknown emails do the same work as
|
|
64
|
+
registered ones (compose, submit, log) but the SMTP recipient is a
|
|
65
|
+
null-route. Times equivalent within 1ms — measured in CI.
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
- Magic link arrives, click → session cookie
|
|
39
|
-
- Your protected endpoints call `auth.handleFromRequest(req)` to gate
|
|
40
|
-
access
|
|
67
|
+
## Two modes
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
identified user at the moment of the action.
|
|
69
|
+
Same library, two flows. They coexist in one app — pick per action.
|
|
44
70
|
|
|
45
|
-
|
|
71
|
+
- **"Sign in, then do the thing"** — a normal login.
|
|
72
|
+
- **"Do the thing, confirm by email"** — drop a pin, post a comment,
|
|
73
|
+
share a link without an account, and the email confirmation creates
|
|
74
|
+
the account in the background.
|
|
46
75
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
session and "promotes" the deferred resource.
|
|
76
|
+
The same sham-work flow runs underneath either mode, so unknown
|
|
77
|
+
emails, rate-limit hits, and real sends look identical to an external
|
|
78
|
+
observer.
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
resources / anywhere logging in first kills the UX.
|
|
80
|
+
Worked code for both in [`GUIDE.md`](GUIDE.md).
|
|
54
81
|
|
|
55
|
-
|
|
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.
|
|
82
|
+
## Two deployment shapes
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
| Shape | When |
|
|
85
|
+
|---|---|
|
|
86
|
+
| **Library mode** | Mount the five handlers (`login`, `callback`, `verify`, `logout`, `loginForm`) in your existing Node app. |
|
|
87
|
+
| **Standalone server** (`npx knowless-server`) | Forward-auth gateway behind Caddy / nginx / Traefik for self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / Jellyfin / etc. One auth subdomain, SSO across services via the parent-domain cookie. |
|
|
62
88
|
|
|
63
89
|
## What's opinionated (locked by design)
|
|
64
90
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
The library refuses, by API shape, to grow into them.
|
|
91
|
+
Deliberate trade-offs. The library refuses, by API shape, to grow
|
|
92
|
+
into them.
|
|
68
93
|
|
|
69
|
-
- **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend.
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
94
|
+
- **Localhost SMTP only.** No Mailgun / Postmark / SES / Resend.
|
|
95
|
+
- **One mail purpose: the sign-in link.** No `sendNotification()` to
|
|
96
|
+
be tempted by.
|
|
75
97
|
- **Plain-text 7-bit email.** No HTML, no tracking pixels, no
|
|
76
98
|
click-rewriting, no read-receipts.
|
|
77
99
|
- **No OAuth / OIDC / SAML.** Different audience.
|
|
78
100
|
- **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
|
|
79
101
|
library if you need them.
|
|
80
102
|
- **No admin UI.** `sqlite3 knowless.db` is the admin UI.
|
|
81
|
-
- **Hardcoded login form.** No template overrides; fork or live
|
|
82
|
-
|
|
83
|
-
- **No telemetry, analytics, or error reporting.**
|
|
84
|
-
|
|
103
|
+
- **Hardcoded login form.** No template overrides; fork or live with
|
|
104
|
+
it.
|
|
105
|
+
- **No telemetry, analytics, or error reporting.** No phone-home of
|
|
106
|
+
any kind.
|
|
85
107
|
- **Walks away at v1.0.0.** Maintenance mode after that — only
|
|
86
108
|
security fixes.
|
|
87
109
|
|
|
88
|
-
|
|
110
|
+
If any of those break your case, knowless isn't the right tool. Look
|
|
111
|
+
at [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
|
|
112
|
+
or commercial offerings.
|
|
89
113
|
|
|
90
|
-
|
|
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."
|
|
115
|
-
|
|
116
|
-
## Two deployment shapes (one codebase)
|
|
117
|
-
|
|
118
|
-
| Mode | Status | When |
|
|
119
|
-
|---|---|---|
|
|
120
|
-
| **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
|
|
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).
|
|
114
|
+
## Operator commitments
|
|
126
115
|
|
|
127
|
-
|
|
116
|
+
By choosing knowless, you commit to running:
|
|
128
117
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
- **Postfix** (or another MTA) on the same host, outbound-only
|
|
119
|
+
- **SPF, DKIM, PTR** records for your sending domain
|
|
120
|
+
- **Outbound port 25** open (some clouds block it)
|
|
121
|
+
- A **null-route** for the configured `shamRecipient` so silent-miss
|
|
122
|
+
sham mail drops, not bounces
|
|
132
123
|
|
|
133
|
-
-
|
|
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)
|
|
124
|
+
Step-by-step in [`OPS.md`](OPS.md).
|
|
141
125
|
|
|
142
|
-
|
|
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.
|
|
126
|
+
## Threat model — one paragraph
|
|
145
127
|
|
|
146
|
-
|
|
128
|
+
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
129
|
+
plaintext-email exfiltration (none persisted), password reuse (no
|
|
130
|
+
passwords), silent email enumeration via the login form (timing-
|
|
131
|
+
equivalent + same response shape), email-bombing a target (per-handle
|
|
132
|
+
token cap), naive bots (honeypot), account-creation spam (per-IP
|
|
133
|
+
caps), replay attacks (atomic mark-token-used), open redirects
|
|
134
|
+
(`next_url` whitelist), CSRF on POST endpoints (Origin/Referer
|
|
135
|
+
whitelist).
|
|
147
136
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for outbound-only mail
|
|
152
|
-
- Setting up **SPF, DKIM, and PTR** for your sending domain
|
|
153
|
-
- Verifying **outbound port 25** is open (some clouds block it)
|
|
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
|
|
158
|
-
|
|
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.
|
|
164
|
-
|
|
165
|
-
## Documentation
|
|
166
|
-
|
|
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.
|
|
176
|
-
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
|
|
177
|
-
requirements, threat model, decisions log, NO-GO table, audit
|
|
178
|
-
findings backlog.
|
|
179
|
-
- [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
|
|
180
|
-
algorithms, byte layouts (reimplementation-grade).
|
|
181
|
-
|
|
182
|
-
## Threat model (one-paragraph)
|
|
183
|
-
|
|
184
|
-
Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
|
|
185
|
-
|
|
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).
|
|
191
|
-
|
|
192
|
-
**Defends partially:** HMAC-secret-only leak (allows targeted
|
|
193
|
-
existence checks but not session forgery), phishing (no password to
|
|
194
|
-
type into a fake site, but a phished mailbox still receives links).
|
|
137
|
+
**Partially:** HMAC-secret-only leak (allows targeted existence
|
|
138
|
+
checks but not session forgery), phishing (no password to type into a
|
|
139
|
+
fake site, but a phished mailbox still receives links).
|
|
195
140
|
|
|
196
141
|
**Does NOT defend against:** sophisticated bots that bypass the
|
|
197
142
|
honeypot, distributed floods from many IPs, full server compromise,
|
|
198
|
-
compromised email accounts, social engineering, insider threat at
|
|
199
|
-
|
|
200
|
-
rate-limits) belong above the library
|
|
201
|
-
|
|
143
|
+
compromised email accounts, social engineering, insider threat at the
|
|
144
|
+
operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
|
|
145
|
+
rate-limits) belong above the library — patterns in
|
|
146
|
+
[`OPS.md`](OPS.md).
|
|
147
|
+
|
|
148
|
+
Full detail in [`knowless.context.md`](knowless.context.md) §
|
|
149
|
+
"Threat model summary."
|
|
202
150
|
|
|
203
151
|
## Sibling projects
|
|
204
152
|
|
|
@@ -207,14 +155,6 @@ covers the patterns.
|
|
|
207
155
|
- [`gitdone`](https://github.com/hamr0/gitdone) — verified email
|
|
208
156
|
actions via DKIM/SPF inbound
|
|
209
157
|
|
|
210
|
-
## Contributing
|
|
211
|
-
|
|
212
|
-
Issues and PRs welcome at <https://github.com/hamr0/knowless>.
|
|
213
|
-
|
|
214
|
-
Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
|
|
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.
|
|
217
|
-
|
|
218
158
|
## License
|
|
219
159
|
|
|
220
160
|
[Apache 2.0](LICENSE) with [`NOTICE`](NOTICE) preservation. Forks
|
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.2.
|
|
4
|
+
> v0.2.2 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
|
|
5
5
|
|
|
6
6
|
## What this is
|
|
7
7
|
|
|
@@ -160,7 +160,7 @@ const auth = knowless({
|
|
|
160
160
|
| `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
|
|
161
161
|
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
162
162
|
| `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
|
|
163
|
-
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
|
|
163
|
+
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bodyOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bodyOverride` (AF-26, v0.2.2) is a `({url}) => string` template fn that replaces the default body — knowless still composes the URL and validates the rendered output (ASCII, URL on its own line, ≤2048 chars); `bodyFooter` still appends. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
|
|
164
164
|
| `deriveHandle` | (email: string) | string | `HMAC-SHA256(secret, normalize(email))` using the configured secret. Normalizes input (lowercase + trim) so `Alice@X.com` and `alice@x.com` produce the same handle. Match what `startLogin` and `POST /login` compute. AF-7.4 / AF-13. |
|
|
165
165
|
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
166
166
|
| `verifyTransport` | -- | Promise\<true\> | Probe the configured SMTP transport (v0.2.1). Resolves true on success, rejects with the underlying error. Adopters call this explicitly when they want fail-fast on misconfigured SMTP at boot — no auto-on-boot variant by design (k8s readiness probes / docker-compose ordering would fail boot for the wrong reason). AF-20. |
|
|
@@ -178,6 +178,7 @@ import {
|
|
|
178
178
|
composeBody, // pure: build the mail body
|
|
179
179
|
validateSubject, // pure: validate operator-supplied subject
|
|
180
180
|
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
181
|
+
validateBodyOverride, // pure: validate per-call body override (AF-26)
|
|
181
182
|
renderLoginForm, // pure: HTML5 page rendering
|
|
182
183
|
normalize, // pure: email normalization
|
|
183
184
|
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
@@ -363,6 +364,21 @@ transport_maps = hash:/etc/postfix/transport
|
|
|
363
364
|
postmap /etc/postfix/transport && systemctl reload postfix
|
|
364
365
|
```
|
|
365
366
|
|
|
367
|
+
Verify the null-route is actually discarding (one-line operator
|
|
368
|
+
check, no knowless code involved):
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
|
|
372
|
+
journalctl -u postfix --since '1 minute ago' | grep 'discard'
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A `discard:` line in the postfix log confirms the null-route caught
|
|
376
|
+
the message. If you see `relay=` or `delivered`, the
|
|
377
|
+
`transport_maps` entry isn't being applied — re-run `postmap` and
|
|
378
|
+
`systemctl reload postfix`. (No `--check-null-route` CLI in
|
|
379
|
+
knowless: an operator's MTA validation lives with the MTA, not
|
|
380
|
+
inside a walk-away library.)
|
|
381
|
+
|
|
366
382
|
## FR-6: timing equivalence (the testable property)
|
|
367
383
|
|
|
368
384
|
The library ships a CI test (`test/integration/timing.test.js`)
|
|
@@ -619,12 +635,12 @@ rate-limits) belongs above the library.
|
|
|
619
635
|
`console.warn` if it sees `Content-Length > 0` with an empty
|
|
620
636
|
body. AF-7.1.
|
|
621
637
|
|
|
622
|
-
16. **Two adoption modes — Mode B
|
|
623
|
-
(
|
|
624
|
-
Mode A is `auth.startLogin({email, nextUrl,
|
|
625
|
-
"drop a pin, claim by email click" patterns.
|
|
626
|
-
identical 12-step sham-work flow; same FR-6
|
|
627
|
-
per-action, not per-app.
|
|
638
|
+
16. **Two adoption modes — "sign in, then do" (Mode B) and "do
|
|
639
|
+
then confirm by email" (Mode A).** Mode B is the form
|
|
640
|
+
(`auth.login`). Mode A is `auth.startLogin({email, nextUrl,
|
|
641
|
+
sourceIp})` for "drop a pin, claim by email click" patterns.
|
|
642
|
+
Both run the identical 12-step sham-work flow; same FR-6
|
|
643
|
+
guarantee. Pick per-action, not per-app.
|
|
628
644
|
|
|
629
645
|
17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
|
|
630
646
|
64-char lowercase hex string; knowless decodes to 32 raw bytes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
package/src/handlers.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { normalize, deriveHandle } from './handle.js';
|
|
3
3
|
import { issueToken, hashToken } from './token.js';
|
|
4
4
|
import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
5
|
-
import { composeBody, validateSubject } from './mailer.js';
|
|
5
|
+
import { composeBody, validateSubject, validateBodyOverride } from './mailer.js';
|
|
6
6
|
import { renderLoginForm } from './form.js';
|
|
7
7
|
import {
|
|
8
8
|
buildTrustedPeers,
|
|
@@ -256,7 +256,14 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
256
256
|
* handle is null only when the email failed to normalize (programmer
|
|
257
257
|
* bug for startLogin; same-shape silent for /login).
|
|
258
258
|
*/
|
|
259
|
-
async function runSendLink({
|
|
259
|
+
async function runSendLink({
|
|
260
|
+
emailRaw,
|
|
261
|
+
nextRaw,
|
|
262
|
+
sourceIp,
|
|
263
|
+
subject,
|
|
264
|
+
bodyOverride,
|
|
265
|
+
bypassRateLimit = false,
|
|
266
|
+
}) {
|
|
260
267
|
// Step 1: parse + normalize
|
|
261
268
|
let emailNorm;
|
|
262
269
|
try {
|
|
@@ -343,13 +350,31 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
343
350
|
// hammering of a single handle without per-event identity leakage.
|
|
344
351
|
if (evicted > 0) ev.rateLimitHit();
|
|
345
352
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
+
// AF-26: per-call body override for startLogin (Mode A). When
|
|
354
|
+
// provided, the adopter's template function receives the composed
|
|
355
|
+
// magic-link URL and returns the full body text. Same submit path,
|
|
356
|
+
// same sham work, same FR-6 timing equivalence — just lets adopters
|
|
357
|
+
// phrase the body to match per-call subjects (pin confirmation,
|
|
358
|
+
// login, expiry warning). bodyFooter still appends; lastLogin line
|
|
359
|
+
// does not (override is full-content replacement).
|
|
360
|
+
let mailBody;
|
|
361
|
+
if (typeof bodyOverride === 'function') {
|
|
362
|
+
const url = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
|
|
363
|
+
const rendered = bodyOverride({ url });
|
|
364
|
+
validateBodyOverride(rendered, url); // throws on invalid
|
|
365
|
+
mailBody = rendered;
|
|
366
|
+
if (cfg.bodyFooter) {
|
|
367
|
+
mailBody += `\n-- \n${cfg.bodyFooter}\n`;
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
mailBody = composeBody({
|
|
371
|
+
tokenRaw: token.raw,
|
|
372
|
+
baseUrl: cfg.baseUrl,
|
|
373
|
+
linkPath: cfg.linkPath,
|
|
374
|
+
lastLoginAt,
|
|
375
|
+
bodyFooter: cfg.bodyFooter,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
353
378
|
|
|
354
379
|
// AF-9: programmatic callers may override the subject per call
|
|
355
380
|
// (addypin sends confirmation / login / expiry-warning all via
|
|
@@ -454,6 +479,7 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
454
479
|
nextUrl,
|
|
455
480
|
sourceIp = '',
|
|
456
481
|
subjectOverride,
|
|
482
|
+
bodyOverride,
|
|
457
483
|
bypassRateLimit = false,
|
|
458
484
|
} = {}) {
|
|
459
485
|
// Programmer-error guards (AF-7.3). These DO throw; they're not
|
|
@@ -480,11 +506,20 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
480
506
|
validateSubject(subjectOverride); // throws on invalid
|
|
481
507
|
subject = subjectOverride;
|
|
482
508
|
}
|
|
509
|
+
// AF-26: per-call body override. The function is called inside
|
|
510
|
+
// runSendLink with the composed magic-link URL; its return value
|
|
511
|
+
// is validated by validateBodyOverride(). The arg-type check
|
|
512
|
+
// happens here at the API edge so a non-function bodyOverride
|
|
513
|
+
// fails fast, before any token is minted.
|
|
514
|
+
if (bodyOverride !== undefined && bodyOverride !== null && typeof bodyOverride !== 'function') {
|
|
515
|
+
throw new Error('startLogin: bodyOverride must be a function when provided');
|
|
516
|
+
}
|
|
483
517
|
const { handle } = await runSendLink({
|
|
484
518
|
emailRaw: email,
|
|
485
519
|
nextRaw: nextUrl ?? null,
|
|
486
520
|
sourceIp,
|
|
487
521
|
subject,
|
|
522
|
+
bodyOverride,
|
|
488
523
|
bypassRateLimit,
|
|
489
524
|
});
|
|
490
525
|
// Same-shape return: rate-limit / sham / real all collapse here.
|
package/src/index.js
CHANGED
|
@@ -273,7 +273,13 @@ export function knowless(options = {}) {
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
export { createStore } from './store.js';
|
|
276
|
-
export {
|
|
276
|
+
export {
|
|
277
|
+
createMailer,
|
|
278
|
+
composeBody,
|
|
279
|
+
validateSubject,
|
|
280
|
+
validateBodyFooter,
|
|
281
|
+
validateBodyOverride,
|
|
282
|
+
} from './mailer.js';
|
|
277
283
|
export { createHandlers } from './handlers.js';
|
|
278
284
|
export { renderLoginForm } from './form.js';
|
|
279
285
|
export { normalize, deriveHandle, secretBytes } from './handle.js';
|
package/src/mailer.js
CHANGED
|
@@ -139,6 +139,60 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFoot
|
|
|
139
139
|
return body;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Validate a body produced by `startLogin`'s `bodyOverride` template
|
|
144
|
+
* function (AF-26). The override lets adopters phrase the email body
|
|
145
|
+
* to match per-call subjects (pin confirmation, login, etc.) without
|
|
146
|
+
* losing knowless's URL-composition / sham-work / 7bit invariants.
|
|
147
|
+
*
|
|
148
|
+
* Constraints (deliberately strict to preserve the v0.11 POC URL-line
|
|
149
|
+
* invariant — QP soft-breaks WILL break the magic link):
|
|
150
|
+
* - non-empty string
|
|
151
|
+
* - ≤ 2048 chars (operator-side overflow guard)
|
|
152
|
+
* - ASCII only
|
|
153
|
+
* - no CR (LF allowed; defense-in-depth header-injection guard)
|
|
154
|
+
* - the magic-link URL appears EXACTLY ONCE
|
|
155
|
+
* - that occurrence is on its own line (no leading or trailing
|
|
156
|
+
* non-newline characters on the same line)
|
|
157
|
+
*
|
|
158
|
+
* Throws on any violation. Adopter is responsible for the rest of
|
|
159
|
+
* the body content (security advice, expiry hint, etc.); knowless
|
|
160
|
+
* does not enforce semantic content.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} body
|
|
163
|
+
* @param {string} url the magic-link URL knowless composed
|
|
164
|
+
* @returns {void} throws on invalid
|
|
165
|
+
*/
|
|
166
|
+
export function validateBodyOverride(body, url) {
|
|
167
|
+
if (typeof body !== 'string' || body.length === 0) {
|
|
168
|
+
throw new Error('bodyOverride must return a non-empty string');
|
|
169
|
+
}
|
|
170
|
+
if (body.length > 2048) {
|
|
171
|
+
throw new Error('bodyOverride must return ≤ 2048 chars');
|
|
172
|
+
}
|
|
173
|
+
if (!ASCII_RE.test(body)) {
|
|
174
|
+
throw new Error('bodyOverride must return ASCII');
|
|
175
|
+
}
|
|
176
|
+
if (body.includes('\r')) {
|
|
177
|
+
throw new Error('bodyOverride must not contain CR (header-injection defense)');
|
|
178
|
+
}
|
|
179
|
+
const occurrences = body.split(url).length - 1;
|
|
180
|
+
if (occurrences === 0) {
|
|
181
|
+
throw new Error('bodyOverride must include the magic-link URL exactly once');
|
|
182
|
+
}
|
|
183
|
+
if (occurrences > 1) {
|
|
184
|
+
throw new Error('bodyOverride must include the magic-link URL exactly once');
|
|
185
|
+
}
|
|
186
|
+
const lines = body.split('\n');
|
|
187
|
+
const ownLineCount = lines.filter((l) => l === url).length;
|
|
188
|
+
if (ownLineCount !== 1) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'bodyOverride must place the magic-link URL on its own line ' +
|
|
191
|
+
'(preserves the 7bit URL-line invariant; QP soft-breaks would break the link)',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
142
196
|
/**
|
|
143
197
|
* Validate operator-overridden subject per SPEC §12.5.
|
|
144
198
|
* Throws on invalid; warns (returns warnings array) on suspicious-but-allowed.
|