knowless 0.1.7 → 0.1.9
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 +73 -0
- package/GUIDE.md +73 -27
- package/OPS.md +97 -0
- package/README.md +1 -1
- package/knowless.context.md +10 -8
- package/package.json +1 -1
- package/src/handlers.js +17 -5
- package/src/index.js +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,80 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
- **Turnkey Docker image** (`knowless/knowless-server:0.2.x`)
|
|
11
|
+
bundling Postfix + null-route + the binary so a self-hoster
|
|
12
|
+
runs `docker compose up` and has a working auth gateway in
|
|
13
|
+
one step. Material UX win for the PRD §4.2 self-hoster
|
|
14
|
+
audience. Targeted for v0.2.0.
|
|
10
15
|
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
16
|
+
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
17
|
+
test message to `shamRecipient` and confirms the local MTA
|
|
18
|
+
discarded it. Targeted for v0.2.0.
|
|
19
|
+
|
|
20
|
+
## [0.1.9] — 2026-04-28
|
|
21
|
+
|
|
22
|
+
addypin manual smoke turned up one real bug, one defaults footgun,
|
|
23
|
+
and one DX gap.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`auth.deriveHandle(email)` now normalizes the email before
|
|
28
|
+
HMAC (AF-13).** Prior versions skipped `normalize()` while
|
|
29
|
+
`auth.startLogin` and `POST /login` ran it — adopters using
|
|
30
|
+
`deriveHandle` to precompute owner-keyed lookups got silent
|
|
31
|
+
handle mismatches whenever email casing varied between
|
|
32
|
+
create-time and click-time. Symptom was "user's records
|
|
33
|
+
disappear after login," which is awful to debug. The bare
|
|
34
|
+
`deriveHandle(emailNormalized, secret)` re-export still
|
|
35
|
+
expects pre-normalized input — that contract is unchanged.
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
- **GUIDE flags the `failureRedirect` Mode-A footgun (AF-14).**
|
|
40
|
+
Adopters running programmatic-only (`startLogin` without
|
|
41
|
+
mounting `loginForm`) hit a default `failureRedirect = /login`
|
|
42
|
+
pointing at a route they don't serve — expired/replayed
|
|
43
|
+
magic-link clicks 302 to a 404. The GUIDE now leads with this
|
|
44
|
+
in the Mode-A walkthrough and adds a callout in the config
|
|
45
|
+
table. Default unchanged to avoid breaking Mode-B users with
|
|
46
|
+
custom paths.
|
|
47
|
+
- **OPS.md §11b — MailHog dev workflow (AF-15).** `docker run
|
|
48
|
+
mailhog/mailhog`, point knowless at port 1025, inspect every
|
|
49
|
+
outgoing mail (including sham submissions) in a UI at port
|
|
50
|
+
8025. Verifies `bodyFooter`, `subjectOverride`, and the
|
|
51
|
+
URL-line-isn't-QP-soft-broken invariant without spinning up
|
|
52
|
+
real Postfix.
|
|
53
|
+
|
|
54
|
+
## [0.1.8] — 2026-04-28
|
|
55
|
+
|
|
56
|
+
addypin round 4 — one small API addition + documentation polish.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **`bypassRateLimit: true` arg on `auth.startLogin` (AF-10).**
|
|
61
|
+
Trusted server-side callers (CLI workers, cron jobs, internal
|
|
62
|
+
services on the same host as the web process) opt out of IP-
|
|
63
|
+
based rate-limit accounting entirely — neither check nor
|
|
64
|
+
increment for the `login_ip` and `create_ip` buckets. The per-
|
|
65
|
+
handle token cap (`maxActiveTokensPerHandle`) is still enforced.
|
|
66
|
+
Solves the "web + CLI sharing 127.0.0.1" starvation problem
|
|
67
|
+
without requiring config divergence between processes. Throws
|
|
68
|
+
on non-boolean. Do NOT plumb this from unauthenticated user
|
|
69
|
+
input.
|
|
70
|
+
|
|
71
|
+
### Documentation
|
|
72
|
+
|
|
73
|
+
- **GUIDE Step 6 rewrite (AF-11).** `auth.handleFromRequest(req)`
|
|
74
|
+
is now front-and-centre as the load-bearing primitive for
|
|
75
|
+
adopter authorization. Worked Express-style examples for
|
|
76
|
+
`requireAuth` middleware and per-handle CRUD gating. Replaces
|
|
77
|
+
the previous "(coming in v0.2.0)" placeholder with the v0.1.1
|
|
78
|
+
reality.
|
|
79
|
+
- **OPS.md §11a "Multi-process deployments" (AF-12).** Half-page
|
|
80
|
+
guide covering when sharing one DB across processes is safe
|
|
81
|
+
(WAL mode, default), sweeper redundancy semantics, rate-limit
|
|
82
|
+
enforcement-vs-accounting under sharing (and why AF-10
|
|
83
|
+
matters), `auth.close()` behavior, and the cross-machine no-go.
|
|
11
84
|
|
|
12
85
|
## [0.1.7] — 2026-04-28
|
|
13
86
|
|
package/GUIDE.md
CHANGED
|
@@ -62,8 +62,8 @@ built-in auth is either missing or weak. The existing alternatives
|
|
|
62
62
|
(Authelia, Authentik, Keycloak, oauth2-proxy) are heavyweight for
|
|
63
63
|
the job: "redirect to login if no cookie, otherwise let through."
|
|
64
64
|
|
|
65
|
-
knowless's standalone server (
|
|
66
|
-
Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
|
|
65
|
+
knowless's standalone server (`bin/knowless-server`, shipped in
|
|
66
|
+
v0.1.3) sits behind Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
|
|
67
67
|
session cookie scoped to the parent eTLD+1, SSO across all your
|
|
68
68
|
services for free.
|
|
69
69
|
|
|
@@ -106,7 +106,7 @@ nothing else*.
|
|
|
106
106
|
- **Walks away at v1.0.0.** Maintenance mode (security patches +
|
|
107
107
|
bug fixes) after that, by design.
|
|
108
108
|
|
|
109
|
-
## Walkthrough: library mode
|
|
109
|
+
## Walkthrough: library mode
|
|
110
110
|
|
|
111
111
|
The shape: import `knowless`, configure it, mount the handlers on
|
|
112
112
|
your HTTP framework.
|
|
@@ -175,7 +175,9 @@ sending domain):
|
|
|
175
175
|
Without all three, Gmail / Outlook will silently drop your auth
|
|
176
176
|
mail. This is the operator commitment knowless asks of you.
|
|
177
177
|
|
|
178
|
-
> Full Postfix walkthrough lives in `OPS.md`
|
|
178
|
+
> Full Postfix walkthrough lives in [`OPS.md`](OPS.md) — Postfix
|
|
179
|
+
> install, null-route, SPF/DKIM/PTR, systemd, reverse-proxy
|
|
180
|
+
> forward-auth examples, multi-process deployments.
|
|
179
181
|
|
|
180
182
|
### Step 4: Mount the handlers
|
|
181
183
|
|
|
@@ -295,7 +297,19 @@ callers. See SPEC §7.3a for the full contract.
|
|
|
295
297
|
|
|
296
298
|
`auth.deriveHandle(email)` returns the same opaque HMAC handle
|
|
297
299
|
that the form path uses, without you having to import the helper
|
|
298
|
-
or pass the secret around.
|
|
300
|
+
or pass the secret around. The instance method **normalizes the
|
|
301
|
+
email** (lowercase, trim) before HMAC (AF-13), so `Alice@X.com`
|
|
302
|
+
and `alice@x.com` produce the same handle — match what the form
|
|
303
|
+
and `startLogin` would compute. The bare `deriveHandle` re-export
|
|
304
|
+
takes pre-normalized input; use the instance method unless you
|
|
305
|
+
have a specific reason to call the lower-level primitive.
|
|
306
|
+
|
|
307
|
+
> **Mode-A heads-up: set `failureRedirect`.** If you only mount
|
|
308
|
+
> `auth.callback` (not `auth.loginForm`), the default
|
|
309
|
+
> `failureRedirect` cascade points at `/login` — a route you
|
|
310
|
+
> don't serve. An expired or replayed magic-link click will 302
|
|
311
|
+
> to a 404. Set `failureRedirect: '/'` (or any route you do
|
|
312
|
+
> serve) when wiring Mode A.
|
|
299
313
|
|
|
300
314
|
### Step 5: Pre-seed users (closed-registration mode, default)
|
|
301
315
|
|
|
@@ -335,25 +349,54 @@ const auth = knowless({ ..., openRegistration: true });
|
|
|
335
349
|
Note that open registration adds a per-IP cap on new handles
|
|
336
350
|
(default 3/hour) to mitigate signup spam.
|
|
337
351
|
|
|
338
|
-
### Step 6: Use sessions in your app
|
|
352
|
+
### Step 6: Use sessions in your app — `auth.handleFromRequest`
|
|
339
353
|
|
|
340
|
-
After `/auth/callback` succeeds, the user has a session cookie.
|
|
341
|
-
|
|
354
|
+
After `/auth/callback` succeeds, the user has a session cookie. To
|
|
355
|
+
gate your own protected endpoints, call `auth.handleFromRequest(req)`:
|
|
356
|
+
it returns the requesting session's opaque handle (64-char hex), or
|
|
357
|
+
`null` when the cookie is missing, malformed, or expired. **This is
|
|
358
|
+
the load-bearing primitive for adopter authorization.** The five
|
|
359
|
+
mounted handlers (`login`, `callback`, `verify`, `logout`, `loginForm`)
|
|
360
|
+
own the auth round-trip; everything *else* in your app uses
|
|
361
|
+
`handleFromRequest`.
|
|
342
362
|
|
|
343
363
|
```js
|
|
364
|
+
// Express-shaped middleware. Same pattern works in Hono / Fastify /
|
|
365
|
+
// node:http — handleFromRequest takes a node-shaped req and returns
|
|
366
|
+
// a string or null synchronously. No async, no DB hop beyond the
|
|
367
|
+
// session lookup.
|
|
344
368
|
function requireAuth(req, res, next) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
369
|
+
const handle = auth.handleFromRequest(req);
|
|
370
|
+
if (!handle) {
|
|
371
|
+
res.statusCode = 401;
|
|
372
|
+
return res.end('unauthorized');
|
|
373
|
+
}
|
|
374
|
+
req.handle = handle;
|
|
350
375
|
next();
|
|
351
376
|
}
|
|
377
|
+
|
|
378
|
+
// Then on every protected endpoint:
|
|
379
|
+
app.get('/api/pins', requireAuth, (req, res) => {
|
|
380
|
+
const pins = db.findPinsByOwner(req.handle); // owner_handle = req.handle
|
|
381
|
+
res.json(pins);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
app.delete('/api/pins/:id', requireAuth, (req, res) => {
|
|
385
|
+
const pin = db.getPin(req.params.id);
|
|
386
|
+
if (pin.owner_handle !== req.handle) {
|
|
387
|
+
res.statusCode = 403;
|
|
388
|
+
return res.end('forbidden');
|
|
389
|
+
}
|
|
390
|
+
db.deletePin(req.params.id);
|
|
391
|
+
res.end();
|
|
392
|
+
});
|
|
352
393
|
```
|
|
353
394
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
395
|
+
The `verify` handler is for **forward-auth deployments** (your
|
|
396
|
+
reverse proxy gates upstreams via `/verify` returning 200/401 +
|
|
397
|
+
`X-User-Handle`). For in-process middleware, prefer
|
|
398
|
+
`handleFromRequest` — same answer, no sub-request round-trip, no
|
|
399
|
+
header parsing.
|
|
357
400
|
|
|
358
401
|
### Step 7: GDPR right-to-erasure
|
|
359
402
|
|
|
@@ -373,11 +416,10 @@ Library doesn't ship a built-in HTTP endpoint for this — operator
|
|
|
373
416
|
chooses the UX (admin CLI, in-app self-service, ticket-driven
|
|
374
417
|
support).
|
|
375
418
|
|
|
376
|
-
## Walkthrough: standalone server mode
|
|
419
|
+
## Walkthrough: standalone server mode
|
|
377
420
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
magic-link login.
|
|
421
|
+
Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
|
|
422
|
+
forward-auth, protect any HTTP service behind magic-link login.
|
|
381
423
|
|
|
382
424
|
The deployment-shape pattern:
|
|
383
425
|
```
|
|
@@ -389,7 +431,9 @@ The deployment-shape pattern:
|
|
|
389
431
|
[Caddy redirects to auth.example.com/login?next=...]
|
|
390
432
|
```
|
|
391
433
|
|
|
392
|
-
Sample Caddyfile (
|
|
434
|
+
Sample Caddyfile (full setup including TLS/ACME + multiple gated
|
|
435
|
+
services lives in [`OPS.md`](OPS.md) §7):
|
|
436
|
+
|
|
393
437
|
```caddy
|
|
394
438
|
auth.example.com {
|
|
395
439
|
reverse_proxy localhost:8080
|
|
@@ -398,7 +442,7 @@ auth.example.com {
|
|
|
398
442
|
kuma.example.com {
|
|
399
443
|
forward_auth localhost:8080 {
|
|
400
444
|
uri /verify
|
|
401
|
-
copy_headers X-
|
|
445
|
+
copy_headers X-Knowless-Handle
|
|
402
446
|
}
|
|
403
447
|
reverse_proxy localhost:3001 # Uptime Kuma
|
|
404
448
|
}
|
|
@@ -406,7 +450,7 @@ kuma.example.com {
|
|
|
406
450
|
adguard.example.com {
|
|
407
451
|
forward_auth localhost:8080 {
|
|
408
452
|
uri /verify
|
|
409
|
-
copy_headers X-
|
|
453
|
+
copy_headers X-Knowless-Handle
|
|
410
454
|
}
|
|
411
455
|
reverse_proxy localhost:3000 # AdGuard Home
|
|
412
456
|
}
|
|
@@ -415,9 +459,11 @@ adguard.example.com {
|
|
|
415
459
|
One auth subdomain, one cookie, SSO across all gated services
|
|
416
460
|
because the cookie is scoped to the parent eTLD+1.
|
|
417
461
|
|
|
418
|
-
|
|
419
|
-
`
|
|
420
|
-
`knowless
|
|
462
|
+
Configuration is via `KNOWLESS_*` env vars — see
|
|
463
|
+
[`config.example.env`](config.example.env) and run
|
|
464
|
+
`knowless-server --help` for the full list. `knowless-server
|
|
465
|
+
--config-check` validates your env, SMTP reachability, and DB
|
|
466
|
+
write access; suitable for systemd `ExecStartPre`.
|
|
421
467
|
|
|
422
468
|
## Configuration reference
|
|
423
469
|
|
|
@@ -450,7 +496,7 @@ Full options table:
|
|
|
450
496
|
| `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
|
|
451
497
|
| `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
|
|
452
498
|
| `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
|
|
453
|
-
| `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. |
|
|
499
|
+
| `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. **Mode-A adopters:** if you don't mount `loginForm`, set this to a route you actually serve (e.g. `/`) — otherwise expired/replayed magic-link clicks 302 to a 404. |
|
|
454
500
|
| `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
|
|
455
501
|
| `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
|
|
456
502
|
|
package/OPS.md
CHANGED
|
@@ -540,6 +540,103 @@ in that order — most spam-folder verdicts trace to one of those four.
|
|
|
540
540
|
|
|
541
541
|
---
|
|
542
542
|
|
|
543
|
+
## 11a. Multi-process deployments (web + worker / CLI)
|
|
544
|
+
|
|
545
|
+
Multiple processes can share one knowless SQLite file. This is a
|
|
546
|
+
common adopter pattern: a long-running web server plus a per-message
|
|
547
|
+
CLI invoked by Postfix's `pipe` transport, or a web server plus a
|
|
548
|
+
cron worker handling 48h reminders. Each process instantiates
|
|
549
|
+
`knowless({...})` against the same `dbPath`.
|
|
550
|
+
|
|
551
|
+
**Why this works.** `better-sqlite3` opens the database in WAL mode
|
|
552
|
+
by default (knowless explicitly sets `journal_mode=WAL` at startup).
|
|
553
|
+
WAL allows multiple readers and one writer concurrently, with the
|
|
554
|
+
OS-level locking semantics needed for cross-process safety. Every
|
|
555
|
+
write goes through a prepared statement under a SQLite transaction,
|
|
556
|
+
so two processes inserting tokens or sessions at the same time can't
|
|
557
|
+
corrupt the table.
|
|
558
|
+
|
|
559
|
+
**What to know about each subsystem under multi-process:**
|
|
560
|
+
|
|
561
|
+
- **Sweeper redundancy is harmless.** Each process runs its own 5-
|
|
562
|
+
minute sweep tick. The DELETE statements are idempotent — once
|
|
563
|
+
one process has deleted a row, the others' DELETEs simply affect
|
|
564
|
+
zero rows. No coordination needed.
|
|
565
|
+
- **Rate-limit rows are shared but enforcement is per-process.**
|
|
566
|
+
All processes read and write the same `rate_limits` table, so
|
|
567
|
+
counter values are consistent. But each process makes its own
|
|
568
|
+
*enforcement* decision against its own configured cap. This
|
|
569
|
+
matters: a CLI worker calling `auth.startLogin` from `127.0.0.1`
|
|
570
|
+
uses the same `login_ip` bucket as web traffic from `127.0.0.1`.
|
|
571
|
+
Trusted CLI callers should pass `bypassRateLimit: true` to
|
|
572
|
+
`startLogin` (AF-10) so they don't starve the shared bucket and
|
|
573
|
+
don't participate in accounting.
|
|
574
|
+
- **`auth.close()` from one process doesn't affect the others.**
|
|
575
|
+
Each process holds its own connection. Closing one is the right
|
|
576
|
+
thing to do at that process's shutdown; the database remains
|
|
577
|
+
open for the others.
|
|
578
|
+
- **Magic-link tokens and session IDs are globally unique.** The
|
|
579
|
+
random-byte primitives have enough entropy that two processes
|
|
580
|
+
minting tokens concurrently won't collide (43-char base64url =
|
|
581
|
+
256 bits).
|
|
582
|
+
|
|
583
|
+
**Don't share the DB across machines.** SQLite WAL only protects
|
|
584
|
+
processes on the same host (it relies on POSIX advisory locks).
|
|
585
|
+
For a multi-host knowless deployment, run a single instance behind
|
|
586
|
+
a load balancer or — better — run one knowless per host, each
|
|
587
|
+
with its own DB. Sessions are per-host but that's usually what you
|
|
588
|
+
want for forward-auth-style deployments anyway.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## 11b. Local development without a real Postfix
|
|
593
|
+
|
|
594
|
+
Spinning up Postfix on a developer laptop just to inspect what
|
|
595
|
+
knowless sends is heavy. Two leaner options:
|
|
596
|
+
|
|
597
|
+
### Option A — `devLogMagicLinks: true` (no MTA needed)
|
|
598
|
+
|
|
599
|
+
knowless already supports this for the URL. When SMTP submission
|
|
600
|
+
fails AND `devLogMagicLinks: true` is set, the magic link is
|
|
601
|
+
printed to stderr tagged `[knowless dev:<from>]`. Sufficient for
|
|
602
|
+
smoke-testing the click flow, not the email content. AF-6.2.
|
|
603
|
+
|
|
604
|
+
### Option B — MailHog (visual UI for the rendered mail)
|
|
605
|
+
|
|
606
|
+
For verifying subject/body/footer rendering or the timing of
|
|
607
|
+
sham-vs-real submissions, run [MailHog](https://github.com/mailhog/MailHog)
|
|
608
|
+
or any compatible test SMTP catcher (e.g. mailpit, smtp4dev) on
|
|
609
|
+
the dev machine and point knowless at it.
|
|
610
|
+
|
|
611
|
+
```sh
|
|
612
|
+
# Docker one-liner for MailHog
|
|
613
|
+
docker run --rm -p 1025:1025 -p 8025:8025 mailhog/mailhog
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Then in your dev knowless config:
|
|
617
|
+
|
|
618
|
+
```js
|
|
619
|
+
const auth = knowless({
|
|
620
|
+
secret: process.env.KNOWLESS_SECRET,
|
|
621
|
+
baseUrl: 'http://localhost:3000',
|
|
622
|
+
from: 'auth@dev.local',
|
|
623
|
+
smtpHost: 'localhost',
|
|
624
|
+
smtpPort: 1025, // MailHog's SMTP port
|
|
625
|
+
cookieSecure: false, // localhost-only dev
|
|
626
|
+
});
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Open `http://localhost:8025` in your browser; every magic-link mail
|
|
630
|
+
(including sham submissions to `null@knowless.invalid`) shows up in
|
|
631
|
+
the inbox UI with full subject/body/headers visible. Perfect for
|
|
632
|
+
verifying `bodyFooter`, `subjectOverride`, the URL line is intact
|
|
633
|
+
without QP soft-breaks, etc.
|
|
634
|
+
|
|
635
|
+
> Don't ship MailHog into production. It accepts mail from anywhere
|
|
636
|
+
> and stores it forever — defeats the entire knowless threat model.
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
543
640
|
## 12. Backup and recovery
|
|
544
641
|
|
|
545
642
|
The only stateful file is the SQLite database (`KNOWLESS_DB_PATH`,
|
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.9 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## What this is
|
|
13
13
|
|
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.1.
|
|
4
|
+
> v0.1.9 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
5
5
|
|
|
6
6
|
## What this is
|
|
7
7
|
|
|
@@ -18,11 +18,13 @@ npm install knowless
|
|
|
18
18
|
|
|
19
19
|
Two integration paths:
|
|
20
20
|
|
|
21
|
-
1. **Library mode
|
|
22
|
-
mount five handlers on Express / Fastify / Hono / `node:http
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
1. **Library mode:** `import { knowless } from 'knowless'` --
|
|
22
|
+
mount five handlers on Express / Fastify / Hono / `node:http`,
|
|
23
|
+
gate your endpoints with `auth.handleFromRequest(req)`.
|
|
24
|
+
2. **Standalone server:** `npx knowless-server` -- forward-auth
|
|
25
|
+
gateway for Caddy / nginx / Traefik in front of no-auth services
|
|
26
|
+
like Uptime Kuma, AdGuard, Pi-hole. Configured via `KNOWLESS_*`
|
|
27
|
+
env vars; see [`OPS.md`](OPS.md) for the full deployment guide.
|
|
26
28
|
|
|
27
29
|
This document is the dense reference. For the why, see
|
|
28
30
|
`docs/01-product/PRD.md`. For the wire formats, see
|
|
@@ -147,8 +149,8 @@ const auth = knowless({
|
|
|
147
149
|
| `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
|
|
148
150
|
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
149
151
|
| `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
|
|
150
|
-
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?}) | 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`
|
|
151
|
-
| `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret.
|
|
152
|
+
| `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. |
|
|
153
|
+
| `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. |
|
|
152
154
|
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
153
155
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
154
156
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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
|
@@ -243,7 +243,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
243
243
|
* handle is null only when the email failed to normalize (programmer
|
|
244
244
|
* bug for startLogin; same-shape silent for /login).
|
|
245
245
|
*/
|
|
246
|
-
async function runSendLink({ emailRaw, nextRaw, sourceIp, subject }) {
|
|
246
|
+
async function runSendLink({ emailRaw, nextRaw, sourceIp, subject, bypassRateLimit = false }) {
|
|
247
247
|
// Step 1: parse + normalize
|
|
248
248
|
let emailNorm;
|
|
249
249
|
try {
|
|
@@ -252,8 +252,13 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
252
252
|
return { handle: null, isSham: false, emailNorm: emailRaw, nextValidated: null };
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
// Step 3: per-IP rate limit on /login — exempt short-circuit
|
|
255
|
+
// Step 3: per-IP rate limit on /login — exempt short-circuit.
|
|
256
|
+
// AF-10: trusted server-side callers (CLI, cron, worker) opt out
|
|
257
|
+
// of IP-based rate-limit accounting entirely — neither check nor
|
|
258
|
+
// increment. Per-handle token cap (insertToken's maxActive) still
|
|
259
|
+
// applies; only the IP buckets are bypassed.
|
|
256
260
|
if (
|
|
261
|
+
!bypassRateLimit &&
|
|
257
262
|
rateLimitExceeded(
|
|
258
263
|
store,
|
|
259
264
|
'login_ip',
|
|
@@ -271,7 +276,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
271
276
|
const exists = store.handleExists(handle);
|
|
272
277
|
let isCreating = !exists && cfg.openRegistration;
|
|
273
278
|
|
|
274
|
-
if (isCreating) {
|
|
279
|
+
if (isCreating && !bypassRateLimit) {
|
|
275
280
|
if (
|
|
276
281
|
rateLimitExceeded(
|
|
277
282
|
store,
|
|
@@ -353,8 +358,10 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
353
358
|
}
|
|
354
359
|
}
|
|
355
360
|
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
if (!bypassRateLimit) {
|
|
362
|
+
rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
|
|
363
|
+
if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
|
|
364
|
+
}
|
|
358
365
|
|
|
359
366
|
return { handle, isSham, emailNorm, nextValidated };
|
|
360
367
|
}
|
|
@@ -404,6 +411,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
404
411
|
nextUrl,
|
|
405
412
|
sourceIp = '',
|
|
406
413
|
subjectOverride,
|
|
414
|
+
bypassRateLimit = false,
|
|
407
415
|
} = {}) {
|
|
408
416
|
// Programmer-error guards (AF-7.3). These DO throw; they're not
|
|
409
417
|
// silent-miss conditions, they're "you called the API wrong."
|
|
@@ -416,6 +424,9 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
416
424
|
if (typeof sourceIp !== 'string') {
|
|
417
425
|
throw new Error('startLogin: sourceIp must be a string when provided');
|
|
418
426
|
}
|
|
427
|
+
if (typeof bypassRateLimit !== 'boolean') {
|
|
428
|
+
throw new Error('startLogin: bypassRateLimit must be a boolean when provided');
|
|
429
|
+
}
|
|
419
430
|
// AF-9: per-call subject override. Validated with the same rules as
|
|
420
431
|
// the factory subject (ASCII, ≤60 chars, no CR/LF). Throws on
|
|
421
432
|
// invalid — same "programmer-error" treatment as other startLogin
|
|
@@ -431,6 +442,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
431
442
|
nextRaw: nextUrl ?? null,
|
|
432
443
|
sourceIp,
|
|
433
444
|
subject,
|
|
445
|
+
bypassRateLimit,
|
|
434
446
|
});
|
|
435
447
|
// Same-shape return: rate-limit / sham / real all collapse here.
|
|
436
448
|
// `handle` is the HMAC of the normalized email (or null if email
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createStore } from './store.js';
|
|
2
2
|
import { createMailer, validateBodyFooter } from './mailer.js';
|
|
3
3
|
import { createHandlers } from './handlers.js';
|
|
4
|
-
import { deriveHandle as deriveHandleRaw } from './handle.js';
|
|
4
|
+
import { deriveHandle as deriveHandleRaw, normalize } from './handle.js';
|
|
5
5
|
|
|
6
6
|
/** Default sweeper tick: 5 minutes. Per FR-13. */
|
|
7
7
|
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -153,9 +153,12 @@ export function knowless(options = {}) {
|
|
|
153
153
|
* See SPEC §7.3a. AF-7.3. */
|
|
154
154
|
startLogin: handlers.startLogin,
|
|
155
155
|
/** Derive the opaque handle for an email using the configured
|
|
156
|
-
* secret.
|
|
157
|
-
*
|
|
158
|
-
|
|
156
|
+
* secret. Normalizes the email first (AF-13) so handles match
|
|
157
|
+
* what `auth.startLogin` and `POST /login` would compute for the
|
|
158
|
+
* same address typed with different casing or surrounding
|
|
159
|
+
* whitespace. Adopters should treat this as the canonical handle
|
|
160
|
+
* derivation. AF-7.4 / AF-13. */
|
|
161
|
+
deriveHandle: (email) => deriveHandleRaw(normalize(email), options.secret),
|
|
159
162
|
/** Effective config (with defaults applied), useful for routing. */
|
|
160
163
|
config: handlers._config,
|
|
161
164
|
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|