knowless 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,198 +7,146 @@ that don't need to email their users for anything but the sign-in link.
7
7
  npm install knowless
8
8
  ```
9
9
 
10
- > v0.2.0 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
10
+ > v0.2.2 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
11
11
 
12
- ## Why this exists
12
+ ## Where to go next
13
13
 
14
- Most auth libraries (Auth0, Clerk, Magic, Firebase Auth) default to
15
- maximum identity collection: full email stored in plaintext, profile
16
- fields, recovery email, federation. Even nominally privacy-focused
17
- options store enough that a breach is materially harmful.
14
+ Two docs live alongside this README. They serve different readers; pick
15
+ the one that matches yours.
18
16
 
19
- knowless is the simpler answer that always worked: **magic link in,
20
- session cookie out, nothing else stored.** Email is HMAC-hashed at the
17
+ | You are | Read this | What's there |
18
+ |---|---|---|
19
+ | **A human integrating for the first time** | [`GUIDE.md`](GUIDE.md) | Step-by-step walkthrough — install, generate the secret, set up Postfix, mount handlers, both modes worked end-to-end. Configuration reference, FAQ, troubleshooting. |
20
+ | **An AI agent, or reading in a hurry** | [`knowless.context.md`](knowless.context.md) | Dense single-file reference. Public API table, every option with defaults, 19 gotchas, lifecycle diagrams, the sham-work pattern, threat model, "what's NOT in knowless and why." Designed to fit one context window. |
21
+ | **Deploying to a real server** | [`OPS.md`](OPS.md) | Postfix install, SPF/DKIM/PTR/DMARC, null-route, systemd, Caddy/nginx/Traefik forward-auth, MailHog dev, fail2ban, multi-process. |
22
+ | **Tracking what changed** | [`CHANGELOG.md`](CHANGELOG.md) | Version history. |
23
+
24
+ ## What it does
25
+
26
+ The simpler answer that always worked: **magic link in, session
27
+ cookie out, nothing else stored.** Email is HMAC-hashed at the
21
28
  boundary and discarded. The library refuses, by API shape, to send
22
29
  anything but the sign-in link or store anything identifying.
23
30
 
31
+ Most auth libraries default to maximum identity collection: full email
32
+ in plaintext, profile fields, recovery email, federation. Even
33
+ nominally privacy-focused options store enough that a breach is
34
+ materially harmful. knowless inverts the default.
35
+
24
36
  The thesis: most services have ten layers of auth tooling where they
25
37
  need two.
26
38
 
27
- ## Two modes — pick per action
28
-
29
- Same library, two flows. They coexist in one app; choose per-endpoint
30
- based on whether forcing a login *before* the action would harm UX.
39
+ ## How it works
31
40
 
32
- ### Mode B — register-first (the form)
41
+ ```
42
+ email → HMAC-SHA256(secret, normalize(email)) → opaque handle
43
+ | |
44
+ v v
45
+ magic-link token (256-bit, single-use) sessions, tokens
46
+ | |
47
+ v v
48
+ submitted via localhost SMTP stored as SHA-256 hashes
49
+ |
50
+ v
51
+ user clicks → handle resolved → signed cookie set
52
+ ```
33
53
 
34
- User must log in before performing the action. Standard "sign in to
35
- continue" flow.
54
+ - **Plaintext email is never persisted.** Only the salted hash
55
+ (`HMAC-SHA256(secret, normalized_email)`).
56
+ - **Only the magic link is ever sent.** No welcome, no digest, no
57
+ notification. There is no API to send anything else.
58
+ - **All outbound mail goes via your localhost MTA.** No vendor SDKs,
59
+ no API tokens.
60
+ - **Tokens are SHA-256 at rest, single-use, 15-min TTL.** Raw token
61
+ never persisted.
62
+ - **Session cookies are HMAC-signed.** No JWT, no algorithm confusion.
63
+ - **Sham work on every miss.** Unknown emails do the same work as
64
+ registered ones (compose, submit, log) but the SMTP recipient is a
65
+ null-route. Times equivalent within 1ms — measured in CI.
36
66
 
37
- - User hits `/login`, types email
38
- - Magic link arrives, click → session cookie
39
- - Your protected endpoints call `auth.handleFromRequest(req)` to gate
40
- access
67
+ ## Two modes
41
68
 
42
- Use for: account settings, paid features, anything that requires an
43
- identified user at the moment of the action.
69
+ Same library, two flows. They coexist in one app — pick per action.
44
70
 
45
- ### Mode Ause-first, claim-later (programmatic)
71
+ - **"Sign in, then do the thing"** a normal login.
72
+ - **"Do the thing, confirm by email"** — drop a pin, post a comment,
73
+ share a link without an account, and the email confirmation creates
74
+ the account in the background.
46
75
 
47
- User performs the action *without* being logged in. You capture their
48
- email along with the action, fire a magic link via
49
- `auth.startLogin({email, nextUrl, ...})`, and clicking it opens a
50
- session and "promotes" the deferred resource.
76
+ The same sham-work flow runs underneath either mode, so unknown
77
+ emails, rate-limit hits, and real sends look identical to an external
78
+ observer.
51
79
 
52
- Use for: drop-a-pin / submit-a-paste / share-a-link / disposable
53
- resources / anywhere logging in first kills the UX.
80
+ Worked code for both in [`GUIDE.md`](GUIDE.md).
54
81
 
55
- The same 12-step sham-work flow runs underneath either mode, so
56
- unknown emails, rate-limit hits, and real sends look identical to an
57
- external observer (the FR-6 timing-equivalence guarantee). Pick per
58
- action; the two coexist.
82
+ ## Two deployment shapes
59
83
 
60
- Worked code for both modes is in [`GUIDE.md`](GUIDE.md). The dense
61
- API reference is [`knowless.context.md`](knowless.context.md).
84
+ | Shape | When |
85
+ |---|---|
86
+ | **Library mode** | Mount the five handlers (`login`, `callback`, `verify`, `logout`, `loginForm`) in your existing Node app. |
87
+ | **Standalone server** (`npx knowless-server`) | Forward-auth gateway behind Caddy / nginx / Traefik for self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / Jellyfin / etc. One auth subdomain, SSO across services via the parent-domain cookie. |
62
88
 
63
89
  ## What's opinionated (locked by design)
64
90
 
65
- These are deliberate trade-offs, documented as `NO-GO` in
66
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §14.
67
- The library refuses, by API shape, to grow into them.
91
+ Deliberate trade-offs. The library refuses, by API shape, to grow
92
+ into them.
68
93
 
69
- - **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend. The
70
- operator runs Postfix (or another MTA) on the same host, in
71
- outbound-only mode.
72
- - **One mail purpose: the sign-in link.** No welcome message, no
73
- digest, no notification. There is no `sendNotification()` to be
74
- tempted by.
94
+ - **Localhost SMTP only.** No Mailgun / Postmark / SES / Resend.
95
+ - **One mail purpose: the sign-in link.** No `sendNotification()` to
96
+ be tempted by.
75
97
  - **Plain-text 7-bit email.** No HTML, no tracking pixels, no
76
98
  click-rewriting, no read-receipts.
77
99
  - **No OAuth / OIDC / SAML.** Different audience.
78
100
  - **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
79
101
  library if you need them.
80
102
  - **No admin UI.** `sqlite3 knowless.db` is the admin UI.
81
- - **Hardcoded login form.** No template overrides; fork or live
82
- with it.
83
- - **No telemetry, analytics, or error reporting.** Self-hostable end
84
- to end. No phone-home of any kind.
103
+ - **Hardcoded login form.** No template overrides; fork or live with
104
+ it.
105
+ - **No telemetry, analytics, or error reporting.** No phone-home of
106
+ any kind.
85
107
  - **Walks away at v1.0.0.** Maintenance mode after that — only
86
108
  security fixes.
87
109
 
88
- ## What's swappable
110
+ If any of those break your case, knowless isn't the right tool. Look
111
+ at [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
112
+ or commercial offerings.
89
113
 
90
- Everything that *isn't* identity-shape or threat-model essential is
91
- config or injection.
92
-
93
- | Knob | Default | Common reasons to change |
94
- |---|---|---|
95
- | `dbPath` | `./knowless.db` | Move to `/var/lib/knowless/...` for systemd; share across processes |
96
- | `smtpHost`, `smtpPort` | `localhost`, `25` | Point at MailHog (`localhost:1025`) for dev mail inspection |
97
- | `cookieDomain` | hostname of `baseUrl` | Set to your eTLD+1 for SSO across subdomains |
98
- | `cookieSecure` | `true` | `false` only for `http://localhost` dev (logs a warning) |
99
- | `tokenTtlSeconds`, `sessionTtlSeconds` | `900`, `2592000` | Tighten for high-security uses; loosen at your peril |
100
- | `openRegistration` | `false` | `true` to let any new email auto-register on first link |
101
- | `subject` | `Sign in` | Match your brand; per-call override on `startLogin` (`subjectOverride`) |
102
- | `bodyFooter` | none | Append a constant brand/legal/feedback line to every magic-link mail |
103
- | `confirmationMessage` | (default copy) | Replace the post-submit "we'll email you" text |
104
- | `maxLoginRequestsPerIpPerHour`, `maxNewHandlesPerIpPerHour` | `30`, `3` | Raise for genuinely shared NATs; `0` to disable in dev |
105
- | `trustedProxies` | `[127.0.0.1, ::1]` | Plain IPs **and** CIDRs (`10.0.0.0/8`) for k8s/docker/cgnat |
106
- | `bypassRateLimit` (per-call) | `false` | Trusted CLI/cron callers via `auth.startLogin` |
107
- | `store` | built-in `node:sqlite` | Inject your own store (Postgres, etc.) |
108
- | `mailer` | built-in nodemailer | Inject your own mailer |
109
- | `transportOverride` | none | Pass a custom `nodemailer.createTransport` |
110
- | `onSweepError(err)` | none | Operator alerting hook for sweeper failures |
111
- | `devLogMagicLinks` | `false` | `true` in dev: print magic-link URLs (or silent-miss hints) to stderr when SMTP fails |
112
-
113
- Full table with defaults, types, and validation rules:
114
- [`GUIDE.md`](GUIDE.md) → "Configuration reference."
115
-
116
- ## Two deployment shapes (one codebase)
117
-
118
- | Mode | Status | When |
119
- |---|---|---|
120
- | **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
121
- | **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. behind Caddy / nginx / Traefik |
122
-
123
- Library mode is the six-line example in [`GUIDE.md`](GUIDE.md).
124
- Standalone server is `npx knowless-server` — full Postfix + DNS +
125
- reverse-proxy walkthrough in [`OPS.md`](OPS.md).
114
+ ## Operator commitments
126
115
 
127
- ## First customer: addypin
116
+ By choosing knowless, you commit to running:
128
117
 
129
- [`addypin`](https://github.com/hamr0/addypin) location-sharing
130
- service in the same hermit-architecture lineage adopted knowless
131
- as its auth+mail layer. The integration delta:
118
+ - **Postfix** (or another MTA) on the same host, outbound-only
119
+ - **SPF, DKIM, PTR** records for your sending domain
120
+ - **Outbound port 25** open (some clouds block it)
121
+ - A **null-route** for the configured `shamRecipient` so silent-miss
122
+ sham mail drops, not bounces
132
123
 
133
- - **~1,150 lines of bespoke auth/mail code removed** (custom mailer,
134
- inbound CLI, login plumbing, pin-confirmation state machine, email
135
- fingerprinting helpers, the matching test files)
136
- - **~35 lines of knowless wiring added**
137
- - **~33× reduction** on the auth/mail surface
138
- - **One production dep** (`nodemailer` only; v0.2.0 dropped
139
- `better-sqlite3` for `node:sqlite`, the stdlib SQLite driver — no
140
- C++ toolchain, no native compile, ~40 transitive packages → 2)
124
+ Step-by-step in [`OPS.md`](OPS.md).
141
125
 
142
- The integration round produced the audit findings AF-7 through AF-17
143
- that drove v0.1.5 → v0.1.10. See [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
144
- §17 for the full backlog.
126
+ ## Threat model one paragraph
145
127
 
146
- ## Operator commitments
128
+ **Defends well:** DB-only leaks (handles are HMAC-salted),
129
+ plaintext-email exfiltration (none persisted), password reuse (no
130
+ passwords), silent email enumeration via the login form (timing-
131
+ equivalent + same response shape), email-bombing a target (per-handle
132
+ token cap), naive bots (honeypot), account-creation spam (per-IP
133
+ caps), replay attacks (atomic mark-token-used), open redirects
134
+ (`next_url` whitelist), CSRF on POST endpoints (Origin/Referer
135
+ whitelist).
147
136
 
148
- By choosing knowless, you commit to:
149
-
150
- - Running your own server with **Postfix** (or another MTA) installed
151
- for outbound-only mail
152
- - Setting up **SPF, DKIM, and PTR** for your sending domain
153
- - Verifying **outbound port 25** is open (some clouds block it)
154
- - A **null-route entry** for the configured `shamRecipient` so
155
- silent-miss sham mail is dropped, not bounced
156
- - Accepting that the magic link is the **only email** your service
157
- ever sends
158
-
159
- Step-by-step in [`OPS.md`](OPS.md): Postfix install, null-route,
160
- SPF/DKIM/PTR/DMARC, systemd unit, Caddy / nginx / Traefik
161
- forward-auth examples, Tailscale pattern, reverse-proxy rate
162
- limiting, fail2ban / Turnstile, multi-process deployments, MailHog
163
- dev workflow, backups.
164
-
165
- ## Documentation
166
-
167
- - [**`GUIDE.md`**](GUIDE.md) — start here. Adopter walkthrough,
168
- install, six-line example, both modes worked end-to-end,
169
- configuration reference, FAQ, troubleshooting.
170
- - [**`knowless.context.md`**](knowless.context.md) — dense reference
171
- for AI agents and humans-in-a-hurry. Public API table, all options,
172
- 18 gotchas, lifecycles, the sham-work pattern, threat model
173
- summary.
174
- - [`OPS.md`](OPS.md) — operator setup, fresh VPS to working forward-auth.
175
- - [`CHANGELOG.md`](CHANGELOG.md) — version history.
176
- - [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
177
- requirements, threat model, decisions log, NO-GO table, audit
178
- findings backlog.
179
- - [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
180
- algorithms, byte layouts (reimplementation-grade).
181
-
182
- ## Threat model (one-paragraph)
183
-
184
- Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
185
-
186
- **Defends well:** DB-only leaks, plaintext-email exfiltration, password
187
- reuse / credential stuffing, silent email enumeration (timing-
188
- equivalent within 1ms locally), email-bombing a target, naive bot
189
- traffic, account-creation spam, replay attacks, open redirects, CSRF
190
- on `POST /login` / `POST /logout` (Origin/Referer whitelist).
191
-
192
- **Defends partially:** HMAC-secret-only leak (allows targeted
193
- existence checks but not session forgery), phishing (no password to
194
- type into a fake site, but a phished mailbox still receives links).
137
+ **Partially:** HMAC-secret-only leak (allows targeted existence
138
+ checks but not session forgery), phishing (no password to type into a
139
+ fake site, but a phished mailbox still receives links).
195
140
 
196
141
  **Does NOT defend against:** sophisticated bots that bypass the
197
142
  honeypot, distributed floods from many IPs, full server compromise,
198
- compromised email accounts, social engineering, insider threat at
199
- the operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
200
- rate-limits) belong above the library; [`OPS.md`](OPS.md) §9–§10
201
- covers the patterns.
143
+ compromised email accounts, social engineering, insider threat at the
144
+ operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
145
+ rate-limits) belong above the library patterns in
146
+ [`OPS.md`](OPS.md).
147
+
148
+ Full detail in [`knowless.context.md`](knowless.context.md) §
149
+ "Threat model summary."
202
150
 
203
151
  ## Sibling projects
204
152
 
@@ -207,14 +155,6 @@ covers the patterns.
207
155
  - [`gitdone`](https://github.com/hamr0/gitdone) — verified email
208
156
  actions via DKIM/SPF inbound
209
157
 
210
- ## Contributing
211
-
212
- Issues and PRs welcome at <https://github.com/hamr0/knowless>.
213
-
214
- Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
215
- v1.0.0 ships will be deflected to the [§14 NO-GO table](docs/01-product/PRD.md)
216
- or to sibling projects. The library being "done" is a feature.
217
-
218
158
  ## License
219
159
 
220
160
  [Apache 2.0](LICENSE) with [`NOTICE`](NOTICE) preservation. Forks
@@ -1,7 +1,7 @@
1
1
  # knowless -- Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring knowless into a project.
4
- > v0.2.0 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
4
+ > v0.2.2 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
@@ -122,6 +122,17 @@ const auth = knowless({
122
122
  sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
123
123
  onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
124
124
 
125
+ // --- Operator visibility (v0.2.1, all opt-in) ---
126
+ // Per-event hooks. Errors swallowed (matches onSweepError contract).
127
+ onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
128
+ onTransportFailure: ({error, timestamp}) => { /* */ },
129
+ // Heartbeat aggregate. Default 60s; emits even when both counters
130
+ // are zero. See "Operator visibility" section for the threat-model
131
+ // reasoning behind aggregating sham + rate-limit branches here
132
+ // rather than emitting per-event.
133
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
134
+ suppressionWindowMs: 60_000,
135
+
125
136
  // --- Dev mode (AF-6.2) ---
126
137
  // When SMTP submission fails AND this flag is true, the magic link
127
138
  // is printed to stderr so a developer can click through. Off by
@@ -149,9 +160,10 @@ const auth = knowless({
149
160
  | `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
150
161
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
151
162
  | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
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. |
163
+ | `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bodyOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bodyOverride` (AF-26, v0.2.2) is a `({url}) => string` template fn that replaces the default body — knowless still composes the URL and validates the rendered output (ASCII, URL on its own line, ≤2048 chars); `bodyFooter` still appends. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
153
164
  | `deriveHandle` | (email: string) | string | `HMAC-SHA256(secret, normalize(email))` using the configured secret. Normalizes input (lowercase + trim) so `Alice@X.com` and `alice@x.com` produce the same handle. Match what `startLogin` and `POST /login` compute. AF-7.4 / AF-13. |
154
165
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
166
+ | `verifyTransport` | -- | Promise\<true\> | Probe the configured SMTP transport (v0.2.1). Resolves true on success, rejects with the underlying error. Adopters call this explicitly when they want fail-fast on misconfigured SMTP at boot — no auto-on-boot variant by design (k8s readiness probes / docker-compose ordering would fail boot for the wrong reason). AF-20. |
155
167
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
156
168
  | `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
157
169
 
@@ -166,6 +178,7 @@ import {
166
178
  composeBody, // pure: build the mail body
167
179
  validateSubject, // pure: validate operator-supplied subject
168
180
  validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
181
+ validateBodyOverride, // pure: validate per-call body override (AF-26)
169
182
  renderLoginForm, // pure: HTML5 page rendering
170
183
  normalize, // pure: email normalization
171
184
  deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
@@ -173,6 +186,91 @@ import {
173
186
  } from 'knowless';
174
187
  ```
175
188
 
189
+ ## Operator visibility (v0.2.1)
190
+
191
+ Three event hooks + one opt-in method, shipped in v0.2.1. Future
192
+ contributors reading this section before extending the surface: do not
193
+ add a per-event `onShamHit`, do not add a per-handle `onRateLimitHit`,
194
+ do not add an auto-on-boot probe, do not add a `lookupMessageId()`
195
+ endpoint. Each was considered and deliberately rejected during the
196
+ forum + addypin negotiation that produced this surface (PRD §17.3,
197
+ v0.2.1) — see "What's NOT in knowless" below for the reasoning.
198
+
199
+ ### Three hooks (factory options)
200
+
201
+ ```js
202
+ const auth = knowless({
203
+ // ...required + existing options...
204
+
205
+ // Per-event, safe to log per-call.
206
+ onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
207
+ onTransportFailure: ({error, timestamp}) => { /* */ },
208
+
209
+ // Batched aggregate. Fires every windowMs regardless of count
210
+ // (heartbeat). Default cadence 60s.
211
+ onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
212
+ suppressionWindowMs: 60_000,
213
+ });
214
+ ```
215
+
216
+ Field types:
217
+ - `messageId`: string — SMTP `Message-ID` returned by nodemailer
218
+ - `handle`: string — 64-char hex; only emitted on real (non-sham) submits
219
+ - `timestamp`: number — epoch ms
220
+ - `error`: Error
221
+ - `sham`, `rateLimited`: integer counters, count within the window
222
+ - `windowMs`: integer — the configured window length, echoed in the payload
223
+
224
+ Errors thrown from hooks are caught and swallowed (matches the existing
225
+ `onSweepError` contract); knowless does not depend on hook delivery for
226
+ correctness.
227
+
228
+ ### Method
229
+
230
+ `auth.verifyTransport()` — wraps `transport.verify()` on the configured
231
+ SMTP transport. Returns `Promise<true>` on success, rejects with the
232
+ underlying error. Adopters call this explicitly when they want fail-fast
233
+ on misconfigured SMTP at boot. **No auto-on-boot variant** by design:
234
+ deployments where knowless starts before Postfix (docker-compose
235
+ ordering, k8s readiness probes) would fail boot for the wrong reason.
236
+
237
+ ### Threat-model justification (the durable part)
238
+
239
+ The two silent-202 branches — sham (handle does not exist) and rate-limit
240
+ (any of the three caps) — are aggregated rather than per-event because
241
+ **NFR-10 timing equivalence applies at the log layer too**, not just the
242
+ HTTP response. A per-event `onShamHit({handle})` lets a careless adopter
243
+ log "sham detected for X" and the log file becomes an enumeration oracle
244
+ — the exact thing sham-work was designed to prevent. The response is
245
+ silent; the log must be silent too.
246
+
247
+ Knowless has three rate limits, and one of them is identity-tied:
248
+ - `maxLoginRequestsPerIpPerHour` — IP-keyed
249
+ - `maxNewHandlesPerIpPerHour` — IP-keyed
250
+ - `maxActiveTokensPerHandle` — **handle-keyed; per-event hits leak
251
+ "this handle exists and has hit a token cap"**
252
+
253
+ Splitting per-event-IP from per-event-handle works in theory and fails
254
+ in practice — future contributor sees the asymmetry and adds the missing
255
+ handle variant for symmetry. Bundling all three into the windowed
256
+ aggregate forecloses that drift.
257
+
258
+ `onMailerSubmit` carries `handle` per-event because it fires *only on
259
+ real submissions*, where the handle was already disclosed to knowless
260
+ by the form input. Emitting it back to the adopter is not a new leak.
261
+ `onTransportFailure` carries no identity data, per-event safe.
262
+
263
+ ### Why no `lookupMessageId()` endpoint
264
+
265
+ An earlier proposal added an authenticated `auth.lookupMessageId(id)`
266
+ behind an operator secret so operators could correlate maillog entries
267
+ to handles. Rejected: the same capability is achievable by the adopter
268
+ maintaining their own `(messageId → handle)` map, populated from
269
+ `onMailerSubmit`. Knowless never stores the mapping, never exposes a
270
+ new authenticated surface, never carries operator-secret rotation
271
+ burden. The hook is the mechanism; the correlation map is adopter
272
+ choice.
273
+
176
274
  ## Handle / token / session lifecycles
177
275
 
178
276
  ```
@@ -266,6 +364,21 @@ transport_maps = hash:/etc/postfix/transport
266
364
  postmap /etc/postfix/transport && systemctl reload postfix
267
365
  ```
268
366
 
367
+ Verify the null-route is actually discarding (one-line operator
368
+ check, no knowless code involved):
369
+
370
+ ```
371
+ swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
372
+ journalctl -u postfix --since '1 minute ago' | grep 'discard'
373
+ ```
374
+
375
+ A `discard:` line in the postfix log confirms the null-route caught
376
+ the message. If you see `relay=` or `delivered`, the
377
+ `transport_maps` entry isn't being applied — re-run `postmap` and
378
+ `systemctl reload postfix`. (No `--check-null-route` CLI in
379
+ knowless: an operator's MTA validation lives with the MTA, not
380
+ inside a walk-away library.)
381
+
269
382
  ## FR-6: timing equivalence (the testable property)
270
383
 
271
384
  The library ships a CI test (`test/integration/timing.test.js`)
@@ -352,6 +465,66 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
352
465
  | `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
353
466
  | `src/form.js` | ~110 | Hardcoded login HTML |
354
467
 
468
+ ## What's NOT in knowless, and why
469
+
470
+ Three capabilities that look like they belong here but don't, listed
471
+ because the "why not" needs to outlast walk-away-at-v1.0.0. When future
472
+ contributors propose adding any of these back, point them here.
473
+
474
+ ### Disposable-domain blocking — adopter / form handler
475
+
476
+ Reject `mailinator.com` etc. before knowless sees the submission.
477
+ Mechanism + list + override + weekly cron all live in the adopter's
478
+ form handler.
479
+
480
+ The argument for putting this in knowless was timing equivalence: if
481
+ the adopter rejects fast, an attacker times the response and learns
482
+ "this domain is on a public blocklist." Counter: the blocklist is a
483
+ public GitHub repo (`disposable-email-domains/disposable-email-domains`).
484
+ Anyone can fetch it directly. Timing-equivalence here protects information
485
+ that isn't secret. Knowless's sham-work protects against email
486
+ *enumeration* (is `alice@x.com` registered?), not domain *classification*
487
+ (is `x.com` on a public list?). Different threat, different defense.
488
+
489
+ Splitting mechanism (knowless) from policy + list curation (adopter) is
490
+ the wrong seam. Both stay in the adopter's form handler.
491
+
492
+ ### App-tenure / account-age — adopter / first-seen tracking
493
+
494
+ Knowless's "handle creation date" is when this email first hit knowless.
495
+ The adopter's interesting question is "how long has this user been
496
+ participating in *my app*" — a different number, and the adopter's
497
+ number is the one that should drive trust decisions.
498
+
499
+ Concrete failure mode: a handle registered with knowless six months ago
500
+ but never posted has zero app-tenure. If the adopter reads knowless's
501
+ age, a brand-new spammer with an old handle gets unearned credibility.
502
+
503
+ Pattern: adopter stores `(handle, first_seen_at)` the first time it sees
504
+ a handle perform a meaningful action. App-tenure is app-derived. Knowless
505
+ doesn't expose age data — and wouldn't even if it could, because
506
+ returning `Date | null` keyed by handle is itself an enumeration leak.
507
+
508
+ ### Per-IP hashcash / proof-of-work — Caddy / perimeter layer
509
+
510
+ `maxNewHandlesPerIpPerHour: 3` already covers the ground hashcash would
511
+ cover. A botnet that can't get past three signups per IP per hour needs
512
+ IP rotation regardless; once rotated, a 2s hashcash is rounding error
513
+ at botnet economics. Costs are real: breaks Lynx/w3m (gotcha #10),
514
+ requires JS in the login form (the only zero-JS exception we'd carry),
515
+ ~2s UX delay for legit users on weak devices. If a deployment observes
516
+ per-IP signup actually saturating the cap, Caddy (or another perimeter
517
+ layer) can run hashcash off-the-shelf without making knowless carry it.
518
+
519
+ ### The deciding lens
520
+
521
+ knowless walks away at v1.0.0 (PRD §6.3). Every config option carried
522
+ into v1.0.0 is something v1.x has to keep stable through the
523
+ maintenance window. The test for any proposed addition: does this
524
+ belong in the **identity layer** (who they are) or the **behavior
525
+ layer** (what they did)? Identity layer is in scope. Behavior layer is
526
+ out. When unsure, default out — less surface, less carrying cost.
527
+
355
528
  ## Threat model summary
356
529
 
357
530
  **Defends well:** DB-only leaks (handles are HMAC-salted),
@@ -462,12 +635,12 @@ rate-limits) belongs above the library.
462
635
  `console.warn` if it sees `Content-Length > 0` with an empty
463
636
  body. AF-7.1.
464
637
 
465
- 16. **Two adoption modes — Mode B (register-first) and Mode A
466
- (use-first claim-later).** Mode B is the form (`auth.login`).
467
- Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
468
- "drop a pin, claim by email click" patterns. Both run the
469
- identical 12-step sham-work flow; same FR-6 guarantee. Pick
470
- per-action, not per-app.
638
+ 16. **Two adoption modes — "sign in, then do" (Mode B) and "do
639
+ then confirm by email" (Mode A).** Mode B is the form
640
+ (`auth.login`). Mode A is `auth.startLogin({email, nextUrl,
641
+ sourceIp})` for "drop a pin, claim by email click" patterns.
642
+ Both run the identical 12-step sham-work flow; same FR-6
643
+ guarantee. Pick per-action, not per-app.
471
644
 
472
645
  17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
473
646
  64-char lowercase hex string; knowless decodes to 32 raw bytes
@@ -482,6 +655,15 @@ rate-limits) belongs above the library.
482
655
  factory startup; fails fast. Goes after RFC 3676 `"-- "`
483
656
  delimiter so mail clients strip it from quoted replies.
484
657
 
658
+ 19. **`startLogin` is silent at every layer (FR-6).** Returns
659
+ `{handle, submitted: true}` for *every* branch — real send, sham,
660
+ rate-limited, missing-handle-with-`openRegistration:false`. Adopters
661
+ cannot derive the branch from the return value, by design.
662
+ Operator visibility comes from the v0.2.1 hooks (`onMailerSubmit`
663
+ per-event, `onSuppressionWindow` aggregated) — *not* from the
664
+ return shape. Don't wrap `startLogin` in something that surfaces
665
+ the branch to the caller; that re-opens the enumeration oracle.
666
+
485
667
  ## Constraints
486
668
 
487
669
  - **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.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",