@blamejs/core 0.8.86 → 0.8.87

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.8.x
10
10
 
11
+ - v0.8.87 (2026-05-11) — **NIST 800-63-4 FAL classifier + RFC 7505 Null-MX helper + Gmail FBL Feedback-ID builder + vendor-update.sh stale-entry cleanup**. **`b.auth.fal`** lands as the federation-side counterpart to the existing `b.auth.aal` band classifier. `fromAssertion({ channel, encrypted?, replayProtected?, hokBinding? })` classifies an incoming federation assertion as `"FAL1"` / `"FAL2"` / `"FAL3"` per NIST 800-63C-4: Holder-of-Key (mTLS / DPoP / SAML HoK) with replay-protection → FAL3; back-channel OR encrypted front-channel with replay-protection → FAL2; bare bearer front-channel → FAL1. Conservative: missing replay-protection on a back-channel assertion downgrades to FAL1 because §5.2 requires nonce / jti binding before back-channel can claim FAL2. `requireFal(minimumBand)` builds a band-check guard that throws `auth/fal-insufficient` for stale-band requests; compose with the request-scope auth state to gate sensitive operations. **`b.network.dns.isNullMx(records)`** lands as the RFC 7505 Null-MX classifier: returns `true` when an operator-supplied MX-record array signals "this domain does not accept email" (single record, priority 0, exchange `.` per RFC 7505 §3). Operators send-side check this before delivery to skip domains that have explicitly opted out — `node:dns.resolveMx` returns `exchange: ""` for the same RDATA, so the classifier accepts both shapes. **`b.mail.feedbackId({ campaignId, customerId, mailType, senderId })`** builds a Gmail Feedback-Loop (FBL) Feedback-ID header value as the canonical 4-tuple `CampaignID:CustomerID:MailType:SenderID`. Refuses missing / empty fields, fields containing `:` (would corrupt the field separator), fields >64 chars (Gmail FBL truncation threshold), and control-char content (CR/LF header-injection defense). Setting Feedback-ID on outbound mail lets Gmail Postmaster Tools surface per-campaign abuse-rate metrics keyed by the operator's vocabulary instead of by SMTP envelope-sender alone. **vendor-update.sh cleanup**: `scripts/vendor-update.sh --check` removed the stale `argon2` entry from `VENDORED_PACKAGES`. argon2 was removed from `lib/vendor/` back in v0.4.x when Node 24's built-in `crypto.argon2*` API replaced the third-party prebuilds (per `lib/argon2-builtin.js`); the script still listed it in the check array, producing a false "UPDATE AVAILABLE" line for an unvendored package. The case-block error path that still says "argon2 is no longer vendored" stays so anyone running `./scripts/vendor-update.sh argon2` gets the operator-friendly explanation.
11
12
  - v0.8.86 (2026-05-11) — **Sectoral + cybersecurity posture sweep + HTTP-hygiene primitives + npm-publish hotfix**. **npm-publish hotfix**: the v0.8.85 `npm audit signatures` step failed with `npm error found no installed dependencies to audit` because the framework's zero-runtime-deps posture produces an empty install tree; the gate now treats that specific message as success while keeping every other failure mode loud (v0.8.85 npm tarball never published — operators upgrade `0.8.83 → 0.8.86` to pick up the carried v0.8.84 + v0.8.85 surface plus the new v0.8.86 primitives). **10 new compliance postures**: `cmmc-2.0` (DoD Cybersecurity Maturity Model Certification 2.0), `cjis-v6` (FBI CJIS Security Policy v6.0), `iso-27001-2022` + `iso-27002-2022` + `iso-27017` + `iso-27018` + `iso-27701` (ISO/IEC 27001 family), `nist-800-66-r2` (HIPAA Security Rule implementation guidance), `ehds` (European Health Data Space), `circia` (US Cyber Incident Reporting for Critical Infrastructure Act). Cascade defaults set encrypted-backup + signed-audit-chain + TLS 1.3 + vacuum-after-erase for the data-tier postures; `iso-27002-2022` + `circia` defer the data-tier mandate to operator choice. **`b.cacheStatus`** — RFC 9211 Cache-Status response-header builder + parser. `append(prev, entry)` chains the operator's current cache decision onto whatever upstream caches wrote; `entry({...})` formats a single entry; `parse(headerValue)` returns the parsed chain as `[{ cache, params }]` records with `hit`/`stored`/`collapsed` as booleans, `ttl`/`fwdStatus` as numbers, `fwd` as the RFC 9211 §2 enum string, `key`/`detail` as unquoted sf-strings. Operators diagnose CDN/reverse-proxy/app-cache decision chains by reading the header instead of guessing from elapsed-time metrics. **`b.serverTiming`** — W3C Server-Timing response-header builder. `create()` returns a per-request collector with `mark(name, durationMs?, description?)` / `measure(name, fn)` async-timing wrapper / `toHeader()` serializer. Surfaces server-side latency in the browser's Performance API. **`b.middleware.noCache`** — RFC 9111 §5.2.2.5 `Cache-Control: no-store` middleware for auth-gated / individualized response paths. Sets `Cache-Control: no-store`, `Pragma: no-cache` (HTTP/1.0 compatibility), `Vary: Cookie, Authorization` so intermediate caches don't store personalized responses keyed by URL alone. Optional `opts.when(req)` predicate for conditional application; `opts.skipExisting:true` skips when `Cache-Control` is already set.
12
13
  - v0.8.85 (2026-05-11) — **MCP tool registry + tool-call signing + A2A v1 task-exchange surface**. Closes the substantial agent-protocol gaps surfaced by the 2026-05-11 audit's MITRE ATLAS v5.3.0 + A2A v1 cross-walk. **MCP tool registry** lands as `b.mcp.toolRegistry.create({ tools, signingKey, verifyingKey?, alg?, ttlMs? })` — every registered tool gets a signed descriptor blob `{ tool, alg, signature }` (defense against compromised MCP server / descriptor drift) and a `descriptorsManifest()` produces a signed `{ body, signature }` document for operator-side attestation. The registry's `signCall({ toolName, args, nonce?, ttlMs? })` builds + signs an outbound tool-call envelope `{ tool, argsHash, nonce, iat, exp }` (defense against MCP middleman / indirect-prompt-injection synthesizing tool calls); `verifyCall(signed, { args?, seen?, nowMs? })` runs the inverse on inbound — refuses signature mismatch (`mcp/call-verify-failed`), expired envelopes (`mcp/call-expired`), replayed nonces via operator-supplied `seen(nonce)` callback (`mcp/call-replay`), unregistered tools (`mcp/call-unregistered-tool`), and args-hash mismatch when raw args supplied (`mcp/call-args-mismatch`). Default algorithm ML-DSA-87 per the framework's PQC-first rule; Ed25519 / ECDSA / SLH-DSA also available. **A2A v1 task-exchange surface** lands as `b.a2a.tasks.{send, get, cancel}` (client-side JSON-RPC dispatchers — `send` posts `tasks/send` to the peer URL with task validation + https-only refusal; `get` polls `tasks/get`; `cancel` requests `tasks/cancel`) plus `b.a2a.middleware.tasks({ scopes, handler, maxBytes? })` (server-side connect-style middleware — parses inbound JSON-RPC 2.0, enforces method allowlist `[tasks/send, tasks/get, tasks/cancel]` with -32601 method-not-found, enforces per-skill scopes via `req.a2aScopes` with -32001 scope-denied, dispatches to operator handler, maps errors to JSON-RPC -32603, refuses non-POST with 405 + non-JSON content-type with 415) plus `b.a2a.middleware.agentCard({ card, maxAgeSec? })` (serves operator's signed Agent Card at `/.well-known/agent.json` per A2A v1 discovery — 405 on non-GET, Cache-Control max-age operator-tunable).
13
14
  - v0.8.84 (2026-05-11) — **Supply-chain hardening trio + HTTP-API hygiene primitives**. **Supply chain**: CodeQL SAST workflow (`.github/workflows/codeql.yml` — PR + push-to-main + weekly Mon-05:31-UTC schedule) running the `security-extended` query pack against the JavaScript surface (catches SQL injection, XSS, prototype pollution, command injection, ReDoS, unsafe deserialization, SSRF, hardcoded credentials beyond what OSV-Scanner sees; findings surface as SARIF in the Security tab); `npm audit signatures` step in `npm-publish.yml` that verifies the cryptographic signing chain for every package in the install tree against the npm registry's public keys before the publish step runs (regression-defense — the framework ships zero npm runtime deps so the tree is trivially empty today; if a future patch accidentally adds a runtime dep, the gate refuses an unsigned registry entry before tag-push triggers a release); vendored SBOM signing extends the existing cosign-keyless flow to sign `sbom.vendored.cdx.json` alongside `sbom.cdx.json` and attaches both `.sigstore` bundles to the GitHub Release. **HTTP-API hygiene primitives**: new `b.problemDetails` family implementing RFC 9457 Problem Details for HTTP APIs (`create({ type, title, status, detail, instance, ...extensions })` builds a frozen problem doc with field validation; `fromError(err)` converts a `FrameworkError` into a problem doc with `type` derived from `err.code` against a configurable base URI; `respond(res, problem)` writes the response with `Content-Type: application/problem+json` + `Cache-Control: no-store` per RFC 9457 §3 + RFC 9111 §5.2.2.5; `validate(doc)` parses inbound problem docs from upstream APIs with shape refusal). New `b.middleware.idempotencyKey` implementing draft-ietf-httpapi-idempotency-key (replay-safe POST/PUT/PATCH/DELETE — operator-supplied store interface with first-party `memoryStore({ maxEntries })`; cached fingerprint = method + path + sha3-256(body); 422 + `idempotency/key-reuse-mismatch` problem-details on same-key-different-body per draft §4.3; 5xx responses are NOT cached because replaying a transient infrastructure failure is not idempotent; default TTL 24h; default methods POST/PUT/PATCH/DELETE). The v0.8.84 git tag landed on a wrong commit due to a release-workflow ordering issue and the npm publish for 0.8.84 did not ship; operators upgrade directly from 0.8.83 to 0.8.85 (which carries the same primitives as part of the combined ship).
package/index.js CHANGED
@@ -187,6 +187,7 @@ var auth = {
187
187
  lockout: require("./lib/auth/lockout"),
188
188
  dpop: require("./lib/auth/dpop"),
189
189
  aal: require("./lib/auth/aal"),
190
+ fal: require("./lib/auth/fal"),
190
191
  statusList: require("./lib/auth/status-list"),
191
192
  sdJwtVc: require("./lib/auth/sd-jwt-vc"),
192
193
  stepUp: require("./lib/auth/step-up"),
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.auth.fal
4
+ * @nav Identity & Access
5
+ * @title NIST 800-63-4 FAL Classifier
6
+ * @order 120
7
+ *
8
+ * @intro
9
+ * NIST SP 800-63-4 Federation Assurance Levels — FAL1 / FAL2 /
10
+ * FAL3. While AAL describes the rigor of authentication (what the
11
+ * user did to prove they are who they say they are), FAL describes
12
+ * the rigor of the FEDERATION assertion that carried that
13
+ * authentication from the IdP to the RP.
14
+ *
15
+ * FAL bands per NIST 800-63C-4:
16
+ *
17
+ * FAL1: Bearer assertion delivered through the front channel
18
+ * (typical OIDC ID token over the browser redirect).
19
+ * Signed by the IdP; verified by the RP. No audience
20
+ * binding beyond the standard `aud` claim.
21
+ *
22
+ * FAL2: Bearer assertion delivered through the back channel
23
+ * OR front-channel assertion that is encrypted to the RP.
24
+ * Replay-protection nonce required. Typical OIDC
25
+ * Authorization Code Flow with mTLS or DPoP-bound token.
26
+ *
27
+ * FAL3: Holder-of-Key assertion. RP verifies the subject
28
+ * cryptographically holds a key bound to the assertion
29
+ * (mTLS client-cert pinned to the subject, DPoP-bound +
30
+ * audience-restricted, OR SAML HoK SubjectConfirmation).
31
+ * Defeats stolen-bearer-token replay.
32
+ *
33
+ * Operators classify the FAL of an incoming federation assertion
34
+ * via `fromAssertion(opts)` — pass the assertion's properties
35
+ * (channel, encrypted, hokBinding, etc.) and get back the band.
36
+ * Compose with `b.middleware.requireFal({ minimum: "FAL2" })` for
37
+ * the gate.
38
+ *
39
+ * @card
40
+ * NIST 800-63-4 Federation Assurance Level classifier — describes the rigor of the federation assertion (FAL1 bearer / FAL2 encrypted-or-back-channel / FAL3 Holder-of-Key) carried from IdP to RP.
41
+ */
42
+
43
+ var validateOpts = require("../validate-opts");
44
+ var { AuthError } = require("../framework-error");
45
+
46
+ var FAL1 = "FAL1";
47
+ var FAL2 = "FAL2";
48
+ var FAL3 = "FAL3";
49
+
50
+ var BANDS = Object.freeze([FAL1, FAL2, FAL3]);
51
+
52
+ function _bandRank(band) {
53
+ if (band === FAL1) return 1;
54
+ if (band === FAL2) return 2;
55
+ if (band === FAL3) return 3;
56
+ return 0;
57
+ }
58
+
59
+ /**
60
+ * @primitive b.auth.fal.isValidBand
61
+ * @signature b.auth.fal.isValidBand(band)
62
+ * @since 0.8.87
63
+ * @status stable
64
+ *
65
+ * Predicate returning `true` when `band` is one of the documented
66
+ * FAL band strings (`"FAL1"` / `"FAL2"` / `"FAL3"`).
67
+ *
68
+ * @example
69
+ * b.auth.fal.isValidBand("FAL2"); // → true
70
+ * b.auth.fal.isValidBand("FALX"); // → false
71
+ */
72
+ function isValidBand(band) {
73
+ return _bandRank(band) > 0;
74
+ }
75
+
76
+ /**
77
+ * @primitive b.auth.fal.meets
78
+ * @signature b.auth.fal.meets(actualBand, requiredBand)
79
+ * @since 0.8.87
80
+ * @status stable
81
+ *
82
+ * Predicate returning `true` when `actualBand` satisfies the
83
+ * `requiredBand` floor (FAL3 ≥ FAL2 ≥ FAL1). Invalid band strings
84
+ * on either argument return `false`.
85
+ *
86
+ * @example
87
+ * b.auth.fal.meets("FAL3", "FAL2"); // → true
88
+ * b.auth.fal.meets("FAL1", "FAL2"); // → false
89
+ */
90
+ function meets(actualBand, requiredBand) {
91
+ return _bandRank(actualBand) >= _bandRank(requiredBand);
92
+ }
93
+
94
+ /**
95
+ * @primitive b.auth.fal.fromAssertion
96
+ * @signature b.auth.fal.fromAssertion(opts)
97
+ * @since 0.8.87
98
+ * @status stable
99
+ *
100
+ * Classify an incoming federation assertion's FAL band per NIST
101
+ * 800-63C-4. Returns one of `"FAL1"` / `"FAL2"` / `"FAL3"`. Throws
102
+ * `auth/bad-fal-opts` on missing required fields.
103
+ *
104
+ * - HoK binding (mTLS client-cert pinned, DPoP-bound, SAML HoK) → FAL3
105
+ * - Back-channel delivery OR encrypted-to-RP front-channel +
106
+ * replay-protection nonce → FAL2
107
+ * - Anything else → FAL1
108
+ *
109
+ * The classifier is conservative: missing replay-protection on a
110
+ * back-channel assertion downgrades to FAL1 because §5.2 requires
111
+ * nonce / jti binding before back-channel can claim FAL2.
112
+ *
113
+ * @opts
114
+ * channel: "front" | "back", // REQUIRED
115
+ * encrypted: boolean, // assertion encrypted to RP
116
+ * replayProtected: boolean, // nonce / jti / iat binding present
117
+ * hokBinding: "mtls" | "dpop" | "saml-hok" | null,
118
+ * // proof-of-possession binding present
119
+ * bearerOnly: boolean, // alias for hokBinding === null
120
+ *
121
+ * @example
122
+ * var fal = b.auth.fal.fromAssertion({
123
+ * channel: "back",
124
+ * encrypted: false,
125
+ * replayProtected: true,
126
+ * hokBinding: null,
127
+ * });
128
+ * // → "FAL2"
129
+ *
130
+ * var fal3 = b.auth.fal.fromAssertion({
131
+ * channel: "back",
132
+ * hokBinding: "mtls",
133
+ * replayProtected: true,
134
+ * });
135
+ * // → "FAL3"
136
+ */
137
+ function fromAssertion(opts) {
138
+ if (!opts || typeof opts !== "object") {
139
+ throw new AuthError("auth/bad-fal-opts",
140
+ "fal.fromAssertion: opts required (channel + replayProtected at minimum)");
141
+ }
142
+ if (opts.channel !== "front" && opts.channel !== "back") {
143
+ throw new AuthError("auth/bad-fal-opts",
144
+ "fal.fromAssertion: channel must be 'front' or 'back'");
145
+ }
146
+ var hokBinding = opts.hokBinding;
147
+ if (hokBinding !== undefined && hokBinding !== null) {
148
+ if (hokBinding !== "mtls" && hokBinding !== "dpop" && hokBinding !== "saml-hok") {
149
+ throw new AuthError("auth/bad-fal-opts",
150
+ "fal.fromAssertion: hokBinding must be 'mtls' | 'dpop' | 'saml-hok' | null");
151
+ }
152
+ }
153
+
154
+ // FAL3 — Holder-of-Key with replay protection.
155
+ if (hokBinding && opts.replayProtected === true) {
156
+ return FAL3;
157
+ }
158
+
159
+ // FAL2 — back-channel OR encrypted front-channel, with replay protection.
160
+ var replaySafe = opts.replayProtected === true;
161
+ if (replaySafe && (opts.channel === "back" || opts.encrypted === true)) {
162
+ return FAL2;
163
+ }
164
+
165
+ // Everything else — FAL1 (bearer front-channel).
166
+ return FAL1;
167
+ }
168
+
169
+ /**
170
+ * @primitive b.auth.fal.requireFal
171
+ * @signature b.auth.fal.requireFal(minimumBand)
172
+ * @since 0.8.87
173
+ * @status stable
174
+ * @related b.auth.fal.fromAssertion
175
+ *
176
+ * Build a guard that throws `auth/fal-insufficient` when the
177
+ * supplied band is below the minimum. The middleware form
178
+ * (`b.middleware.requireFal`) wraps this guard at the request layer.
179
+ *
180
+ * @example
181
+ * var fal3Only = b.auth.fal.requireFal("FAL3");
182
+ * fal3Only(req.session.federationFal);
183
+ * // throws auth/fal-insufficient if not FAL3
184
+ */
185
+ function requireFal(minimumBand) {
186
+ validateOpts.requireNonEmptyString(
187
+ minimumBand, "fal.requireFal.minimumBand", AuthError, "auth/bad-fal-band");
188
+ if (!isValidBand(minimumBand)) {
189
+ throw new AuthError("auth/bad-fal-band",
190
+ "fal.requireFal: minimumBand must be one of " + BANDS.join(", "));
191
+ }
192
+ return function falGuard(actualBand) {
193
+ if (!isValidBand(actualBand) || !meets(actualBand, minimumBand)) {
194
+ throw new AuthError("auth/fal-insufficient",
195
+ "fal.requireFal: actual band '" + actualBand + "' does not meet minimum '" + minimumBand + "'");
196
+ }
197
+ return actualBand;
198
+ };
199
+ }
200
+
201
+ module.exports = {
202
+ FAL1: FAL1,
203
+ FAL2: FAL2,
204
+ FAL3: FAL3,
205
+ BANDS: BANDS,
206
+ isValidBand: isValidBand,
207
+ meets: meets,
208
+ fromAssertion: fromAssertion,
209
+ requireFal: requireFal,
210
+ };
package/lib/mail.js CHANGED
@@ -1735,8 +1735,92 @@ function create(opts) {
1735
1735
  };
1736
1736
  }
1737
1737
 
1738
+ /**
1739
+ * @primitive b.mail.feedbackId
1740
+ * @signature b.mail.feedbackId(opts)
1741
+ * @since 0.8.87
1742
+ * @status stable
1743
+ * @related b.mail.create
1744
+ *
1745
+ * Build a Gmail Feedback Loop (FBL) Feedback-ID header value per
1746
+ * Google's FBL convention: a colon-separated 4-tuple
1747
+ * `CampaignID:CustomerID:MailType:SenderID`. Setting Feedback-ID on
1748
+ * outbound mail lets Gmail surface per-campaign abuse-rate metrics
1749
+ * back via the Postmaster Tools API so operators see spam
1750
+ * complaints aggregated by their own campaign vocabulary instead of
1751
+ * by SMTP envelope-sender alone.
1752
+ *
1753
+ * Refuses missing / empty fields (`mail/bad-feedback-id-field`),
1754
+ * fields containing `:` (would corrupt the 4-tuple separator), and
1755
+ * fields longer than 64 bytes (Gmail truncates beyond ~64 chars per
1756
+ * field). Operators set the result via `mail.create({ headers:
1757
+ * { "Feedback-ID": b.mail.feedbackId({...}) } })` or attach it to
1758
+ * an individual send().
1759
+ *
1760
+ * @opts
1761
+ * campaignId: string, // operator's campaign tag (e.g. "wk26-promo")
1762
+ * customerId: string, // operator's tenant or user-segment id
1763
+ * mailType: string, // operator-defined message type (e.g. "marketing")
1764
+ * senderId: string, // operator's app / IP-pool / domain reputation id
1765
+ *
1766
+ * @example
1767
+ * var feedbackId = b.mail.feedbackId({
1768
+ * campaignId: "wk26-promo",
1769
+ * customerId: "acme",
1770
+ * mailType: "marketing",
1771
+ * senderId: "mail-pool-1",
1772
+ * });
1773
+ * // → "wk26-promo:acme:marketing:mail-pool-1"
1774
+ *
1775
+ * mail.send({
1776
+ * to: "...",
1777
+ * headers: { "Feedback-ID": feedbackId },
1778
+ * });
1779
+ */
1780
+ function feedbackId(opts) {
1781
+ if (!opts || typeof opts !== "object") {
1782
+ throw new MailError("mail/bad-feedback-id-opts",
1783
+ "feedbackId: opts required (campaignId + customerId + mailType + senderId)");
1784
+ }
1785
+ var fields = [
1786
+ { key: "campaignId", value: opts.campaignId },
1787
+ { key: "customerId", value: opts.customerId },
1788
+ { key: "mailType", value: opts.mailType },
1789
+ { key: "senderId", value: opts.senderId },
1790
+ ];
1791
+ var parts = [];
1792
+ for (var i = 0; i < fields.length; i += 1) {
1793
+ var f = fields[i];
1794
+ if (typeof f.value !== "string" || f.value.length === 0) {
1795
+ throw new MailError("mail/bad-feedback-id-field",
1796
+ "feedbackId: " + f.key + " must be a non-empty string");
1797
+ }
1798
+ if (f.value.length > 64) { // allow:raw-byte-literal — Gmail FBL per-field cap, not byte arithmetic
1799
+ throw new MailError("mail/bad-feedback-id-field",
1800
+ "feedbackId: " + f.key + " exceeds 64 chars (Gmail FBL truncation threshold)");
1801
+ }
1802
+ if (f.value.indexOf(":") !== -1) {
1803
+ throw new MailError("mail/bad-feedback-id-field",
1804
+ "feedbackId: " + f.key + " contains ':' which is the field separator");
1805
+ }
1806
+ // Refuse CR/LF (header-injection) + control chars. Walk codepoints
1807
+ // manually because eslint's no-control-regex refuses control-char
1808
+ // ranges in regex literals regardless of escape form.
1809
+ for (var ci = 0; ci < f.value.length; ci += 1) {
1810
+ var code = f.value.charCodeAt(ci);
1811
+ if (code < 32 || code === 127) { // allow:raw-byte-literal — C0 + DEL codepoint range
1812
+ throw new MailError("mail/bad-feedback-id-field",
1813
+ "feedbackId: " + f.key + " contains control characters");
1814
+ }
1815
+ }
1816
+ parts.push(f.value);
1817
+ }
1818
+ return parts.join(":");
1819
+ }
1820
+
1738
1821
  module.exports = {
1739
1822
  create: create,
1823
+ feedbackId: feedbackId,
1740
1824
  MailError: MailError,
1741
1825
  unsubscribe: mailUnsubscribe,
1742
1826
  // RFC 3492 Punycode IDN domain encode/decode (b.mail.toAscii /
@@ -1670,8 +1670,47 @@ function _resetForTest() {
1670
1670
  _resetDotPool();
1671
1671
  }
1672
1672
 
1673
+ /**
1674
+ * @primitive b.network.dns.isNullMx
1675
+ * @signature b.network.dns.isNullMx(mxRecords)
1676
+ * @since 0.8.87
1677
+ * @status stable
1678
+ *
1679
+ * RFC 7505 Null-MX check — returns `true` when the supplied MX
1680
+ * records signal "this domain does not accept email" (a single MX
1681
+ * record with priority 0 and exchange `.`). Operators sending mail
1682
+ * call this before delivery to skip domains that have explicitly
1683
+ * opted out of email. Returns `false` for any other shape (zero
1684
+ * records, multiple records, non-zero priority, non-`.` exchange).
1685
+ *
1686
+ * MX records are expected in the `{ priority, exchange }` shape
1687
+ * returned by `node:dns.resolveMx` (or `b.network.dns.resolve(host,
1688
+ * "MX")`). Operator supplies the records; this is a pure
1689
+ * classifier, no network call.
1690
+ *
1691
+ * @example
1692
+ * var node = require("node:dns/promises");
1693
+ * var mx;
1694
+ * try { mx = await node.resolveMx("example.com"); }
1695
+ * catch (e) { mx = []; }
1696
+ * if (b.network.dns.isNullMx(mx)) {
1697
+ * throw new Error("example.com publishes Null-MX (RFC 7505) — does not accept email");
1698
+ * }
1699
+ */
1700
+ function isNullMx(mxRecords) {
1701
+ if (!Array.isArray(mxRecords) || mxRecords.length !== 1) return false;
1702
+ var only = mxRecords[0];
1703
+ if (!only || typeof only !== "object") return false;
1704
+ if (only.priority !== 0) return false;
1705
+ // node's resolveMx returns the exchange as "" (empty) when the
1706
+ // RDATA is "." (root); other resolvers may keep "." literal. Accept
1707
+ // both.
1708
+ return only.exchange === "" || only.exchange === ".";
1709
+ }
1710
+
1673
1711
  module.exports = {
1674
1712
  setServers: setServers,
1713
+ isNullMx: isNullMx,
1675
1714
  getServers: getServers,
1676
1715
  setResultOrder: setResultOrder,
1677
1716
  setFamily: setFamily,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.86",
3
+ "version": "0.8.87",
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:94157e54-2402-4932-84de-96074af286be",
5
+ "serialNumber": "urn:uuid:1d067739-a1a9-4df9-8c82-febef293933a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-11T17:16:07.899Z",
8
+ "timestamp": "2026-05-11T17:46:06.986Z",
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.8.86",
22
+ "bom-ref": "@blamejs/core@0.8.87",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.86",
25
+ "version": "0.8.87",
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.8.86",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.87",
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.8.86",
57
+ "ref": "@blamejs/core@0.8.87",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]