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/CHANGELOG.md +185 -8
- package/GUIDE.md +193 -14
- package/README.md +99 -159
- package/knowless.context.md +190 -8
- package/package.json +1 -1
- package/src/handlers.js +91 -13
- package/src/index.js +105 -2
- package/src/mailer.js +75 -0
- package/src/store.js +14 -1
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({
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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,
|