@blamejs/core 0.8.88 → 0.8.90
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 +2 -0
- package/lib/early-hints.js +29 -9
- package/lib/mail-require-tls.js +209 -0
- package/lib/mail-srs.js +248 -0
- package/lib/mail.js +5 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.90 (2026-05-11) — **RFC 8689 REQUIRETLS support** (`b.mail.requireTls`). Per-message TLS-requirement signaling between sender and receiver MTAs. Complements MTA-STS / DANE (policy-side, domain-scoped) with a per-message knob that overrides policy when the operator wants stricter-than-policy delivery — message bounces instead of falling back to cleartext if no downstream MTA can deliver under TLS. **`peerSupports(ehloLines)`**: walks a parsed EHLO response and returns `true` when the peer advertised the `REQUIRETLS` keyword; case-insensitive per RFC 5321 §2.4; refuses substring matches (`FOO-REQUIRETLS-BAR` does NOT match); empty / non-array input returns `false`. **`mailFromExtension({ requireTls })`**: builds the trailing `" REQUIRETLS"` token to append to a MAIL FROM line; refuses non-boolean flag value (a truthy-but-wrong-shape value like `"yes"` throws instead of silently succeeding). **`parseTlsRequiredHeader(headerValue)`**: parses the RFC 8689 §5 `TLS-Required` header — returns `"no"` only when the value is the literal token `no` (case-insensitive, ignoring whitespace) per spec; any other non-empty value returns `"yes"` (RFC 8689 §5: "any value other than 'No' MUST be treated as if the field had been absent" — conservative strict path); returns `null` for absent / empty / non-string input; refuses control characters on the **raw** header value before `trim()` runs so a leading `\n` / trailing `\r` / NUL / DEL byte can no longer slip past as the literal token `no` (ASCII HT remains permitted as structural folding whitespace).
|
|
12
|
+
- v0.8.89 (2026-05-11) — **Hotfix: `b.earlyHints.send()` case-variant link bypass + new `b.mail.srs` Sender Rewriting Scheme**. **Hotfix (PRIMARY)**: pre-v0.8.89, supplying both `link` (lowercase) AND `Link` (capital, or any other case variant) to `b.earlyHints.send()` bypassed the validator. `opts.link` got the dedicated `_validateLink` pass and was assigned to `headers.link`; the trailing header loop then iterated `Object.keys(opts)`, skipped only the exact-match `"link"` key, and for `"Link"` lowercased the name and wrote `headers.link = opts.Link` — overwriting the validated value with unvalidated content. Malformed Link headers (missing `rel=`, unknown relation, oversized) reached `writeEarlyHints()` despite the API contract. The fix collapses all opt keys to a single canonical lowercase map up front; duplicate case-variants of any header (not just `link`) now refuse with `early-hints/duplicate-header` so operators see the collision instead of getting silent winner-take-all behavior. Capital `Link` alone (no lowercase variant) still works — it goes through the same validator. Tests added: case-variant-collision refuse, capital-Link-alone validates, capital-Link with malformed value still throws `bad-link`. **New**: `b.mail.srs.create({ secret, forwarderDomain, expiryDays? })` — Sender Rewriting Scheme (SRS0) implementation for forwarder envelope-from rewriting so the next-hop SPF check passes and bounces route correctly back to the original sender. Returns `{ rewrite, reverse }`. `rewrite(addr)` produces an SRS-encoded `SRS0=HHHH=TT=domain=local@forwarder.example` form; `reverse(srs)` decodes back to the original sender, verifying the HMAC-SHA-256 short-tag (operator-supplied secret), the day-stamp expiry window (default 30 days), and the canonical 4-field SRS0 grammar. Domain-binding check: `reverse(srs)` refuses with `srs/wrong-forwarder` when the SRS0 address's `@domain` part doesn't match the rewriter's `forwarderDomain` (case-insensitive per RFC 5321 §2.3.5) so a tag signed with the same secret but addressed to a different forwarder domain can no longer be accepted. Refuses tampered tags via `srs/bad-tag`, expired rewrites via `srs/expired`, double-SRS-encoding via `srs/already-rewritten`, and bad address shapes via `srs/bad-address`. HMAC uses `b.crypto.timingSafeEqual` for tag comparison so the verification side stays constant-time against operator-controlled tag inputs.
|
|
11
13
|
- 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
14
|
- 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.
|
|
13
15
|
- 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.
|
package/lib/early-hints.js
CHANGED
|
@@ -120,13 +120,34 @@ function send(res, opts) {
|
|
|
120
120
|
"earlyHints.send: opts required (link + optional header pairs)", true);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Lowercase every key up front so case-variants (`Link`, `LINK`,
|
|
124
|
+
// `LiNk`) collapse to the same canonical key. Without this pass
|
|
125
|
+
// a caller could supply both `link` (which gets validated) AND
|
|
126
|
+
// `Link` (which the validator's `if (name === "link") continue;`
|
|
127
|
+
// would skip), then the lowercase rewrite in the trailing loop
|
|
128
|
+
// would overwrite the validated value with unvalidated content.
|
|
129
|
+
// Refuse the collision explicitly rather than silently pick one;
|
|
130
|
+
// operators should pass each header exactly once.
|
|
131
|
+
var canonical = {};
|
|
132
|
+
var rawKeys = Object.keys(opts);
|
|
133
|
+
for (var rk = 0; rk < rawKeys.length; rk += 1) {
|
|
134
|
+
var rawName = rawKeys[rk];
|
|
135
|
+
var lowerName = rawName.toLowerCase();
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(canonical, lowerName)) {
|
|
137
|
+
throw new EarlyHintsError("early-hints/duplicate-header",
|
|
138
|
+
"earlyHints.send: duplicate header '" + lowerName + "' " +
|
|
139
|
+
"(case-variant supplied twice — pass each header exactly once)");
|
|
140
|
+
}
|
|
141
|
+
canonical[lowerName] = opts[rawName];
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
var headers = {};
|
|
124
145
|
|
|
125
|
-
if (
|
|
146
|
+
if (canonical.link === undefined || canonical.link === null) {
|
|
126
147
|
throw new EarlyHintsError("early-hints/no-link",
|
|
127
148
|
"earlyHints.send: opts.link is required (RFC 8297 §2 requires at least one Link header)", true);
|
|
128
149
|
}
|
|
129
|
-
var linkArr = Array.isArray(
|
|
150
|
+
var linkArr = Array.isArray(canonical.link) ? canonical.link : [canonical.link];
|
|
130
151
|
if (linkArr.length === 0) {
|
|
131
152
|
throw new EarlyHintsError("early-hints/no-link",
|
|
132
153
|
"earlyHints.send: opts.link must contain at least one Link-header value", true);
|
|
@@ -136,21 +157,20 @@ function send(res, opts) {
|
|
|
136
157
|
}
|
|
137
158
|
headers.link = linkArr;
|
|
138
159
|
|
|
139
|
-
var
|
|
140
|
-
for (var k = 0; k <
|
|
141
|
-
var name =
|
|
160
|
+
var canonicalKeys = Object.keys(canonical);
|
|
161
|
+
for (var k = 0; k < canonicalKeys.length; k += 1) {
|
|
162
|
+
var name = canonicalKeys[k];
|
|
142
163
|
if (name === "link") continue;
|
|
143
|
-
|
|
144
|
-
if (REFUSED_HEADERS.indexOf(lower) !== -1) {
|
|
164
|
+
if (REFUSED_HEADERS.indexOf(name) !== -1) {
|
|
145
165
|
throw new EarlyHintsError("early-hints/refused-header",
|
|
146
166
|
"earlyHints.send: header '" + name + "' refused — RFC 8297 §3 prohibits " +
|
|
147
167
|
"per-request state in interim responses (refused set: " + REFUSED_HEADERS.join(", ") + ")");
|
|
148
168
|
}
|
|
149
|
-
if (typeof
|
|
169
|
+
if (typeof canonical[name] !== "string" && !Array.isArray(canonical[name])) {
|
|
150
170
|
throw new EarlyHintsError("early-hints/bad-header-value",
|
|
151
171
|
"earlyHints.send: header '" + name + "' must be a string or string[]", true);
|
|
152
172
|
}
|
|
153
|
-
headers[
|
|
173
|
+
headers[name] = canonical[name];
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
try {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.requireTls
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title REQUIRETLS — RFC 8689
|
|
6
|
+
* @order 460
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 8689 SMTP REQUIRETLS — per-message TLS-requirement signaling
|
|
10
|
+
* between sender and receiver MTAs. The sender advertises that the
|
|
11
|
+
* message MUST NOT be relayed over a cleartext (non-TLS) hop; if
|
|
12
|
+
* no downstream MTA can deliver under TLS, the message bounces
|
|
13
|
+
* instead of falling back to cleartext. Complements MTA-STS / DANE
|
|
14
|
+
* (which are policy-side, domain-scoped) with a per-message
|
|
15
|
+
* knob that overrides the policy when the operator wants
|
|
16
|
+
* stricter-than-policy delivery.
|
|
17
|
+
*
|
|
18
|
+
* Wire surface (RFC 8689 §3):
|
|
19
|
+
*
|
|
20
|
+
* EHLO peer advertises: 250 REQUIRETLS
|
|
21
|
+
* Client sends: MAIL FROM:<sender> REQUIRETLS
|
|
22
|
+
* Server replies: 250 OK (or 550 if it can't honor)
|
|
23
|
+
*
|
|
24
|
+
* Header surface (RFC 8689 §5):
|
|
25
|
+
*
|
|
26
|
+
* TLS-Required: No Explicit operator override; sender
|
|
27
|
+
* requests REQUIRETLS-style behavior be
|
|
28
|
+
* DISABLED for this message even if the
|
|
29
|
+
* policy infrastructure (MTA-STS / DANE)
|
|
30
|
+
* says otherwise. Use sparingly — primary
|
|
31
|
+
* use case is delivery to legacy peers
|
|
32
|
+
* during a controlled migration.
|
|
33
|
+
*
|
|
34
|
+
* This module ships:
|
|
35
|
+
*
|
|
36
|
+
* b.mail.requireTls.peerSupports(ehloLines) → boolean
|
|
37
|
+
* Walks EHLO response lines and returns true when the peer
|
|
38
|
+
* advertised the REQUIRETLS keyword.
|
|
39
|
+
*
|
|
40
|
+
* b.mail.requireTls.mailFromExtension({ requireTls }) → string
|
|
41
|
+
* Returns the trailing " REQUIRETLS" token (or empty string)
|
|
42
|
+
* to append to a MAIL FROM line.
|
|
43
|
+
*
|
|
44
|
+
* b.mail.requireTls.parseTlsRequiredHeader(headerValue) → "yes" | "no" | null
|
|
45
|
+
* Parses the TLS-Required header field per §5. Returns "no"
|
|
46
|
+
* only when the value is the literal token "no" (case-
|
|
47
|
+
* insensitive); any other value returns "yes" (the conservative
|
|
48
|
+
* default — operators must opt OUT explicitly, never default to
|
|
49
|
+
* fall-back-to-cleartext). null when the header is absent.
|
|
50
|
+
*
|
|
51
|
+
* @card
|
|
52
|
+
* RFC 8689 REQUIRETLS — per-message TLS-requirement signaling between MTAs (EHLO keyword + MAIL FROM extension + TLS-Required header parser).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
56
|
+
var { defineClass } = require("./framework-error");
|
|
57
|
+
|
|
58
|
+
var RequireTlsError = defineClass("RequireTlsError", { alwaysPermanent: true });
|
|
59
|
+
|
|
60
|
+
var REQUIRETLS_TOKEN = "REQUIRETLS";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @primitive b.mail.requireTls.peerSupports
|
|
64
|
+
* @signature b.mail.requireTls.peerSupports(ehloLines)
|
|
65
|
+
* @since 0.8.90
|
|
66
|
+
* @status stable
|
|
67
|
+
*
|
|
68
|
+
* Walk a parsed EHLO response and return `true` when the peer
|
|
69
|
+
* advertised the `REQUIRETLS` keyword. `ehloLines` is the array of
|
|
70
|
+
* post-greeting capability lines returned by the SMTP transport
|
|
71
|
+
* (each entry is the capability token, e.g. `"SIZE 10485760"`,
|
|
72
|
+
* `"PIPELINING"`, `"REQUIRETLS"`). Case-insensitive match per RFC
|
|
73
|
+
* 5321 §2.4 (EHLO keywords are uppercase by convention but
|
|
74
|
+
* comparison is case-insensitive).
|
|
75
|
+
*
|
|
76
|
+
* Returns `false` for empty / non-array input — operators who can't
|
|
77
|
+
* parse the EHLO get a definitive "not supported" verdict rather
|
|
78
|
+
* than a throw, matching the "defensive request-shape reader"
|
|
79
|
+
* convention used elsewhere.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* var ehlo = ["mail.example.com", "PIPELINING", "SIZE 10485760", "REQUIRETLS", "STARTTLS"];
|
|
83
|
+
* b.mail.requireTls.peerSupports(ehlo); // → true
|
|
84
|
+
*
|
|
85
|
+
* b.mail.requireTls.peerSupports(["PIPELINING", "SIZE 10485760"]); // → false
|
|
86
|
+
*/
|
|
87
|
+
function peerSupports(ehloLines) {
|
|
88
|
+
if (!Array.isArray(ehloLines)) return false;
|
|
89
|
+
for (var i = 0; i < ehloLines.length; i += 1) {
|
|
90
|
+
var line = ehloLines[i];
|
|
91
|
+
if (typeof line !== "string") continue;
|
|
92
|
+
// Keyword is everything up to the first space (RFC 5321 §4.1.1.1).
|
|
93
|
+
var sp = line.indexOf(" ");
|
|
94
|
+
var keyword = sp === -1 ? line : line.slice(0, sp);
|
|
95
|
+
if (keyword.toUpperCase() === REQUIRETLS_TOKEN) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @primitive b.mail.requireTls.mailFromExtension
|
|
102
|
+
* @signature b.mail.requireTls.mailFromExtension(opts)
|
|
103
|
+
* @since 0.8.90
|
|
104
|
+
* @status stable
|
|
105
|
+
*
|
|
106
|
+
* Build the trailing SMTP MAIL FROM extension token for REQUIRETLS.
|
|
107
|
+
* Returns `" REQUIRETLS"` (with a leading space, ready to append)
|
|
108
|
+
* when `opts.requireTls === true`; empty string otherwise. The
|
|
109
|
+
* primitive does NOT validate the operator's address — that's the
|
|
110
|
+
* SMTP transport's job. This only emits the standard-defined token
|
|
111
|
+
* suffix.
|
|
112
|
+
*
|
|
113
|
+
* Refuses non-object opts. `requireTls` must be a boolean when
|
|
114
|
+
* provided (any other type throws `mail-require-tls/bad-flag`) so
|
|
115
|
+
* a truthy-but-wrong-shape value (e.g. `"yes"`) doesn't silently
|
|
116
|
+
* succeed.
|
|
117
|
+
*
|
|
118
|
+
* @opts
|
|
119
|
+
* requireTls: boolean, // true to emit " REQUIRETLS"; falsy/absent → ""
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* var line = "MAIL FROM:<alice@example.com>" +
|
|
123
|
+
* b.mail.requireTls.mailFromExtension({ requireTls: true });
|
|
124
|
+
* // → "MAIL FROM:<alice@example.com> REQUIRETLS"
|
|
125
|
+
*/
|
|
126
|
+
function mailFromExtension(opts) {
|
|
127
|
+
if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
|
|
128
|
+
throw new RequireTlsError("mail-require-tls/bad-opts",
|
|
129
|
+
"mailFromExtension: opts must be a non-null object", true);
|
|
130
|
+
}
|
|
131
|
+
if (opts.requireTls === undefined || opts.requireTls === false) return "";
|
|
132
|
+
if (opts.requireTls !== true) {
|
|
133
|
+
throw new RequireTlsError("mail-require-tls/bad-flag",
|
|
134
|
+
"mailFromExtension: requireTls must be a boolean (got " + typeof opts.requireTls + ")");
|
|
135
|
+
}
|
|
136
|
+
return " " + REQUIRETLS_TOKEN;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @primitive b.mail.requireTls.parseTlsRequiredHeader
|
|
141
|
+
* @signature b.mail.requireTls.parseTlsRequiredHeader(headerValue)
|
|
142
|
+
* @since 0.8.90
|
|
143
|
+
* @status stable
|
|
144
|
+
*
|
|
145
|
+
* Parse the RFC 8689 §5 `TLS-Required` header field. Returns:
|
|
146
|
+
*
|
|
147
|
+
* - `"no"` when the value is the literal token `no` (case-
|
|
148
|
+
* insensitive, ignoring surrounding whitespace) — the sender
|
|
149
|
+
* EXPLICITLY opts out of REQUIRETLS-style behavior for this
|
|
150
|
+
* message.
|
|
151
|
+
* - `"yes"` for any other non-empty value — conservative default
|
|
152
|
+
* so an operator who set a typo / malformed value still gets
|
|
153
|
+
* the strict path (RFC 8689 §5: "if a recipient receives a
|
|
154
|
+
* message containing a TLS-Required field with any value other
|
|
155
|
+
* than 'No', it MUST be treated as if the field had been
|
|
156
|
+
* absent").
|
|
157
|
+
* - `null` when the header is absent / empty / not a string —
|
|
158
|
+
* operator code branches on null vs "yes" / "no".
|
|
159
|
+
*
|
|
160
|
+
* Refuses CR / LF / NUL in the value (header-injection-shape inputs
|
|
161
|
+
* shouldn't reach a parser that's downstream of header splitters
|
|
162
|
+
* anyway, but a defensive check here catches operator-side mistakes).
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* b.mail.requireTls.parseTlsRequiredHeader("No"); // → "no"
|
|
166
|
+
* b.mail.requireTls.parseTlsRequiredHeader("no"); // → "no"
|
|
167
|
+
* b.mail.requireTls.parseTlsRequiredHeader(" no "); // → "no"
|
|
168
|
+
* b.mail.requireTls.parseTlsRequiredHeader("yes"); // → "yes"
|
|
169
|
+
* b.mail.requireTls.parseTlsRequiredHeader("anything"); // → "yes" (RFC 8689 §5 default)
|
|
170
|
+
* b.mail.requireTls.parseTlsRequiredHeader(""); // → null
|
|
171
|
+
* b.mail.requireTls.parseTlsRequiredHeader(undefined); // → null
|
|
172
|
+
*/
|
|
173
|
+
function parseTlsRequiredHeader(headerValue) {
|
|
174
|
+
if (typeof headerValue !== "string") return null;
|
|
175
|
+
// Refuse control characters defensively on the RAW value — scanning
|
|
176
|
+
// after trim() would strip leading/trailing \r\n\t etc. before the
|
|
177
|
+
// check ran, letting a header-injection-shape input like "\nno" or
|
|
178
|
+
// "no\r" slip past as the literal "no" token. Validate the original
|
|
179
|
+
// string so the contract ("control bytes are refused") holds for any
|
|
180
|
+
// position in the value.
|
|
181
|
+
for (var i = 0; i < headerValue.length; i += 1) {
|
|
182
|
+
var code = headerValue.charCodeAt(i);
|
|
183
|
+
// ASCII HT (0x09) is structural folding whitespace in HTTP/email
|
|
184
|
+
// headers — strip-equivalent at the parser layer, so the trim()
|
|
185
|
+
// below absorbs it. Everything else in C0 + DEL is rejected.
|
|
186
|
+
if (code === 9) continue; // allow:raw-byte-literal — ASCII HT codepoint
|
|
187
|
+
if (code < 32 || code === 127) { // allow:raw-byte-literal — C0 + DEL codepoint range
|
|
188
|
+
throw new RequireTlsError("mail-require-tls/bad-header-value",
|
|
189
|
+
"parseTlsRequiredHeader: value contains control characters");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
var trimmed = headerValue.trim();
|
|
193
|
+
if (trimmed.length === 0) return null;
|
|
194
|
+
if (trimmed.toLowerCase() === "no") return "no";
|
|
195
|
+
// RFC 8689 §5 — any other value treated as if absent (strict path).
|
|
196
|
+
return "yes";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
peerSupports: peerSupports,
|
|
201
|
+
mailFromExtension: mailFromExtension,
|
|
202
|
+
parseTlsRequiredHeader: parseTlsRequiredHeader,
|
|
203
|
+
REQUIRETLS_TOKEN: REQUIRETLS_TOKEN,
|
|
204
|
+
RequireTlsError: RequireTlsError,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Reserved for future field validation paths; kept in canonical
|
|
208
|
+
// require ordering.
|
|
209
|
+
void validateOpts;
|
package/lib/mail-srs.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.srs
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title SRS — Sender Rewriting Scheme
|
|
6
|
+
* @order 450
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Sender Rewriting Scheme (SRS0 / SRS1) — when a forwarder
|
|
10
|
+
* retransmits a message it received, SPF on the next hop will
|
|
11
|
+
* typically fail because the envelope-from sender is the original
|
|
12
|
+
* sender's domain, but the message is now coming from the
|
|
13
|
+
* forwarder's IP. SRS rewrites the envelope-from local-part to
|
|
14
|
+
* encode the original sender + a HMAC signature; the receiver
|
|
15
|
+
* verifies + reverses to deliver bounces correctly.
|
|
16
|
+
*
|
|
17
|
+
* Wire format (SRS0):
|
|
18
|
+
*
|
|
19
|
+
* SRS0=HHH=TT=domain=local@forwarder.example
|
|
20
|
+
*
|
|
21
|
+
* Where:
|
|
22
|
+
* - `HHH` is the first 4 chars of base32(HMAC-SHA-256(secret,
|
|
23
|
+
* lowercase(TT=domain=local))) — short-tag binding the rewrite
|
|
24
|
+
* to the operator's signing secret
|
|
25
|
+
* - `TT` is a 2-character base32 day-of-time stamp (mod-1024
|
|
26
|
+
* day rotation; rejects rewrites older than ~30 days)
|
|
27
|
+
* - `domain` is the original sender's domain
|
|
28
|
+
* - `local` is the original sender's local-part
|
|
29
|
+
* - `forwarder.example` is the rewriting forwarder's domain
|
|
30
|
+
*
|
|
31
|
+
* SRS1 (double-forward case): when an already-SRS0-encoded address
|
|
32
|
+
* gets forwarded a second time, SRS1 wraps the SRS0 envelope
|
|
33
|
+
* instead of re-encoding from scratch, preserving the original
|
|
34
|
+
* sender chain.
|
|
35
|
+
*
|
|
36
|
+
* `b.mail.srs.create({ secret, forwarderDomain })` returns
|
|
37
|
+
* `{ rewrite, reverse }`. `rewrite(originalSender)` produces the
|
|
38
|
+
* SRS-encoded address; `reverse(srsAddress)` decodes back to the
|
|
39
|
+
* original sender + verifies the HMAC.
|
|
40
|
+
*
|
|
41
|
+
* @card
|
|
42
|
+
* SRS Sender Rewriting Scheme — forwarder envelope-from rewriting with HMAC-bound day-rotated tags so the next-hop SPF check passes and bounces route correctly back to the original sender.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
var nodeCrypto = require("node:crypto");
|
|
46
|
+
var blamejsCrypto = require("./crypto");
|
|
47
|
+
var validateOpts = require("./validate-opts");
|
|
48
|
+
var { defineClass } = require("./framework-error");
|
|
49
|
+
|
|
50
|
+
var SrsError = defineClass("SrsError", { alwaysPermanent: true });
|
|
51
|
+
|
|
52
|
+
// SRS spec: 2-char base32 day stamp. The rotation cycle is 1024 days
|
|
53
|
+
// (32 * 32) which is ~2.8 years; valid-window is the operator-supplied
|
|
54
|
+
// expiry (default 30 days).
|
|
55
|
+
var BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
|
|
56
|
+
|
|
57
|
+
function _base32Encode(buf) {
|
|
58
|
+
var out = "";
|
|
59
|
+
var bits = 0;
|
|
60
|
+
var value = 0;
|
|
61
|
+
for (var i = 0; i < buf.length; i += 1) {
|
|
62
|
+
value = (value << 8) | buf[i]; // allow:raw-byte-literal — byte-aligned shift
|
|
63
|
+
bits += 8; // allow:raw-byte-literal — bits-per-byte constant
|
|
64
|
+
while (bits >= 5) {
|
|
65
|
+
out += BASE32.charAt((value >>> (bits - 5)) & 31);
|
|
66
|
+
bits -= 5;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (bits > 0) out += BASE32.charAt((value << (5 - bits)) & 31);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _hashTag(secret, hashInput) {
|
|
74
|
+
var mac = nodeCrypto.createHmac("sha256", secret).update(hashInput.toLowerCase(), "utf8").digest();
|
|
75
|
+
return _base32Encode(mac.subarray(0, 4)).slice(0, 4); // allow:raw-byte-literal — SRS spec 4-char short-tag
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _dayStamp(nowMs) {
|
|
79
|
+
// Days since epoch, mod 1024. Two-char base32 = 1024 possible values.
|
|
80
|
+
var days = Math.floor(nowMs / 86400000) % 1024; // allow:raw-byte-literal — ms-per-day + mod-1024 SRS rotation
|
|
81
|
+
return BASE32.charAt(days >>> 5) + BASE32.charAt(days & 31); // allow:raw-byte-literal — 5-bit base32 split
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _dayDiff(stamp, nowMs) {
|
|
85
|
+
if (typeof stamp !== "string" || stamp.length !== 2) return Infinity;
|
|
86
|
+
var hi = BASE32.indexOf(stamp.charAt(0));
|
|
87
|
+
var lo = BASE32.indexOf(stamp.charAt(1));
|
|
88
|
+
if (hi < 0 || lo < 0) return Infinity;
|
|
89
|
+
var stampVal = (hi << 5) | lo; // allow:raw-byte-literal — 5-bit base32 split
|
|
90
|
+
var nowVal = Math.floor(nowMs / 86400000) % 1024; // allow:raw-byte-literal — ms-per-day + mod-1024 rotation
|
|
91
|
+
// Modular distance — assume positive (rewrites in the future are
|
|
92
|
+
// refused via _dayDiff > 0 callers).
|
|
93
|
+
var diff = (nowVal - stampVal + 1024) % 1024; // allow:raw-byte-literal — mod-1024 rotation
|
|
94
|
+
return diff;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @primitive b.mail.srs.create
|
|
99
|
+
* @signature b.mail.srs.create(opts)
|
|
100
|
+
* @since 0.8.89
|
|
101
|
+
* @status stable
|
|
102
|
+
*
|
|
103
|
+
* Build an SRS rewriter bound to the operator's forwarder domain +
|
|
104
|
+
* HMAC signing secret. Returns `{ rewrite, reverse }`.
|
|
105
|
+
*
|
|
106
|
+
* @opts
|
|
107
|
+
* secret: string, // operator's HMAC-SHA-256 signing secret (>=32 bytes recommended)
|
|
108
|
+
* forwarderDomain: string, // the forwarder's own domain (where bounces land)
|
|
109
|
+
* expiryDays: number, // default 30 — reject reverse() of rewrites older than this
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* var srs = b.mail.srs.create({
|
|
113
|
+
* secret: b.crypto.generateToken(64),
|
|
114
|
+
* forwarderDomain: "forwarder.example",
|
|
115
|
+
* });
|
|
116
|
+
*
|
|
117
|
+
* // Inbound: alice@bob.com → forwarder → carol@dest.com
|
|
118
|
+
* var rewritten = srs.rewrite("alice@bob.com");
|
|
119
|
+
* // → "SRS0=HHHH=TT=bob.com=alice@forwarder.example"
|
|
120
|
+
*
|
|
121
|
+
* // Bounce arrives back at SRS0=...; decode to deliver
|
|
122
|
+
* var original = srs.reverse(rewritten);
|
|
123
|
+
* // → "alice@bob.com"
|
|
124
|
+
*/
|
|
125
|
+
function create(opts) {
|
|
126
|
+
if (!opts || typeof opts !== "object") {
|
|
127
|
+
throw new SrsError("srs/bad-opts",
|
|
128
|
+
"srs.create: opts required (secret + forwarderDomain)", true);
|
|
129
|
+
}
|
|
130
|
+
validateOpts.requireNonEmptyString(
|
|
131
|
+
opts.secret, "srs.create.secret", SrsError, "srs/bad-secret");
|
|
132
|
+
validateOpts.requireNonEmptyString(
|
|
133
|
+
opts.forwarderDomain, "srs.create.forwarderDomain", SrsError, "srs/bad-forwarder");
|
|
134
|
+
if (opts.secret.length < 16) { // allow:raw-byte-literal — minimum HMAC secret length
|
|
135
|
+
throw new SrsError("srs/bad-secret",
|
|
136
|
+
"srs.create: secret must be >= 16 chars (operator-supplied entropy floor)");
|
|
137
|
+
}
|
|
138
|
+
var expiryDays = opts.expiryDays !== undefined ? opts.expiryDays : 30; // allow:raw-byte-literal — default expiry window in days
|
|
139
|
+
if (typeof expiryDays !== "number" || !Number.isInteger(expiryDays) ||
|
|
140
|
+
expiryDays < 1 || expiryDays > 1024) { // allow:raw-byte-literal — SRS rotation cycle cap
|
|
141
|
+
throw new SrsError("srs/bad-expiry",
|
|
142
|
+
"srs.create: expiryDays must be an integer 1..1024 (SRS rotation cycle)");
|
|
143
|
+
}
|
|
144
|
+
var secret = opts.secret;
|
|
145
|
+
var forwarderDomain = opts.forwarderDomain;
|
|
146
|
+
|
|
147
|
+
function rewrite(originalAddress, nowMs) {
|
|
148
|
+
validateOpts.requireNonEmptyString(
|
|
149
|
+
originalAddress, "srs.rewrite.address", SrsError, "srs/bad-address");
|
|
150
|
+
var at = originalAddress.lastIndexOf("@");
|
|
151
|
+
if (at <= 0 || at === originalAddress.length - 1) {
|
|
152
|
+
throw new SrsError("srs/bad-address",
|
|
153
|
+
"srs.rewrite: address must be in localPart@domain form");
|
|
154
|
+
}
|
|
155
|
+
var localPart = originalAddress.slice(0, at);
|
|
156
|
+
var domain = originalAddress.slice(at + 1);
|
|
157
|
+
if (localPart.length > 64 || domain.length > 253) { // allow:raw-byte-literal — RFC 5321 local-part / domain caps
|
|
158
|
+
throw new SrsError("srs/bad-address",
|
|
159
|
+
"srs.rewrite: localPart / domain exceeds RFC 5321 length cap");
|
|
160
|
+
}
|
|
161
|
+
// Refuse SRS double-encoding from this primitive — operator must
|
|
162
|
+
// use srs1Rewrite() for already-SRS0 inputs (deferred per the
|
|
163
|
+
// v1-defensible decision: SRS1 wrapping is rare in operator
|
|
164
|
+
// deployments and adds substantial spec surface).
|
|
165
|
+
if (/^SRS[01]=/i.test(localPart)) {
|
|
166
|
+
throw new SrsError("srs/already-rewritten",
|
|
167
|
+
"srs.rewrite: address already SRS-encoded; chain forwarding through SRS1 is not yet supported (operator demand TBD)");
|
|
168
|
+
}
|
|
169
|
+
var now = typeof nowMs === "number" ? nowMs : Date.now();
|
|
170
|
+
var ts = _dayStamp(now);
|
|
171
|
+
var hashInput = ts + "=" + domain + "=" + localPart;
|
|
172
|
+
var tag = _hashTag(secret, hashInput);
|
|
173
|
+
return "SRS0=" + tag + "=" + ts + "=" + domain + "=" + localPart + "@" + forwarderDomain;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function reverse(srsAddress, nowMs) {
|
|
177
|
+
validateOpts.requireNonEmptyString(
|
|
178
|
+
srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
|
|
179
|
+
var at = srsAddress.lastIndexOf("@");
|
|
180
|
+
if (at <= 0 || at === srsAddress.length - 1) {
|
|
181
|
+
throw new SrsError("srs/bad-address",
|
|
182
|
+
"srs.reverse: address must be in srsLocal@forwarder form");
|
|
183
|
+
}
|
|
184
|
+
var localPart = srsAddress.slice(0, at);
|
|
185
|
+
var rcptDomain = srsAddress.slice(at + 1);
|
|
186
|
+
// Allow case-insensitive SRS0 prefix per the spec. Check this
|
|
187
|
+
// FIRST so an obviously-non-SRS0 input (`plain@example.com`)
|
|
188
|
+
// gets the specific not-srs0 verdict instead of the more general
|
|
189
|
+
// wrong-forwarder verdict.
|
|
190
|
+
if (!/^SRS0=/i.test(localPart)) {
|
|
191
|
+
throw new SrsError("srs/not-srs0",
|
|
192
|
+
"srs.reverse: address local-part does not start with SRS0=");
|
|
193
|
+
}
|
|
194
|
+
// Domain binding — the rewriter is scoped to a specific forwarder
|
|
195
|
+
// domain, and reverse() must verify the bounce arrived at THAT
|
|
196
|
+
// domain. Otherwise an SRS0 local-part signed with the same
|
|
197
|
+
// secret but addressed to a different forwarder (multi-domain
|
|
198
|
+
// deployment, or a misrouted DNS record) would still verify, and
|
|
199
|
+
// the operator would mis-deliver the bounce. RFC 5321 §2.3.5
|
|
200
|
+
// says domains are case-insensitive, so compare lowercased.
|
|
201
|
+
if (rcptDomain.toLowerCase() !== forwarderDomain.toLowerCase()) {
|
|
202
|
+
throw new SrsError("srs/wrong-forwarder",
|
|
203
|
+
"srs.reverse: bounce addressed to '" + rcptDomain + "' but rewriter " +
|
|
204
|
+
"is bound to forwarderDomain '" + forwarderDomain + "'");
|
|
205
|
+
}
|
|
206
|
+
var rest = localPart.slice(5);
|
|
207
|
+
var parts = rest.split("=");
|
|
208
|
+
if (parts.length < 4) {
|
|
209
|
+
throw new SrsError("srs/malformed",
|
|
210
|
+
"srs.reverse: expected SRS0=tag=ts=domain=local local-part shape (need >= 4 '=' fields)");
|
|
211
|
+
}
|
|
212
|
+
var tag = parts[0];
|
|
213
|
+
var ts = parts[1];
|
|
214
|
+
var origDomain = parts[2];
|
|
215
|
+
var origLocal = parts.slice(3).join("="); // local-part may itself contain '='
|
|
216
|
+
// Verify tag.
|
|
217
|
+
var hashInput = ts + "=" + origDomain + "=" + origLocal;
|
|
218
|
+
var expectedTag = _hashTag(secret, hashInput);
|
|
219
|
+
if (!_timingSafeStringEqual(tag, expectedTag)) {
|
|
220
|
+
throw new SrsError("srs/bad-tag",
|
|
221
|
+
"srs.reverse: HMAC tag does not verify (wrong secret or tampered envelope-from)");
|
|
222
|
+
}
|
|
223
|
+
// Verify expiry window.
|
|
224
|
+
var now = typeof nowMs === "number" ? nowMs : Date.now();
|
|
225
|
+
var dayDiff = _dayDiff(ts, now);
|
|
226
|
+
if (dayDiff > expiryDays) {
|
|
227
|
+
throw new SrsError("srs/expired",
|
|
228
|
+
"srs.reverse: rewrite is " + dayDiff + " days old; expiry window is " + expiryDays + " days");
|
|
229
|
+
}
|
|
230
|
+
return origLocal + "@" + origDomain;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
rewrite: rewrite,
|
|
235
|
+
reverse: reverse,
|
|
236
|
+
forwarderDomain: forwarderDomain,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _timingSafeStringEqual(a, b) {
|
|
241
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
242
|
+
return blamejsCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
create: create,
|
|
247
|
+
SrsError: SrsError,
|
|
248
|
+
};
|
package/lib/mail.js
CHANGED
|
@@ -1818,9 +1818,14 @@ function feedbackId(opts) {
|
|
|
1818
1818
|
return parts.join(":");
|
|
1819
1819
|
}
|
|
1820
1820
|
|
|
1821
|
+
var mailRequireTls = require("./mail-require-tls");
|
|
1822
|
+
var mailSrs = require("./mail-srs");
|
|
1823
|
+
|
|
1821
1824
|
module.exports = {
|
|
1822
1825
|
create: create,
|
|
1823
1826
|
feedbackId: feedbackId,
|
|
1827
|
+
requireTls: mailRequireTls,
|
|
1828
|
+
srs: mailSrs,
|
|
1824
1829
|
MailError: MailError,
|
|
1825
1830
|
unsubscribe: mailUnsubscribe,
|
|
1826
1831
|
// RFC 3492 Punycode IDN domain encode/decode (b.mail.toAscii /
|
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:860a5246-eb35-4113-adf2-886982273421",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-11T19:04:39.738Z",
|
|
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.90",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.90",
|
|
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.90",
|
|
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.90",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|