@blamejs/core 0.8.86 → 0.8.88

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.88 (2026-05-11) — **Hotfix: `b.auth.fal.meets()` authorization-correctness bug + new `b.earlyHints` RFC 8297 helper**. **Hotfix (PRIMARY)**: `b.auth.fal.meets(actualBand, requiredBand)` previously compared raw ranks (`_bandRank(actual) >= _bandRank(required)`) without validating either input. Unknown bands mapped to rank `0`, so `meets("FAL1", "FALX")` returned `true` (because `1 >= 0`) and `meets("bad", "bad")` returned `true` (because `0 >= 0`) — both contradicting the documented contract that invalid bands MUST return `false`. Operators calling `meets()` directly for authorization decisions could grant access on malformed input pairs. The new implementation validates both bands via `isValidBand()` first; any invalid band on either side returns `false`. The `requireFal()` guard was already correct (it used `meets()` after a separate `isValidBand(actualBand)` check, but a defense-in-depth pass into `meets()` itself now catches direct callers too). Tests added: 7 invalid-input shapes (`FALX` actual, `FALX` required, `bad`/`bad`, `FALX`/`FALX`, null on either side, both null). **New**: `b.earlyHints.send(res, { link })` — RFC 8297 103 Early Hints interim-response helper. Wraps Node 18.11+'s built-in `res.writeEarlyHints()` with: link-header validation (RFC 8288 form with one of `preload` / `preconnect` / `prefetch` / `dns-prefetch` / `modulepreload` / `prerender` / `next` / `prev`); silent no-op when the response object lacks `writeEarlyHints` (HTTP/1.0, mocks, older Node); refusal of per-request-state headers per RFC 8297 §3 (`set-cookie`, `authorization`, `content-length`, `content-type`, etc.). Operators use it to start browser-side preload of CSS / JS / fonts / preconnect origins in parallel with the server-side composition of the final response.
12
+ - 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
13
  - 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
14
  - 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
15
  - 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
@@ -146,6 +146,7 @@ var dataAct = require("./lib/data-act");
146
146
  var problemDetails = require("./lib/problem-details");
147
147
  var cacheStatus = require("./lib/cache-status");
148
148
  var serverTiming = require("./lib/server-timing");
149
+ var earlyHints = require("./lib/early-hints");
149
150
  var gateContract = require("./lib/gate-contract");
150
151
  var guardCsv = require("./lib/guard-csv");
151
152
  var guardHtml = require("./lib/guard-html");
@@ -187,6 +188,7 @@ var auth = {
187
188
  lockout: require("./lib/auth/lockout"),
188
189
  dpop: require("./lib/auth/dpop"),
189
190
  aal: require("./lib/auth/aal"),
191
+ fal: require("./lib/auth/fal"),
190
192
  statusList: require("./lib/auth/status-list"),
191
193
  sdJwtVc: require("./lib/auth/sd-jwt-vc"),
192
194
  stepUp: require("./lib/auth/step-up"),
@@ -376,6 +378,7 @@ module.exports = {
376
378
  problemDetails: problemDetails,
377
379
  cacheStatus: cacheStatus,
378
380
  serverTiming: serverTiming,
381
+ earlyHints: earlyHints,
379
382
  gateContract: gateContract,
380
383
  guardCsv: guardCsv,
381
384
  guardHtml: guardHtml,
@@ -0,0 +1,220 @@
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` — operators using `meets`
85
+ * directly for authorization decisions never get a "true" verdict
86
+ * out of a malformed input pair.
87
+ *
88
+ * @example
89
+ * b.auth.fal.meets("FAL3", "FAL2"); // → true
90
+ * b.auth.fal.meets("FAL1", "FAL2"); // → false
91
+ * b.auth.fal.meets("FAL1", "FALX"); // → false (invalid required band)
92
+ * b.auth.fal.meets("bad", "bad"); // → false (both invalid)
93
+ */
94
+ function meets(actualBand, requiredBand) {
95
+ // Validate BOTH inputs before comparing ranks. The previous
96
+ // implementation compared raw ranks (`>=`) — unknown bands mapped
97
+ // to rank 0 and `0 >= 0` returned true, contradicting the
98
+ // documented contract and producing false-positive authorization
99
+ // decisions for operators using meets() directly.
100
+ if (!isValidBand(actualBand) || !isValidBand(requiredBand)) return false;
101
+ return _bandRank(actualBand) >= _bandRank(requiredBand);
102
+ }
103
+
104
+ /**
105
+ * @primitive b.auth.fal.fromAssertion
106
+ * @signature b.auth.fal.fromAssertion(opts)
107
+ * @since 0.8.87
108
+ * @status stable
109
+ *
110
+ * Classify an incoming federation assertion's FAL band per NIST
111
+ * 800-63C-4. Returns one of `"FAL1"` / `"FAL2"` / `"FAL3"`. Throws
112
+ * `auth/bad-fal-opts` on missing required fields.
113
+ *
114
+ * - HoK binding (mTLS client-cert pinned, DPoP-bound, SAML HoK) → FAL3
115
+ * - Back-channel delivery OR encrypted-to-RP front-channel +
116
+ * replay-protection nonce → FAL2
117
+ * - Anything else → FAL1
118
+ *
119
+ * The classifier is conservative: missing replay-protection on a
120
+ * back-channel assertion downgrades to FAL1 because §5.2 requires
121
+ * nonce / jti binding before back-channel can claim FAL2.
122
+ *
123
+ * @opts
124
+ * channel: "front" | "back", // REQUIRED
125
+ * encrypted: boolean, // assertion encrypted to RP
126
+ * replayProtected: boolean, // nonce / jti / iat binding present
127
+ * hokBinding: "mtls" | "dpop" | "saml-hok" | null,
128
+ * // proof-of-possession binding present
129
+ * bearerOnly: boolean, // alias for hokBinding === null
130
+ *
131
+ * @example
132
+ * var fal = b.auth.fal.fromAssertion({
133
+ * channel: "back",
134
+ * encrypted: false,
135
+ * replayProtected: true,
136
+ * hokBinding: null,
137
+ * });
138
+ * // → "FAL2"
139
+ *
140
+ * var fal3 = b.auth.fal.fromAssertion({
141
+ * channel: "back",
142
+ * hokBinding: "mtls",
143
+ * replayProtected: true,
144
+ * });
145
+ * // → "FAL3"
146
+ */
147
+ function fromAssertion(opts) {
148
+ if (!opts || typeof opts !== "object") {
149
+ throw new AuthError("auth/bad-fal-opts",
150
+ "fal.fromAssertion: opts required (channel + replayProtected at minimum)");
151
+ }
152
+ if (opts.channel !== "front" && opts.channel !== "back") {
153
+ throw new AuthError("auth/bad-fal-opts",
154
+ "fal.fromAssertion: channel must be 'front' or 'back'");
155
+ }
156
+ var hokBinding = opts.hokBinding;
157
+ if (hokBinding !== undefined && hokBinding !== null) {
158
+ if (hokBinding !== "mtls" && hokBinding !== "dpop" && hokBinding !== "saml-hok") {
159
+ throw new AuthError("auth/bad-fal-opts",
160
+ "fal.fromAssertion: hokBinding must be 'mtls' | 'dpop' | 'saml-hok' | null");
161
+ }
162
+ }
163
+
164
+ // FAL3 — Holder-of-Key with replay protection.
165
+ if (hokBinding && opts.replayProtected === true) {
166
+ return FAL3;
167
+ }
168
+
169
+ // FAL2 — back-channel OR encrypted front-channel, with replay protection.
170
+ var replaySafe = opts.replayProtected === true;
171
+ if (replaySafe && (opts.channel === "back" || opts.encrypted === true)) {
172
+ return FAL2;
173
+ }
174
+
175
+ // Everything else — FAL1 (bearer front-channel).
176
+ return FAL1;
177
+ }
178
+
179
+ /**
180
+ * @primitive b.auth.fal.requireFal
181
+ * @signature b.auth.fal.requireFal(minimumBand)
182
+ * @since 0.8.87
183
+ * @status stable
184
+ * @related b.auth.fal.fromAssertion
185
+ *
186
+ * Build a guard that throws `auth/fal-insufficient` when the
187
+ * supplied band is below the minimum. The middleware form
188
+ * (`b.middleware.requireFal`) wraps this guard at the request layer.
189
+ *
190
+ * @example
191
+ * var fal3Only = b.auth.fal.requireFal("FAL3");
192
+ * fal3Only(req.session.federationFal);
193
+ * // throws auth/fal-insufficient if not FAL3
194
+ */
195
+ function requireFal(minimumBand) {
196
+ validateOpts.requireNonEmptyString(
197
+ minimumBand, "fal.requireFal.minimumBand", AuthError, "auth/bad-fal-band");
198
+ if (!isValidBand(minimumBand)) {
199
+ throw new AuthError("auth/bad-fal-band",
200
+ "fal.requireFal: minimumBand must be one of " + BANDS.join(", "));
201
+ }
202
+ return function falGuard(actualBand) {
203
+ if (!isValidBand(actualBand) || !meets(actualBand, minimumBand)) {
204
+ throw new AuthError("auth/fal-insufficient",
205
+ "fal.requireFal: actual band '" + actualBand + "' does not meet minimum '" + minimumBand + "'");
206
+ }
207
+ return actualBand;
208
+ };
209
+ }
210
+
211
+ module.exports = {
212
+ FAL1: FAL1,
213
+ FAL2: FAL2,
214
+ FAL3: FAL3,
215
+ BANDS: BANDS,
216
+ isValidBand: isValidBand,
217
+ meets: meets,
218
+ fromAssertion: fromAssertion,
219
+ requireFal: requireFal,
220
+ };
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.earlyHints
4
+ * @nav HTTP
5
+ * @title RFC 8297 103 Early Hints
6
+ * @order 320
7
+ *
8
+ * @intro
9
+ * RFC 8297 103 Early Hints — interim informational response the
10
+ * server sends BEFORE the final response, telling the browser
11
+ * which subresources it should start preloading while the server
12
+ * is still composing the final HTML / JSON. Browsers (Chrome 103+,
13
+ * Edge 103+, Firefox 120+) honor `Link: rel=preload` /
14
+ * `rel=preconnect` headers in the 103 to kick off resource fetches
15
+ * in parallel with the main render.
16
+ *
17
+ * Operators reach for early-hints when the server has slow upstream
18
+ * dependencies (DB query, downstream API) but already knows the
19
+ * final response will reference specific CSS / JS / fonts /
20
+ * API origins. The 103 turns a single-RTT-bound page load into a
21
+ * parallel resource-prefetch chain.
22
+ *
23
+ * `b.earlyHints.send(res, { link, ... })` writes the interim 103
24
+ * with the supplied headers. The framework wraps Node's built-in
25
+ * `res.writeEarlyHints()` (Node 18.11+) and adds:
26
+ *
27
+ * - input validation (link entries must be RFC 8288 Link-header
28
+ * form: `<uri>; rel=preload[; as=script][; crossorigin=...]`)
29
+ * - silent no-op when the operator-supplied `res` is not an
30
+ * HTTP/1.1+ socket-backed response (HTTP/1.0 clients don't
31
+ * understand 103; serializing one would corrupt the stream)
32
+ * - validation of cacheable header set per RFC 8297 section 3
33
+ * (only headers that hint about the FINAL response are
34
+ * honored; Set-Cookie / authentication-related headers are
35
+ * refused)
36
+ *
37
+ * The 103 does NOT replace the final response — the operator's
38
+ * handler still writes the regular 200/400/etc. status + body.
39
+ * Multiple 103s before the final response are permitted (Node's
40
+ * writeEarlyHints can be called repeatedly).
41
+ *
42
+ * @card
43
+ * RFC 8297 103 Early Hints helper — operator-friendly wrapper around Node's response writeEarlyHints API for browser-side parallel resource-prefetch hints.
44
+ */
45
+
46
+ var validateOpts = require("./validate-opts");
47
+ var { defineClass } = require("./framework-error");
48
+
49
+ var EarlyHintsError = defineClass("EarlyHintsError", { alwaysPermanent: true });
50
+
51
+ // Headers that are SAFE to surface in a 103 are the ones describing
52
+ // the upcoming final response. Refused header names mostly carry
53
+ // per-request state (cookies, auth) that a 103 would prematurely
54
+ // leak or that a 103 cannot honor (Content-Length, Transfer-
55
+ // Encoding, Content-Type all describe THIS interim response, not
56
+ // the final).
57
+ var REFUSED_HEADERS = Object.freeze([
58
+ "set-cookie",
59
+ "authorization",
60
+ "www-authenticate",
61
+ "content-length",
62
+ "content-type",
63
+ "transfer-encoding",
64
+ "connection",
65
+ "upgrade",
66
+ "trailer",
67
+ ]);
68
+
69
+ var LINK_RELATION_RE = /^(preload|preconnect|prefetch|dns-prefetch|modulepreload|prerender|next|prev)$/i;
70
+ var LINK_MAX_BYTES = 4096; // allow:raw-byte-literal — per-link length cap, not bytes
71
+
72
+ /**
73
+ * @primitive b.earlyHints.send
74
+ * @signature b.earlyHints.send(res, opts)
75
+ * @since 0.8.88
76
+ * @status stable
77
+ *
78
+ * Write an RFC 8297 103 Early Hints interim response to `res`.
79
+ * Returns `true` when the 103 was written, `false` when the
80
+ * underlying response does not support early hints (HTTP/1.0, a
81
+ * non-HTTP-shaped object, or the response writeEarlyHints API is
82
+ * missing).
83
+ *
84
+ * `link` is either a single Link-header value string OR an array
85
+ * of strings. Each must follow the RFC 8288 Link-header grammar
86
+ * with a `rel=` parameter naming one of: `preload`, `preconnect`,
87
+ * `prefetch`, `dns-prefetch`, `modulepreload`, `prerender`, `next`,
88
+ * `prev`. Refused: per-link size > 4 KiB, missing `rel=`, unknown
89
+ * relation. Other operator-supplied header keys must NOT be in
90
+ * `REFUSED_HEADERS` (set-cookie / authorization / content-length
91
+ * / etc.) — those carry per-request state a 103 must not surface.
92
+ *
93
+ * @opts
94
+ * link: string | string[], // RFC 8288 Link-header values (REQUIRED)
95
+ *
96
+ * @example
97
+ * b.earlyHints.send(res, {
98
+ * link: [
99
+ * "</style.css>; rel=preload; as=style",
100
+ * "</app.js>; rel=preload; as=script",
101
+ * "<https://cdn.example.com>; rel=preconnect",
102
+ * ],
103
+ * });
104
+ * res.statusCode = 200;
105
+ * res.setHeader("Content-Type", "text/html");
106
+ * res.end(html);
107
+ */
108
+ function send(res, opts) {
109
+ if (!res || typeof res !== "object") {
110
+ throw new EarlyHintsError("early-hints/bad-res",
111
+ "earlyHints.send: res must be an HTTP response object", true);
112
+ }
113
+ if (typeof res.writeEarlyHints !== "function") {
114
+ // Node < 18.11 OR HTTP/1.0 OR mock res — silent no-op so
115
+ // operator code stays the same across deployments.
116
+ return false;
117
+ }
118
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
119
+ throw new EarlyHintsError("early-hints/bad-opts",
120
+ "earlyHints.send: opts required (link + optional header pairs)", true);
121
+ }
122
+
123
+ var headers = {};
124
+
125
+ if (opts.link === undefined || opts.link === null) {
126
+ throw new EarlyHintsError("early-hints/no-link",
127
+ "earlyHints.send: opts.link is required (RFC 8297 §2 requires at least one Link header)", true);
128
+ }
129
+ var linkArr = Array.isArray(opts.link) ? opts.link : [opts.link];
130
+ if (linkArr.length === 0) {
131
+ throw new EarlyHintsError("early-hints/no-link",
132
+ "earlyHints.send: opts.link must contain at least one Link-header value", true);
133
+ }
134
+ for (var i = 0; i < linkArr.length; i += 1) {
135
+ _validateLink(linkArr[i], i);
136
+ }
137
+ headers.link = linkArr;
138
+
139
+ var keys = Object.keys(opts);
140
+ for (var k = 0; k < keys.length; k += 1) {
141
+ var name = keys[k];
142
+ if (name === "link") continue;
143
+ var lower = name.toLowerCase();
144
+ if (REFUSED_HEADERS.indexOf(lower) !== -1) {
145
+ throw new EarlyHintsError("early-hints/refused-header",
146
+ "earlyHints.send: header '" + name + "' refused — RFC 8297 §3 prohibits " +
147
+ "per-request state in interim responses (refused set: " + REFUSED_HEADERS.join(", ") + ")");
148
+ }
149
+ if (typeof opts[name] !== "string" && !Array.isArray(opts[name])) {
150
+ throw new EarlyHintsError("early-hints/bad-header-value",
151
+ "earlyHints.send: header '" + name + "' must be a string or string[]", true);
152
+ }
153
+ headers[lower] = opts[name];
154
+ }
155
+
156
+ try {
157
+ res.writeEarlyHints(headers);
158
+ return true;
159
+ } catch (writeErr) {
160
+ throw new EarlyHintsError("early-hints/write-failed",
161
+ "earlyHints.send: writeEarlyHints failed: " + (writeErr.message || writeErr));
162
+ }
163
+ }
164
+
165
+ function _validateLink(linkValue, idx) {
166
+ validateOpts.requireNonEmptyString(linkValue, "earlyHints.send.link[" + idx + "]",
167
+ EarlyHintsError, "early-hints/bad-link");
168
+ if (linkValue.length > LINK_MAX_BYTES) {
169
+ throw new EarlyHintsError("early-hints/bad-link",
170
+ "link[" + idx + "] exceeds " + LINK_MAX_BYTES + " bytes");
171
+ }
172
+ var relMatch = /;\s*rel\s*=\s*"?([a-zA-Z0-9-]+)"?/i.exec(linkValue);
173
+ if (!relMatch) {
174
+ throw new EarlyHintsError("early-hints/bad-link",
175
+ "link[" + idx + "] missing rel= parameter (RFC 8288)");
176
+ }
177
+ if (relMatch[1].length > 32 || !LINK_RELATION_RE.test(relMatch[1])) { // allow:raw-byte-literal — rel-token length cap, not bytes
178
+ throw new EarlyHintsError("early-hints/bad-link",
179
+ "link[" + idx + "].rel '" + relMatch[1] + "' must be one of: " +
180
+ "preload, preconnect, prefetch, dns-prefetch, modulepreload, prerender, next, prev");
181
+ }
182
+ if (linkValue.charAt(0) !== "<" || linkValue.indexOf(">") < 1) {
183
+ throw new EarlyHintsError("early-hints/bad-link",
184
+ "link[" + idx + "] must start with angle-bracketed URI per RFC 8288 (e.g. <https://x.com>; rel=preload)");
185
+ }
186
+ }
187
+
188
+ module.exports = {
189
+ send: send,
190
+ REFUSED_HEADERS: REFUSED_HEADERS,
191
+ EarlyHintsError: EarlyHintsError,
192
+ };
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.88",
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:75038048-e27a-4af6-a354-0cabce037597",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-11T17:16:07.899Z",
8
+ "timestamp": "2026-05-11T18:13:26.544Z",
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.88",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.86",
25
+ "version": "0.8.88",
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.88",
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.88",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]