@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 +1 -0
- package/index.js +1 -0
- package/lib/auth/fal.js +210 -0
- package/lib/mail.js +84 -0
- package/lib/network-dns.js +39 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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"),
|
package/lib/auth/fal.js
ADDED
|
@@ -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 /
|
package/lib/network-dns.js
CHANGED
|
@@ -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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:1d067739-a1a9-4df9-8c82-febef293933a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-11T17:
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.87",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.8.87",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|