@blamejs/core 0.10.11 → 0.10.12

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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.12 (2026-05-18) — **`b.agent.tenant` adoption across the mail-server listeners.** The v0.10.11 shared `b.mail.serverRegistry` primitive gains optional `opts.tenantScope` (a `b.agent.tenant.create()` instance) + `opts.agentTenantId` (the tenant this listener serves). When supplied, every method dispatch first gates on `tenantScope.check(state.actor, agentTenantId)` BEFORE guard validation or audit emission; cross-tenant access surfaces as the v0.9.25-typed `agent-tenant/cross-tenant-access-refused` which the listener's catch-path converts to the protocol's `BAD` / `NO` refusal reply. **(a) `b.mail.server.imap.create({ tenantScope, agentTenantId })`** — IMAP dispatch is gated for every command after AUTH. **(b) `b.mail.server.jmap.create({ tenantScope, agentTenantId })`** — JMAP per-method dispatch routes through the tenant scope alongside its existing per-`accountId` isolation. **(c) `b.mail.server.managesieve.create({ tenantScope, agentTenantId })`** — ManageSieve same pattern. **(d) `b.mail.server.submission.create({ tenantScope, agentTenantId })`** — submission listener gates at the AUTH-success boundary (before `state.actor` is committed) so cross-tenant authentication surfaces as `535 5.7.0 Authentication rejected (cross-tenant)` and the SMTP envelope never begins under the wrong tenant. **(e) `b.mail.server.pop3.create({ tenantScope, agentTenantId })`** — same AUTH-success gate; cross-tenant refusal returns `-ERR Authentication rejected (cross-tenant)`. New audit events: `mail.server.submission.cross_tenant_refused` and `mail.server.pop3.cross_tenant_refused`. **Operator impact:** no breaking changes — `tenantScope` / `agentTenantId` are optional; operators not running multi-tenant see identical behavior. Operators with multi-tenant deployments wire `b.agent.tenant.create({...})` once and pass the same scope to every per-tenant listener instance — cross-tenant isolation becomes structural rather than per-handler opt-in. **Deferred to v0.10.12.1:** per-tenant `b.mailStore` seal-key derivation via `tenantScope.derivedKey(tenantId, "seal")` and per-tenant audit namespaces via `tenantScope.auditFor(tenantId)`. Today every mail listener seals through the framework primary vault key — adequate for single-tenant and multi-tenant-trusted deployments; the v0.10.12.1 follow-up adds per-tenant key separation for compromise-isolation use cases. References: [RFC 9051 IMAP4rev2 §3 state machine](https://www.rfc-editor.org/rfc/rfc9051#section-3) · [RFC 8620 JMAP Core §1.6.2 accountId](https://www.rfc-editor.org/rfc/rfc8620#section-1.6.2) · [RFC 6409 Submission §6.1 actor-to-MAIL-FROM identity binding](https://www.rfc-editor.org/rfc/rfc6409#section-6.1) · [RFC 1939 POP3 §6 transaction state](https://www.rfc-editor.org/rfc/rfc1939#section-6) · v0.9.25 `b.agent.tenant` contract.
11
12
  - v0.10.11 (2026-05-18) — **Mail-server per-method registration sweep.** New shared primitive `b.mail.serverRegistry` (`lib/mail-server-registry.js`) replaces the hand-rolled `switch (verb)` dispatchers in the IMAP, JMAP, and ManageSieve listener factories. Operators can now override individual command / method handlers (e.g. IMAP `FETCH`, JMAP `Email/query`, ManageSieve `PUTSCRIPT`) via `opts.overrides: { NAME: { fn, maxHandlerBytes, maxHandlerMs } }` without re-implementing wire-protocol state machines or bypassing the guard substrate. **(a) Per-handler resource budgets — required at registration.** Operators MUST supply `maxHandlerBytes` (≤ 256 MiB) and `maxHandlerMs` (≤ 5 min) on every override; the registration throws `mail-server-registry/bad-max-handler-bytes` / `bad-max-handler-ms` on missing or out-of-range budgets. Defends [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) (Cyrus authenticated OOM) and [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312) (Stalwart malformed nested `message/rfc822` cyclical OOM) by forcing operators to declare the resource ceiling explicitly. **(b) Catalogue gate.** Per-protocol method names outside the IANA / RFC catalogue refuse registration unless `allowExperimental: true` is supplied — opting in audits the registration so operators can grep for off-spec handlers. **(c) Guard chain preserved.** The listener factories run `b.guardImapCommand` / `b.guardJmap` / `b.guardManagesieveCommand` BEFORE the registry lookup; operator overrides cannot bypass the wire-protocol validation, smuggling defenses, or rate-limit budgets. **(d) Handler timeout.** Promise-returning handlers wrap through `b.safeAsync.withTimeout(maxHandlerMs)`; a runaway override raises `mail-server-registry/handler-timeout` rather than pinning the connection. **(e) Defaults seeded.** IMAP picks up 30 verbs (CAPABILITY, NOOP, LOGOUT, ID, STARTTLS, AUTHENTICATE, LOGIN, ENABLE, SELECT, EXAMINE, LIST, STATUS, NAMESPACE, APPEND, CHECK, CLOSE, UNSELECT, EXPUNGE, FETCH, STORE, UID, IDLE, DONE — plus the previously-undispatched SEARCH / CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE / COPY / MOVE which default to `NO not-configured` until operator overrides); ManageSieve picks up 12 verbs (CAPABILITY, NOOP, STARTTLS, LOGOUT, AUTHENTICATE, HAVESPACE, PUTSCRIPT, LISTSCRIPTS, SETACTIVE, GETSCRIPT, DELETESCRIPT, RENAMESCRIPT); JMAP wraps the existing `opts.methods` map with a one-time deprecation audit (`mail.server.jmap.methods_opt_deprecated`) and routes through the same registry — operators migrate to `opts.overrides` with explicit budgets. **(f) New audit event:** `mail.serverRegistry.method_dispatch` carries `{ protocol, name, source: "builtin" | "operator-override" }` on every dispatch; `mail.serverRegistry.experimental_registration` audits opt-in off-catalogue registrations. **Operator impact:** existing JMAP `opts.methods` callers see the deprecation audit but continue to function (legacy auto-budget = 10 MiB / 30 s); existing IMAP / ManageSieve operators have no migration burden — the listener factories continue to accept the same opts shape. Operators wiring NEW overrides MUST supply explicit budgets. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051) · [RFC 8620 JMAP Core](https://www.rfc-editor.org/rfc/rfc8620) · [RFC 8621 JMAP for Mail](https://www.rfc-editor.org/rfc/rfc8621) · [RFC 5804 ManageSieve](https://www.rfc-editor.org/rfc/rfc5804) · [RFC 2971 IMAP4 ID](https://www.rfc-editor.org/rfc/rfc2971) · [RFC 2177 IMAP IDLE](https://www.rfc-editor.org/rfc/rfc2177) · [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) · [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312).
12
13
  - v0.10.10 (2026-05-17) — **PQC envelope completion (experimental).** Two new opt-in PQC-protocol primitives behind explicit experimental namespaces. **(a) `b.jose.jwe.experimental.encrypt` / `.decrypt`** — RFC 7516 compact-serialization JWE with ML-KEM-1024 key encapsulation and XChaCha20-Poly1305 AEAD content encryption. Lives under `b.jose.jwe.experimental` because the JOSE PQC IANA codepoint registration ([draft-ietf-jose-pqc-kem-05](https://datatracker.ietf.org/doc/draft-ietf-jose-pqc-kem/)) hasn't finalized — the namespace name is the contract: codepoints may change between minors without affecting the framework's stable surface. Header carries `{ alg: "ML-KEM-1024", enc: "XC20P", "x-blamejs-experimental": true }`; decrypt refuses any envelope missing the experimental marker (defends a stable-system consumer that accidentally ingests an experimental envelope and treats it as IANA-compliant). Header bytes route through `b.safeJson.parse` for proto-pollution / depth / size defenses; header is byte-capped at 4 KiB. **(b) `b.crypto.hpke.pq.connolly.seal` / `.open` + `b.crypto.hpke.pq.wg.seal` / `.open`** — both active PQ-HPKE drafts behind explicit opt-in: [draft-connolly-cfrg-hpke-mlkem-04](https://datatracker.ietf.org/doc/draft-connolly-cfrg-hpke-mlkem/) (individual; codepoints today) and [draft-ietf-hpke-pq-03](https://datatracker.ietf.org/doc/draft-ietf-hpke-pq/) (WG-adopted). Each wrapper binds a draft-distinguishing label into the RFC 9180 §5.1 `info` parameter so cross-draft substitution (sealing under connolly and opening as wg, or vice versa) refuses by construction — the derived AEAD key diverges and Poly1305 verify fails. Both compose the existing `b.crypto.hpke.seal` / `.open` core (ML-KEM-1024 KEM + HKDF-SHA3-512 + ChaCha20-Poly1305 per project PQC-first policy); the wrappers add the draft-isolation label without touching the wire-format primitives. **New audit namespace:** `jose` (`jose.jwe.experimental.encrypt` / `.decrypt`). **Operator impact:** no breaking changes. The stable `b.crypto.hpke.seal` and the existing `b.crypto.encrypt` envelope shape are unaffected. Operators integrating against systems speaking one of the active PQ-HPKE drafts use the explicit `.pq.connolly` / `.pq.wg` paths; operators wanting IANA-final codepoints wait for graduation to the stable surface (one-minor deprecation window will ship when IANA registration lands). The framework refuses to silently pick a winner between the two drafts. **Deferred to follow-up:** COSE-PQ signatures (draft-ietf-cose-pqc-* — pending IANA codepoint registration), JWE JSON serialization variant (compact-only at this experimental tier), FIPS 203 KAT test vectors against the vendored bundle (test-corpus addition; functional parity is established by the existing v0.8.41 hybrid-KEM verify path). References: [draft-ietf-jose-pqc-kem-05](https://datatracker.ietf.org/doc/draft-ietf-jose-pqc-kem/) · [draft-connolly-cfrg-hpke-mlkem-04](https://datatracker.ietf.org/doc/draft-connolly-cfrg-hpke-mlkem/) · [draft-ietf-hpke-pq-03](https://datatracker.ietf.org/doc/draft-ietf-hpke-pq/) · [RFC 9180 HPKE](https://www.rfc-editor.org/rfc/rfc9180.html) · [RFC 7516 JWE](https://www.rfc-editor.org/rfc/rfc7516.html) · [FIPS 203 ML-KEM](https://csrc.nist.gov/pubs/fips/203/final) · [draft-irtf-cfrg-xchacha XChaCha20-Poly1305](https://datatracker.ietf.org/doc/draft-irtf-cfrg-xchacha/).
13
14
  - v0.10.9 (2026-05-17) — **Ergonomic helpers bundle (A / B / C / D / E / H).** Six small DX primitives bundled into one release. **(a) `b.safePath.resolve` / `.resolveOrNull` / `.validate`** — path-traversal-safe multi-segment resolve. Refuses absolute / UNC / drive-letter `rel`, NUL bytes, C0 control chars, bidi-override codepoints (CVE-2021-42574 Trojan Source class), URL-encoded + fullwidth + division-slash path separators, Windows reserved device names CON / PRN / AUX / NUL / COM[0-9] / LPT[0-9] on EVERY platform (closes [CVE-2025-27210](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) cross-mount class), trailing-`.`/trailing-space segments under windows-mode, NTFS Alternate Data Stream markers ([CVE-2024-12217](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) class), and `..` segments that escape `base` after lexical resolve. Optional `opts.realpath: true` adds symlink-escape detection via `fs.realpathSync.native`. Every documented failure mode → coded refusal (`safe-path/absolute-rel` / `null-byte` / `bidi` / `win-reserved` / `escapes-base` / etc.); no best-effort path. **(b) `b.bootGates.run([{ name, fn, timeoutMs?, exitCode?, onFail? }], opts?)`** — sequential boot-invariant runner. Each gate runs in order; on first failure: emits `bootgates.failed` audit, runs the gate's `onFail` callback (swallows + audits onFail throws), writes a single-line failure summary via `opts.log`, and calls the operator-supplied `opts.exit(code)`. The default `exit` throws `BootGatesError("bootgates/no-exit-wired")` rather than calling `process.exit` directly (lib/ never terminates the process — the CLI surface owns that wiring). Each gate runs under a 60s default `timeoutMs` budget configurable per-gate; overall budget via `opts.overallTimeoutMs`. **(c) `b.metrics.snapshot.shadowRegistry({ namespace, counters, gauges, info, cardinalityCap?, onCardinalityExceeded? })`** — namespaced shadow registry that mirrors a subset of a primary registry's metrics for export to systems needing isolated views (sidecar / per-tenant scrape endpoint / compliance-tagged subset). Cardinality cap (default 10000 per metric name) closes the [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) unbounded-cardinality DoS class; policy is `drop` (default), `audit-only`, or `refuse`. Emits `metrics.shadow.cardinality_dropped` audit (rate-limited to 1/sec per shadow registry). **(d) Per-instance `agent.reloadCerts({ cert, key, ca })` on `b.pqcAgent.create()` returns** — long-running daemons that rotate TLS material via explicit `b.pqcAgent.create()` agents previously needed a process restart; the new instance method tests the new material via `tls.createSecureContext`, swaps `agent.options` atomically, closes idle keep-alive sockets via `agent.destroy()` (in-flight sockets complete naturally), and emits `pqcagent.reloadCerts` audit. Cert/key mismatch surfaces as `pqcagent/reload-mismatch` with the OpenSSL chain; CA bundle parse failures surface as `pqcagent/reload-bad-ca`. **(e) `b.metrics.snapshot.render(snap, { format: "text", groups })`** — operator-readable text format gains an `opts.groups` map that sections the output (`== HTTP ==` / `== Queue ==` / `== TLS ==`); fields not named in any group fall to `== Other ==`. Group ordering preserved per insertion order. Prometheus / OpenMetrics formats unchanged. **(h) ISO-8601 date strings render-eligible in the text format** — timestamps shaped as `2026-05-17T20:00:00.000Z` (length-bounded at 64 chars) now render verbatim in the text format instead of degrading to `[skipped: non-numeric]`; the Prometheus format gets a parallel `<name>_epoch_ms` gauge so downstream alerting can compute durations per OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). Non-ISO strings continue to skip in Prometheus (label-value injection defense). **New compliance scaffold:** new namespace `b.bootGates`. New audit namespaces: `bootgates`, `metrics`. **Operator impact:** no breaking changes. `b.bootGates.run` callers MUST supply `opts.exit: process.exit.bind(process)` from their daemon main() if they want the failure path to terminate the process — the default-throw shape exists so lib/-internal callers can't accidentally `process.exit` from inside a primitive. `b.safePath.resolve` is a brand-new primitive; existing code is unaffected. References: [Node.js path.resolve docs](https://nodejs.org/api/path.html#pathresolvepaths) · [CVE-2025-27210 Windows device-name bypass](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) · [CVE-2024-12217 NTFS ADS](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) · [CVE-2021-42574 Trojan Source](https://nvd.nist.gov/vuln/detail/CVE-2021-42574) · [CVE-2022-21698 Prometheus cardinality DoS](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) · [OpenMetrics 1.0 spec](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md) · [CVE-2026-21637 SNI sync-throw](https://nvd.nist.gov/vuln/detail/CVE-2026-21637).
@@ -582,6 +582,14 @@ function create(opts) {
582
582
  protocol: "imap",
583
583
  defaults: defaults,
584
584
  overrides: opts.overrides || {},
585
+ // b.agent.tenant adoption (v0.10.12). Operators wiring multi-
586
+ // tenant IMAP deployments pass `tenantScope` from
587
+ // `b.agent.tenant.create({...})` plus the per-listener tenant id.
588
+ // The registry then gates every dispatch on
589
+ // `tenantScope.check(state.actor, agentTenantId)` before guard
590
+ // validation or audit emission.
591
+ tenantScope: opts.tenantScope || null,
592
+ agentTenantId: opts.agentTenantId || null,
585
593
  notFoundHandler: function (verb, _state, socket, parsed) {
586
594
  return _writeTagged(socket, parsed.tag,
587
595
  "BAD Verb '" + verb + "' not implemented in v1");
@@ -220,9 +220,15 @@ function create(opts) {
220
220
  };
221
221
  }
222
222
  var registry = mailServerRegistry.create({
223
- protocol: "jmap",
224
- defaults: defaults,
225
- overrides: opts.overrides || {},
223
+ protocol: "jmap",
224
+ defaults: defaults,
225
+ overrides: opts.overrides || {},
226
+ // b.agent.tenant adoption (v0.10.12). When `opts.tenantScope` is
227
+ // supplied, every method dispatch first gates on
228
+ // `tenantScope.check(state.actor, agentTenantId)` — JMAP's
229
+ // accountId scoping continues to apply inside operator handlers.
230
+ tenantScope: opts.tenantScope || null,
231
+ agentTenantId: opts.agentTenantId || null,
226
232
  });
227
233
  var sessionState = bCrypto.generateToken(16); // allow:raw-byte-literal — opaque session-state token length
228
234
 
@@ -446,6 +446,10 @@ function create(opts) {
446
446
  protocol: "managesieve",
447
447
  defaults: defaults,
448
448
  overrides: opts.overrides || {},
449
+ // b.agent.tenant adoption (v0.10.12) — see imap factory for the
450
+ // shape.
451
+ tenantScope: opts.tenantScope || null,
452
+ agentTenantId: opts.agentTenantId || null,
449
453
  notFoundHandler: function (verb, _state, socket) {
450
454
  return _writeNo(socket, "Unknown verb '" + verb + "'");
451
455
  },
@@ -196,6 +196,38 @@ function create(opts) {
196
196
  var profile = opts.profile || "strict";
197
197
  var authConfig = opts.auth || null;
198
198
  var mailStore = opts.mailStore;
199
+ // b.agent.tenant adoption (v0.10.12) — cross-tenant authentication
200
+ // is refused at the AUTH-success boundary BEFORE the listener
201
+ // accepts the actor into transaction state. The scope's `.check`
202
+ // method is validated at create() time so a malformed scope object
203
+ // surfaces as a configuration error rather than rejecting every
204
+ // otherwise-valid auth as "cross-tenant".
205
+ var tenantScope = opts.tenantScope || null;
206
+ var agentTenantId = opts.agentTenantId || null;
207
+ if (tenantScope && typeof tenantScope.check !== "function") {
208
+ throw new MailServerPop3Error("mail-server-pop3/bad-tenant-scope",
209
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance " +
210
+ "(missing .check); a malformed scope would refuse every auth as cross-tenant");
211
+ }
212
+ if (tenantScope && !agentTenantId) {
213
+ throw new MailServerPop3Error("mail-server-pop3/no-agent-tenant-id",
214
+ "create: opts.tenantScope requires opts.agentTenantId");
215
+ }
216
+
217
+ function _assertTenantOrRefuse(state, socket, result) {
218
+ if (!tenantScope || !agentTenantId) return true;
219
+ try { tenantScope.check(result.actor, agentTenantId); return true; }
220
+ catch (tenantErr) {
221
+ _emit("mail.server.pop3.cross_tenant_refused",
222
+ { connectionId: state.id,
223
+ actorTenant: (result.actor && result.actor.tenantId) || null,
224
+ agentTenant: agentTenantId,
225
+ code: (tenantErr && tenantErr.code) || null },
226
+ "denied");
227
+ _writeErr(socket, "Authentication rejected (cross-tenant)");
228
+ return false;
229
+ }
230
+ }
199
231
 
200
232
  var rateLimit;
201
233
  if (opts.rateLimit === false) {
@@ -467,6 +499,7 @@ function create(opts) {
467
499
  })
468
500
  .then(function (result) {
469
501
  if (result && result.ok && result.actor) {
502
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
470
503
  state.actor = result.actor;
471
504
  _enterTransaction(state, socket, "PASS");
472
505
  return;
@@ -527,6 +560,7 @@ function create(opts) {
527
560
  })
528
561
  .then(function (result) {
529
562
  if (result && result.ok && result.actor) {
563
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
530
564
  state.actor = result.actor;
531
565
  _enterTransaction(state, socket, "APOP");
532
566
  return;
@@ -595,6 +629,7 @@ function create(opts) {
595
629
  })
596
630
  .then(function (result) {
597
631
  if (result && result.ok && result.actor) {
632
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
598
633
  state.actor = result.actor;
599
634
  _enterTransaction(state, socket, "AUTH/" + mech);
600
635
  return;
@@ -131,6 +131,27 @@ function create(opts) {
131
131
  throw new MailServerRegistryError("mail-server-registry/unknown-protocol",
132
132
  "create: protocol must be 'imap', 'jmap', or 'managesieve' (got '" + opts.protocol + "')");
133
133
  }
134
+ // Tenant scope (v0.10.12 — b.agent.tenant adoption).
135
+ // When `opts.tenantScope` is supplied alongside `opts.agentTenantId`,
136
+ // every dispatch first gates on `tenantScope.check(actor,
137
+ // agentTenantId)`. Actor without matching tenantId surfaces as a
138
+ // typed `agent-tenant/cross-tenant-access-refused` per the v0.9.25
139
+ // contract — the listener's catch path converts that into the
140
+ // protocol's `BAD AUTH` / `NO not authorized` reply.
141
+ //
142
+ // Optional: when omitted, dispatch behaves identically to v0.10.11
143
+ // (no per-tenant gate; operators that don't run multi-tenant don't
144
+ // pay the check cost).
145
+ var tenantScope = opts.tenantScope || null;
146
+ var agentTenantId = opts.agentTenantId || null;
147
+ if (tenantScope && typeof tenantScope.check !== "function") {
148
+ throw new MailServerRegistryError("mail-server-registry/bad-tenant-scope",
149
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance (missing .check)");
150
+ }
151
+ if (tenantScope && !agentTenantId) {
152
+ throw new MailServerRegistryError("mail-server-registry/no-agent-tenant-id",
153
+ "create: opts.tenantScope requires opts.agentTenantId (the tenant this listener serves)");
154
+ }
134
155
  var catalogue = CATALOGUE[opts.protocol];
135
156
  var entries = Object.create(null);
136
157
 
@@ -251,6 +272,30 @@ function create(opts) {
251
272
  */
252
273
  function dispatch(name) {
253
274
  var argsArr = Array.prototype.slice.call(arguments, 1);
275
+ // Tenant scope check — pre-dispatch, pre-guard, pre-audit.
276
+ //
277
+ // Two argument shapes occur across the three listeners:
278
+ // - IMAP / ManageSieve dispatch with `(state, socket, parsed)` —
279
+ // state.actor is the actor.
280
+ // - JMAP dispatches with `(actor, resolvedArgs, ctx)` — the
281
+ // first argument IS the actor object directly.
282
+ //
283
+ // Detect both shapes: if argsArr[0].actor exists, use it; else if
284
+ // argsArr[0] itself carries a `tenantId` field, treat it as the
285
+ // actor. The dispatch shapes are documented at the listener
286
+ // factory layer; the registry's job here is uniform enforcement.
287
+ if (tenantScope && argsArr.length > 0 && argsArr[0]) {
288
+ var actor = argsArr[0].actor ||
289
+ (typeof argsArr[0] === "object" && argsArr[0] !== null &&
290
+ Object.prototype.hasOwnProperty.call(argsArr[0], "tenantId")
291
+ ? argsArr[0] : null);
292
+ if (actor) {
293
+ // tenantScope.check throws AgentTenantError on cross-tenant;
294
+ // we let the typed error propagate so the listener's
295
+ // catch-path converts it to the protocol's refusal reply.
296
+ tenantScope.check(actor, agentTenantId);
297
+ }
298
+ }
254
299
  var entry = entries[name];
255
300
  if (!entry) {
256
301
  if (typeof opts.notFoundHandler === "function") {
@@ -266,6 +266,18 @@ function create(opts) {
266
266
  throw new MailServerSubmissionError("mail-server-submission/no-tls-context",
267
267
  "mail.server.submission.create: tlsContext is required");
268
268
  }
269
+ // b.agent.tenant shape validation at create() time — a malformed
270
+ // scope object would refuse every auth as cross-tenant, masking the
271
+ // configuration error as an auth outage.
272
+ if (opts.tenantScope && typeof opts.tenantScope.check !== "function") {
273
+ throw new MailServerSubmissionError("mail-server-submission/bad-tenant-scope",
274
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance " +
275
+ "(missing .check); a malformed scope would refuse every auth as cross-tenant");
276
+ }
277
+ if (opts.tenantScope && !opts.agentTenantId) {
278
+ throw new MailServerSubmissionError("mail-server-submission/no-agent-tenant-id",
279
+ "create: opts.tenantScope requires opts.agentTenantId");
280
+ }
269
281
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
270
282
  ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
271
283
  "mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
@@ -742,6 +754,27 @@ function create(opts) {
742
754
  // successful verify, not whatever state.authPending happens
743
755
  // to be at the post-null read (which is always null).
744
756
  var successfulMechanism = state.authPending && state.authPending.mechanism;
757
+ // b.agent.tenant gate (v0.10.12). When the listener is
758
+ // wired with `opts.tenantScope` + `opts.agentTenantId`,
759
+ // every authenticated actor must belong to the listener's
760
+ // tenant. Cross-tenant authentication surfaces here as a
761
+ // `535 5.7.0` refusal — the actor never reaches authenticated
762
+ // state, mail submission never begins under the wrong tenant.
763
+ if (opts.tenantScope && opts.agentTenantId) {
764
+ try { opts.tenantScope.check(result.actor, opts.agentTenantId); }
765
+ catch (tenantErr) {
766
+ state.authPending = null;
767
+ _emit("mail.server.submission.cross_tenant_refused",
768
+ { connectionId: state.id,
769
+ actorTenant: (result.actor && result.actor.tenantId) || null,
770
+ agentTenant: opts.agentTenantId,
771
+ code: (tenantErr && tenantErr.code) || null },
772
+ "denied");
773
+ _writeReply(socket, REPLY_535_AUTH_FAILED,
774
+ "5.7.0 Authentication rejected (cross-tenant)");
775
+ return;
776
+ }
777
+ }
745
778
  state.authenticated = true;
746
779
  state.actor = result.actor;
747
780
  state.authPending = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.11",
3
+ "version": "0.10.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:2bcbf942-6319-4d65-a08c-1e385ac35198",
5
+ "serialNumber": "urn:uuid:c0157c5c-5ef3-45a3-aef2-004727f9fd8c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T04:40:34.689Z",
8
+ "timestamp": "2026-05-18T06:22:30.244Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.10.11",
22
+ "bom-ref": "@blamejs/core@0.10.12",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.11",
25
+ "version": "0.10.12",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.10.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.12",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.10.11",
57
+ "ref": "@blamejs/core@0.10.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]