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/CHANGELOG.md +148 -5
- package/GUIDE.md +148 -13
- package/OPS.md +8 -7
- package/README.md +155 -103
- package/knowless.context.md +173 -6
- package/package.json +2 -3
- package/src/handlers.js +46 -3
- package/src/index.js +98 -1
- package/src/mailer.js +21 -0
- package/src/store.js +50 -9
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
80
|
-
db.
|
|
81
|
-
db.
|
|
82
|
-
db.
|
|
83
|
-
db.
|
|
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
|
-
|
|
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
|
|
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,
|