knowless 1.0.1 → 1.1.1
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 +70 -0
- package/GUIDE.md +211 -16
- package/README.md +14 -5
- package/knowless.context.md +109 -19
- package/package.json +1 -1
- package/src/index.js +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,12 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
12
12
|
auth+mail layer. ~1,150 LOC of bespoke auth/mail code removed,
|
|
13
13
|
~35 LOC of knowless wiring added (~33× reduction). Drove audit
|
|
14
14
|
findings AF-7 → AF-17 across v0.1.5–v0.1.10.
|
|
15
|
+
- **2026-05-02 — Three adopters in production.** plato (Mode B,
|
|
16
|
+
forum) and gitdone (Mode A, multi-party email workflows) joined
|
|
17
|
+
addypin (Mode A, location sharing). gitdone's pre-merge review
|
|
18
|
+
surfaced the "wrong-shape integration" failure mode (parallel
|
|
19
|
+
tokens table + manual session minting alongside knowless instead
|
|
20
|
+
of `auth.startLogin`). Patched docs in v1.1.1; no API change.
|
|
15
21
|
|
|
16
22
|
## [Unreleased]
|
|
17
23
|
|
|
@@ -22,6 +28,70 @@ v1.0.0 are:
|
|
|
22
28
|
user-visible impact)
|
|
23
29
|
- Bug fixes that don't change the API surface
|
|
24
30
|
- Documentation corrections
|
|
31
|
+
- Helper exports that pull existing mechanism back into the library
|
|
32
|
+
|
|
33
|
+
## [1.1.1] — 2026-05-02
|
|
34
|
+
|
|
35
|
+
Documentation-only release. Adds the wrong-shape-integration
|
|
36
|
+
anti-pattern callout that gitdone's pre-merge review surfaced, and
|
|
37
|
+
records plato + gitdone as the second and third production adopters.
|
|
38
|
+
No code changes.
|
|
39
|
+
|
|
40
|
+
### Documented
|
|
41
|
+
|
|
42
|
+
- `README.md` — replaced "Sibling projects" with an "Adopters"
|
|
43
|
+
section explicitly listing addypin (Mode A), plato (Mode B), and
|
|
44
|
+
gitdone (Mode A) with a pointer that addypin and gitdone are the
|
|
45
|
+
Mode A worked references.
|
|
46
|
+
- `GUIDE.md` § "Two adoption modes" — added an anti-pattern callout
|
|
47
|
+
at the top: if you're considering writing pending rows to a
|
|
48
|
+
custom tokens table or minting your own confirmation links, that's
|
|
49
|
+
Mode A and `auth.startLogin({ subjectOverride, bodyOverride })`
|
|
50
|
+
already does it. Includes a side-by-side wrong-shape vs Mode A
|
|
51
|
+
sketch. Surfaced by gitdone's pre-merge review where a parallel
|
|
52
|
+
activation system was built before the existing Mode A flow was
|
|
53
|
+
noticed.
|
|
54
|
+
- `GUIDE.md` Mode A/B sub-headings — appended the API entrypoint
|
|
55
|
+
(`auth.startLogin` for Mode A, `auth.login` for Mode B) so the
|
|
56
|
+
use-case leads and `startLogin` doesn't read as "login button
|
|
57
|
+
click."
|
|
58
|
+
|
|
59
|
+
## [1.1.0] — 2026-05-02
|
|
60
|
+
|
|
61
|
+
Mailer-contract clarification release. Pulls FR-6 sham-routing
|
|
62
|
+
mechanism back into the library, makes the custom-mailer contract
|
|
63
|
+
explicit, and codifies walk-away scope in the docs.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- `dropShamRecipient(envelope, shamRecipient?)` — exported pure
|
|
68
|
+
helper. Custom mailers call it to no-op sham sends without external
|
|
69
|
+
delivery. Exact-match predicate against the configured
|
|
70
|
+
`shamRecipient` (default `null@knowless.invalid`). No behaviour
|
|
71
|
+
change inside the library; ~10 LOC.
|
|
72
|
+
|
|
73
|
+
### Documented
|
|
74
|
+
|
|
75
|
+
- `knowless.context.md` § "What knowless is and is not" — scope
|
|
76
|
+
doctrine: knowless is the substrate for session-bearing logins, not
|
|
77
|
+
generic confirmation tokens. Adopters with one-shot non-login flows
|
|
78
|
+
keep their own token system.
|
|
79
|
+
- `knowless.context.md` § "Custom mailer contract" — five explicit
|
|
80
|
+
obligations for injected mailers: sham-recipient handling, FR-6
|
|
81
|
+
timing equivalence ownership (≤1ms, mailer's responsibility),
|
|
82
|
+
RFC822 fidelity, `verify()` semantics, `close()` lifecycle.
|
|
83
|
+
- `knowless.context.md` Public API — "Post-callback handle resolution"
|
|
84
|
+
block: no callback hook, `nextUrl` route is the seam.
|
|
85
|
+
- `GUIDE.md` § "Stability commitments under walk-away" — what v1.x
|
|
86
|
+
will and will not accept.
|
|
87
|
+
- `GUIDE.md` § "Custom mailer adapter" — appendix with sendmail-pipe
|
|
88
|
+
skeleton, FR-6 CI smoke-test pattern, and Postfix fallback note.
|
|
89
|
+
- `GUIDE.md` FAQ — custom login form contract, rate-limit defaults
|
|
90
|
+
and response shape, Origin/Referer CSRF failure mode (accurate;
|
|
91
|
+
replaces stale v0.2-era note), SQLite single-process envelope
|
|
92
|
+
(qualitative; no numeric guarantee carried forward).
|
|
93
|
+
|
|
94
|
+
No breaking changes. No behaviour changes inside the library.
|
|
25
95
|
|
|
26
96
|
Feature requests are deflected to PRD §14 NO-GO, to sibling projects,
|
|
27
97
|
or to forking. The library being "done" is a feature.
|
package/GUIDE.md
CHANGED
|
@@ -106,6 +106,27 @@ 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
|
+
### Stability commitments under walk-away
|
|
110
|
+
|
|
111
|
+
v1.x will accept:
|
|
112
|
+
- Documentation corrections and clarifications.
|
|
113
|
+
- Helper exports that pull existing mechanism back into the library
|
|
114
|
+
(where adopters were re-implementing it inline).
|
|
115
|
+
- Bug fixes that don't change the API surface.
|
|
116
|
+
- Security fixes (CVEs in `nodemailer` or `node:sqlite` with
|
|
117
|
+
user-visible impact).
|
|
118
|
+
|
|
119
|
+
v1.x will NOT accept:
|
|
120
|
+
- New flows, new methods on the auth instance, new constructor options.
|
|
121
|
+
- Generalisations toward token-issuance frameworks (see
|
|
122
|
+
[`knowless.context.md`](knowless.context.md) § "What knowless is and
|
|
123
|
+
is not").
|
|
124
|
+
- Per-adopter ergonomic shortcuts that the host can implement in ≤30
|
|
125
|
+
LOC against the existing API.
|
|
126
|
+
|
|
127
|
+
The library being closed is a feature. Forks are encouraged for
|
|
128
|
+
requirements that don't fit.
|
|
129
|
+
|
|
109
130
|
## Walkthrough: library mode
|
|
110
131
|
|
|
111
132
|
The shape: import `knowless`, configure it, mount the handlers on
|
|
@@ -265,7 +286,31 @@ both out of the box; pick per-action, not per-app — they coexist.
|
|
|
265
286
|
The Mode A/B labels are used here and in the CHANGELOG so
|
|
266
287
|
discussions across the docs stay unambiguous.
|
|
267
288
|
|
|
268
|
-
|
|
289
|
+
> ⚠ **Stop before you build a parallel activation system.** If
|
|
290
|
+
> you're considering writing pending rows to a custom tokens table,
|
|
291
|
+
> minting your own confirmation links, or calling into the sessions
|
|
292
|
+
> table directly to mark an account "activated by email" — that is
|
|
293
|
+
> Mode A, and it's already in this library. Use
|
|
294
|
+
> `auth.startLogin({ email, subjectOverride, bodyOverride })` and
|
|
295
|
+
> promote your pending resource in the callback handler. The
|
|
296
|
+
> wrong-shape integration is what every adopter has tried first; the
|
|
297
|
+
> right shape is the worked example below.
|
|
298
|
+
|
|
299
|
+
The wrong shape vs Mode A, side by side:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
WRONG SHAPE MODE A (use auth.startLogin)
|
|
303
|
+
───────────────────────────── ─────────────────────────────
|
|
304
|
+
your_tokens table (none — knowless owns the token)
|
|
305
|
+
your custom email composer subjectOverride + bodyOverride
|
|
306
|
+
your /confirm/:token handler auth.callback (already mounted)
|
|
307
|
+
manual session insert handled by callback
|
|
308
|
+
your duplicate rate-limit code knowless rate-limit applies
|
|
309
|
+
sham-work + timing equivalence
|
|
310
|
+
preserved automatically
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Mode B — "sign in, then do the thing" (register-first, the default, `auth.login`).**
|
|
269
314
|
User must log in before performing the action. Wire `auth.login` /
|
|
270
315
|
`auth.callback` as above; gate your action with
|
|
271
316
|
`auth.handleFromRequest(req)`. Use when the action requires a session
|
|
@@ -280,7 +325,7 @@ app.post('/api/comments', (req, res) => {
|
|
|
280
325
|
});
|
|
281
326
|
```
|
|
282
327
|
|
|
283
|
-
**Mode A — "do the thing, confirm by email" (use-first, claim-later).**
|
|
328
|
+
**Mode A — "do the thing, confirm by email" (use-first, claim-later, `auth.startLogin`).**
|
|
284
329
|
User performs the action without logging in; you capture their email
|
|
285
330
|
and trigger a magic link. Clicking it opens a session and your
|
|
286
331
|
callback handler "promotes" the deferred resource. Use for "drop a
|
|
@@ -574,6 +619,114 @@ boot for the wrong reason. Adopters who want fail-fast call
|
|
|
574
619
|
`verifyTransport()` explicitly; everyone else gets eventually-consistent
|
|
575
620
|
SMTP startup.
|
|
576
621
|
|
|
622
|
+
## Custom mailer adapter (hosts with their own outbound stack)
|
|
623
|
+
|
|
624
|
+
The default mailer submits via nodemailer to localhost:25 (your Postfix).
|
|
625
|
+
If you already run outbound infrastructure with DKIM signing, a
|
|
626
|
+
transactional API, or a sendmail pipe, you can inject a mailer object:
|
|
627
|
+
|
|
628
|
+
```js
|
|
629
|
+
import { knowless, dropShamRecipient } from 'knowless';
|
|
630
|
+
|
|
631
|
+
// Timing-equivalence self-equalisation for subprocess-based mailers.
|
|
632
|
+
// Pre-measure your P95 delivery time, pad real sends to match.
|
|
633
|
+
const TRANSPORT_FLOOR_MS = 8; // example — calibrate for your stack
|
|
634
|
+
|
|
635
|
+
async function padToTransportFloor(startMs) {
|
|
636
|
+
const elapsed = performance.now() - startMs;
|
|
637
|
+
if (elapsed < TRANSPORT_FLOOR_MS) {
|
|
638
|
+
await new Promise(r => setTimeout(r, TRANSPORT_FLOOR_MS - elapsed));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const mailer = {
|
|
643
|
+
async send(envelope, raw) {
|
|
644
|
+
const t0 = performance.now();
|
|
645
|
+
|
|
646
|
+
if (dropShamRecipient(envelope)) {
|
|
647
|
+
// FR-6: no wire bytes, but burn the same wall time real delivery
|
|
648
|
+
// would take so real-vs-sham timing stays within ≤1ms.
|
|
649
|
+
await padToTransportFloor(t0);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Example: pipe to sendmail (local MTA handles DKIM via opendkim milter)
|
|
654
|
+
await new Promise((resolve, reject) => {
|
|
655
|
+
const proc = spawn('sendmail', ['-t', '-oi'], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
656
|
+
let stderr = '';
|
|
657
|
+
proc.stderr.on('data', d => { stderr += d; });
|
|
658
|
+
proc.stdin.write(raw);
|
|
659
|
+
proc.stdin.end();
|
|
660
|
+
proc.on('close', code => {
|
|
661
|
+
if (code === 0) resolve();
|
|
662
|
+
else reject(new Error(`sendmail exited ${code}: ${stderr}`));
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
await padToTransportFloor(t0);
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
async verify() {
|
|
669
|
+
// Optional — called once at factory construction.
|
|
670
|
+
// Throw here to abort startup on misconfigured transport.
|
|
671
|
+
// Example: probe that sendmail is available.
|
|
672
|
+
await execFile('sendmail', ['-bv', 'root']).catch(err => {
|
|
673
|
+
throw new Error(`sendmail probe failed: ${err.message}`);
|
|
674
|
+
});
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
// close() is optional — call yourself on shutdown if you allocated resources.
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const auth = knowless({ secret, baseUrl, from, mailer });
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### Timing-equivalence CI smoke test
|
|
684
|
+
|
|
685
|
+
Add this to your integration tests to verify your adapter meets the
|
|
686
|
+
≤1ms FR-6 bar:
|
|
687
|
+
|
|
688
|
+
```js
|
|
689
|
+
// Run 200 warmup pairs, then 500 timed pairs.
|
|
690
|
+
// Assert |mean(real) - mean(sham)| < 1.0ms.
|
|
691
|
+
const WARMUP = 200, SAMPLES = 500;
|
|
692
|
+
const realTimes = [], shamTimes = [];
|
|
693
|
+
|
|
694
|
+
for (let i = 0; i < WARMUP + SAMPLES; i++) {
|
|
695
|
+
const t0 = performance.now();
|
|
696
|
+
await mailer.send({ to: 'alice@example.com' }, RAW_FIXTURE);
|
|
697
|
+
const tReal = performance.now() - t0;
|
|
698
|
+
|
|
699
|
+
const t1 = performance.now();
|
|
700
|
+
await mailer.send({ to: 'null@knowless.invalid' }, RAW_FIXTURE);
|
|
701
|
+
const tSham = performance.now() - t1;
|
|
702
|
+
|
|
703
|
+
if (i >= WARMUP) { realTimes.push(tReal); shamTimes.push(tSham); }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const mean = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
707
|
+
const delta = Math.abs(mean(realTimes) - mean(shamTimes));
|
|
708
|
+
assert(delta < 1.0, `timing delta ${delta.toFixed(3)}ms exceeds 1ms FR-6 bar`);
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### Postfix transport_maps fallback
|
|
712
|
+
|
|
713
|
+
If you don't want to write a custom mailer, the default nodemailer
|
|
714
|
+
path still works even with opendkim: configure Postfix to sign on the
|
|
715
|
+
way out (MTA-level DKIM via `milter_default_action`). You only need a
|
|
716
|
+
custom mailer if you want to bypass localhost submission entirely.
|
|
717
|
+
|
|
718
|
+
Whichever path you take, ensure the `transport_maps` null-route is in
|
|
719
|
+
place:
|
|
720
|
+
|
|
721
|
+
```
|
|
722
|
+
# /etc/postfix/transport
|
|
723
|
+
knowless.invalid discard:silently dropped by knowless null-route
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
```
|
|
727
|
+
postmap /etc/postfix/transport && systemctl reload postfix
|
|
728
|
+
```
|
|
729
|
+
|
|
577
730
|
## Walkthrough: standalone server mode
|
|
578
731
|
|
|
579
732
|
Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
|
|
@@ -761,11 +914,22 @@ once, back it up safely, never expose it.
|
|
|
761
914
|
|
|
762
915
|
### Can I customise the login HTML?
|
|
763
916
|
|
|
764
|
-
|
|
917
|
+
The built-in form is hardcoded. Operators wanting branding fork the
|
|
765
918
|
project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
|
|
766
919
|
is a slope ("let me put my logo" → "let me theme the page" →
|
|
767
|
-
"let me embed a JS framework"). The hardcoded form refuses to
|
|
768
|
-
|
|
920
|
+
"let me embed a JS framework"). The hardcoded form refuses to drift.
|
|
921
|
+
|
|
922
|
+
However, you can **skip mounting `loginForm`** entirely and serve your
|
|
923
|
+
own form, provided it satisfies the handler contract:
|
|
924
|
+
|
|
925
|
+
- POST to `loginPath` (default `/login`).
|
|
926
|
+
- Include an `email` field in the `application/x-www-form-urlencoded`
|
|
927
|
+
body.
|
|
928
|
+
- Do **not** pre-parse the body upstream — knowless reads the request
|
|
929
|
+
stream itself. A body-parser middleware mounted before `auth.login`
|
|
930
|
+
will silently steal the data (see gotcha #15 in
|
|
931
|
+
[`knowless.context.md`](knowless.context.md)).
|
|
932
|
+
- Optional: include a `next` field for the post-callback redirect URL.
|
|
769
933
|
|
|
770
934
|
### How do I add 2FA / WebAuthn / TOTP?
|
|
771
935
|
|
|
@@ -774,20 +938,51 @@ stop. WebAuthn after login is a different layer.
|
|
|
774
938
|
|
|
775
939
|
### What about CSRF on POST /login?
|
|
776
940
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
941
|
+
Knowless validates the `Origin` (or `Referer`) header against
|
|
942
|
+
`cookieDomain` on both `POST /login` and `POST /logout`.
|
|
943
|
+
|
|
944
|
+
- **Failure mode:** 403 with a plain-text body `"Forbidden"`. No
|
|
945
|
+
redirect, no JSON envelope.
|
|
946
|
+
- **Browser-absent callers** (curl, server-to-server): if neither
|
|
947
|
+
`Origin` nor `Referer` is present, the check passes — the
|
|
948
|
+
browser is the CSRF threat model, not API callers. Programmatic
|
|
949
|
+
callers that do send an `Origin` header must ensure it matches
|
|
950
|
+
`cookieDomain`.
|
|
951
|
+
- **The check is not disable-able.** Do not add an upstream
|
|
952
|
+
exception; the Origin check is the CSRF defence (SPEC §7.3 Step
|
|
953
|
+
0). Do NOT add a separate CSRF token — it would duplicate a
|
|
954
|
+
defence already in place and complicate custom form integration.
|
|
955
|
+
|
|
956
|
+
### What are the rate-limit defaults, and what does a rate-limited response look like?
|
|
957
|
+
|
|
958
|
+
Defaults (all configurable at construction, no live tuning):
|
|
959
|
+
|
|
960
|
+
| Option | Default | Scope |
|
|
961
|
+
|---|---|---|
|
|
962
|
+
| `maxLoginRequestsPerIpPerHour` | 30 | Per source IP |
|
|
963
|
+
| `maxNewHandlesPerIpPerHour` | 3 | Per source IP (open-registration only) |
|
|
964
|
+
| `maxActiveTokensPerHandle` | 5 | Per handle (unexpired, unused) |
|
|
965
|
+
|
|
966
|
+
A rate-limited submission returns **202** with the same HTML body as a
|
|
967
|
+
successful send — identical to the sham and real-send responses. This
|
|
968
|
+
is intentional: distinct status or body would let an attacker enumerate
|
|
969
|
+
which IP is being throttled. Aggregate counts are surfaced via the
|
|
970
|
+
`onSuppressionWindow` hook (v0.2.1); individual rate-limit hits are
|
|
971
|
+
not exposed per-event by design (see [`knowless.context.md`](knowless.context.md)
|
|
972
|
+
§ "Why three hooks, not four").
|
|
783
973
|
|
|
784
974
|
### Can I run multiple instances behind a load balancer?
|
|
785
975
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
976
|
+
The default SQLite store is a single-process embedded engine. Knowless
|
|
977
|
+
is designed for single-process deployments. Contention shows up as
|
|
978
|
+
`SQLITE_BUSY` errors in logs; if you see them, your write rate exceeds
|
|
979
|
+
the single-process envelope. Benchmark in your own environment — the
|
|
980
|
+
answer depends on kernel, filesystem, SQLite version, and Node version,
|
|
981
|
+
and v1.x carries no numeric guarantee across upgrades.
|
|
982
|
+
|
|
983
|
+
For multi-process or multi-node deployments, implement the store
|
|
984
|
+
interface against Postgres, Redis, or any other backend. The interface
|
|
985
|
+
is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §13.
|
|
791
986
|
|
|
792
987
|
### How do I see what's in the store?
|
|
793
988
|
|
package/README.md
CHANGED
|
@@ -148,12 +148,21 @@ rate-limits) belong above the library — patterns in
|
|
|
148
148
|
Full detail in [`knowless.context.md`](knowless.context.md) §
|
|
149
149
|
"Threat model summary."
|
|
150
150
|
|
|
151
|
-
##
|
|
151
|
+
## Adopters
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
- [`
|
|
156
|
-
|
|
153
|
+
Production users of knowless, in adoption order:
|
|
154
|
+
|
|
155
|
+
- [`addypin`](https://github.com/hamr0/addypin) — pin-drop location
|
|
156
|
+
sharing. First knowless adopter; Mode A (drop-pin-then-confirm).
|
|
157
|
+
- [`plato`](https://github.com/hamr0/plato) — forum (Reddit-shaped,
|
|
158
|
+
one fingerprint per site). Mode B (sign-in-then-do).
|
|
159
|
+
- [`gitdone`](https://github.com/hamr0/gitdone) — multi-party email
|
|
160
|
+
workflows verified via DKIM/SPF inbound. Mode A
|
|
161
|
+
(start-workflow-then-confirm).
|
|
162
|
+
|
|
163
|
+
If you're picking knowless up: the addypin and gitdone callsites are
|
|
164
|
+
both Mode A and good worked references for the use-first / claim-later
|
|
165
|
+
shape.
|
|
157
166
|
|
|
158
167
|
## License
|
|
159
168
|
|
package/knowless.context.md
CHANGED
|
@@ -30,6 +30,23 @@ This document is the dense reference. For the why, see
|
|
|
30
30
|
`docs/01-product/PRD.md`. For the wire formats, see
|
|
31
31
|
`docs/02-design/SPEC.md`. For an adopter walkthrough, see `GUIDE.md`.
|
|
32
32
|
|
|
33
|
+
## What knowless is and is not
|
|
34
|
+
|
|
35
|
+
Knowless is the substrate for **session-bearing logins**: prove control
|
|
36
|
+
of an email, get a session cookie, do work under that session. The mint
|
|
37
|
+
path is single-use, short-TTL, and exists to bootstrap the cookie — not
|
|
38
|
+
as a generic confirmation primitive.
|
|
39
|
+
|
|
40
|
+
If you need ad-hoc one-shot confirmation tokens with caller-chosen TTLs
|
|
41
|
+
(event activation that may sit in an inbox over a weekend, email-change
|
|
42
|
+
confirmation, magic-action links that aren't logins), **keep your own
|
|
43
|
+
token system for that step** and use knowless for the session that
|
|
44
|
+
follows. The walk-away release intentionally does not grow toward generic
|
|
45
|
+
token issuance — that's a different library with a different threat
|
|
46
|
+
model.
|
|
47
|
+
|
|
48
|
+
See § "What's NOT in knowless, and why" for the full lens.
|
|
49
|
+
|
|
33
50
|
## Which mode do I need?
|
|
34
51
|
|
|
35
52
|
| Mode | What it does | When to use |
|
|
@@ -175,23 +192,41 @@ const auth = knowless({
|
|
|
175
192
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
176
193
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
177
194
|
|
|
195
|
+
### Post-callback handle resolution
|
|
196
|
+
|
|
197
|
+
There is no callback hook fired by knowless on confirmation. The host's
|
|
198
|
+
`nextUrl` route is the seam. Read the handle there:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
// In your post-callback landing route:
|
|
202
|
+
const handle = auth.handleFromRequest(req);
|
|
203
|
+
if (!handle) return res.writeHead(401).end();
|
|
204
|
+
// Now do host-side work keyed by handle.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This is deliberate: the host knows what "successful login means for me";
|
|
208
|
+
knowless does not. Both `auth.login` (form) and `auth.startLogin`
|
|
209
|
+
(programmatic) land here after the link is clicked — the post-callback
|
|
210
|
+
route is the single seam for all login modes.
|
|
211
|
+
|
|
178
212
|
Re-exports for advanced consumers:
|
|
179
213
|
|
|
180
214
|
```js
|
|
181
215
|
import {
|
|
182
|
-
knowless,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
knowless, // factory
|
|
217
|
+
dropShamRecipient, // pure: sham-address predicate for custom mailers
|
|
218
|
+
createStore, // direct store access (admin scripts)
|
|
219
|
+
createMailer, // direct mailer access
|
|
220
|
+
createHandlers, // bring your own factory wiring
|
|
221
|
+
composeBody, // pure: build the mail body
|
|
222
|
+
validateSubject, // pure: validate operator-supplied subject
|
|
223
|
+
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
189
224
|
validateBodyOverride, // pure: validate per-call body override (AF-26)
|
|
190
|
-
validateFromName,
|
|
191
|
-
renderLoginForm,
|
|
192
|
-
normalize,
|
|
193
|
-
deriveHandle,
|
|
194
|
-
secretBytes,
|
|
225
|
+
validateFromName, // pure: validate operator-supplied From: display name (AF-27)
|
|
226
|
+
renderLoginForm, // pure: HTML5 page rendering
|
|
227
|
+
normalize, // pure: email normalization
|
|
228
|
+
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
229
|
+
secretBytes, // pure: coerce hex string → 32-byte HMAC key
|
|
195
230
|
} from 'knowless';
|
|
196
231
|
```
|
|
197
232
|
|
|
@@ -448,6 +483,57 @@ which requires the operator secret.
|
|
|
448
483
|
[Caddy verifies cookie via /verify, proxies to Uptime Kuma]
|
|
449
484
|
```
|
|
450
485
|
|
|
486
|
+
## Custom mailer contract
|
|
487
|
+
|
|
488
|
+
When you inject `options.mailer`, knowless hands off five obligations.
|
|
489
|
+
The default localhost-SMTP mailer satisfies them all; custom mailers
|
|
490
|
+
must satisfy them deliberately.
|
|
491
|
+
|
|
492
|
+
**1. Sham-recipient handling.** Knowless addresses sham sends (FR-6
|
|
493
|
+
timing-equivalence sends for missed handles, rate-limit hits,
|
|
494
|
+
exempt-handle short-circuits) to `shamRecipient` (default
|
|
495
|
+
`null@knowless.invalid`). Custom mailers MUST drop these without
|
|
496
|
+
external delivery. Use the exported helper:
|
|
497
|
+
|
|
498
|
+
```js
|
|
499
|
+
import { dropShamRecipient } from 'knowless';
|
|
500
|
+
|
|
501
|
+
async function send(envelope, raw) {
|
|
502
|
+
if (dropShamRecipient(envelope)) return; // no wire send, no error
|
|
503
|
+
// ...actually deliver raw
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Forgetting this lets sham mail bounce, distinguishing miss from hit on
|
|
508
|
+
the wire and reopening the enumeration oracle that FR-6 closes. If you
|
|
509
|
+
configured a custom `shamRecipient`, pass it as the second argument:
|
|
510
|
+
`dropShamRecipient(envelope, cfg.shamRecipient)`.
|
|
511
|
+
|
|
512
|
+
**2. Timing equivalence (≤1ms).** Real-vs-sham wire-time difference
|
|
513
|
+
must stay within ≤1ms, measured from the start of the mailer's
|
|
514
|
+
`send(envelope, raw)` call to its returned promise's resolution. The
|
|
515
|
+
host knows its transport's jitter; the library cannot equalise around an
|
|
516
|
+
opaque mailer. Mailers spawning a subprocess per send (e.g. `sendmail`
|
|
517
|
+
pipe) MUST self-equalise: pre-warm the subprocess,
|
|
518
|
+
parallel-spawn-then-discard, or measure-and-pad. See FR-6 above for
|
|
519
|
+
why the bar matters.
|
|
520
|
+
|
|
521
|
+
**3. RFC822 fidelity.** Ship the body knowless composes byte-for-byte.
|
|
522
|
+
No quoted-printable re-encoding, no header rewriting (other than
|
|
523
|
+
envelope routing your transport requires), no soft-break wrapping, no
|
|
524
|
+
charset transcoding. See gotcha #9.
|
|
525
|
+
|
|
526
|
+
**4. `verify()` semantics.** Optional. If present, knowless calls it
|
|
527
|
+
once at factory construction (synchronously awaited before `knowless()`
|
|
528
|
+
resolves). Throwing aborts startup; resolving with any value is success.
|
|
529
|
+
Knowless does not call `verify()` again on a per-send basis. Hosts
|
|
530
|
+
whose transports can fail-after-warm-up should monitor independently.
|
|
531
|
+
|
|
532
|
+
**5. `close()` lifecycle.** Optional. Knowless does not call it on
|
|
533
|
+
normal operation. Hosts that wire it up are responsible for calling it
|
|
534
|
+
on their own shutdown path. Knowless guarantees `close()` is safe to
|
|
535
|
+
call multiple times after the auth instance has stopped issuing sends.
|
|
536
|
+
|
|
451
537
|
## Architecture
|
|
452
538
|
|
|
453
539
|
```
|
|
@@ -568,11 +654,13 @@ rate-limits) belongs above the library.
|
|
|
568
654
|
philosophy. If you can't run Postfix, knowless isn't your
|
|
569
655
|
library.
|
|
570
656
|
|
|
571
|
-
3. **`shamRecipient` MUST be discarded
|
|
572
|
-
is `null@knowless.invalid`.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
`
|
|
657
|
+
3. **`shamRecipient` MUST be discarded without external delivery.**
|
|
658
|
+
Default is `null@knowless.invalid`. With the default
|
|
659
|
+
localhost-SMTP mailer, add the `transport_maps` entry per Postfix
|
|
660
|
+
snippet above. With a custom injected mailer, call
|
|
661
|
+
`dropShamRecipient(envelope)` and no-op the send; forgetting this
|
|
662
|
+
bounces sham mail and reopens the enumeration oracle. See §
|
|
663
|
+
"Custom mailer contract".
|
|
576
664
|
|
|
577
665
|
4. **Cookie domain defaults to baseUrl's hostname.** This is the
|
|
578
666
|
*narrow* default; for SSO across subdomains (forward-auth
|
|
@@ -603,8 +691,10 @@ rate-limits) belongs above the library.
|
|
|
603
691
|
default encoding picks base64 or QP for plain ASCII bodies,
|
|
604
692
|
breaking the magic-link URL with QP soft-breaks (the v0.11
|
|
605
693
|
POC finding). We sidestep by composing the message ourselves
|
|
606
|
-
and using nodemailer only for SMTP. If you
|
|
607
|
-
|
|
694
|
+
and using nodemailer only for SMTP. If you inject a custom
|
|
695
|
+
mailer, ship the body byte-for-byte — no QP re-encoding, no
|
|
696
|
+
soft-break wrapping, no charset transcoding. See § "Custom
|
|
697
|
+
mailer contract" obligation 3.
|
|
608
698
|
|
|
609
699
|
10. **No JavaScript in any HTML page.** The login form, the
|
|
610
700
|
confirmation page, error pages — all static HTML5. Works in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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/index.js
CHANGED
|
@@ -279,6 +279,24 @@ export function knowless(options = {}) {
|
|
|
279
279
|
};
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Returns true if `envelope.to` equals the sham null-route recipient
|
|
284
|
+
* that knowless uses for FR-6 timing-equivalence sends. Custom mailers
|
|
285
|
+
* MUST call this and no-op the wire send when it returns true. The
|
|
286
|
+
* library still records the sham token row; this helper only governs
|
|
287
|
+
* whether to put bytes on the wire.
|
|
288
|
+
*
|
|
289
|
+
* Pass the same `shamRecipient` you configured the auth instance with,
|
|
290
|
+
* or omit to use the default `null@knowless.invalid`.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} envelope Object with a `to` field (the recipient address).
|
|
293
|
+
* @param {string} [shamRecipient] Defaults to `'null@knowless.invalid'`.
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function dropShamRecipient(envelope, shamRecipient = 'null@knowless.invalid') {
|
|
297
|
+
return envelope?.to === shamRecipient;
|
|
298
|
+
}
|
|
299
|
+
|
|
282
300
|
export { createStore } from './store.js';
|
|
283
301
|
export {
|
|
284
302
|
createMailer,
|