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/src/handlers.js CHANGED
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { normalize, deriveHandle } from './handle.js';
3
3
  import { issueToken, hashToken } from './token.js';
4
4
  import { newSid, signSession, verifySessionSignature } from './session.js';
5
- import { composeBody, validateSubject } from './mailer.js';
5
+ import { composeBody, validateSubject, validateBodyOverride } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
8
  buildTrustedPeers,
@@ -163,7 +163,20 @@ function sidHashOf(sid) {
163
163
  * validateNextUrl: (raw:string)=>string|null
164
164
  * }}
165
165
  */
166
- export function createHandlers({ store, mailer, config }) {
166
+ export function createHandlers({ store, mailer, config, events }) {
167
+ // v0.2.1 operator-visibility hooks. All optional; treat missing as
168
+ // no-ops so the handler hot path is identical for adopters who don't
169
+ // wire them. The factory passes a fully-populated `events` object;
170
+ // direct callers of createHandlers (tests / advanced wiring) may omit
171
+ // it entirely.
172
+ const noop = () => {};
173
+ const ev = {
174
+ shamHit: events?.shamHit ?? noop,
175
+ rateLimitHit: events?.rateLimitHit ?? noop,
176
+ onMailerSubmit: events?.onMailerSubmit ?? noop,
177
+ onTransportFailure: events?.onTransportFailure ?? noop,
178
+ };
179
+
167
180
  const cfg = { ...DEFAULTS, ...config };
168
181
  if (!cfg.secret) throw new Error('config.secret required');
169
182
  if (typeof cfg.secret !== 'string' || cfg.secret.length < 64) {
@@ -243,7 +256,14 @@ export function createHandlers({ store, mailer, config }) {
243
256
  * handle is null only when the email failed to normalize (programmer
244
257
  * bug for startLogin; same-shape silent for /login).
245
258
  */
246
- async function runSendLink({ emailRaw, nextRaw, sourceIp, subject, bypassRateLimit = false }) {
259
+ async function runSendLink({
260
+ emailRaw,
261
+ nextRaw,
262
+ sourceIp,
263
+ subject,
264
+ bodyOverride,
265
+ bypassRateLimit = false,
266
+ }) {
247
267
  // Step 1: parse + normalize
248
268
  let emailNorm;
249
269
  try {
@@ -267,6 +287,7 @@ export function createHandlers({ store, mailer, config }) {
267
287
  HOUR_MS,
268
288
  )
269
289
  ) {
290
+ ev.rateLimitHit();
270
291
  return { handle: null, isSham: false, emailNorm, nextValidated: null };
271
292
  }
272
293
 
@@ -287,6 +308,11 @@ export function createHandlers({ store, mailer, config }) {
287
308
  )
288
309
  ) {
289
310
  // Cap exceeded — fall through to sham, do NOT short-circuit.
311
+ // The fall-through becomes a sham-hit too; both counters
312
+ // increment because they're independent dimensions (operator
313
+ // can correlate from `rateLimited` jumping in lockstep with
314
+ // `sham`).
315
+ ev.rateLimitHit();
290
316
  isCreating = false;
291
317
  }
292
318
  }
@@ -308,9 +334,10 @@ export function createHandlers({ store, mailer, config }) {
308
334
  } else {
309
335
  isSham = true;
310
336
  toAddress = cfg.shamRecipient;
337
+ ev.shamHit();
311
338
  }
312
339
 
313
- store.insertToken({
340
+ const evicted = store.insertToken({
314
341
  tokenHash: token.hash,
315
342
  handle,
316
343
  expiresAt,
@@ -318,14 +345,36 @@ export function createHandlers({ store, mailer, config }) {
318
345
  isSham,
319
346
  maxActive: cfg.maxActiveTokensPerHandle,
320
347
  });
321
-
322
- const mailBody = composeBody({
323
- tokenRaw: token.raw,
324
- baseUrl: cfg.baseUrl,
325
- linkPath: cfg.linkPath,
326
- lastLoginAt,
327
- bodyFooter: cfg.bodyFooter,
328
- });
348
+ // Per-handle token cap rotation is the third rate limit. Counted
349
+ // here in the aggregate `rateLimited` window so operators see
350
+ // hammering of a single handle without per-event identity leakage.
351
+ if (evicted > 0) ev.rateLimitHit();
352
+
353
+ // AF-26: per-call body override for startLogin (Mode A). When
354
+ // provided, the adopter's template function receives the composed
355
+ // magic-link URL and returns the full body text. Same submit path,
356
+ // same sham work, same FR-6 timing equivalence — just lets adopters
357
+ // phrase the body to match per-call subjects (pin confirmation,
358
+ // login, expiry warning). bodyFooter still appends; lastLogin line
359
+ // does not (override is full-content replacement).
360
+ let mailBody;
361
+ if (typeof bodyOverride === 'function') {
362
+ const url = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
363
+ const rendered = bodyOverride({ url });
364
+ validateBodyOverride(rendered, url); // throws on invalid
365
+ mailBody = rendered;
366
+ if (cfg.bodyFooter) {
367
+ mailBody += `\n-- \n${cfg.bodyFooter}\n`;
368
+ }
369
+ } else {
370
+ mailBody = composeBody({
371
+ tokenRaw: token.raw,
372
+ baseUrl: cfg.baseUrl,
373
+ linkPath: cfg.linkPath,
374
+ lastLoginAt,
375
+ bodyFooter: cfg.bodyFooter,
376
+ });
377
+ }
329
378
 
330
379
  // AF-9: programmatic callers may override the subject per call
331
380
  // (addypin sends confirmation / login / expiry-warning all via
@@ -334,10 +383,29 @@ export function createHandlers({ store, mailer, config }) {
334
383
  // so timing equivalence is preserved.
335
384
  const effectiveSubject = subject ?? cfg.subject;
336
385
  try {
337
- await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
386
+ const info = await mailer.submit({
387
+ to: toAddress,
388
+ subject: effectiveSubject,
389
+ body: mailBody,
390
+ });
391
+ // v0.2.1: per-event hook on real (non-sham) submissions only.
392
+ // Sham branches go through the windowed aggregate; emitting them
393
+ // per-event here would let a careless adopter log per-handle
394
+ // data and reopen the enumeration oracle that sham-work exists
395
+ // to prevent.
396
+ if (!isSham) {
397
+ ev.onMailerSubmit({
398
+ messageId: info?.messageId ?? null,
399
+ handle,
400
+ timestamp: Date.now(),
401
+ });
402
+ }
338
403
  } catch (err) {
339
404
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
340
405
  console.error('[knowless] mail submit failed:', err.message);
406
+ // v0.2.1: per-event hook for SMTP failures. Carries no identity
407
+ // data, safe per-event. Operator wires this to alerting.
408
+ ev.onTransportFailure({ error: err, timestamp: Date.now() });
341
409
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
342
410
  // development the operator otherwise has no way to obtain the magic
343
411
  // link. Print it to stderr only when explicitly opted in.
@@ -411,6 +479,7 @@ export function createHandlers({ store, mailer, config }) {
411
479
  nextUrl,
412
480
  sourceIp = '',
413
481
  subjectOverride,
482
+ bodyOverride,
414
483
  bypassRateLimit = false,
415
484
  } = {}) {
416
485
  // Programmer-error guards (AF-7.3). These DO throw; they're not
@@ -437,11 +506,20 @@ export function createHandlers({ store, mailer, config }) {
437
506
  validateSubject(subjectOverride); // throws on invalid
438
507
  subject = subjectOverride;
439
508
  }
509
+ // AF-26: per-call body override. The function is called inside
510
+ // runSendLink with the composed magic-link URL; its return value
511
+ // is validated by validateBodyOverride(). The arg-type check
512
+ // happens here at the API edge so a non-function bodyOverride
513
+ // fails fast, before any token is minted.
514
+ if (bodyOverride !== undefined && bodyOverride !== null && typeof bodyOverride !== 'function') {
515
+ throw new Error('startLogin: bodyOverride must be a function when provided');
516
+ }
440
517
  const { handle } = await runSendLink({
441
518
  emailRaw: email,
442
519
  nextRaw: nextUrl ?? null,
443
520
  sourceIp,
444
521
  subject,
522
+ bodyOverride,
445
523
  bypassRateLimit,
446
524
  });
447
525
  // Same-shape return: rate-limit / sham / real all collapse here.
package/src/index.js CHANGED
@@ -9,8 +9,27 @@ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
9
9
  /** Default rate-limit retention: 24 hours past window-start. */
10
10
  const DEFAULT_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000;
11
11
 
12
+ /** Default suppression-window cadence: 60 seconds. v0.2.1. */
13
+ const DEFAULT_SUPPRESSION_WINDOW_MS = 60 * 1000;
14
+
12
15
  const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
13
16
 
17
+ /**
18
+ * Wrap a user-supplied hook so its errors are caught and swallowed.
19
+ * Matches the `onSweepError` contract: knowless never crashes because
20
+ * an operator's observability sink threw.
21
+ */
22
+ function safeHook(fn, label) {
23
+ if (typeof fn !== 'function') return () => {};
24
+ return (arg) => {
25
+ try {
26
+ fn(arg);
27
+ } catch (err) {
28
+ console.error(`[knowless] ${label} hook threw:`, err?.message ?? err);
29
+ }
30
+ };
31
+ }
32
+
14
33
  /**
15
34
  * @typedef {Object} KnowlessOptions
16
35
  * @property {string} secret HMAC secret, ≥64 hex chars (32 bytes). FR-47, FR-48.
@@ -37,6 +56,21 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
37
56
  * @property {string[]} [trustedProxies]
38
57
  * @property {string} [shamRecipient='null@knowless.invalid'] See SPEC §7.4.
39
58
  * @property {number} [sweepIntervalMs] Sweeper tick; defaults to 5 minutes.
59
+ * @property {function} [onSweepError] Optional alerting hook for sweep failures.
60
+ * @property {function} [onMailerSubmit] v0.2.1. Per-event hook fired on
61
+ * successful mail submission for *real* (non-sham) sends only. Payload:
62
+ * `{messageId, handle, timestamp}`. Errors are caught and swallowed.
63
+ * @property {function} [onTransportFailure] v0.2.1. Per-event hook fired
64
+ * on SMTP errors. Payload: `{error, timestamp}`. Errors swallowed.
65
+ * @property {function} [onSuppressionWindow] v0.2.1. Heartbeat hook fired
66
+ * every `suppressionWindowMs` with aggregate counters. Payload:
67
+ * `{sham, rateLimited, windowMs}`. Aggregates the silent-202 branches
68
+ * (sham + rate-limit hits) without per-event identity disclosure;
69
+ * see knowless.context.md § "v0.2.1 design" for the threat-model
70
+ * reasoning. Fires even when both counters are zero (heartbeat).
71
+ * Errors swallowed.
72
+ * @property {number} [suppressionWindowMs=60000] v0.2.1. Cadence of
73
+ * `onSuppressionWindow` emissions. Default 60 seconds.
40
74
  * @property {object} [store] Inject your own store implementation.
41
75
  * @property {object} [mailer] Inject your own mailer.
42
76
  * @property {object} [transportOverride] Pass to nodemailer.createTransport (tests).
@@ -64,7 +98,12 @@ const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
64
98
  * verify: Function,
65
99
  * logout: Function,
66
100
  * loginForm: Function,
101
+ * handleFromRequest: (req: any) => string | null,
67
102
  * deleteHandle: (handle: string) => void,
103
+ * revokeSessions: (handle: string) => number,
104
+ * startLogin: (args: object) => Promise<{handle: string|null, submitted: true}>,
105
+ * deriveHandle: (email: string) => string,
106
+ * verifyTransport: () => Promise<true>,
68
107
  * config: object,
69
108
  * close: () => void,
70
109
  * }}
@@ -102,7 +141,58 @@ export function knowless(options = {}) {
102
141
  transportOverride: options.transportOverride,
103
142
  });
104
143
 
105
- const handlers = createHandlers({ store, mailer, config: options });
144
+ // v0.2.1 operator-visibility hooks. All optional. Validate types up
145
+ // front so a typo is caught at startup, not on the first hit.
146
+ for (const k of ['onMailerSubmit', 'onTransportFailure', 'onSuppressionWindow']) {
147
+ if (options[k] !== undefined && typeof options[k] !== 'function') {
148
+ throw new Error(`knowless: ${k} must be a function when provided`);
149
+ }
150
+ }
151
+ const suppressionWindowMs =
152
+ options.suppressionWindowMs ?? DEFAULT_SUPPRESSION_WINDOW_MS;
153
+ if (
154
+ typeof suppressionWindowMs !== 'number' ||
155
+ !Number.isFinite(suppressionWindowMs) ||
156
+ suppressionWindowMs <= 0
157
+ ) {
158
+ throw new Error('knowless: suppressionWindowMs must be a positive number');
159
+ }
160
+
161
+ // Counters reset every windowMs. Aggregating sham + rate-limit
162
+ // branches behind a windowed counter (rather than per-event hooks)
163
+ // is the deliberate design — see knowless.context.md § "Why three
164
+ // hooks, not four" for the threat-model justification.
165
+ let shamCount = 0;
166
+ let rateLimitedCount = 0;
167
+ const onMailerSubmit = safeHook(options.onMailerSubmit, 'onMailerSubmit');
168
+ const onTransportFailure = safeHook(options.onTransportFailure, 'onTransportFailure');
169
+ const onSuppressionWindow = safeHook(options.onSuppressionWindow, 'onSuppressionWindow');
170
+
171
+ const events = {
172
+ shamHit: () => { shamCount++; },
173
+ rateLimitHit: () => { rateLimitedCount++; },
174
+ onMailerSubmit,
175
+ onTransportFailure,
176
+ };
177
+
178
+ const handlers = createHandlers({ store, mailer, config: options, events });
179
+
180
+ // The window timer fires every windowMs as a heartbeat — emits even
181
+ // when both counters are zero. Operators rely on the heartbeat as a
182
+ // liveness signal ("knowless is processing"); a missing emission is
183
+ // itself diagnostic. Only run the timer when the hook is wired so we
184
+ // don't spend a setInterval slot on adopters who don't use it.
185
+ let suppressionTimer = null;
186
+ if (typeof options.onSuppressionWindow === 'function') {
187
+ suppressionTimer = setInterval(() => {
188
+ const sham = shamCount;
189
+ const rateLimited = rateLimitedCount;
190
+ shamCount = 0;
191
+ rateLimitedCount = 0;
192
+ onSuppressionWindow({ sham, rateLimited, windowMs: suppressionWindowMs });
193
+ }, suppressionWindowMs);
194
+ if (typeof suppressionTimer.unref === 'function') suppressionTimer.unref();
195
+ }
106
196
 
107
197
  const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
108
198
  const onSweepError = options.onSweepError;
@@ -163,8 +253,15 @@ export function knowless(options = {}) {
163
253
  config: handlers._config,
164
254
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
165
255
  _sweep: runSweep,
256
+ /** Probe the configured SMTP transport (v0.2.1). Resolves true on
257
+ * success, rejects with the underlying error. Opt-in fail-fast for
258
+ * adopters who want to validate SMTP at boot; no auto-on-boot
259
+ * variant by design — k8s readiness probes / docker-compose
260
+ * ordering would fail boot for the wrong reason. */
261
+ verifyTransport: () => mailer.verify(),
166
262
  close() {
167
263
  clearInterval(sweepTimer);
264
+ if (suppressionTimer !== null) clearInterval(suppressionTimer);
168
265
  try {
169
266
  mailer.close?.();
170
267
  } catch {
@@ -176,7 +273,13 @@ export function knowless(options = {}) {
176
273
  }
177
274
 
178
275
  export { createStore } from './store.js';
179
- export { createMailer, composeBody, validateSubject, validateBodyFooter } from './mailer.js';
276
+ export {
277
+ createMailer,
278
+ composeBody,
279
+ validateSubject,
280
+ validateBodyFooter,
281
+ validateBodyOverride,
282
+ } from './mailer.js';
180
283
  export { createHandlers } from './handlers.js';
181
284
  export { renderLoginForm } from './form.js';
182
285
  export { normalize, deriveHandle, secretBytes } from './handle.js';
package/src/mailer.js CHANGED
@@ -139,6 +139,60 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFoot
139
139
  return body;
140
140
  }
141
141
 
142
+ /**
143
+ * Validate a body produced by `startLogin`'s `bodyOverride` template
144
+ * function (AF-26). The override lets adopters phrase the email body
145
+ * to match per-call subjects (pin confirmation, login, etc.) without
146
+ * losing knowless's URL-composition / sham-work / 7bit invariants.
147
+ *
148
+ * Constraints (deliberately strict to preserve the v0.11 POC URL-line
149
+ * invariant — QP soft-breaks WILL break the magic link):
150
+ * - non-empty string
151
+ * - ≤ 2048 chars (operator-side overflow guard)
152
+ * - ASCII only
153
+ * - no CR (LF allowed; defense-in-depth header-injection guard)
154
+ * - the magic-link URL appears EXACTLY ONCE
155
+ * - that occurrence is on its own line (no leading or trailing
156
+ * non-newline characters on the same line)
157
+ *
158
+ * Throws on any violation. Adopter is responsible for the rest of
159
+ * the body content (security advice, expiry hint, etc.); knowless
160
+ * does not enforce semantic content.
161
+ *
162
+ * @param {unknown} body
163
+ * @param {string} url the magic-link URL knowless composed
164
+ * @returns {void} throws on invalid
165
+ */
166
+ export function validateBodyOverride(body, url) {
167
+ if (typeof body !== 'string' || body.length === 0) {
168
+ throw new Error('bodyOverride must return a non-empty string');
169
+ }
170
+ if (body.length > 2048) {
171
+ throw new Error('bodyOverride must return ≤ 2048 chars');
172
+ }
173
+ if (!ASCII_RE.test(body)) {
174
+ throw new Error('bodyOverride must return ASCII');
175
+ }
176
+ if (body.includes('\r')) {
177
+ throw new Error('bodyOverride must not contain CR (header-injection defense)');
178
+ }
179
+ const occurrences = body.split(url).length - 1;
180
+ if (occurrences === 0) {
181
+ throw new Error('bodyOverride must include the magic-link URL exactly once');
182
+ }
183
+ if (occurrences > 1) {
184
+ throw new Error('bodyOverride must include the magic-link URL exactly once');
185
+ }
186
+ const lines = body.split('\n');
187
+ const ownLineCount = lines.filter((l) => l === url).length;
188
+ if (ownLineCount !== 1) {
189
+ throw new Error(
190
+ 'bodyOverride must place the magic-link URL on its own line ' +
191
+ '(preserves the 7bit URL-line invariant; QP soft-breaks would break the link)',
192
+ );
193
+ }
194
+ }
195
+
142
196
  /**
143
197
  * Validate operator-overridden subject per SPEC §12.5.
144
198
  * Throws on invalid; warns (returns warnings array) on suspicious-but-allowed.
@@ -221,6 +275,27 @@ export function createMailer(cfg) {
221
275
  raw,
222
276
  });
223
277
  },
278
+ /**
279
+ * Probe the underlying SMTP transport. Resolves to true on success,
280
+ * rejects with the underlying error otherwise. Adopters call this
281
+ * explicitly when they want fail-fast on misconfigured SMTP at boot.
282
+ * No auto-on-boot variant: deployments where knowless starts before
283
+ * Postfix (docker-compose ordering, k8s readiness probes) would
284
+ * fail boot for the wrong reason. v0.2.1.
285
+ *
286
+ * Contract: non-rejection means success. The underlying nodemailer
287
+ * transport may return a truthy value, falsy value, or throw —
288
+ * non-throwing is treated as success and normalized to `true`.
289
+ * Tests using `streamTransport` exercise this normalization
290
+ * (streamTransport's verify() returns false even on healthy probes).
291
+ */
292
+ async verify() {
293
+ if (typeof transport.verify !== 'function') {
294
+ return true;
295
+ }
296
+ await transport.verify();
297
+ return true;
298
+ },
224
299
  close() {
225
300
  if (typeof transport.close === 'function') transport.close();
226
301
  },
package/src/store.js CHANGED
@@ -194,17 +194,23 @@ export function createStore(dbPath = ':memory:') {
194
194
  };
195
195
 
196
196
  // Transactional cap-check + insert per SPEC §4.7.
197
+ // Returns the number of tokens evicted to make room for the new one
198
+ // (always 0 when maxActive is 0). Callers use this to count
199
+ // per-handle-cap rotation events for operator monitoring (v0.2.1).
197
200
  const insertTokenAtomic = makeTransaction(db,
198
201
  (tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
202
+ let evicted = 0;
199
203
  if (maxActive > 0) {
200
204
  const { n: count } = stmt.countActiveTokens.get(handle, now);
201
205
  let toEvict = count - maxActive + 1;
202
206
  while (toEvict > 0) {
203
207
  stmt.evictOldestActive.run(handle, now);
204
208
  toEvict--;
209
+ evicted++;
205
210
  }
206
211
  }
207
212
  stmt.insertToken.run(tokenHash, handle, expiresAt, nextUrl, isSham);
213
+ return evicted;
208
214
  },
209
215
  );
210
216
 
@@ -231,6 +237,13 @@ export function createStore(dbPath = ':memory:') {
231
237
  },
232
238
 
233
239
  // --- Token ---
240
+ /**
241
+ * Insert a token, evicting oldest active tokens for the handle when
242
+ * the per-handle cap (maxActive) would be exceeded.
243
+ * @returns {number} count of tokens evicted to make room (0 when no
244
+ * rotation occurred). Used for operator monitoring (v0.2.1
245
+ * `onSuppressionWindow.rateLimited` counter).
246
+ */
234
247
  insertToken(args) {
235
248
  const {
236
249
  tokenHash,
@@ -243,7 +256,7 @@ export function createStore(dbPath = ':memory:') {
243
256
  } = args;
244
257
  assertHexHash(tokenHash, 'tokenHash');
245
258
  assertHexHash(handle, 'handle');
246
- insertTokenAtomic(
259
+ return insertTokenAtomic(
247
260
  tokenHash,
248
261
  handle,
249
262
  expiresAt,