knowless 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -30,6 +30,93 @@ 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.5] — 2026-05-09
34
+
35
+ Documentation-only release. plato (Mode A adopter) hit a real seam
36
+ under `bodyOverride`: they want to reorder the body (expiry warning
37
+ before URL) AND keep the "Last sign-in" security signal. The current
38
+ contract drops the line under override — deliberately, since override
39
+ is full-content replacement — and that read as a missing capability.
40
+ It isn't: `auth.deriveHandle(email)` and a parallel `createStore`
41
+ handle calling `getLastLogin(handle)` already give adopters everything
42
+ they need to compose the signal themselves. The gap was discoverability,
43
+ not surface area. Considered exporting a `formatLastLogin` helper to
44
+ centralize the canonical wording across adopters; held off under the
45
+ walk-away default-no, since the population that hits the
46
+ override + reorder + want-signal intersection is narrow (most
47
+ `bodyOverride` uses are per-call subject branding without a sign-in
48
+ event), and the drift risk for the few who do is small and recoverable.
49
+ Cross-product wording consistency for operators running multiple
50
+ knowless adopters belongs in a shared module on the operator side, not
51
+ in knowless. Revisit if a second independent adopter asks for the
52
+ helper. No code changes.
53
+
54
+ ### Documented
55
+
56
+ - `GUIDE.md` Mode A walkthrough — added a "Composing the 'Last
57
+ sign-in' security signal under `bodyOverride`" callout right after
58
+ the `auth.deriveHandle` paragraph. Explains why the line doesn't
59
+ auto-append on overridden bodies (override is full-content
60
+ replacement, AF-26), points at the existing recipe
61
+ (`auth.deriveHandle` + parallel `createStore` + `getLastLogin`),
62
+ notes that `upsertLastLogin` only fires on callback consumption so
63
+ a pre-call read matches what knowless reads internally, and
64
+ includes a worked example. Targeted at adopters who reorder the
65
+ body and want the signal back; default-path adopters (no override)
66
+ are unaffected.
67
+
68
+ ## [1.1.4] — 2026-05-08
69
+
70
+ Documentation + small bug fix. Adopters (plato, addypin, bareagent,
71
+ bareguard) repeatedly filed feature requests for things knowless
72
+ already shipped (`onTransportFailure` since v0.2.1) or deliberately
73
+ refuses (vendor SMTP, built-in DKIM). Triage showed a single root
74
+ cause: `README.md` under-routed adopters to `GUIDE.md`, `OPS.md`,
75
+ and PRD §16, so adopters read the README and assumed gaps. Pair
76
+ with one bug fix: the legacy `console.error('[knowless] mail submit
77
+ failed:', ...)` in `src/handlers.js` fired alongside
78
+ `onTransportFailure` and actively misled adopters into thinking no
79
+ callback existed.
80
+
81
+ ### Fixed
82
+
83
+ - `src/handlers.js` — removed the duplicate `console.error` on SMTP
84
+ submission failure. The `onTransportFailure` hook is now the only
85
+ reporting path, with the default impl preserving stderr behaviour
86
+ (see below). Adopters who wired `onTransportFailure` previously
87
+ saw the same failure logged twice (once via stderr, once via
88
+ their hook); they now see it once via their hook.
89
+ - `src/index.js` — `onTransportFailure` default changed from a
90
+ no-op to a stderr printer (`[knowless] mail submit failed:
91
+ <message>`). Preserves NFR-10 ("SMTP delivery failures MUST be
92
+ logged") for adopters who don't wire the hook. Adopters who
93
+ explicitly want silence pass `onTransportFailure: () => {}`.
94
+ *Behavioural note:* adopters who were relying on the implicit
95
+ no-op default to suppress stderr will now see lines on transport
96
+ failure. Pass an explicit no-op to restore prior behaviour.
97
+
98
+ ### Documented
99
+
100
+ - `README.md` "Where to go next" → "Required reading (before
101
+ integrating or filing an issue)". Reframed the routing block
102
+ from an optional menu to required reading, with each linked
103
+ doc's hook spelled out (hooks live in `GUIDE.md`, registrar
104
+ setup in `OPS.md` §5, refusal reasoning in PRD §16). Goal:
105
+ reduce the recurring "missing feature" filings that turn out to
106
+ be already-shipped or deliberately-refused.
107
+ - `README.md` "What's opinionated (locked by design)" → "What
108
+ knowless refuses (by design)". Sharper framing — closed doors,
109
+ not omissions — with PRD §16.2 / §16.7 / §16.12 anchors inline so
110
+ adopters who want to litigate a refusal land on the reasoning,
111
+ not the bullet. Added the "No DKIM/SPF in the library" line
112
+ explicitly (was implicit before; PRD §16.7 reasoning); pointed
113
+ at `OPS.md` §5 for the operator-side setup.
114
+ - `README.md` new section "Observability (wire it or be silent)" —
115
+ worked example of the three hooks (`onMailerSubmit`,
116
+ `onTransportFailure`, `onSuppressionWindow`) with the per-hook
117
+ threat-model framing. Previously surfaced only in `GUIDE.md`
118
+ Step 8, which adopters reliably missed.
119
+
33
120
  ## [1.1.3] — 2026-05-03
34
121
 
35
122
  Documentation-only release. Surfaces a partial enumeration leak
package/GUIDE.md CHANGED
@@ -384,6 +384,39 @@ 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
+ > **Composing the "Last sign-in" security signal under
388
+ > `bodyOverride`.** The default last-sign-in line lives in
389
+ > `composeBody` and does **not** auto-append on overridden bodies —
390
+ > override is full-content replacement (handlers.js AF-26). If your
391
+ > override needs the same signal, look up the timestamp before
392
+ > calling `startLogin` and interpolate it yourself. Everything you
393
+ > need is already exported: `auth.deriveHandle(email)` returns the
394
+ > handle, and a parallel read-only `createStore(dbPath)` exposes
395
+ > `getLastLogin(handle)` (Unix ms when the handle exists, `null` for
396
+ > sham / new / opted-out). `upsertLastLogin` only fires on callback
397
+ > consumption, so a pre-call read returns the same value knowless
398
+ > reads internally. Wording stays your responsibility under override —
399
+ > small drift cost for taking the wheel.
400
+ >
401
+ > ```js
402
+ > import { createStore } from 'knowless';
403
+ > const store = createStore(process.env.KNOWLESS_DB_PATH);
404
+ > // ... in your request handler ...
405
+ > const handle = auth.deriveHandle(email);
406
+ > const lastLoginAt = store.getLastLogin(handle); // null for sham/new
407
+ > await auth.startLogin({
408
+ > email,
409
+ > bodyOverride: ({ url }) =>
410
+ > `Click to sign in:\n\n${url}\n\n` +
411
+ > `This link expires in 15 minutes. If you didn't request this,\n` +
412
+ > `ignore this email.\n` +
413
+ > (lastLoginAt != null
414
+ > ? `\nLast sign-in: ${new Date(lastLoginAt).toISOString()}.\n` +
415
+ > `If that wasn't you, do not click the link above.\n`
416
+ > : ''),
417
+ > });
418
+ > ```
419
+
387
420
  > **Set `failureRedirect: '/'` — it's part of the silent-miss
388
421
  > contract, not just a UX knob.** The default falls back to
389
422
  > `loginPath` (typically `/login`). That's a partial enumeration
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/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 = {