knowless 0.1.10 → 0.2.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/src/handlers.js CHANGED
@@ -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) {
@@ -267,6 +280,7 @@ export function createHandlers({ store, mailer, config }) {
267
280
  HOUR_MS,
268
281
  )
269
282
  ) {
283
+ ev.rateLimitHit();
270
284
  return { handle: null, isSham: false, emailNorm, nextValidated: null };
271
285
  }
272
286
 
@@ -287,6 +301,11 @@ export function createHandlers({ store, mailer, config }) {
287
301
  )
288
302
  ) {
289
303
  // Cap exceeded — fall through to sham, do NOT short-circuit.
304
+ // The fall-through becomes a sham-hit too; both counters
305
+ // increment because they're independent dimensions (operator
306
+ // can correlate from `rateLimited` jumping in lockstep with
307
+ // `sham`).
308
+ ev.rateLimitHit();
290
309
  isCreating = false;
291
310
  }
292
311
  }
@@ -308,9 +327,10 @@ export function createHandlers({ store, mailer, config }) {
308
327
  } else {
309
328
  isSham = true;
310
329
  toAddress = cfg.shamRecipient;
330
+ ev.shamHit();
311
331
  }
312
332
 
313
- store.insertToken({
333
+ const evicted = store.insertToken({
314
334
  tokenHash: token.hash,
315
335
  handle,
316
336
  expiresAt,
@@ -318,6 +338,10 @@ export function createHandlers({ store, mailer, config }) {
318
338
  isSham,
319
339
  maxActive: cfg.maxActiveTokensPerHandle,
320
340
  });
341
+ // Per-handle token cap rotation is the third rate limit. Counted
342
+ // here in the aggregate `rateLimited` window so operators see
343
+ // hammering of a single handle without per-event identity leakage.
344
+ if (evicted > 0) ev.rateLimitHit();
321
345
 
322
346
  const mailBody = composeBody({
323
347
  tokenRaw: token.raw,
@@ -334,10 +358,29 @@ export function createHandlers({ store, mailer, config }) {
334
358
  // so timing equivalence is preserved.
335
359
  const effectiveSubject = subject ?? cfg.subject;
336
360
  try {
337
- await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
361
+ const info = await mailer.submit({
362
+ to: toAddress,
363
+ subject: effectiveSubject,
364
+ body: mailBody,
365
+ });
366
+ // v0.2.1: per-event hook on real (non-sham) submissions only.
367
+ // Sham branches go through the windowed aggregate; emitting them
368
+ // per-event here would let a careless adopter log per-handle
369
+ // data and reopen the enumeration oracle that sham-work exists
370
+ // to prevent.
371
+ if (!isSham) {
372
+ ev.onMailerSubmit({
373
+ messageId: info?.messageId ?? null,
374
+ handle,
375
+ timestamp: Date.now(),
376
+ });
377
+ }
338
378
  } catch (err) {
339
379
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
340
380
  console.error('[knowless] mail submit failed:', err.message);
381
+ // v0.2.1: per-event hook for SMTP failures. Carries no identity
382
+ // data, safe per-event. Operator wires this to alerting.
383
+ ev.onTransportFailure({ error: err, timestamp: Date.now() });
341
384
  // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
342
385
  // development the operator otherwise has no way to obtain the magic
343
386
  // link. Print it to stderr only when explicitly opted in.
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 {
package/src/mailer.js CHANGED
@@ -221,6 +221,27 @@ export function createMailer(cfg) {
221
221
  raw,
222
222
  });
223
223
  },
224
+ /**
225
+ * Probe the underlying SMTP transport. Resolves to true on success,
226
+ * rejects with the underlying error otherwise. Adopters call this
227
+ * explicitly when they want fail-fast on misconfigured SMTP at boot.
228
+ * No auto-on-boot variant: deployments where knowless starts before
229
+ * Postfix (docker-compose ordering, k8s readiness probes) would
230
+ * fail boot for the wrong reason. v0.2.1.
231
+ *
232
+ * Contract: non-rejection means success. The underlying nodemailer
233
+ * transport may return a truthy value, falsy value, or throw —
234
+ * non-throwing is treated as success and normalized to `true`.
235
+ * Tests using `streamTransport` exercise this normalization
236
+ * (streamTransport's verify() returns false even on healthy probes).
237
+ */
238
+ async verify() {
239
+ if (typeof transport.verify !== 'function') {
240
+ return true;
241
+ }
242
+ await transport.verify();
243
+ return true;
244
+ },
224
245
  close() {
225
246
  if (typeof transport.close === 'function') transport.close();
226
247
  },
package/src/store.js CHANGED
@@ -1,4 +1,32 @@
1
- import Database from 'better-sqlite3';
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ /**
4
+ * Wrap a function in a SQLite transaction. Mirrors better-sqlite3's
5
+ * `db.transaction(fn)` shape: returns a function that opens
6
+ * BEGIN IMMEDIATE, runs `fn`, COMMITs on success, ROLLBACKs on
7
+ * throw, and propagates the original error.
8
+ *
9
+ * BEGIN IMMEDIATE rather than BEGIN DEFERRED — knowless's
10
+ * transactional cap-check (SPEC §4.7) needs a write lock from the
11
+ * start to serialise concurrent issuance attempts.
12
+ *
13
+ * @param {DatabaseSync} db
14
+ * @param {Function} fn
15
+ */
16
+ function makeTransaction(db, fn) {
17
+ return (...args) => {
18
+ db.exec('BEGIN IMMEDIATE');
19
+ let result;
20
+ try {
21
+ result = fn(...args);
22
+ } catch (err) {
23
+ try { db.exec('ROLLBACK'); } catch { /* tolerate stack-unwind issues */ }
24
+ throw err;
25
+ }
26
+ db.exec('COMMIT');
27
+ return result;
28
+ };
29
+ }
2
30
 
3
31
  /**
4
32
  * Default token-sweeper grace: keep used tokens for 24h after redemption
@@ -76,11 +104,11 @@ const DDL = `
76
104
  * @returns {Store}
77
105
  */
78
106
  export function createStore(dbPath = ':memory:') {
79
- const db = new Database(dbPath);
80
- db.pragma('journal_mode = WAL');
81
- db.pragma('synchronous = NORMAL');
82
- db.pragma('foreign_keys = OFF');
83
- db.pragma('temp_store = MEMORY');
107
+ const db = new DatabaseSync(dbPath);
108
+ db.exec('PRAGMA journal_mode = WAL');
109
+ db.exec('PRAGMA synchronous = NORMAL');
110
+ db.exec('PRAGMA foreign_keys = OFF');
111
+ db.exec('PRAGMA temp_store = MEMORY');
84
112
  db.exec(DDL);
85
113
 
86
114
  const existing = db
@@ -166,22 +194,28 @@ export function createStore(dbPath = ':memory:') {
166
194
  };
167
195
 
168
196
  // Transactional cap-check + insert per SPEC §4.7.
169
- const insertTokenAtomic = db.transaction(
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).
200
+ const insertTokenAtomic = makeTransaction(db,
170
201
  (tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
202
+ let evicted = 0;
171
203
  if (maxActive > 0) {
172
204
  const { n: count } = stmt.countActiveTokens.get(handle, now);
173
205
  let toEvict = count - maxActive + 1;
174
206
  while (toEvict > 0) {
175
207
  stmt.evictOldestActive.run(handle, now);
176
208
  toEvict--;
209
+ evicted++;
177
210
  }
178
211
  }
179
212
  stmt.insertToken.run(tokenHash, handle, expiresAt, nextUrl, isSham);
213
+ return evicted;
180
214
  },
181
215
  );
182
216
 
183
217
  // Transactional account deletion per FR-37a.
184
- const deleteHandleAtomic = db.transaction((handle) => {
218
+ const deleteHandleAtomic = makeTransaction(db, (handle) => {
185
219
  stmt.deleteHandleSessions.run(handle);
186
220
  stmt.deleteHandleTokens.run(handle);
187
221
  stmt.deleteHandleRow.run(handle);
@@ -203,6 +237,13 @@ export function createStore(dbPath = ':memory:') {
203
237
  },
204
238
 
205
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
+ */
206
247
  insertToken(args) {
207
248
  const {
208
249
  tokenHash,
@@ -215,7 +256,7 @@ export function createStore(dbPath = ':memory:') {
215
256
  } = args;
216
257
  assertHexHash(tokenHash, 'tokenHash');
217
258
  assertHexHash(handle, 'handle');
218
- insertTokenAtomic(
259
+ return insertTokenAtomic(
219
260
  tokenHash,
220
261
  handle,
221
262
  expiresAt,