knowless 1.1.2 → 1.1.4

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 CHANGED
@@ -30,6 +30,90 @@ v1.0.0 are:
30
30
  - Documentation corrections
31
31
  - Helper exports that pull existing mechanism back into the library
32
32
 
33
+ ## [1.1.4] — 2026-05-08
34
+
35
+ Documentation + small bug fix. Adopters (plato, addypin, bareagent,
36
+ bareguard) repeatedly filed feature requests for things knowless
37
+ already shipped (`onTransportFailure` since v0.2.1) or deliberately
38
+ refuses (vendor SMTP, built-in DKIM). Triage showed a single root
39
+ cause: `README.md` under-routed adopters to `GUIDE.md`, `OPS.md`,
40
+ and PRD §16, so adopters read the README and assumed gaps. Pair
41
+ with one bug fix: the legacy `console.error('[knowless] mail submit
42
+ failed:', ...)` in `src/handlers.js` fired alongside
43
+ `onTransportFailure` and actively misled adopters into thinking no
44
+ callback existed.
45
+
46
+ ### Fixed
47
+
48
+ - `src/handlers.js` — removed the duplicate `console.error` on SMTP
49
+ submission failure. The `onTransportFailure` hook is now the only
50
+ reporting path, with the default impl preserving stderr behaviour
51
+ (see below). Adopters who wired `onTransportFailure` previously
52
+ saw the same failure logged twice (once via stderr, once via
53
+ their hook); they now see it once via their hook.
54
+ - `src/index.js` — `onTransportFailure` default changed from a
55
+ no-op to a stderr printer (`[knowless] mail submit failed:
56
+ <message>`). Preserves NFR-10 ("SMTP delivery failures MUST be
57
+ logged") for adopters who don't wire the hook. Adopters who
58
+ explicitly want silence pass `onTransportFailure: () => {}`.
59
+ *Behavioural note:* adopters who were relying on the implicit
60
+ no-op default to suppress stderr will now see lines on transport
61
+ failure. Pass an explicit no-op to restore prior behaviour.
62
+
63
+ ### Documented
64
+
65
+ - `README.md` "Where to go next" → "Required reading (before
66
+ integrating or filing an issue)". Reframed the routing block
67
+ from an optional menu to required reading, with each linked
68
+ doc's hook spelled out (hooks live in `GUIDE.md`, registrar
69
+ setup in `OPS.md` §5, refusal reasoning in PRD §16). Goal:
70
+ reduce the recurring "missing feature" filings that turn out to
71
+ be already-shipped or deliberately-refused.
72
+ - `README.md` "What's opinionated (locked by design)" → "What
73
+ knowless refuses (by design)". Sharper framing — closed doors,
74
+ not omissions — with PRD §16.2 / §16.7 / §16.12 anchors inline so
75
+ adopters who want to litigate a refusal land on the reasoning,
76
+ not the bullet. Added the "No DKIM/SPF in the library" line
77
+ explicitly (was implicit before; PRD §16.7 reasoning); pointed
78
+ at `OPS.md` §5 for the operator-side setup.
79
+ - `README.md` new section "Observability (wire it or be silent)" —
80
+ worked example of the three hooks (`onMailerSubmit`,
81
+ `onTransportFailure`, `onSuppressionWindow`) with the per-hook
82
+ threat-model framing. Previously surfaced only in `GUIDE.md`
83
+ Step 8, which adopters reliably missed.
84
+
85
+ ## [1.1.3] — 2026-05-03
86
+
87
+ Documentation-only release. Surfaces a partial enumeration leak
88
+ in the default `failureRedirect` fallback that previously read as
89
+ "Mode-A only" guidance but actually applies to every adopter.
90
+ POST /login is built to make valid/invalid/rate-limited responses
91
+ indistinguishable (timing equivalence, sham tokens, sham-recipient
92
+ mailing, identical response shapes); the link-click stage extends
93
+ that contract, but the default `failureRedirect` cascade points at
94
+ `loginPath` (typically `/login`) — so a user clicking a sham link
95
+ lands on a "Sign in" page and immediately knows the link was
96
+ rejected, defeating the POST-stage work. plato wraps knowless with
97
+ `failureRedirect: '/'` for exactly this reason. No code changes.
98
+
99
+ ### Documented
100
+
101
+ - `GUIDE.md` Step-4 callout — replaced the narrow "Mode-A
102
+ heads-up" with a broader "set `failureRedirect: '/'` — it's
103
+ part of the silent-miss contract, not just a UX knob"
104
+ guidance, with the reasoning chain (POST-stage work, link-
105
+ click extension, leak-free landing) and the explicit opt-in
106
+ for adopters who want "try again" UX (`failureRedirect:
107
+ cfg.loginPath`). plato cited as the reference adopter.
108
+ - `GUIDE.md` config-table row for `failureRedirect` —
109
+ expanded the cell to lead with the silent-miss framing
110
+ rather than the Mode-A 404 framing.
111
+ - `knowless.context.md` `failureRedirect` comment in the
112
+ options block — flagged the default as a leak, pointer to
113
+ the new gotcha.
114
+ - `knowless.context.md` gotcha #20 — full silent-miss
115
+ contract write-up with the trade-off and reference adopter.
116
+
33
117
  ## [1.1.2] — 2026-05-03
34
118
 
35
119
  Documentation-only release. Adopters were repeatedly missing that
package/GUIDE.md CHANGED
@@ -384,12 +384,25 @@ and `startLogin` would compute. The bare `deriveHandle` re-export
384
384
  takes pre-normalized input; use the instance method unless you
385
385
  have a specific reason to call the lower-level primitive.
386
386
 
387
- > **Mode-A heads-up: set `failureRedirect`.** If you only mount
388
- > `auth.callback` (not `auth.loginForm`), the default
389
- > `failureRedirect` cascade points at `/login` a route you
390
- > don't serve. An expired or replayed magic-link click will 302
391
- > to a 404. Set `failureRedirect: '/'` (or any route you do
392
- > serve) when wiring Mode A.
387
+ > **Set `failureRedirect: '/'` it's part of the silent-miss
388
+ > contract, not just a UX knob.** The default falls back to
389
+ > `loginPath` (typically `/login`). That's a partial enumeration
390
+ > leak: `POST /login` goes to significant lengths to make
391
+ > valid/invalid/rate-limited submissions indistinguishable
392
+ > (timing equivalence, sham tokens, sham-recipient mailing,
393
+ > identical response shapes) — but if a user clicks a sham or
394
+ > expired magic link and lands on a "Sign in" page, they
395
+ > immediately know the link was rejected, defeating the work the
396
+ > POST stage did. The leak-free landing is the same page any
397
+ > logged-out visitor sees on first arrival — usually `/`. Mode-A
398
+ > adopters have an extra reason: the default `failureRedirect`
399
+ > cascade points at `/login`, a route Mode A doesn't serve, so
400
+ > expired/replayed clicks 302 to a 404. Reference: plato wraps
401
+ > knowless with `failureRedirect: '/'` for this reason.
402
+ > Adopters who genuinely want a "try again" UX after failure
403
+ > can opt in explicitly with `failureRedirect: cfg.loginPath`,
404
+ > but understand you're trading the silent-miss guarantee at
405
+ > the link-click stage for that UX.
393
406
 
394
407
  ### Step 5: Pre-seed users (closed-registration mode, default)
395
408
 
@@ -808,7 +821,7 @@ Full options table:
808
821
  | `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
809
822
  | `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
810
823
  | `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
811
- | `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. |
824
+ | `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures (expired / used / sham / malformed token) redirect. **Set this to `'/'` (or any logged-out landing).** Default falls back to `loginPath`, which is a silent-miss leak: a user who clicks a sham/expired link and lands on a "Sign in" page knows the link was rejected, defeating the anti-enumeration work done at POST /login. Part of the silent-miss contract, not a UX knob. Mode-A adopters who don't mount `loginForm` *also* hit a 404 with the default. Opt back into the "try again" UX by passing `loginPath` explicitly if you understand the trade. |
812
825
  | `store` | no | (built-in `node:sqlite`) | Inject your own store implementation. |
813
826
  | `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
814
827
 
package/README.md CHANGED
@@ -9,17 +9,20 @@ npm install knowless
9
9
 
10
10
  > v1.0.0 (walk-away release) | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
11
11
 
12
- ## Where to go next
12
+ ## Required reading (before integrating or filing an issue)
13
13
 
14
- Two docs live alongside this README. They serve different readers; pick
15
- the one that matches yours.
14
+ This README is the front door, not the manual. Most "missing feature"
15
+ questions about knowless turn out to be answered in the docs below
16
+ hooks that already exist, refusals that are deliberate, operator
17
+ setup steps already documented. **Read these before assuming a gap.**
16
18
 
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. |
19
+ | Read this | Why you need it |
20
+ |---|---|
21
+ | [`GUIDE.md`](GUIDE.md) | Integration walkthrough, **observability hooks** (`onMailerSubmit` / `onTransportFailure` / `onSuppressionWindow`), edge cases, FAQ, troubleshooting. |
22
+ | [`OPS.md`](OPS.md) | Operator setup Postfix install, **SPF / DKIM / PTR / DMARC at your domain registrar** (§5), null-route, systemd, Caddy/nginx/Traefik forward-auth, MailHog dev, fail2ban. |
23
+ | [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16 | Why knowless refuses what it refuses. Decisions log — read §16.2 before asking for vendor SMTP, §16.7 before asking for built-in DKIM, §16.12 before asking for a templatable login form. |
24
+ | [`knowless.context.md`](knowless.context.md) | Dense single-file reference for AI agents and quick lookups. Public API table, every option with defaults, 19 gotchas, threat model. Fits one context window. |
25
+ | [`CHANGELOG.md`](CHANGELOG.md) | Version history. |
23
26
 
24
27
  ## What it does
25
28
 
@@ -86,30 +89,71 @@ Worked code for both in [`GUIDE.md`](GUIDE.md).
86
89
  | **Library mode** | Mount the five handlers (`login`, `callback`, `verify`, `logout`, `loginForm`) in your existing Node app. |
87
90
  | **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. |
88
91
 
89
- ## What's opinionated (locked by design)
92
+ ## What knowless refuses (by design)
90
93
 
91
- Deliberate trade-offs. The library refuses, by API shape, to grow
92
- into them.
94
+ These are closed doors, not omissions. Don't file feature requests
95
+ for them — the reasoning is locked in
96
+ [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16. If any
97
+ break your case, knowless isn't the right tool — look at
98
+ [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
99
+ or commercial offerings.
93
100
 
94
101
  - **Localhost SMTP only.** No Mailgun / Postmark / SES / Resend.
102
+ Reasoning: PRD §16.2 — vendor relationships invite reusing the
103
+ mailer for non-auth mail, which collapses the "one mail purpose"
104
+ invariant.
95
105
  - **One mail purpose: the sign-in link.** No `sendNotification()` to
96
106
  be tempted by.
97
107
  - **Plain-text 7-bit email.** No HTML, no tracking pixels, no
98
108
  click-rewriting, no read-receipts.
109
+ - **No DKIM/SPF in the library.** PRD §16.7 — that's the MTA's job;
110
+ knowless emits clean RFC822 and your Postfix + opendkim signs it.
111
+ Setup steps in [`OPS.md`](OPS.md) §5.
99
112
  - **No OAuth / OIDC / SAML.** Different audience.
100
113
  - **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
101
114
  library if you need them.
102
115
  - **No admin UI.** `sqlite3 knowless.db` is the admin UI.
103
- - **Hardcoded login form.** No template overrides; fork or live with
104
- it.
116
+ - **Hardcoded login form.** No template overrides PRD §16.12.
117
+ Fork, override the route entirely, or live with it.
105
118
  - **No telemetry, analytics, or error reporting.** No phone-home of
106
- any kind.
119
+ any kind. (Operator-side observability is opt-in via hooks — see
120
+ below.)
107
121
  - **Walks away at v1.0.0.** Maintenance mode after that — only
108
122
  security fixes.
109
123
 
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.
124
+ ## Observability (wire it or be silent)
125
+
126
+ knowless emits **three operator-visibility hooks** on the mail-send
127
+ path. They're the only API for SMTP outcomes — there is no internal
128
+ logging the library does on your behalf beyond an unwired-default
129
+ stderr line on transport failure. If you want metrics, alerting, or
130
+ an admin UI showing send results, you wire these.
131
+
132
+ ```js
133
+ const auth = knowless({
134
+ secret, baseUrl, from,
135
+
136
+ onMailerSubmit: ({ messageId, handle, timestamp }) => {
137
+ // Real (non-sham) submission succeeded. Safe per-event — fires
138
+ // ONLY on registered handles, so no enumeration oracle.
139
+ },
140
+ onTransportFailure: ({ error, timestamp }) => {
141
+ // SMTP submission failed. Carries no identity data. Wire to
142
+ // your alerting / admin "last 10 sends" panel.
143
+ },
144
+ onSuppressionWindow: ({ sham, rateLimited, windowMs }) => {
145
+ // Aggregate counters for the silent-202 branches (sham + rate-
146
+ // limit hits). Windowed, NOT per-event — per-event would reopen
147
+ // the enumeration oracle that sham-work exists to prevent.
148
+ },
149
+ });
150
+ ```
151
+
152
+ Threat-model reasoning for why three hooks (and not a fourth
153
+ per-event sham hook) lives in [`GUIDE.md`](GUIDE.md) Step 8 and
154
+ `knowless.context.md` § "Why three hooks, not four". **Read it
155
+ before logging payloads** — careless aggregation can leak handles
156
+ into log lines.
113
157
 
114
158
  ## Operator commitments
115
159
 
@@ -114,7 +114,7 @@ const auth = knowless({
114
114
  linkPath: '/auth/callback',
115
115
  verifyPath: '/verify',
116
116
  logoutPath: '/logout',
117
- failureRedirect: null, // null → loginPath
117
+ failureRedirect: null, // null → loginPath (LEAK — set '/'; see gotcha #20)
118
118
 
119
119
  // --- Mail / SMTP ---
120
120
  smtpHost: 'localhost',
@@ -770,6 +770,24 @@ rate-limits) belongs above the library.
770
770
  return shape. Don't wrap `startLogin` in something that surfaces
771
771
  the branch to the caller; that re-opens the enumeration oracle.
772
772
 
773
+ 20. **`failureRedirect` is part of the silent-miss contract.**
774
+ Default falls back to `loginPath` — a partial enumeration
775
+ leak. POST /login goes to significant lengths to keep
776
+ valid/invalid/rate-limited submissions indistinguishable
777
+ (timing, sham tokens, sham-recipient mailing, identical
778
+ response shapes), and the link-click stage extends that
779
+ contract: real tokens issue a session + redirect to
780
+ `nextUrl`; failures (expired, used, sham, malformed) go to
781
+ `failureRedirect`. If that's `/login`, a user who clicks a
782
+ sham link lands on a "Sign in" page and immediately knows
783
+ the link was rejected — defeating the POST-stage work. Set
784
+ `failureRedirect: '/'` (or any route a logged-out visitor
785
+ would see on first arrival) so a sham click is
786
+ indistinguishable from a never-clicked link. Adopters who
787
+ genuinely want a "try again" UX after failure opt back in
788
+ explicitly with `failureRedirect: cfg.loginPath`. plato is
789
+ the reference adopter for the leak-free wiring.
790
+
773
791
  ## Constraints
774
792
 
775
793
  - **Node 20+** -- targeting LTS; tested on Node 22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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
@@ -399,10 +399,14 @@ export function createHandlers({ store, mailer, config, events }) {
399
399
  });
400
400
  }
401
401
  } catch (err) {
402
- // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
403
- console.error('[knowless] mail submit failed:', err.message);
404
- // v0.2.1: per-event hook for SMTP failures. Carries no identity
405
- // data, safe per-event. Operator wires this to alerting.
402
+ // NFR-10: SMTP failure must be reported to the operator but
403
+ // MUST NOT leak to response shape. Reporting goes through the
404
+ // onTransportFailure hook only the default impl (see
405
+ // index.js safeHook) writes to stderr when the adopter hasn't
406
+ // wired their own; an adopter who wires the hook owns the
407
+ // policy. The previous explicit console.error fired alongside
408
+ // the hook and misled adopters into thinking no callback
409
+ // existed.
406
410
  ev.onTransportFailure({ error: err, timestamp: Date.now() });
407
411
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
408
412
  // development the operator otherwise has no way to obtain the magic
package/src/index.js CHANGED
@@ -172,7 +172,19 @@ export function knowless(options = {}) {
172
172
  let shamCount = 0;
173
173
  let rateLimitedCount = 0;
174
174
  const onMailerSubmit = safeHook(options.onMailerSubmit, 'onMailerSubmit');
175
- const onTransportFailure = safeHook(options.onTransportFailure, 'onTransportFailure');
175
+ // NFR-10: SMTP failures must be reported to the operator. When the
176
+ // adopter doesn't wire onTransportFailure, default to a stderr
177
+ // printer so the unwired case still satisfies NFR-10. Adopters who
178
+ // want programmatic alerting override; adopters who want silence
179
+ // pass `() => {}` explicitly.
180
+ const onTransportFailure = safeHook(
181
+ options.onTransportFailure ??
182
+ ((p) =>
183
+ process.stderr.write(
184
+ `[knowless] mail submit failed: ${p?.error?.message ?? p?.error ?? 'unknown'}\n`,
185
+ )),
186
+ 'onTransportFailure',
187
+ );
176
188
  const onSuppressionWindow = safeHook(options.onSuppressionWindow, 'onSuppressionWindow');
177
189
 
178
190
  const events = {