knowless 0.1.3 → 0.1.5
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 +105 -0
- package/GUIDE.md +130 -0
- package/README.md +6 -6
- package/knowless.context.md +56 -7
- package/package.json +1 -1
- package/src/abuse.js +63 -3
- package/src/form.js +6 -1
- package/src/handlers.js +137 -39
- package/src/index.js +15 -0
- package/src/mailer.js +12 -0
- package/src/store.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,111 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
11
|
+
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
12
|
+
test message to `shamRecipient` and confirms the local MTA
|
|
13
|
+
discarded it. Honest answer to "does the operator's null-route
|
|
14
|
+
actually work?" — the library can know what it submitted but
|
|
15
|
+
not what the MTA did, so this is the closest we can get.
|
|
16
|
+
Targeted for v0.2.0.
|
|
17
|
+
|
|
18
|
+
## [0.1.5] — 2026-04-28
|
|
19
|
+
|
|
20
|
+
addypin POC findings round. Adds programmatic magic-link entry
|
|
21
|
+
(unblocks "use first, claim later" UX patterns), one ergonomic
|
|
22
|
+
helper, two safety/diagnostic fixes, and three doc updates.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`auth.startLogin({email, nextUrl?, sourceIp?})`** — programmatic
|
|
27
|
+
entry that runs the same 12-step sham-work flow as `POST /login`
|
|
28
|
+
but skips Origin/honeypot (no browser context). Returns
|
|
29
|
+
`{handle, submitted: true}` — same shape on rate-limit / sham /
|
|
30
|
+
real to preserve FR-6 timing equivalence. Throws only on
|
|
31
|
+
programmer error. SPEC §7.3a. Closes AF-7.3.
|
|
32
|
+
- **`auth.deriveHandle(email)`** — instance method that uses the
|
|
33
|
+
configured secret. Lets adopters compute owner-handles outside
|
|
34
|
+
HTTP context without spreading the secret across modules. Closes
|
|
35
|
+
AF-7.4.
|
|
36
|
+
- **GUIDE.md "Two adoption modes" section** — Mode B (register-
|
|
37
|
+
first, the form) and Mode A (use-first-claim-later, programmatic).
|
|
38
|
+
Both supported, pickable per-action.
|
|
39
|
+
- **GUIDE.md "Constraints / install footprint" section** — direct
|
|
40
|
+
deps, transitive count, deprecation-warning context. Closes AF-7.7.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`devLogMagicLinks` lines now tagged with `cfg.from`** —
|
|
45
|
+
`[knowless dev:auth@app.example.com] magic link: ...`. Disambiguates
|
|
46
|
+
multi-instance dev logs. Closes AF-7.6.
|
|
47
|
+
- **`devLogMagicLinks` + sham + SMTP-fail now prints a one-line
|
|
48
|
+
silent-miss hint** instead of staying silent. Surfaces the
|
|
49
|
+
closed-registration-is-on case that previously cost adopters
|
|
50
|
+
~30min of debugging. Strictly opt-in dev mode. Closes AF-7.2.
|
|
51
|
+
|
|
52
|
+
### Safety / diagnostics
|
|
53
|
+
|
|
54
|
+
- **`createMailer` validates `transportOverride` at startup.** A
|
|
55
|
+
malformed override (e.g. an options bag mistaken for a transport)
|
|
56
|
+
throws fast with a pointed error instead of failing at first
|
|
57
|
+
submission. Closes AF-7.5.
|
|
58
|
+
- **`POST /login` warns once on stderr when `Content-Length > 0`
|
|
59
|
+
but body is empty.** Catches the common non-Express trap of
|
|
60
|
+
mounting `express.urlencoded()` or similar body parsers ahead of
|
|
61
|
+
`auth.login` (which consumes the stream itself). One warning per
|
|
62
|
+
handler instance. Closes AF-7.1.
|
|
63
|
+
|
|
64
|
+
### Documentation
|
|
65
|
+
|
|
66
|
+
- **SPEC §7.3a** specifies the programmatic entry's contract: which
|
|
67
|
+
steps it skips (Origin, honeypot), why FR-6 still holds, and
|
|
68
|
+
what programmer-error throws look like.
|
|
69
|
+
- **GUIDE.md** adds two traps for non-Express integrators (body-
|
|
70
|
+
parser conflict, Origin requirement) and a worked Mode-A example.
|
|
71
|
+
- **knowless.context.md** lists `startLogin` + `deriveHandle` in the
|
|
72
|
+
public API table and adds gotchas 15–16.
|
|
73
|
+
|
|
74
|
+
## [0.1.4] — 2026-04-28
|
|
75
|
+
|
|
76
|
+
First real-world integration release. Bugs and ergonomics surfaced
|
|
77
|
+
by the addypin team's spike on v0.1.3, plus two minor security
|
|
78
|
+
hardenings that fell out of the audit.
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **`auth.revokeSessions(handle)`** — log out everywhere without
|
|
83
|
+
deleting the account. Returns the number of sessions removed.
|
|
84
|
+
Closes AF-6.1.
|
|
85
|
+
- **`devLogMagicLinks: true`** opt-in — when SMTP fails AND this
|
|
86
|
+
flag is set, prints the magic link to stderr so a developer can
|
|
87
|
+
click through. Off by default; never fires for sham (silent-miss)
|
|
88
|
+
submissions; never replaces real SMTP delivery on success. Closes
|
|
89
|
+
AF-6.2.
|
|
90
|
+
- **CIDR support in `trustedProxies`** — accept `10.0.0.0/8`,
|
|
91
|
+
`fd00::/8`, etc. in addition to plain IPs. Uses `node:net`
|
|
92
|
+
`BlockList`, no new dep. Closes AF-6.3.
|
|
93
|
+
|
|
94
|
+
### Security
|
|
95
|
+
|
|
96
|
+
- **CSRF on `POST /logout`.** Origin/Referer validation now mirrors
|
|
97
|
+
`POST /login` (AF-4.3). Without this, a malicious page could
|
|
98
|
+
force-logout an authenticated victim. Closes AF-6.4.
|
|
99
|
+
- **`confirmationMessage` is HTML-escaped before rendering.** The
|
|
100
|
+
message is operator-config (not user input), but a careless
|
|
101
|
+
operator interpolating user data into it would have produced an
|
|
102
|
+
XSS. The whole message is now escaped before `{email}` substitution
|
|
103
|
+
(which was already escaped). Operators who want HTML in the
|
|
104
|
+
confirmation message must pre-render upstream. Closes AF-6.5.
|
|
105
|
+
|
|
106
|
+
### Documentation
|
|
107
|
+
|
|
108
|
+
- **SPEC §10.2** documents the new logout Origin check.
|
|
109
|
+
- **SPEC §7.3 Step 0** adds an explicit "do NOT add a CSRF token
|
|
110
|
+
upstream — the Origin/Referer whitelist IS the CSRF defense"
|
|
111
|
+
note for adopters. Closes AF-6.6.
|
|
112
|
+
- **GUIDE.md** front-matter now leads with the v1.0.0 walks-away
|
|
113
|
+
commitment. Procurement signal: a library that has explicitly
|
|
114
|
+
committed to *not growing* is a different risk profile from a
|
|
115
|
+
typical OSS package. Closes AF-6.7.
|
|
11
116
|
|
|
12
117
|
## [0.1.3] — 2026-04-28
|
|
13
118
|
|
package/GUIDE.md
CHANGED
|
@@ -5,6 +5,32 @@
|
|
|
5
5
|
> For the product philosophy, see
|
|
6
6
|
> [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
|
|
7
7
|
|
|
8
|
+
## Read this first: knowless walks away at v1.0.0
|
|
9
|
+
|
|
10
|
+
knowless commits to a small, audit-able surface and a *closed* feature
|
|
11
|
+
list. v1.0.0 is the **terminal release** for new functionality: only
|
|
12
|
+
security fixes ship after that. There will be no v2.0 with sessions+,
|
|
13
|
+
no plugin system, no second mailer, no SaaS counterpart.
|
|
14
|
+
|
|
15
|
+
What this means for you as an adopter:
|
|
16
|
+
|
|
17
|
+
- **You own integration breadth.** If knowless's defaults don't fit
|
|
18
|
+
exactly, you patch around it (the API is small enough to do this) or
|
|
19
|
+
fork it (Apache 2.0). We won't add a config knob to absorb your
|
|
20
|
+
case.
|
|
21
|
+
- **You can pin and forget.** v1.0.0 will work the same way three
|
|
22
|
+
years later. Security patches will land in v1.x.
|
|
23
|
+
- **Procurement signal.** A library that has explicitly committed to
|
|
24
|
+
*not growing* is a different risk profile from a typical OSS
|
|
25
|
+
package. Most reviews assume "still actively developed" is good;
|
|
26
|
+
for an auth dependency, "still actively developed" is also "still
|
|
27
|
+
changing in ways you'll have to track." knowless inverts that.
|
|
28
|
+
|
|
29
|
+
If you need a kitchen-sink auth library with active feature
|
|
30
|
+
development, this isn't the right tool. See
|
|
31
|
+
[Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
|
|
32
|
+
or commercial offerings.
|
|
33
|
+
|
|
8
34
|
## Who this is for
|
|
9
35
|
|
|
10
36
|
Three audiences, in order of fit:
|
|
@@ -179,6 +205,95 @@ Fastify, Hono, `node:http` — all work. Each handler is a plain
|
|
|
179
205
|
`(req, res) => Promise<void>` function. No framework hooks, no
|
|
180
206
|
middleware injection.
|
|
181
207
|
|
|
208
|
+
#### Trap: do NOT pre-parse the body
|
|
209
|
+
|
|
210
|
+
knowless reads `POST /login`'s body itself. If a body parser
|
|
211
|
+
middleware runs ahead of `auth.login` and consumes the stream,
|
|
212
|
+
knowless sees an empty body and silently null-routes the request
|
|
213
|
+
(as if no email was submitted). Symptoms: form posts return 200,
|
|
214
|
+
no magic link arrives, no error logged.
|
|
215
|
+
|
|
216
|
+
Express works in the example above because `express.urlencoded()`
|
|
217
|
+
is mounted as application middleware but doesn't intercept the
|
|
218
|
+
specific path. **On Hono / Fastify-without-body-plugin / raw
|
|
219
|
+
node:http, mount `auth.login` directly with no body parser in
|
|
220
|
+
front.** Same goes for `auth.logout` (it doesn't read a body, but
|
|
221
|
+
keep the symmetry).
|
|
222
|
+
|
|
223
|
+
knowless will emit a one-time `console.warn(...)` if it sees an
|
|
224
|
+
empty body where `Content-Length > 0` — that's the canary for this
|
|
225
|
+
bug.
|
|
226
|
+
|
|
227
|
+
#### Trap: non-browser callers need an Origin header
|
|
228
|
+
|
|
229
|
+
`POST /login` runs an Origin/Referer whitelist (CSRF defense, see
|
|
230
|
+
the FAQ below). Browsers always send `Origin` on cross-origin
|
|
231
|
+
POSTs, so the form path is fine. **Curl / scripts / server-to-
|
|
232
|
+
server callers must send no Origin header at all** (knowless
|
|
233
|
+
allows browser-absent requests) **or** an Origin matching your
|
|
234
|
+
`cookieDomain`. If you set a foreign Origin, the request silently
|
|
235
|
+
falls through to a sham send. For programmatic callers, prefer
|
|
236
|
+
[`auth.startLogin()`](#mode-a-use-first-claim-later) over POSTing
|
|
237
|
+
the form.
|
|
238
|
+
|
|
239
|
+
### Two adoption modes (Mode A vs Mode B)
|
|
240
|
+
|
|
241
|
+
knowless supports two UX flows out of the box. Pick per-action,
|
|
242
|
+
not per-app — both can coexist.
|
|
243
|
+
|
|
244
|
+
**Mode B — register-first (the default).** User must log in before
|
|
245
|
+
performing the action. Wire `auth.login` / `auth.callback` as
|
|
246
|
+
above; gate your action with `auth.handleFromRequest(req)`. Use
|
|
247
|
+
when the action requires a session (account settings, paid
|
|
248
|
+
features, anything you want tied to an identity at the moment of
|
|
249
|
+
the action).
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
app.post('/api/comments', (req, res) => {
|
|
253
|
+
const handle = auth.handleFromRequest(req);
|
|
254
|
+
if (!handle) return res.status(401).end();
|
|
255
|
+
// create comment owned by `handle`
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Mode A — use-first, claim-later.** User performs the action
|
|
260
|
+
without logging in; you capture their email and trigger a magic
|
|
261
|
+
link. Clicking it opens a session and your callback handler
|
|
262
|
+
"promotes" the deferred resource. Use for "drop a pin," "post a
|
|
263
|
+
share link," "submit a paste" — patterns where forcing a login
|
|
264
|
+
*before* the action would harm the UX.
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
app.post('/api/pins', async (req, res) => {
|
|
268
|
+
const { email, lat, lng } = await readJsonBody(req);
|
|
269
|
+
const owner = auth.deriveHandle(email); // AF-7.4
|
|
270
|
+
await db.insertPendingPin({ owner, lat, lng }); // your code
|
|
271
|
+
await auth.startLogin({ // AF-7.3
|
|
272
|
+
email,
|
|
273
|
+
nextUrl: 'https://app.example.com/manage',
|
|
274
|
+
sourceIp: req.socket.remoteAddress,
|
|
275
|
+
});
|
|
276
|
+
res.status(202).end(); // "we'll email you the link"
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// On callback, promote pending pins owned by the now-logged-in handle.
|
|
280
|
+
app.get('/manage', (req, res) => {
|
|
281
|
+
const owner = auth.handleFromRequest(req);
|
|
282
|
+
if (!owner) return res.redirect('/login');
|
|
283
|
+
// db.promotePendingPinsFor(owner)
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`startLogin` runs the same 12-step sham-work flow as the form
|
|
288
|
+
handler, so unknown emails, rate-limit hits, and real sends all
|
|
289
|
+
return identical shapes — the caller can't observe which happened.
|
|
290
|
+
This preserves FR-6 timing equivalence even for programmatic
|
|
291
|
+
callers. See SPEC §7.3a for the full contract.
|
|
292
|
+
|
|
293
|
+
`auth.deriveHandle(email)` returns the same opaque HMAC handle
|
|
294
|
+
that the form path uses, without you having to import the helper
|
|
295
|
+
or pass the secret around.
|
|
296
|
+
|
|
182
297
|
### Step 5: Pre-seed users (closed-registration mode, default)
|
|
183
298
|
|
|
184
299
|
By default, knowless is closed: a handle must already exist before
|
|
@@ -439,3 +554,18 @@ than weakening the bar — see SPEC §14.5.
|
|
|
439
554
|
Yes: `tokenTtlSeconds`. Don't set it absurdly high. Magic links
|
|
440
555
|
that linger in inboxes are a phishing-amplification risk if the
|
|
441
556
|
mail account is later compromised.
|
|
557
|
+
|
|
558
|
+
## Constraints / install footprint
|
|
559
|
+
|
|
560
|
+
- **Two direct dependencies.** `nodemailer` (SMTP submission) and
|
|
561
|
+
`better-sqlite3` (storage). Both audited and pinned at major
|
|
562
|
+
versions in `package.json`.
|
|
563
|
+
- **~40 transitive packages** in a typical install. The bulk are
|
|
564
|
+
`nodemailer`'s ecosystem deps (mostly idle in our usage) and
|
|
565
|
+
`better-sqlite3`'s build-time prebuild fetcher. You may see one
|
|
566
|
+
deprecation warning during install for `prebuild-install` —
|
|
567
|
+
build-chain noise, not runtime code.
|
|
568
|
+
- **Node ≥ 20.** Uses `node:util parseArgs`, `node:net BlockList`
|
|
569
|
+
for CIDR support, and `--env-file=` for the standalone server.
|
|
570
|
+
- **No optional deps, no postinstall scripts** beyond `better-
|
|
571
|
+
sqlite3`'s native binding fetch.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ that don't need to email their users for anything but the sign-in link.
|
|
|
7
7
|
npm install knowless
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
> v0.1.
|
|
10
|
+
> v0.1.4 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## What this is
|
|
13
13
|
|
|
@@ -83,12 +83,12 @@ A short list (full table in [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
|
|
|
83
83
|
|
|
84
84
|
| Mode | Status | When |
|
|
85
85
|
|---|---|---|
|
|
86
|
-
| **Library mode** | v0.1.0 | Mount handlers in your existing Node app |
|
|
87
|
-
| **Standalone server** (forward-auth) | v0.
|
|
86
|
+
| **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
|
|
87
|
+
| **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. via Caddy or nginx |
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
Library mode is the six-line example above. Standalone server is
|
|
90
|
+
`npx knowless-server` — see [`OPS.md`](OPS.md) for the full Postfix +
|
|
91
|
+
DNS + reverse-proxy walkthrough.
|
|
92
92
|
|
|
93
93
|
## Operator commitments
|
|
94
94
|
|
package/knowless.context.md
CHANGED
|
@@ -76,6 +76,7 @@ const auth = knowless({
|
|
|
76
76
|
// --- Cookie / session ---
|
|
77
77
|
cookieDomain: 'app.example.com', // default: hostname of baseUrl
|
|
78
78
|
cookieName: 'knowless_session', // default 'knowless_session'
|
|
79
|
+
cookieSecure: true, // default true; "false" only for localhost dev
|
|
79
80
|
sessionTtlSeconds: 30 * 86400, // 30 days
|
|
80
81
|
|
|
81
82
|
// --- Token ---
|
|
@@ -97,17 +98,27 @@ const auth = knowless({
|
|
|
97
98
|
// --- Behavior ---
|
|
98
99
|
openRegistration: false, // first-email-wins handle creation
|
|
99
100
|
includeLastLoginInEmail: true, // append "Last sign-in: <ISO ts>" line
|
|
100
|
-
confirmationMessage: 'Thanks. If
|
|
101
|
+
confirmationMessage: 'Thanks. If {email} is registered, a link is on its way.',
|
|
102
|
+
// ^ NOTE: the message is HTML-escaped before render (AF-6.5).
|
|
103
|
+
// {email} placeholder still works. For HTML, pre-render upstream.
|
|
101
104
|
|
|
102
105
|
// --- Abuse defenses (FR-38..41) ---
|
|
103
106
|
maxActiveTokensPerHandle: 5, // 0 to disable
|
|
104
107
|
maxLoginRequestsPerIpPerHour: 30, // 0 to disable
|
|
105
108
|
maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
|
|
106
109
|
honeypotFieldName: 'website',
|
|
107
|
-
|
|
110
|
+
// Plain IPs and/or CIDR ranges (AF-6.3). Useful for k8s/docker/cgnat.
|
|
111
|
+
trustedProxies: ['127.0.0.1', '::1', '10.0.0.0/8'],
|
|
108
112
|
|
|
109
113
|
// --- Lifecycle ---
|
|
110
114
|
sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
|
|
115
|
+
onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
|
|
116
|
+
|
|
117
|
+
// --- Dev mode (AF-6.2) ---
|
|
118
|
+
// When SMTP submission fails AND this flag is true, the magic link
|
|
119
|
+
// is printed to stderr so a developer can click through. Off by
|
|
120
|
+
// default. Never fires for sham (silent-miss) submissions.
|
|
121
|
+
devLogMagicLinks: false,
|
|
111
122
|
|
|
112
123
|
// --- Injection (tests / advanced) ---
|
|
113
124
|
store: undefined, // bring your own store
|
|
@@ -127,7 +138,12 @@ const auth = knowless({
|
|
|
127
138
|
| `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
|
|
128
139
|
| `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
|
|
129
140
|
| `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
|
|
141
|
+
| `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
|
|
130
142
|
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
143
|
+
| `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
|
|
144
|
+
| `startLogin` | ({email, nextUrl?, sourceIp?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. SPEC §7.3a. AF-7.3. |
|
|
145
|
+
| `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
|
|
146
|
+
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
131
147
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
132
148
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
133
149
|
|
|
@@ -372,11 +388,10 @@ rate-limits) belongs above the library.
|
|
|
372
388
|
The library does NOT compute eTLD+1 automatically (would
|
|
373
389
|
require a public-suffix-list dep).
|
|
374
390
|
|
|
375
|
-
5. **`Secure` cookie attribute
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
open question; works in Chrome with `--unsafely-treat-insecure-origin-as-secure`).
|
|
391
|
+
5. **`Secure` cookie attribute toggles via `cookieSecure`.** Default
|
|
392
|
+
is `true`. Set `cookieSecure: false` ONLY for `http://localhost`
|
|
393
|
+
development; the library logs a stderr warning at startup (AF-4.4).
|
|
394
|
+
Production deployments MUST use HTTPS and leave `cookieSecure: true`.
|
|
380
395
|
|
|
381
396
|
6. **Forward-auth needs the parent-domain cookie.** If your auth
|
|
382
397
|
subdomain is `auth.example.com` and protected service is
|
|
@@ -410,6 +425,40 @@ rate-limits) belongs above the library.
|
|
|
410
425
|
so it won't *prevent* exit, but the SQLite handle held by
|
|
411
426
|
`better-sqlite3` will leave a finalizer warning.
|
|
412
427
|
|
|
428
|
+
12. **CSRF defense is the Origin/Referer whitelist, not a token.**
|
|
429
|
+
Modern browsers always emit `Origin` on cross-origin POSTs;
|
|
430
|
+
knowless validates host against `cookieDomain` on POST /login
|
|
431
|
+
AND POST /logout. Browser-absent (curl / programmatic) is
|
|
432
|
+
allowed. **Do NOT add a CSRF token upstream** — the Origin
|
|
433
|
+
check is the defense. SPEC §7.3 Step 0.
|
|
434
|
+
|
|
435
|
+
13. **`confirmationMessage` is plain text + `{email}` placeholder.**
|
|
436
|
+
The whole message is HTML-escaped before render (AF-6.5). If
|
|
437
|
+
you want bold/italic/links in the confirmation copy, pre-render
|
|
438
|
+
the HTML upstream and pass the escaped string — but understand
|
|
439
|
+
you're then responsible for not interpolating user data.
|
|
440
|
+
|
|
441
|
+
14. **`devLogMagicLinks` is opt-in and dev-only.** When set true
|
|
442
|
+
AND SMTP submission fails, the magic link is printed to stderr
|
|
443
|
+
tagged `[knowless dev:<from>]`. Sham (silent-miss) submissions
|
|
444
|
+
print a `silent-miss: ...` hint instead of a link — opt-in dev
|
|
445
|
+
only, since this leaks closed-reg state. Don't enable in
|
|
446
|
+
production.
|
|
447
|
+
|
|
448
|
+
15. **POST /login: don't pre-parse the body.** knowless reads the
|
|
449
|
+
request stream itself. Any framework body parser mounted in
|
|
450
|
+
front of `auth.login` will silently steal the form data and
|
|
451
|
+
null-route the request. knowless emits a one-time
|
|
452
|
+
`console.warn` if it sees `Content-Length > 0` with an empty
|
|
453
|
+
body. AF-7.1.
|
|
454
|
+
|
|
455
|
+
16. **Two adoption modes — Mode B (register-first) and Mode A
|
|
456
|
+
(use-first claim-later).** Mode B is the form (`auth.login`).
|
|
457
|
+
Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
|
|
458
|
+
"drop a pin, claim by email click" patterns. Both run the
|
|
459
|
+
identical 12-step sham-work flow; same FR-6 guarantee. Pick
|
|
460
|
+
per-action, not per-app.
|
|
461
|
+
|
|
413
462
|
## Constraints
|
|
414
463
|
|
|
415
464
|
- **Node 20+** -- targeting LTS; tested on Node 22
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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/abuse.js
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a peer-IP matcher from a list of plain IPs and/or CIDR ranges.
|
|
5
|
+
* AF-6.3 — CIDR support so docker / k8s / cgnat ranges can be trusted
|
|
6
|
+
* without enumerating every address.
|
|
7
|
+
*
|
|
8
|
+
* Accepts:
|
|
9
|
+
* - bare IPv4/IPv6 ("127.0.0.1", "::1")
|
|
10
|
+
* - CIDR ("10.0.0.0/8", "fd00::/8")
|
|
11
|
+
* - a Set or array of either
|
|
12
|
+
* - a `node:net` BlockList (passed through)
|
|
13
|
+
*
|
|
14
|
+
* @param {Set<string>|string[]|net.BlockList} trustedProxies
|
|
15
|
+
* @returns {{ has: (ip: string) => boolean }}
|
|
16
|
+
*/
|
|
17
|
+
export function buildTrustedPeers(trustedProxies) {
|
|
18
|
+
if (trustedProxies && typeof trustedProxies.check === 'function') {
|
|
19
|
+
return { has: (ip) => safeBlockListCheck(trustedProxies, ip) };
|
|
20
|
+
}
|
|
21
|
+
const list = Array.isArray(trustedProxies)
|
|
22
|
+
? trustedProxies
|
|
23
|
+
: trustedProxies instanceof Set
|
|
24
|
+
? [...trustedProxies]
|
|
25
|
+
: [];
|
|
26
|
+
const exact = new Set();
|
|
27
|
+
const block = new net.BlockList();
|
|
28
|
+
for (const entry of list) {
|
|
29
|
+
if (typeof entry !== 'string' || !entry) continue;
|
|
30
|
+
const slash = entry.indexOf('/');
|
|
31
|
+
if (slash >= 0) {
|
|
32
|
+
const addr = entry.slice(0, slash);
|
|
33
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
34
|
+
const family = net.isIPv6(addr) ? 'ipv6' : 'ipv4';
|
|
35
|
+
try {
|
|
36
|
+
block.addSubnet(addr, prefix, family);
|
|
37
|
+
} catch {
|
|
38
|
+
/* skip malformed CIDR rather than crash */
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
exact.add(entry);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
has: (ip) => exact.has(ip) || safeBlockListCheck(block, ip),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeBlockListCheck(block, ip) {
|
|
50
|
+
if (typeof ip !== 'string' || ip.length === 0) return false;
|
|
51
|
+
const family = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null;
|
|
52
|
+
if (!family) return false;
|
|
53
|
+
try {
|
|
54
|
+
return block.check(ip, family);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
1
60
|
/**
|
|
2
61
|
* Determine the source IP of a request per FR-42 and SPEC §7.6.
|
|
3
62
|
*
|
|
@@ -6,19 +65,20 @@
|
|
|
6
65
|
* fall back to the connection's remote address. This prevents IP
|
|
7
66
|
* spoofing from clients while supporting forward-auth deployments.
|
|
8
67
|
*
|
|
68
|
+
* `trustedProxies` accepts plain IPs and CIDR ranges (AF-6.3).
|
|
69
|
+
*
|
|
9
70
|
* @param {{
|
|
10
71
|
* socket?: { remoteAddress?: string },
|
|
11
72
|
* connection?: { remoteAddress?: string },
|
|
12
73
|
* headers?: Record<string, string|string[]|undefined>
|
|
13
74
|
* }} req a node:http request (or shape-compatible)
|
|
14
|
-
* @param {Set<string>|string[]} trustedProxies
|
|
75
|
+
* @param {Set<string>|string[]|net.BlockList} trustedProxies trusted peer IPs / CIDRs
|
|
15
76
|
* @returns {string} the determined IP, or '' if undeterminable
|
|
16
77
|
*/
|
|
17
78
|
export function determineSourceIp(req, trustedProxies) {
|
|
18
79
|
const peer =
|
|
19
80
|
req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
|
|
20
|
-
const trusted =
|
|
21
|
-
trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
|
|
81
|
+
const trusted = buildTrustedPeers(trustedProxies);
|
|
22
82
|
if (!trusted.has(peer)) {
|
|
23
83
|
return peer;
|
|
24
84
|
}
|
package/src/form.js
CHANGED
|
@@ -56,10 +56,15 @@ export function renderLoginForm(args) {
|
|
|
56
56
|
next,
|
|
57
57
|
} = args;
|
|
58
58
|
|
|
59
|
+
// confirmationMessage is operator-supplied config, not user input — but
|
|
60
|
+
// operators may naively interpolate user data into it. Escape the whole
|
|
61
|
+
// message before substituting {email} (which is itself escaped). The
|
|
62
|
+
// contract is "confirmationMessage is plain text + {email} placeholder";
|
|
63
|
+
// operators who want HTML can pre-render upstream. Closes AF-6.5.
|
|
59
64
|
const messageBlock =
|
|
60
65
|
confirmationMessage != null
|
|
61
66
|
? `<div class="msg" role="status">${
|
|
62
|
-
confirmationMessage.replace(
|
|
67
|
+
htmlEscape(confirmationMessage).replace(
|
|
63
68
|
/\{email\}/g,
|
|
64
69
|
htmlEscape(echoedEmail ?? ''),
|
|
65
70
|
)
|
package/src/handlers.js
CHANGED
|
@@ -5,6 +5,7 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
|
5
5
|
import { composeBody } from './mailer.js';
|
|
6
6
|
import { renderLoginForm } from './form.js';
|
|
7
7
|
import {
|
|
8
|
+
buildTrustedPeers,
|
|
8
9
|
determineSourceIp,
|
|
9
10
|
rateLimitExceeded,
|
|
10
11
|
rateLimitIncrement,
|
|
@@ -178,7 +179,23 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
// Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
|
|
183
|
+
const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
|
|
184
|
+
|
|
185
|
+
// AF-7.1: emit at most one warning per handler instance about an
|
|
186
|
+
// upstream body parser swallowing the request body. Loud enough to
|
|
187
|
+
// notice in dev, quiet enough not to spam.
|
|
188
|
+
let emptyBodyWarned = false;
|
|
189
|
+
function warnEmptyBodyOnce() {
|
|
190
|
+
if (emptyBodyWarned) return;
|
|
191
|
+
emptyBodyWarned = true;
|
|
192
|
+
console.warn(
|
|
193
|
+
'[knowless] POST /login received an empty body but Content-Length > 0. ' +
|
|
194
|
+
'A body parser running ahead of this handler likely consumed the stream. ' +
|
|
195
|
+
'knowless reads req itself; do not mount express.urlencoded() / express.json() / ' +
|
|
196
|
+
'similar middleware in front of POST /login. (Warned once per instance.)',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
182
199
|
|
|
183
200
|
// SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
|
|
184
201
|
// emitted by default and omitted only when cookieSecure: false (localhost
|
|
@@ -206,56 +223,46 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
206
223
|
res.end();
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The 12-step sham-work flow, reusable by both POST /login and the
|
|
228
|
+
* programmatic auth.startLogin() entry. Returns {handle, isSham} so
|
|
229
|
+
* the form handler can drive its same-response and the programmatic
|
|
230
|
+
* caller can return the handle to its caller. See SPEC §7.3 (form)
|
|
231
|
+
* and §7.3a (programmatic). AF-7.3.
|
|
232
|
+
*
|
|
233
|
+
* Steps skipped depending on the entry point:
|
|
234
|
+
* - Origin (§7.3 Step 0): form-only; programmatic callers are
|
|
235
|
+
* trusted server-side code.
|
|
236
|
+
* - Honeypot (§7.3 Step 2): form-only; no form context.
|
|
237
|
+
*
|
|
238
|
+
* Both entries run steps 1, 3, 4–12 identically — so the timing-
|
|
239
|
+
* equivalence guarantee (FR-6) holds for either.
|
|
240
|
+
*
|
|
241
|
+
* @returns {Promise<{handle: string|null, isSham: boolean,
|
|
242
|
+
* emailNorm: string, nextValidated: string|null}>}
|
|
243
|
+
* handle is null only when the email failed to normalize (programmer
|
|
244
|
+
* bug for startLogin; same-shape silent for /login).
|
|
245
|
+
*/
|
|
246
|
+
async function runSendLink({ emailRaw, nextRaw, sourceIp }) {
|
|
231
247
|
// Step 1: parse + normalize
|
|
232
248
|
let emailNorm;
|
|
233
249
|
try {
|
|
234
250
|
emailNorm = normalize(emailRaw);
|
|
235
251
|
} catch {
|
|
236
|
-
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Step 2: honeypot — exempt short-circuit (no sham work)
|
|
241
|
-
if (typeof honeypot === 'string' && honeypot.length > 0) {
|
|
242
|
-
sameResponse(res, emailNorm, nextRaw);
|
|
243
|
-
return;
|
|
252
|
+
return { handle: null, isSham: false, emailNorm: emailRaw, nextValidated: null };
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
// Step 3: per-IP rate limit on /login — exempt short-circuit
|
|
247
|
-
const ip = determineSourceIp(req, trustedProxies);
|
|
248
256
|
if (
|
|
249
257
|
rateLimitExceeded(
|
|
250
258
|
store,
|
|
251
259
|
'login_ip',
|
|
252
|
-
|
|
260
|
+
sourceIp,
|
|
253
261
|
cfg.maxLoginRequestsPerIpPerHour,
|
|
254
262
|
HOUR_MS,
|
|
255
263
|
)
|
|
256
264
|
) {
|
|
257
|
-
|
|
258
|
-
return;
|
|
265
|
+
return { handle: null, isSham: false, emailNorm, nextValidated: null };
|
|
259
266
|
}
|
|
260
267
|
|
|
261
268
|
// ---- Equivalent-work region begins (SPEC §7.3 step 4) ----
|
|
@@ -269,7 +276,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
269
276
|
rateLimitExceeded(
|
|
270
277
|
store,
|
|
271
278
|
'create_ip',
|
|
272
|
-
|
|
279
|
+
sourceIp,
|
|
273
280
|
cfg.maxNewHandlesPerIpPerHour,
|
|
274
281
|
HOUR_MS,
|
|
275
282
|
)
|
|
@@ -319,12 +326,93 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
319
326
|
} catch (err) {
|
|
320
327
|
// Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
|
|
321
328
|
console.error('[knowless] mail submit failed:', err.message);
|
|
329
|
+
// AF-6.2: dev-mode fallback. When SMTP is unreachable in local
|
|
330
|
+
// development the operator otherwise has no way to obtain the magic
|
|
331
|
+
// link. Print it to stderr only when explicitly opted in.
|
|
332
|
+
if (cfg.devLogMagicLinks) {
|
|
333
|
+
// AF-7.6: include `from` to disambiguate multi-instance logs.
|
|
334
|
+
const tag = `[knowless dev:${cfg.from}]`;
|
|
335
|
+
if (isSham) {
|
|
336
|
+
// AF-7.2 dev hint: silent-miss is by-design but in dev mode
|
|
337
|
+
// operators repeatedly debug "why no link?" — surface the
|
|
338
|
+
// reason. Only fires on opt-in dev mode + SMTP failure.
|
|
339
|
+
process.stderr.write(
|
|
340
|
+
`${tag} silent-miss: handle for "${emailNorm}" does not exist (openRegistration=${cfg.openRegistration})\n`,
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
|
|
344
|
+
process.stderr.write(`${tag} magic link: ${link}\n`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
rateLimitIncrement(store, 'login_ip',
|
|
325
|
-
if (isCreating) rateLimitIncrement(store, 'create_ip',
|
|
349
|
+
rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
|
|
350
|
+
if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
|
|
326
351
|
|
|
327
|
-
|
|
352
|
+
return { handle, isSham, emailNorm, nextValidated };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function login(req, res) {
|
|
356
|
+
// Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
|
|
357
|
+
if (!validateOrigin(req, cfg.cookieDomain)) {
|
|
358
|
+
sameResponse(res, '', '');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let raw;
|
|
363
|
+
try {
|
|
364
|
+
raw = await readBody(req);
|
|
365
|
+
} catch {
|
|
366
|
+
sameResponse(res, '', '');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// AF-7.1: warn when a body parser ahead of us has consumed the stream.
|
|
370
|
+
// POST /login with Content-Length > 0 but empty raw body is the
|
|
371
|
+
// signature; without this, the request silently null-routes and the
|
|
372
|
+
// adopter loses 30 minutes wondering why magic links never arrive.
|
|
373
|
+
if (raw.length === 0) {
|
|
374
|
+
const cl = Number(req.headers?.['content-length']);
|
|
375
|
+
if (Number.isFinite(cl) && cl > 0) {
|
|
376
|
+
warnEmptyBodyOnce();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const body = parseBody(raw, req.headers['content-type']);
|
|
380
|
+
const emailRaw = typeof body.email === 'string' ? body.email : '';
|
|
381
|
+
const honeypot = body[cfg.honeypotFieldName];
|
|
382
|
+
const nextRaw = body.next;
|
|
383
|
+
|
|
384
|
+
// Step 2: honeypot — exempt short-circuit (no sham work)
|
|
385
|
+
if (typeof honeypot === 'string' && honeypot.length > 0) {
|
|
386
|
+
sameResponse(res, emailRaw, nextRaw);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const sourceIp = determineSourceIp(req, trustedProxies);
|
|
391
|
+
const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
|
|
392
|
+
sameResponse(res, result.emailNorm, result.nextValidated ?? '');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
|
|
396
|
+
// Programmer-error guards (AF-7.3). These DO throw; they're not
|
|
397
|
+
// silent-miss conditions, they're "you called the API wrong."
|
|
398
|
+
if (typeof email !== 'string' || email.length === 0) {
|
|
399
|
+
throw new Error('startLogin: email is required (string)');
|
|
400
|
+
}
|
|
401
|
+
if (nextUrl !== undefined && nextUrl !== null && typeof nextUrl !== 'string') {
|
|
402
|
+
throw new Error('startLogin: nextUrl must be a string when provided');
|
|
403
|
+
}
|
|
404
|
+
if (typeof sourceIp !== 'string') {
|
|
405
|
+
throw new Error('startLogin: sourceIp must be a string when provided');
|
|
406
|
+
}
|
|
407
|
+
const { handle } = await runSendLink({
|
|
408
|
+
emailRaw: email,
|
|
409
|
+
nextRaw: nextUrl ?? null,
|
|
410
|
+
sourceIp,
|
|
411
|
+
});
|
|
412
|
+
// Same-shape return: rate-limit / sham / real all collapse here.
|
|
413
|
+
// `handle` is the HMAC of the normalized email (or null if email
|
|
414
|
+
// was malformed). It leaks nothing about existence per FR-6.
|
|
415
|
+
return { handle, submitted: true };
|
|
328
416
|
}
|
|
329
417
|
|
|
330
418
|
async function callback(req, res) {
|
|
@@ -400,6 +488,15 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
400
488
|
}
|
|
401
489
|
|
|
402
490
|
async function logout(req, res) {
|
|
491
|
+
// CSRF defense — same Origin/Referer check as POST /login (AF-4.3).
|
|
492
|
+
// Without this, a third-party page can force-logout a victim. Closes
|
|
493
|
+
// AF-6.4. Browser-absent (curl/programmatic) is allowed.
|
|
494
|
+
if (!validateOrigin(req, cfg.cookieDomain)) {
|
|
495
|
+
res.statusCode = 403;
|
|
496
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
497
|
+
res.end('forbidden\n');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
403
500
|
const cookie = getCookie(req, cfg.cookieName);
|
|
404
501
|
if (cookie) {
|
|
405
502
|
const sid = verifySessionSignature(cookie, cfg.secret);
|
|
@@ -434,6 +531,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
434
531
|
logout,
|
|
435
532
|
loginForm,
|
|
436
533
|
handleFromRequest,
|
|
534
|
+
startLogin,
|
|
437
535
|
validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
|
|
438
536
|
// exposed for tests
|
|
439
537
|
_config: cfg,
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createStore } from './store.js';
|
|
2
2
|
import { createMailer } from './mailer.js';
|
|
3
3
|
import { createHandlers } from './handlers.js';
|
|
4
|
+
import { deriveHandle as deriveHandleRaw } from './handle.js';
|
|
4
5
|
|
|
5
6
|
/** Default sweeper tick: 5 minutes. Per FR-13. */
|
|
6
7
|
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -137,6 +138,20 @@ export function knowless(options = {}) {
|
|
|
137
138
|
handleFromRequest: handlers.handleFromRequest,
|
|
138
139
|
/** Delete a handle + all tokens + all sessions atomically (FR-37a). */
|
|
139
140
|
deleteHandle: (handle) => store.deleteHandle(handle),
|
|
141
|
+
/** Revoke every session for `handle` without deleting the handle.
|
|
142
|
+
* "Log out everywhere." Returns the number of sessions removed.
|
|
143
|
+
* AF-6.1. */
|
|
144
|
+
revokeSessions: (handle) => store.revokeSessions(handle),
|
|
145
|
+
/** Programmatic magic-link send. Use this for "use first, claim
|
|
146
|
+
* later" UX flows (drop a pin, post a comment, then confirm via
|
|
147
|
+
* email). Returns `{handle, submitted: true}` — same shape on
|
|
148
|
+
* rate-limit / sham / real to preserve FR-6 timing equivalence.
|
|
149
|
+
* See SPEC §7.3a. AF-7.3. */
|
|
150
|
+
startLogin: handlers.startLogin,
|
|
151
|
+
/** Derive the opaque handle for an email using the configured
|
|
152
|
+
* secret. Lets adopters compute owner-handles outside HTTP context
|
|
153
|
+
* without spreading the secret across modules. AF-7.4. */
|
|
154
|
+
deriveHandle: (email) => deriveHandleRaw(email, options.secret),
|
|
140
155
|
/** Effective config (with defaults applied), useful for routing. */
|
|
141
156
|
config: handlers._config,
|
|
142
157
|
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
package/src/mailer.js
CHANGED
|
@@ -151,6 +151,18 @@ export function createMailer(cfg) {
|
|
|
151
151
|
auth: undefined,
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
// AF-7.5: validate the resolved transport at startup. Without this,
|
|
155
|
+
// a malformed transportOverride (or a bare options bag mistaken for a
|
|
156
|
+
// transport) silently constructs something that throws "sendMail is
|
|
157
|
+
// not a function" only at first submission — which in production may
|
|
158
|
+
// be hours later. Fail fast at factory time instead.
|
|
159
|
+
if (typeof transport.sendMail !== 'function') {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'mailer: transport has no sendMail() — if you passed transportOverride, ' +
|
|
162
|
+
'pass the result of nodemailer.createTransport(opts), not an opts bag.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
154
166
|
return {
|
|
155
167
|
async submit({ to, subject, body }) {
|
|
156
168
|
if (typeof to !== 'string' || !ASCII_RE.test(to)) {
|
package/src/store.js
CHANGED
|
@@ -278,6 +278,11 @@ export function createStore(dbPath = ':memory:') {
|
|
|
278
278
|
assertHexHash(sidHash, 'sidHash');
|
|
279
279
|
return stmt.deleteSession.run(sidHash).changes > 0;
|
|
280
280
|
},
|
|
281
|
+
/** Delete every session for `handle`. Returns rows-deleted. AF-6.1. */
|
|
282
|
+
revokeSessions(handle) {
|
|
283
|
+
assertHexHash(handle, 'handle');
|
|
284
|
+
return stmt.deleteHandleSessions.run(handle).changes;
|
|
285
|
+
},
|
|
281
286
|
sweepSessions(now = Date.now()) {
|
|
282
287
|
return stmt.sweepSessions.run(now).changes;
|
|
283
288
|
},
|