@blamejs/core 0.10.8 → 0.10.11

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.
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.jose.jwe.experimental
4
+ * @nav Crypto
5
+ * @title JOSE JWE (experimental, ML-KEM)
6
+ *
7
+ * @intro
8
+ * JSON Web Encryption (RFC 7516) with ML-KEM-1024 key encapsulation
9
+ * and XChaCha20-Poly1305 AEAD content encryption. Lives under
10
+ * `b.jose.jwe.experimental` because the JOSE PQC IANA codepoint
11
+ * registration (draft-ietf-jose-pqc-kem-05) hasn't finalized — the
12
+ * namespace name is the contract: codepoints may change between
13
+ * minors without the framework's stable surface being affected.
14
+ *
15
+ * When the JOSE WG closes the draft and IANA registers final
16
+ * codepoints, the same primitives graduate to `b.jose.jwe` (or a
17
+ * stable equivalent) with explicit deprecation of the experimental
18
+ * namespace and a one-minor migration window per the framework's
19
+ * stable-upgrade-policy rule.
20
+ *
21
+ * Compact serialization only — no JSON serialization variant
22
+ * (saves wire-format complexity at this experimental tier;
23
+ * operators that need JWE-JSON wait for the stable surface).
24
+ *
25
+ * @card
26
+ * Experimental JWE with ML-KEM-1024 KEM + XChaCha20-Poly1305 content encryption. Codepoints follow draft-ietf-jose-pqc-kem (may change before IANA registration).
27
+ */
28
+
29
+ var bCrypto = require("./crypto");
30
+ var canonicalJson = require("./canonical-json");
31
+ var { defineClass } = require("./framework-error");
32
+ var audit = require("./audit");
33
+
34
+ var JoseJweExperimentalError = defineClass("JoseJweExperimentalError", { alwaysPermanent: true });
35
+
36
+ // Active draft (as of 2026-05-17). When IANA finalizes the codepoint
37
+ // the framework graduates the alg name to its stable form and ships a
38
+ // one-minor deprecation window via the existing `deprecate()` chain.
39
+ var EXPERIMENTAL_ALG = "ML-KEM-1024";
40
+ var EXPERIMENTAL_ENC = "XC20P"; // XChaCha20-Poly1305 per draft-irtf-cfrg-xchacha; aligns with framework PQC-first defaults
41
+
42
+ /**
43
+ * @primitive b.jose.jwe.experimental.encrypt
44
+ * @signature b.jose.jwe.experimental.encrypt(plaintext, recipientPublicKeyPem, opts?)
45
+ * @since 0.10.10
46
+ * @status experimental
47
+ * @related b.jose.jwe.experimental.decrypt, b.crypto.encrypt
48
+ *
49
+ * Encrypt a payload under the recipient's ML-KEM-1024 public key.
50
+ * Returns the JWE compact serialization
51
+ * `<header>.<encrypted_key>.<iv>.<ciphertext>.<tag>` (base64url
52
+ * segments) per RFC 7516 §3.1 with experimental PQC codepoints.
53
+ *
54
+ * Header includes `{ alg: "ML-KEM-1024", enc: "XC20P", typ: "JWE",
55
+ * "x-blamejs-experimental": true }` — operators that scrape JWE
56
+ * envelopes can refuse the experimental marker until the codepoint
57
+ * stabilises.
58
+ *
59
+ * @opts
60
+ * typ: string, // optional JWE "typ" header
61
+ * contentType: string, // optional JWE "cty" header
62
+ * audit: boolean, // default true; emit audit event on encrypt
63
+ *
64
+ * @example
65
+ * var pair = b.crypto.generateEncryptionKeyPair();
66
+ * var jwe = b.jose.jwe.experimental.encrypt("hello", pair.mlkem.publicKey);
67
+ * typeof jwe; // → "string" (compact form)
68
+ */
69
+ function encrypt(plaintext, recipientPublicKeyPem, opts) {
70
+ opts = opts || {};
71
+ if (!(plaintext instanceof Buffer)) {
72
+ if (typeof plaintext === "string") plaintext = Buffer.from(plaintext, "utf8");
73
+ else {
74
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-plaintext",
75
+ "encrypt: plaintext must be a Buffer or string");
76
+ }
77
+ }
78
+ if (typeof recipientPublicKeyPem !== "string" || recipientPublicKeyPem.length === 0) {
79
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-key",
80
+ "encrypt: recipientPublicKeyPem must be a non-empty PEM string");
81
+ }
82
+
83
+ var header = {
84
+ alg: EXPERIMENTAL_ALG,
85
+ enc: EXPERIMENTAL_ENC,
86
+ typ: opts.typ || "JWE",
87
+ "x-blamejs-experimental": true,
88
+ };
89
+ if (typeof opts.contentType === "string" && opts.contentType.length > 0) {
90
+ header.cty = opts.contentType;
91
+ }
92
+
93
+ // Encapsulate under the recipient's ML-KEM-1024 public key. The
94
+ // framework's `encrypt(plaintext, pemString)` form selects the
95
+ // ML-KEM-only path and returns a base64 envelope string.
96
+ //
97
+ // The experimental JWE serialization carries the framework envelope
98
+ // as a single base64url segment (`ciphertext` slot). The empty
99
+ // `encrypted_key` / `iv` / `tag` segments are valid under RFC 7516
100
+ // §3.1 (compact serialization permits empty Base64URL parts when
101
+ // the cryptographic primitives are AEAD-with-direct-key — the
102
+ // framework envelope is self-contained). Operators that need the
103
+ // segmented shape with each field carved out wait for the
104
+ // post-IANA stable surface where layout is contract.
105
+ var fwEnvelopeB64 = bCrypto.encrypt(plaintext, recipientPublicKeyPem);
106
+ var fwEnvelopeUrl = bCrypto.toBase64Url(Buffer.from(fwEnvelopeB64, "base64"));
107
+ var headerB64 = bCrypto.toBase64Url(Buffer.from(canonicalJson.stringify(header), "utf8"));
108
+ // RFC 7516 §3.1 compact-serialization slots: header / encrypted_key
109
+ // / iv / ciphertext / tag. The framework envelope (a self-contained
110
+ // AEAD output) lives in the `ciphertext` slot (index 3). Other slots
111
+ // are empty under this experimental shape.
112
+ var compact = headerB64 + "..." + fwEnvelopeUrl + ".";
113
+ if (opts.audit !== false) {
114
+ audit.safeEmit({
115
+ action: "jose.jwe.experimental.encrypt",
116
+ outcome: "success",
117
+ metadata: { alg: EXPERIMENTAL_ALG, enc: EXPERIMENTAL_ENC, ptLen: plaintext.length },
118
+ });
119
+ }
120
+ return compact;
121
+ }
122
+
123
+ /**
124
+ * @primitive b.jose.jwe.experimental.decrypt
125
+ * @signature b.jose.jwe.experimental.decrypt(compact, recipientPrivateKeyPem, opts?)
126
+ * @since 0.10.10
127
+ * @status experimental
128
+ * @related b.jose.jwe.experimental.encrypt
129
+ *
130
+ * Decrypt a compact-serialization JWE produced by the experimental
131
+ * `encrypt` path. Returns the plaintext Buffer. Refuses on alg / enc
132
+ * mismatch, missing experimental marker, or any cryptographic verify
133
+ * failure. Never throws on adversarial input — typed
134
+ * `JoseJweExperimentalError` with a coded refusal.
135
+ *
136
+ * @opts
137
+ * audit: boolean, // default true
138
+ *
139
+ * @example
140
+ * var plaintext = b.jose.jwe.experimental.decrypt(jwe, pair.mlkem.privateKey);
141
+ * plaintext.toString("utf8"); // → "hello"
142
+ */
143
+ function decrypt(compact, recipientPrivateKeyPem, opts) {
144
+ opts = opts || {};
145
+ if (typeof compact !== "string" || compact.length === 0) {
146
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-compact",
147
+ "decrypt: compact must be a non-empty string");
148
+ }
149
+ if (typeof recipientPrivateKeyPem !== "string" || recipientPrivateKeyPem.length === 0) {
150
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-key",
151
+ "decrypt: recipientPrivateKeyPem must be a non-empty PEM string");
152
+ }
153
+ var parts = compact.split(".");
154
+ if (parts.length !== 5) { // allow:raw-byte-literal — JWE compact serialization is 5 dot-separated segments (RFC 7516 §3.1)
155
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-format",
156
+ "decrypt: JWE compact serialization MUST have 5 segments (RFC 7516 §3.1), got " + parts.length);
157
+ }
158
+ var header;
159
+ // Header is base64url-decoded; route through safeJson.parse for
160
+ // proto-pollution + depth + size defenses (operator-supplied compact
161
+ // bytes are adversarial). Both the base64url decode AND the JSON
162
+ // parse live inside the same typed try/catch so a malformed header
163
+ // surfaces as the typed `jose-jwe-exp/bad-header` refusal class
164
+ // rather than a raw TypeError leaking from b.crypto.fromBase64Url.
165
+ var headerBytes;
166
+ try { headerBytes = bCrypto.fromBase64Url(parts[0]); }
167
+ catch (_eb) {
168
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-header",
169
+ "decrypt: protected header is not valid base64url");
170
+ }
171
+ if (headerBytes.length > 4096) { // allow:raw-byte-literal — JWE header byte cap, not bytes-as-storage
172
+ throw new JoseJweExperimentalError("jose-jwe-exp/header-too-large",
173
+ "decrypt: protected header exceeds 4 KiB cap");
174
+ }
175
+ try { header = require("./safe-json").parse(headerBytes.toString("utf8")); } // allow:inline-require — safe-json only needed on the rare decrypt path
176
+ catch (_e) {
177
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-header",
178
+ "decrypt: protected header is not base64url-encoded JSON");
179
+ }
180
+ if (header.alg !== EXPERIMENTAL_ALG) {
181
+ throw new JoseJweExperimentalError("jose-jwe-exp/alg-mismatch",
182
+ "decrypt: alg '" + header.alg + "' is not '" + EXPERIMENTAL_ALG + "'");
183
+ }
184
+ if (header.enc !== EXPERIMENTAL_ENC) {
185
+ throw new JoseJweExperimentalError("jose-jwe-exp/enc-mismatch",
186
+ "decrypt: enc '" + header.enc + "' is not '" + EXPERIMENTAL_ENC + "'");
187
+ }
188
+ if (header["x-blamejs-experimental"] !== true) {
189
+ throw new JoseJweExperimentalError("jose-jwe-exp/missing-experimental-marker",
190
+ "decrypt: header missing `x-blamejs-experimental: true` — refuse to decrypt unmarked envelopes");
191
+ }
192
+ // Per the experimental serialization (see `encrypt`), the framework
193
+ // envelope lives in segment 3. Other segments must be empty.
194
+ if (parts[1].length > 0 || parts[2].length > 0 || parts[4].length > 0) {
195
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-format",
196
+ "decrypt: experimental JWE shape requires empty encrypted_key / iv / tag segments");
197
+ }
198
+ var fwEnvelopeBuf;
199
+ try { fwEnvelopeBuf = bCrypto.fromBase64Url(parts[3]); }
200
+ catch (_eb2) {
201
+ throw new JoseJweExperimentalError("jose-jwe-exp/bad-format",
202
+ "decrypt: ciphertext segment is not valid base64url");
203
+ }
204
+ var fwEnvelopeB64 = fwEnvelopeBuf.toString("base64");
205
+ // `raw: true` keeps the decrypted plaintext as a Buffer rather than
206
+ // utf8-decoding it (which would corrupt binary payloads — `0xff`
207
+ // becomes the Unicode replacement character). The JWE primitives
208
+ // document Buffer-in / Buffer-out so the contract holds across
209
+ // arbitrary plaintext shapes (signed-blob carriers, binary tokens).
210
+ var plaintext = bCrypto.decrypt(fwEnvelopeB64,
211
+ { privateKey: recipientPrivateKeyPem }, { raw: true });
212
+ if (opts.audit !== false) {
213
+ audit.safeEmit({
214
+ action: "jose.jwe.experimental.decrypt",
215
+ outcome: "success",
216
+ metadata: { alg: EXPERIMENTAL_ALG, enc: EXPERIMENTAL_ENC },
217
+ });
218
+ }
219
+ return plaintext;
220
+ }
221
+
222
+ module.exports = {
223
+ encrypt: encrypt,
224
+ decrypt: decrypt,
225
+ EXPERIMENTAL_ALG: EXPERIMENTAL_ALG,
226
+ EXPERIMENTAL_ENC: EXPERIMENTAL_ENC,
227
+ JoseJweExperimentalError: JoseJweExperimentalError,
228
+ };
@@ -124,6 +124,7 @@ var numericBounds = require("./numeric-bounds");
124
124
  var validateOpts = require("./validate-opts");
125
125
  var guardImapCommand = require("./guard-imap-command");
126
126
  var mailServerRateLimit = require("./mail-server-rate-limit");
127
+ var mailServerRegistry = require("./mail-server-registry");
127
128
  var mailServerTls = require("./mail-server-tls");
128
129
  var { defineClass } = require("./framework-error");
129
130
 
@@ -491,37 +492,139 @@ function create(opts) {
491
492
  _dispatch(state, socket, parsed, lineNoLit, pending.body);
492
493
  }
493
494
 
494
- function _dispatch(state, socket, parsed, _rawLine, literalBody) {
495
- var tag = parsed.tag;
496
- var verb = parsed.verb;
497
- var args = parsed.args;
495
+ // Adapter shim — uniform `(state, socket, parsed, literalBody)`
496
+ // dispatch contract over the per-verb handlers. Builds the registry
497
+ // defaults lazily on first dispatch so the closure-scoped handler
498
+ // references are bound when needed (handlers are hoisted by their
499
+ // function-declarations; the registry init runs at dispatch time).
500
+ var _registry = null;
501
+ function _ensureRegistry() {
502
+ if (_registry !== null) return _registry;
503
+ // Per-handler resource budgets. Sized per the verb's known
504
+ // payload shape (LIST scans the folder tree; FETCH walks N
505
+ // messages; APPEND accepts a literal up to maxLiteralBytes).
506
+ var SHORT_MS = 5 * 1000; // allow:raw-time-literal — 5s short-command budget
507
+ var MEDIUM_MS = 30 * 1000; // allow:raw-time-literal — 30s medium-command budget
508
+ var LONG_MS = 2 * 60 * 1000; // allow:raw-time-literal — 2 min long-command budget (FETCH / APPEND)
509
+ var SHORT_B = 8 * 1024; // allow:raw-byte-literal — 8 KiB short-command response cap
510
+ var MEDIUM_B = 1024 * 1024; // allow:raw-byte-literal — 1 MiB medium-command response cap
511
+ var LONG_B = 64 * 1024 * 1024; // allow:raw-byte-literal — 64 MiB FETCH/APPEND response cap
512
+ var defaults = {
513
+ CAPABILITY: { fn: function (s, so, p) { return _handleCapability(s, so, p.tag); },
514
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
515
+ NOOP: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "OK NOOP completed"); },
516
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
517
+ LOGOUT: { fn: function (s, so, p) { return _handleLogout(s, so, p.tag); },
518
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
519
+ ID: { fn: function (s, so, p) { return _handleId(s, so, p.tag, p.args); },
520
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
521
+ STARTTLS: { fn: function (s, so, p) { return _handleStartTls(s, so, p.tag); },
522
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
523
+ AUTHENTICATE: { fn: function (s, so, p) { return _handleAuthenticate(s, so, p.tag, p.args); },
524
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
525
+ LOGIN: { fn: function (s, so, p) { return _handleLogin(s, so, p.tag, p.args); },
526
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
527
+ ENABLE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "OK ENABLED"); },
528
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
529
+ SELECT: { fn: function (s, so, p) { return _handleSelect(s, so, p.tag, p.args, false); },
530
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
531
+ EXAMINE: { fn: function (s, so, p) { return _handleSelect(s, so, p.tag, p.args, true); },
532
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
533
+ LIST: { fn: function (s, so, p) { return _handleList(s, so, p.tag, p.args); },
534
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
535
+ STATUS: { fn: function (s, so, p) { return _handleStatus(s, so, p.tag, p.args); },
536
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
537
+ NAMESPACE: { fn: function (s, so, p) {
538
+ _writeUntagged(so, "NAMESPACE ((\"\" \"/\")) NIL NIL");
539
+ return _writeTagged(so, p.tag, "OK NAMESPACE completed");
540
+ },
541
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
542
+ APPEND: { fn: function (s, so, p, lit) { return _handleAppend(s, so, p.tag, p.args, lit); },
543
+ maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
544
+ CHECK: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "OK CHECK completed"); },
545
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
546
+ CLOSE: { fn: function (s, so, p) { return _handleClose(s, so, p.tag); },
547
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
548
+ UNSELECT: { fn: function (s, so, p) { return _handleClose(s, so, p.tag); },
549
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
550
+ EXPUNGE: { fn: function (s, so, p) { return _handleExpunge(s, so, p.tag); },
551
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
552
+ FETCH: { fn: function (s, so, p) { return _handleFetch(s, so, p.tag, p.args); },
553
+ maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
554
+ STORE: { fn: function (s, so, p) { return _handleStore(s, so, p.tag, p.args); },
555
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
556
+ UID: { fn: function (s, so, p) { return _handleUid(s, so, p.tag, p.args); },
557
+ maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
558
+ IDLE: { fn: function (s, so, p) { return _handleIdle(s, so, p.tag); },
559
+ maxHandlerBytes: SHORT_B, maxHandlerMs: LONG_MS },
560
+ DONE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "BAD DONE outside IDLE"); },
561
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
562
+ // Defaults for the verbs the v0.9.49 listener didn't dispatch —
563
+ // operators wire concrete handlers via opts.overrides.
564
+ SEARCH: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO SEARCH not configured"); },
565
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
566
+ CREATE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO CREATE not configured"); },
567
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
568
+ DELETE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO DELETE not configured"); },
569
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
570
+ RENAME: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO RENAME not configured"); },
571
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
572
+ SUBSCRIBE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO SUBSCRIBE not configured"); },
573
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
574
+ UNSUBSCRIBE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO UNSUBSCRIBE not configured"); },
575
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
576
+ COPY: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO COPY not configured"); },
577
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
578
+ MOVE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "NO MOVE not configured"); },
579
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
580
+ };
581
+ _registry = mailServerRegistry.create({
582
+ protocol: "imap",
583
+ defaults: defaults,
584
+ overrides: opts.overrides || {},
585
+ notFoundHandler: function (verb, _state, socket, parsed) {
586
+ return _writeTagged(socket, parsed.tag,
587
+ "BAD Verb '" + verb + "' not implemented in v1");
588
+ },
589
+ });
590
+ return _registry;
591
+ }
498
592
 
499
- switch (verb) {
500
- case "CAPABILITY": return _handleCapability(state, socket, tag);
501
- case "NOOP": return _writeTagged(socket, tag, "OK NOOP completed");
502
- case "LOGOUT": return _handleLogout(state, socket, tag);
503
- case "ID": return _handleId(state, socket, tag, args);
504
- case "STARTTLS": return _handleStartTls(state, socket, tag);
505
- case "AUTHENTICATE": return _handleAuthenticate(state, socket, tag, args);
506
- case "LOGIN": return _handleLogin(state, socket, tag, args);
507
- case "ENABLE": return _writeTagged(socket, tag, "OK ENABLED");
508
- case "SELECT":
509
- case "EXAMINE": return _handleSelect(state, socket, tag, args, verb === "EXAMINE");
510
- case "LIST": return _handleList(state, socket, tag, args);
511
- case "STATUS": return _handleStatus(state, socket, tag, args);
512
- case "NAMESPACE": return _writeUntagged(socket, "NAMESPACE ((\"\" \"/\")) NIL NIL"), _writeTagged(socket, tag, "OK NAMESPACE completed");
513
- case "APPEND": return _handleAppend(state, socket, tag, args, literalBody);
514
- case "CHECK": return _writeTagged(socket, tag, "OK CHECK completed");
515
- case "CLOSE":
516
- case "UNSELECT": return _handleClose(state, socket, tag);
517
- case "EXPUNGE": return _handleExpunge(state, socket, tag);
518
- case "FETCH": return _handleFetch(state, socket, tag, args);
519
- case "STORE": return _handleStore(state, socket, tag, args);
520
- case "UID": return _handleUid(state, socket, tag, args);
521
- case "IDLE": return _handleIdle(state, socket, tag);
522
- case "DONE": return _writeTagged(socket, tag, "BAD DONE outside IDLE");
523
- default: return _writeTagged(socket, tag, "BAD Verb '" + verb + "' not implemented in v1");
593
+ function _dispatch(state, socket, parsed, _rawLine, literalBody) {
594
+ // Registry dispatch may return a Promise (async override handler,
595
+ // or a safeAsync.withTimeout-wrapped Promise). The caller's
596
+ // try/catch is synchronous, so a Promise rejection would surface
597
+ // as an unhandled rejection AND the client would never receive
598
+ // the tagged error reply. Attach a catch that converts the
599
+ // rejection into a `BAD`/`NO` tagged response + audit emit.
600
+ var result;
601
+ try {
602
+ result = _ensureRegistry().dispatch(parsed.verb, state, socket, parsed, literalBody);
603
+ } catch (err) {
604
+ _writeTagged(socket, parsed.tag,
605
+ "NO " + ((err && err.message) || "handler threw").slice(0, ERR_CLAMP));
606
+ _emit("mail.server.imap.handler_threw",
607
+ { connectionId: state.id, verb: parsed.verb,
608
+ error: (err && err.message) || String(err) }, "failure");
609
+ return;
610
+ }
611
+ if (result && typeof result.then === "function") {
612
+ result.then(
613
+ function () { /* tagged response already written by handler */ },
614
+ function (err) {
615
+ try {
616
+ _writeTagged(socket, parsed.tag,
617
+ "NO " + ((err && err.message) || "handler rejected").slice(0, ERR_CLAMP));
618
+ } catch (_we) { /* socket may already be gone */ }
619
+ try {
620
+ _emit("mail.server.imap.handler_rejected",
621
+ { connectionId: state.id, verb: parsed.verb,
622
+ error: (err && err.message) || String(err) }, "failure");
623
+ } catch (_ae) { /* drop-silent */ }
624
+ }
625
+ );
524
626
  }
627
+ return result;
525
628
  }
526
629
 
527
630
  function _capabilityLine(state) {
@@ -123,6 +123,7 @@ var bCrypto = require("./crypto");
123
123
  var safeJson = require("./safe-json");
124
124
  var validateOpts = require("./validate-opts");
125
125
  var guardJmap = require("./guard-jmap");
126
+ var mailServerRegistry = require("./mail-server-registry");
126
127
  var { defineClass } = require("./framework-error");
127
128
 
128
129
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -195,7 +196,34 @@ function create(opts) {
195
196
  var profile = opts.profile || DEFAULT_PROFILE;
196
197
  var posture = opts.posture || null;
197
198
  var serverCapabilities = opts.serverCapabilities || {};
198
- var methods = opts.methods;
199
+
200
+ // JMAP method registry. Wrap operator-supplied `opts.methods` map
201
+ // through `b.mail.serverRegistry` so per-handler resource budgets
202
+ // (maxHandlerBytes / maxHandlerMs) apply uniformly across the IMAP
203
+ // / JMAP / ManageSieve listeners. Legacy `opts.methods` callers get
204
+ // an auto-default budget (10 MiB / 30s) with a one-time deprecation
205
+ // audit event per process; new callers use `opts.overrides` with
206
+ // explicit budgets per the stricter-mode register contract.
207
+ var LEGACY_JMAP_BYTES = 10 * 1024 * 1024; // allow:raw-byte-literal — 10 MiB legacy auto-budget for JMAP methods
208
+ var LEGACY_JMAP_MS = 30 * 1000; // allow:raw-time-literal — 30s legacy auto-budget
209
+ var _legacyDeprecationEmitted = false;
210
+ var defaults = {};
211
+ var methodNames = Object.keys(opts.methods);
212
+ for (var mi = 0; mi < methodNames.length; mi += 1) {
213
+ var mname = methodNames[mi];
214
+ if (typeof opts.methods[mname] !== "function") continue;
215
+ defaults[mname] = {
216
+ fn: opts.methods[mname],
217
+ maxHandlerBytes: LEGACY_JMAP_BYTES,
218
+ maxHandlerMs: LEGACY_JMAP_MS,
219
+ allowExperimental: true, // legacy callers wired anything; preserve the openness
220
+ };
221
+ }
222
+ var registry = mailServerRegistry.create({
223
+ protocol: "jmap",
224
+ defaults: defaults,
225
+ overrides: opts.overrides || {},
226
+ });
199
227
  var sessionState = bCrypto.generateToken(16); // allow:raw-byte-literal — opaque session-state token length
200
228
 
201
229
  function _emit(action, metadata, outcome) {
@@ -321,18 +349,24 @@ function create(opts) {
321
349
  methodResponses.push(["error", { type: refType, description: (e && e.message) || "" }, clientId]);
322
350
  continue;
323
351
  }
324
- var handler = methods[methodName];
325
- if (typeof handler !== "function") {
352
+ if (!registry.has(methodName)) {
326
353
  methodResponses.push(["error",
327
354
  { type: "urn:ietf:params:jmap:error:unknownMethod",
328
355
  description: "Method '" + methodName + "' not implemented on this server" }, clientId]);
329
356
  continue;
330
357
  }
358
+ if (!_legacyDeprecationEmitted && registry.source(methodName) === "builtin") {
359
+ _legacyDeprecationEmitted = true;
360
+ _emit("mail.server.jmap.methods_opt_deprecated",
361
+ { note: "opts.methods is shimmed through b.mail.serverRegistry with auto-budget; " +
362
+ "future minor will require opts.overrides with explicit budgets" },
363
+ "warning");
364
+ }
331
365
  try {
332
366
  // JMAP methodCalls execute sequentially by spec (RFC 8620 §3.7 —
333
367
  // back-references require strict ordering). The await-in-loop
334
368
  // pattern is intentional here.
335
- var result = await handler(actor, resolvedArgs, {
369
+ var result = await registry.dispatch(methodName, actor, resolvedArgs, {
336
370
  using: parsed.using,
337
371
  createdIds: parsed.createdIds,
338
372
  methodName: methodName,
@@ -136,6 +136,7 @@ var validateOpts = require("./validate-opts");
136
136
  var guardManageSieveCommand = require("./guard-managesieve-command");
137
137
  var safeSieve = require("./safe-sieve");
138
138
  var mailServerRateLimit = require("./mail-server-rate-limit");
139
+ var mailServerRegistry = require("./mail-server-registry");
139
140
  var { defineClass } = require("./framework-error");
140
141
 
141
142
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -378,7 +379,26 @@ function create(opts) {
378
379
  return;
379
380
  }
380
381
  try {
381
- _dispatch(state, socket, parsed);
382
+ var result = _dispatch(state, socket, parsed);
383
+ // Registry dispatch may return a Promise (async override handler
384
+ // or safeAsync.withTimeout-wrapped Promise). The synchronous
385
+ // try/catch above only catches throw-during-dispatch; Promise
386
+ // rejections need an attached catch to avoid unhandled-rejection
387
+ // termination + missing NO reply.
388
+ if (result && typeof result.then === "function") {
389
+ result.then(
390
+ function () { /* OK reply already written by handler */ },
391
+ function (e) {
392
+ try {
393
+ _emit("mail.server.managesieve.handler_rejected",
394
+ { connectionId: state.id, verb: parsed && parsed.verb,
395
+ error: (e && e.message) || String(e) }, "failure");
396
+ } catch (_ae) { /* drop-silent */ }
397
+ try { _writeNo(socket, "Internal error"); }
398
+ catch (_we) { /* socket may already be gone */ }
399
+ }
400
+ );
401
+ }
382
402
  } catch (e) {
383
403
  _emit("mail.server.managesieve.handler_threw",
384
404
  { connectionId: state.id, verb: parsed && parsed.verb,
@@ -387,23 +407,54 @@ function create(opts) {
387
407
  }
388
408
  }
389
409
 
410
+ var _registry = null;
411
+ function _ensureRegistry() {
412
+ if (_registry !== null) return _registry;
413
+ var SHORT_MS = 5 * 1000; // allow:raw-time-literal — 5s short-command budget
414
+ var MEDIUM_MS = 30 * 1000; // allow:raw-time-literal — 30s medium-command budget
415
+ var LONG_MS = 2 * 60 * 1000; // allow:raw-time-literal — 2 min PUTSCRIPT / GETSCRIPT budget
416
+ var SHORT_B = 8 * 1024; // allow:raw-byte-literal — 8 KiB short-command cap
417
+ var MEDIUM_B = 1024 * 1024; // allow:raw-byte-literal — 1 MiB medium-command cap
418
+ var LONG_B = 16 * 1024 * 1024; // allow:raw-byte-literal — 16 MiB PUTSCRIPT cap
419
+ var defaults = {
420
+ CAPABILITY: { fn: function (s, so) { return _handleCapability(s, so); },
421
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
422
+ NOOP: { fn: function (s, so, p) { return _handleNoop(s, so, p); },
423
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
424
+ STARTTLS: { fn: function (s, so) { return _handleStartTls(s, so); },
425
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
426
+ LOGOUT: { fn: function (s, so) { return _handleLogout(s, so); },
427
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
428
+ AUTHENTICATE: { fn: function (s, so, p) { return _handleAuthenticate(s, so, p); },
429
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
430
+ HAVESPACE: { fn: function (s, so, p) { return _handleHaveSpace(s, so, p); },
431
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
432
+ PUTSCRIPT: { fn: function (s, so, p) { return _handlePutScript(s, so, p); },
433
+ maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
434
+ LISTSCRIPTS: { fn: function (s, so) { return _handleListScripts(s, so); },
435
+ maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
436
+ SETACTIVE: { fn: function (s, so, p) { return _handleSetActive(s, so, p); },
437
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
438
+ GETSCRIPT: { fn: function (s, so, p) { return _handleGetScript(s, so, p); },
439
+ maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
440
+ DELETESCRIPT: { fn: function (s, so, p) { return _handleDeleteScript(s, so, p); },
441
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
442
+ RENAMESCRIPT: { fn: function (s, so, p) { return _handleRenameScript(s, so, p); },
443
+ maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
444
+ };
445
+ _registry = mailServerRegistry.create({
446
+ protocol: "managesieve",
447
+ defaults: defaults,
448
+ overrides: opts.overrides || {},
449
+ notFoundHandler: function (verb, _state, socket) {
450
+ return _writeNo(socket, "Unknown verb '" + verb + "'");
451
+ },
452
+ });
453
+ return _registry;
454
+ }
455
+
390
456
  function _dispatch(state, socket, parsed) {
391
- var verb = parsed.verb;
392
- switch (verb) {
393
- case "CAPABILITY": return _handleCapability(state, socket);
394
- case "NOOP": return _handleNoop(state, socket, parsed);
395
- case "STARTTLS": return _handleStartTls(state, socket);
396
- case "LOGOUT": return _handleLogout(state, socket);
397
- case "AUTHENTICATE": return _handleAuthenticate(state, socket, parsed);
398
- case "HAVESPACE": return _handleHaveSpace(state, socket, parsed);
399
- case "PUTSCRIPT": return _handlePutScript(state, socket, parsed);
400
- case "LISTSCRIPTS": return _handleListScripts(state, socket);
401
- case "SETACTIVE": return _handleSetActive(state, socket, parsed);
402
- case "GETSCRIPT": return _handleGetScript(state, socket, parsed);
403
- case "DELETESCRIPT": return _handleDeleteScript(state, socket, parsed);
404
- case "RENAMESCRIPT": return _handleRenameScript(state, socket, parsed);
405
- default: return _writeNo(socket, "Unknown verb '" + verb + "'");
406
- }
457
+ return _ensureRegistry().dispatch(parsed.verb, state, socket, parsed);
407
458
  }
408
459
 
409
460
  // _emitCapabilityBanner — RFC 5804 §1.7 capability banner. Lines