@blamejs/core 0.18.54 → 0.18.56

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/NOTICE +5 -5
  3. package/README.md +9 -9
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/audit-sign.js +24 -5
  6. package/lib/audit.js +26 -24
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/chain-writer.js +17 -0
  9. package/lib/db-file-lifecycle.js +14 -3
  10. package/lib/db.js +505 -49
  11. package/lib/guard-filename.js +8 -1
  12. package/lib/guard-html.js +10 -2
  13. package/lib/guard-list-unsubscribe.js +6 -1
  14. package/lib/guard-managesieve-command.js +24 -3
  15. package/lib/guard-smtp-command.js +20 -4
  16. package/lib/guard-svg.js +6 -1
  17. package/lib/http-client.js +17 -3
  18. package/lib/mail-agent.js +6 -4
  19. package/lib/mail-auth.js +59 -2
  20. package/lib/mail-server-imap.js +65 -34
  21. package/lib/mail-server-managesieve.js +65 -42
  22. package/lib/mail-server-mx.js +313 -40
  23. package/lib/mail-server-net.js +155 -1
  24. package/lib/mail-server-pop3.js +16 -19
  25. package/lib/mail-server-rate-limit.js +104 -6
  26. package/lib/mail-server-submission.js +162 -33
  27. package/lib/mail-server-tls.js +71 -11
  28. package/lib/mcp.js +11 -3
  29. package/lib/middleware/csrf-protect.js +37 -20
  30. package/lib/middleware/require-mtls.js +8 -1
  31. package/lib/network-tls.js +18 -0
  32. package/lib/safe-mount-info.js +39 -6
  33. package/lib/safe-smtp.js +96 -1
  34. package/lib/safe-url.js +8 -2
  35. package/lib/self-update.js +4 -1
  36. package/lib/session-stores.js +6 -3
  37. package/lib/vendor/MANIFEST.json +34 -34
  38. package/lib/vendor/blamejs-pki.cjs +396 -37
  39. package/lib/vendor/browser/noble-ciphers.mjs +15 -1
  40. package/lib/vendor/browser/noble-hashes.mjs +12 -4
  41. package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
  42. package/lib/vendor/noble-ciphers.cjs +15 -1
  43. package/lib/vendor/noble-curves.cjs +46 -21
  44. package/lib/vendor/noble-post-quantum.cjs +184 -75
  45. package/lib/watcher.js +31 -6
  46. package/lib/ws-client.js +17 -2
  47. package/package.json +1 -1
  48. package/sbom.cdx.json +6 -6
@@ -3,6 +3,7 @@
3
3
  "use strict";
4
4
 
5
5
  var codepointClass = require("./codepoint-class");
6
+ var bCrypto = require("./crypto");
6
7
 
7
8
  // mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
8
9
  // servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
@@ -16,6 +17,14 @@ var codepointClass = require("./codepoint-class");
16
17
  // EACCES) rejects the listen promise instead of crashing the process. That, plus
17
18
  // the listening/server state, is what createTcpListener owns.
18
19
 
20
+ // The listener's own ceiling when an operator names none. Every mail listener
21
+ // shares it, so a deployment that raises or lowers it does so in one place and
22
+ // the five listeners cannot drift apart on what "too many" means. Set well
23
+ // above any single-host mail deployment's working set and far below what an
24
+ // unbounded accept loop will take: the point is that a ceiling EXISTS, since
25
+ // the per-address cap alone leaves the total at (cap x source addresses).
26
+ var DEFAULT_MAX_CONNECTIONS = 1024;
27
+
19
28
  // createTcpListener(net, cfg) — build a listener lifecycle.
20
29
  // cfg.defaultPort port used when listenOpts.port is omitted (an explicit
21
30
  // 0 is honored, for an ephemeral test bind).
@@ -26,6 +35,15 @@ var codepointClass = require("./codepoint-class");
26
35
  // cfg.listeningEvent the "...listening" audit action.
27
36
  // cfg.listeningExtra optional () => object merged onto the listening event
28
37
  // payload (Submission reports implicitTls).
38
+ // cfg.maxConnections the listener's own ceiling on concurrently accepted
39
+ // sockets. The per-address cap in b.mail.server.rateLimit
40
+ // bounds ONE peer; it says nothing about how many peers
41
+ // there are, so the process-wide total was the per-address
42
+ // cap times however many source addresses the caller could
43
+ // speak from — a number a botnet, a NAT pool or a single
44
+ // v6 /64 makes large. Enforced by the runtime, which closes
45
+ // the excess socket before handleConnection ever sees it,
46
+ // so a refusal costs no descriptor and no state machine.
29
47
  // Returns { listen, getServer, isListening, markClosed } — the server wires its
30
48
  // own close() through getServer()/isListening()/markClosed().
31
49
  function createTcpListener(net, cfg) {
@@ -40,6 +58,7 @@ function createTcpListener(net, cfg) {
40
58
  var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
41
59
  var address = listenOpts.address || "0.0.0.0";
42
60
  server = net.createServer(function (socket) { cfg.handleConnection(socket); });
61
+ server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
43
62
  return new Promise(function (resolve, reject) {
44
63
  server.once("error", reject);
45
64
  server.listen(port, address, function () {
@@ -110,6 +129,7 @@ function createStoreServer(net, cfg) {
110
129
  var ErrorClass = cfg.errorClass;
111
130
  var listener = createTcpListener(net, {
112
131
  defaultPort: cfg.defaultPort,
132
+ maxConnections: cfg.maxConnections,
113
133
  handleConnection: cfg.handleConnection,
114
134
  errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
115
135
  emit: cfg.emit,
@@ -122,7 +142,137 @@ function createStoreServer(net, cfg) {
122
142
  closedEvent: cfg.eventBase + ".closed",
123
143
  });
124
144
  }
125
- return { listen: listener.listen, close: close };
145
+ return {
146
+ listen: listener.listen,
147
+ close: close,
148
+ connectionCount: function () { return cfg.connections.size; },
149
+ };
150
+ }
151
+
152
+ // trackConnection(socket, cfg) — the other half of admitConnection, and the
153
+ // reason it is here rather than written out per listener.
154
+ //
155
+ // A connection occupies two ledgers: the rate limiter's per-address count, and
156
+ // the listener's live-socket set that shutdown drains. Both are released by the
157
+ // SAME event — the socket closing — and it does not matter who closed it. Five
158
+ // listeners each wrote that pairing by hand and one of them registered only the
159
+ // release, so a peer that opened a connection, took the greeting and dropped TCP
160
+ // freed its rate-limit slot (and could reconnect at once) while its set entry
161
+ // stayed forever. Nothing authenticates before that point, so the growth was
162
+ // unauthenticated and unbounded.
163
+ //
164
+ // Registering both in one handler is what makes the pair impossible to
165
+ // half-write.
166
+ // cfg.connections the listener's live-socket Set.
167
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
168
+ // cfg.remoteAddress the address admitConnection returned.
169
+ // cfg.closeSource optional socket whose "close" drives teardown, when the
170
+ // tracked socket is a wrapper (Submission's implicit-TLS
171
+ // path tracks the TLSSocket but the raw socket is the one
172
+ // that carries the FIN).
173
+ function trackConnection(socket, cfg) {
174
+ cfg.connections.add(socket);
175
+ var source = cfg.closeSource || socket;
176
+ source.once("close", function () {
177
+ cfg.rateLimit.releaseConnection(cfg.remoteAddress);
178
+ cfg.connections.delete(socket);
179
+ });
180
+ }
181
+
182
+ // createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
183
+ // windows instead of the whole transfer.
184
+ //
185
+ // A lifetime average lets an early burst pay for an arbitrarily slow tail: at
186
+ // the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
187
+ // six, which a peer spends holding a connection and its slot in the per-address
188
+ // cap while sending a byte at a time. The floor was enforced and still
189
+ // bypassable, which is the worse of the two states — it reads as covered.
190
+ //
191
+ // Each window has to meet the floor on its own, so nothing sent earlier pays
192
+ // for what is sent now. A window only rolls forward once it has run long enough
193
+ // to be a real judgement; inside that stretch no verdict is reached, so a
194
+ // sender pausing to read from its own spool is not cut off for the pause, and a
195
+ // fast chunk cannot keep resetting the clock to dodge the next measurement.
196
+ //
197
+ // `now` is a parameter rather than a call to the clock so the whole thing can
198
+ // be driven across days of simulated time without waiting for them.
199
+ // rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
200
+ function createBodyRateWindow(rateLimit) {
201
+ var windowStart = 0;
202
+ var windowBytes = 0;
203
+ return {
204
+ // Open the first window. `bytesSeen` is the caller's running total AT THIS
205
+ // MOMENT, which becomes the baseline every later reading is measured
206
+ // against — so the caller is free to keep one connection-lifetime counter
207
+ // incremented at the wire boundary rather than a per-transfer one. That
208
+ // matters where a listener re-feeds part of a chunk through its own parser:
209
+ // a counter maintained inside the parser credits those bytes twice, and a
210
+ // peer that pipelines a one-byte payload with the next command gets roughly
211
+ // double the rate it is actually sending.
212
+ start: function (now, bytesSeen) {
213
+ windowStart = now;
214
+ windowBytes = bytesSeen || 0;
215
+ },
216
+ // `bytesSeen` is the running total for the whole body; the window's own
217
+ // count is derived, so the caller keeps one counter rather than two.
218
+ starved: function (bytesSeen, now) {
219
+ var elapsed = now - windowStart;
220
+ if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
221
+ // The limiter says how long a window must run before rolling, because the
222
+ // limiter is what decides when a measurement is old enough to mean
223
+ // something. Rolling on a number held HERE would ask a limiter that
224
+ // judges over a longer stretch only ever before it can answer: every call
225
+ // returning "too early", the window resetting underneath it, and its rate
226
+ // protection silently disabled while looking wired.
227
+ if (elapsed >= rateLimit.bodyRateWindowMs()) {
228
+ windowStart = now;
229
+ windowBytes = bytesSeen;
230
+ }
231
+ return false;
232
+ },
233
+ };
234
+ }
235
+
236
+ // acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
237
+ // accept and its own state machine: gate the address, mint the connection id,
238
+ // wrap the socket if the protocol starts in TLS, and enter both ledgers.
239
+ //
240
+ // Every listener performed these four steps in the same order, and the parts
241
+ // that differ between them are three strings and an optional wrap. Keeping the
242
+ // order in one place is what stops a fifth listener from being written with one
243
+ // of the steps missing, which is how the tracking-set entry and the rate-limit
244
+ // slot came apart in the first place.
245
+ //
246
+ // Returns null when the address was refused — the caller returns immediately;
247
+ // the refusal line and teardown are already done.
248
+ // cfg.rateLimit the resolved b.mail.server.rateLimit.
249
+ // cfg.connections the listener's live-socket Set.
250
+ // cfg.emit the listener's audit emitter.
251
+ // cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
252
+ // cfg.refusalLine the protocol's refusal bytes.
253
+ // cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
254
+ // cfg.wrap optional (rawSocket) => socket, for a protocol that
255
+ // starts inside TLS. The RAW socket still drives teardown:
256
+ // a handshake that never completes closes it without the
257
+ // wrapper ever being established.
258
+ function acceptConnection(rawSocket, cfg) {
259
+ var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
260
+ refusedEvent: cfg.refusedEvent,
261
+ refusalLine: cfg.refusalLine,
262
+ });
263
+ if (remoteAddress === null) return null;
264
+ var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
265
+ trackConnection(socket, {
266
+ connections: cfg.connections,
267
+ rateLimit: cfg.rateLimit,
268
+ remoteAddress: remoteAddress,
269
+ closeSource: socket === rawSocket ? null : rawSocket,
270
+ });
271
+ return {
272
+ socket: socket,
273
+ remoteAddress: remoteAddress,
274
+ connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
275
+ };
126
276
  }
127
277
 
128
278
  // admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
@@ -299,6 +449,10 @@ module.exports = {
299
449
  runSaslStep: runSaslStep,
300
450
  createStoreServer: createStoreServer,
301
451
  admitConnection: admitConnection,
452
+ trackConnection: trackConnection,
453
+ acceptConnection: acceptConnection,
454
+ createBodyRateWindow: createBodyRateWindow,
455
+ DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
302
456
  validateDomainHardened: validateDomainHardened,
303
457
  saslChallengeOrNull: saslChallengeOrNull,
304
458
  replyTextOrFallback: replyTextOrFallback,
@@ -52,9 +52,10 @@
52
52
  * `authFailuresPerIpPer15Min` cap applies to USER+PASS / APOP /
53
53
  * AUTH refusals.
54
54
  *
55
- * - **Slow-loris on RETR / TOP** — per-connection `idleTimeoutMs`
56
- * bounds dead connections; `b.mail.server.rateLimit.minBytesPerSecond`
57
- * bounds trickle-receive class.
55
+ * - **Slow-loris** — per-connection `idleTimeoutMs` bounds a peer that
56
+ * stops making progress, in either direction: a client that sends no
57
+ * further command, and one that stops taking a RETR / TOP response.
58
+ * `maxLineBytes` bounds a command line that never ends.
58
59
  *
59
60
  * ## Audit lifecycle
60
61
  *
@@ -107,7 +108,6 @@
107
108
  var net = require("node:net");
108
109
  var safeBuffer = require("./safe-buffer");
109
110
  var C = require("./constants");
110
- var bCrypto = require("./crypto");
111
111
  var numericBounds = require("./numeric-bounds");
112
112
  var validateOpts = require("./validate-opts");
113
113
  var guardPop3Command = require("./guard-pop3-command");
@@ -152,6 +152,7 @@ var ERR_CLAMP = 200;
152
152
  * greeting: string, // default "blamejs POP3"
153
153
  * maxLineBytes: number, // default 1024
154
154
  * idleTimeoutMs: number, // default 10 min
155
+ * maxConnections: number, // default 1024 — listener-wide ceiling
155
156
  * commitTimeoutMs: number, // default 30 s (UPDATE-state mailStore.commitPop3Drop cap)
156
157
  * profile: "strict" | "balanced" | "permissive",
157
158
  * auth: {
@@ -189,7 +190,7 @@ function create(opts) {
189
190
  "getMessage/listMessages/markDelete; compose b.mailStore.create or operator-supplied backend)");
190
191
  }
191
192
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
192
- ["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs"],
193
+ ["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs", "maxConnections"],
193
194
  "mail.server.pop3.", MailServerPop3Error, "mail-server-pop3/bad-bound");
194
195
 
195
196
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -239,23 +240,18 @@ function create(opts) {
239
240
  var _emit = auditEmit.emit;
240
241
 
241
242
  function _handleConnection(rawSocket) {
242
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
243
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
244
+ rateLimit: rateLimit,
245
+ connections: connections,
246
+ emit: _emit,
243
247
  refusedEvent: "mail.server.pop3.rate_limit_refused",
244
248
  refusalLine: "-ERR Too many connections from your IP\r\n",
249
+ idPrefix: "pop3conn-",
245
250
  });
246
- if (remoteAddress === null) return;
247
- var connectionId = "pop3conn-" + bCrypto.generateToken(8); // connection-id length
248
- var socket = rawSocket;
249
- connections.add(socket);
250
- // Single close handler covers BOTH operator-driven `_close(socket)`
251
- // and client-initiated disconnects (TCP FIN / RST without a
252
- // server-side close call) — releases the rate-limit slot AND
253
- // removes the socket from the tracking set so it can't accumulate
254
- // stale entries across long-lived deployments.
255
- rawSocket.once("close", function () {
256
- rateLimit.releaseConnection(remoteAddress);
257
- connections.delete(socket);
258
- });
251
+ if (accepted === null) return;
252
+ var remoteAddress = accepted.remoteAddress;
253
+ var connectionId = accepted.connectionId;
254
+ var socket = accepted.socket;
259
255
 
260
256
  var state = {
261
257
  id: connectionId,
@@ -900,6 +896,7 @@ function create(opts) {
900
896
  // ---- Lifecycle ----------------------------------------------------------
901
897
  return mailServerNet.createStoreServer(net, {
902
898
  defaultPort: 110, // RFC 1939 POP3 port (IANA)
899
+ maxConnections: opts.maxConnections,
903
900
  handleConnection: _handleConnection,
904
901
  errorClass: MailServerPop3Error,
905
902
  errorCodePrefix: "mail-server-pop3/",
@@ -45,6 +45,11 @@
45
45
  * trickling one byte per minute to hold a connection for hours
46
46
  * within the idle window.
47
47
  *
48
+ * It measures bytes ARRIVING, so it applies to the two listeners
49
+ * that take a message body: `b.mail.server.mx` and
50
+ * `b.mail.server.submission`. A peer that stops taking a response
51
+ * is a different condition, and idleTimeoutMs is what bounds it.
52
+ *
48
53
  * ## What this module is NOT
49
54
  *
50
55
  * - **Not an HTTP rate-limiter.** `b.middleware.rateLimit` covers
@@ -114,6 +119,14 @@ var DEFAULTS = Object.freeze({
114
119
  });
115
120
 
116
121
  var CONNECTION_RATE_WINDOW_MS = C.TIME.minutes(1);
122
+ // How long a DATA body is given before its byte rate is judged. Below this the
123
+ // measurement means nothing: the first chunk arrives with almost no elapsed
124
+ // time, so any rate computed from it is noise, and a sender that pauses briefly
125
+ // to read from its own spool would be cut off for it. Ten seconds at the
126
+ // default 100 B/s floor means a peer must have delivered a kilobyte before it
127
+ // is asked to justify itself, which no real MTA fails and a one-byte-per-minute
128
+ // trickler cannot reach.
129
+ var BODY_RATE_GRACE_MS = C.TIME.seconds(10);
117
130
  var AUTH_FAILURE_WINDOW_MS = C.TIME.minutes(15);
118
131
  var RCPT_FAILURE_WINDOW_MS = C.TIME.minutes(1);
119
132
 
@@ -294,8 +307,45 @@ function create(opts) {
294
307
  }
295
308
 
296
309
  function minBytesPerSecond() { return cfg.disabled ? 0 : cfg.minBytesPerSecond; }
310
+
311
+ // How long one body-rate window must run before a verdict from it means
312
+ // anything, and therefore how often the caller rolls its window forward.
313
+ //
314
+ // It lives on the limiter because the limiter is what decides when a
315
+ // measurement is old enough to judge. A caller holding its own number would
316
+ // ask a limiter with a longer interval only ever before it can answer, and
317
+ // that limiter's protection would be off while appearing to be wired.
318
+ function bodyRateWindowMs() { return BODY_RATE_GRACE_MS; }
297
319
  function isDisabled() { return cfg.disabled; }
298
320
 
321
+ // Is a DATA body arriving below the configured floor?
322
+ //
323
+ // The module has documented this floor since it shipped and nothing enforced
324
+ // it: the value was validated, defaulted and exposed as a getter that no
325
+ // listener ever called. `idleTimeoutMs` cuts a fully stalled connection, but
326
+ // a peer trickling a byte every few seconds resets that timer forever and
327
+ // holds a connection — and its slot in the per-address cap — for as long as
328
+ // it likes. This is the check that ends it.
329
+ //
330
+ // Both listeners reach it, so the policy lives here with the number rather
331
+ // than in two copies that can drift. Nothing is stored, so a connection needs
332
+ // no teardown to release it.
333
+ //
334
+ // `bytes` and `elapsedMs` describe ONE WINDOW, not the whole transfer. The
335
+ // difference is the whole defence: measured across the lifetime of a body, an
336
+ // early burst pays for an arbitrarily slow tail — at the default floor an
337
+ // 8 MiB burst buys about a day and 50 MiB buys six. The caller owns the
338
+ // window because the window is per-connection state and this limiter is
339
+ // shared across every connection; b.mail.server.net's createBodyRateWindow is
340
+ // what both listeners use to keep those windows identical.
341
+ function bodyRateStarved(bytes, elapsedMs) {
342
+ if (cfg.disabled) return false;
343
+ if (cfg.minBytesPerSecond <= 0) return false;
344
+ if (!(elapsedMs >= BODY_RATE_GRACE_MS)) return false; // too early to judge
345
+ var seconds = elapsedMs / C.TIME.seconds(1);
346
+ return (bytes / seconds) < cfg.minBytesPerSecond;
347
+ }
348
+
299
349
  return {
300
350
  admitConnection: admitConnection,
301
351
  releaseConnection: releaseConnection,
@@ -304,6 +354,8 @@ function create(opts) {
304
354
  checkRcptAdmit: checkRcptAdmit,
305
355
  noteRcptFailure: noteRcptFailure,
306
356
  minBytesPerSecond: minBytesPerSecond,
357
+ bodyRateStarved: bodyRateStarved,
358
+ bodyRateWindowMs: bodyRateWindowMs,
307
359
  isDisabled: isDisabled,
308
360
  };
309
361
  }
@@ -316,20 +368,61 @@ function create(opts) {
316
368
  * @related b.mail.server.rateLimit.create
317
369
  *
318
370
  * Resolve a rate-limit `spec` into a limiter. `false` disables limiting
319
- * (a disabled limiter that always admits), an already-built limiter — one
320
- * exposing `admitConnection` — passes through unchanged, and anything else
321
- * is treated as `create()` options. Every mail server (IMAP / POP3 / SMTP
322
- * MX / Submission / ManageSieve) composes this at the top of its `create()`
323
- * so the spec contract is identical across protocols.
371
+ * (a disabled limiter that always admits), an already-built limiter passes
372
+ * through unchanged, and anything else is treated as `create()` options.
373
+ * Every mail server (IMAP / POP3 / SMTP MX / Submission / ManageSieve)
374
+ * composes this at the top of its `create()` so the spec contract is
375
+ * identical across protocols.
376
+ *
377
+ * A custom limiter is recognised by `admitConnection` and must implement the
378
+ * whole interface the listeners call — `admitConnection`, `releaseConnection`,
379
+ * `checkAuthAdmit`, `noteAuthFailure`, `checkRcptAdmit`, `noteRcptFailure`,
380
+ * `minBytesPerSecond`, `bodyRateStarved`, `bodyRateWindowMs` — or resolve
381
+ * refuses at config time
382
+ * and names what is missing. A partial object used to be accepted here and
383
+ * fail later from inside a connection handler, on whichever request first
384
+ * reached a method it did not have. The supported way to customise part of the
385
+ * behaviour is to build `create({...})` and override what you want to change,
386
+ * so a method added in a later release cannot silently go unimplemented.
324
387
  *
325
388
  * @example
326
389
  * var b = require("blamejs");
327
390
  * var rl = b.mail.server.rateLimit.resolve(false); // disabled
328
391
  * // → a limiter whose admitConnection always admits
329
392
  */
393
+ // What a listener actually calls on a limiter. `admitConnection` alone used to
394
+ // be the whole sniff, so an operator-supplied object carrying only that was
395
+ // accepted here and then failed later, from inside a connection handler, on the
396
+ // first request that reached one of the other calls — a TypeError mid-request
397
+ // rather than an error at boot. `releaseConnection` has been called on every
398
+ // socket close for as long as the listeners have tracked connections, so an
399
+ // incomplete limiter was already breaking before the body-rate floor was added;
400
+ // the floor only made it break sooner and more visibly.
401
+ //
402
+ // Config-time refusal naming the gap is the right tier for this: the operator
403
+ // learns at boot, not from a message that died mid-transaction.
404
+ var LIMITER_INTERFACE = Object.freeze([
405
+ "admitConnection", "releaseConnection",
406
+ "checkAuthAdmit", "noteAuthFailure",
407
+ "checkRcptAdmit", "noteRcptFailure",
408
+ "minBytesPerSecond", "bodyRateStarved", "bodyRateWindowMs",
409
+ ]);
410
+
330
411
  function resolve(spec) {
331
412
  if (spec === false) return create({ disabled: true });
332
- if (spec && typeof spec.admitConnection === "function") return spec;
413
+ if (spec && typeof spec.admitConnection === "function") {
414
+ var missing = LIMITER_INTERFACE.filter(function (m) {
415
+ return typeof spec[m] !== "function";
416
+ });
417
+ if (missing.length > 0) {
418
+ throw new MailServerRateLimitError("mail-server-rate-limit/incomplete-limiter",
419
+ "mail.server.rateLimit.resolve: a custom limiter must implement the whole " +
420
+ "interface the listeners call; missing: " + missing.join(", ") + ". Wrap " +
421
+ "b.mail.server.rateLimit.create({...}) and override the parts you want to " +
422
+ "change, so a method added later cannot silently go unimplemented.");
423
+ }
424
+ return spec;
425
+ }
333
426
  return create(spec || {});
334
427
  }
335
428
 
@@ -338,4 +431,9 @@ module.exports = {
338
431
  resolve: resolve,
339
432
  MailServerRateLimitError: MailServerRateLimitError,
340
433
  DEFAULTS: DEFAULTS,
434
+ // How long a body-rate measurement must run before it means anything.
435
+ // Exported because the listener side owns the WINDOW — this limiter answers
436
+ // "is this rate below the floor", and the caller decides which stretch of
437
+ // the transfer to ask about.
438
+ BODY_RATE_GRACE_MS: BODY_RATE_GRACE_MS,
341
439
  };