@blamejs/core 0.8.42 → 0.8.49
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 +93 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/mail.js
CHANGED
|
@@ -1,66 +1,59 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.mail
|
|
4
|
+
* @featured true
|
|
5
|
+
* @nav Communication
|
|
6
|
+
* @title Mail
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
8
|
+
* @intro
|
|
9
|
+
* SMTP / HTTP-API email send with multipart RFC 5322 message
|
|
10
|
+
* composition, DKIM signing on the way out, and full inbound mail-
|
|
11
|
+
* authentication parsing on the way in. Builds a multipart/alternative
|
|
12
|
+
* body for text+html, multipart/related for inline images via `cid:`
|
|
13
|
+
* references, multipart/mixed when attachments are present, and
|
|
14
|
+
* handles SMTPUTF8 (RFC 6531) + IDN domain Punycode (RFC 3492) for
|
|
15
|
+
* internationalized addresses.
|
|
7
16
|
*
|
|
8
|
-
* mail.transports
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Mailgun, SES HTTP, SendGrid, Resend, …)
|
|
17
|
-
* mail.transports.resend — thin preset that wires http to the
|
|
18
|
-
* Resend API (illustrates the pattern)
|
|
17
|
+
* Transports ship as `b.mail.transports.*`: `console` (stderr dev
|
|
18
|
+
* default), `memory` (captures to `sent[]` for fixtures), `smtp`
|
|
19
|
+
* (raw RFC 5321 over net / tls with STARTTLS, AUTH LOGIN, and PQC-
|
|
20
|
+
* friendly TLS opts), `http` (generic JSON-over-HTTPS for any vendor
|
|
21
|
+
* speaking that contract — Postmark / Mailgun / SES HTTP / SendGrid /
|
|
22
|
+
* Resend), `resend` (thin preset wiring `http` to the Resend API as
|
|
23
|
+
* the worked example). Operators can also pass any function or
|
|
24
|
+
* `{ send }` object as a custom transport.
|
|
19
25
|
*
|
|
20
|
-
*
|
|
26
|
+
* DKIM-Signature header generation lives at `b.mail.dkim` (rsa-sha256
|
|
27
|
+
* default, ed25519-sha256 opt-in, dual-signer per RFC 8463 §3 for
|
|
28
|
+
* transition windows). Inbound authentication-results parsing —
|
|
29
|
+
* SPF (RFC 7208), DMARC (RFC 7489), ARC chain trust evaluation
|
|
30
|
+
* (RFC 8617) — is exposed as `b.mail.spf` / `b.mail.dmarc` /
|
|
31
|
+
* `b.mail.arc` / `b.mail.authResults`. BIMI (RFC draft) is at
|
|
32
|
+
* `b.mail.bimi`. RFC 8058 one-click List-Unsubscribe lives at
|
|
33
|
+
* `b.mail.unsubscribe` and folds in automatically when the message
|
|
34
|
+
* carries `unsubscribe: { url | mailto, oneClick? }`.
|
|
21
35
|
*
|
|
22
|
-
*
|
|
36
|
+
* CAN-SPAM Act §7704 enforcement is on-by-default for instances
|
|
37
|
+
* created with `commercial: true`: every send refuses unless the
|
|
38
|
+
* instance supplied `postalAddress` AND the message exposes a
|
|
39
|
+
* functional opt-out (List-Unsubscribe header or `unsubscribe.{url|
|
|
40
|
+
* mailto}` on the message). The postal address auto-appends to both
|
|
41
|
+
* text and html bodies via the configured separator; operators
|
|
42
|
+
* override the html footer with `footerHtml` (must still contain the
|
|
43
|
+
* country + postal-code bytes — the framework refuses operator
|
|
44
|
+
* overrides that drop the legally-required address).
|
|
23
45
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
46
|
+
* Validation surface uses `MailError` (a `FrameworkError` subclass)
|
|
47
|
+
* with stable codes per failure: `missing-to` / `missing-from` /
|
|
48
|
+
* `missing-body` / `invalid-recipient` / `mail/transport-failed` /
|
|
49
|
+
* `smtp-*` / `http-*` / `resend-*`. Vendor-specific presets carry
|
|
50
|
+
* their own code prefix so diagnostic logs identify the provider
|
|
51
|
+
* that rejected the message. Audit emits `mail.send.success` /
|
|
52
|
+
* `mail.send.failure` / `mail.canspam.refused` and records recipient
|
|
53
|
+
* COUNTS only — addresses are PII, never auto-logged.
|
|
29
54
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* to: "x@y" | ["x@y", ...]
|
|
33
|
-
* cc: string | string[]
|
|
34
|
-
* bcc: string | string[]
|
|
35
|
-
* from: "Name <noreply@app>" (or instance default)
|
|
36
|
-
* replyTo: "..."
|
|
37
|
-
* subject: "..."
|
|
38
|
-
* text: "plain body" (at least one of text/html)
|
|
39
|
-
* html: "<p>...</p>"
|
|
40
|
-
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
-
* attachments: [{
|
|
42
|
-
* filename: "report.pdf", // required
|
|
43
|
-
* content: buf, // Buffer or string
|
|
44
|
-
* contentType: "application/pdf", // default application/octet-stream
|
|
45
|
-
* contentDisposition: "attachment", // or "inline"
|
|
46
|
-
* cid: "logo-1", // for inline images:
|
|
47
|
-
* // <img src="cid:logo-1">
|
|
48
|
-
* }, ...]
|
|
49
|
-
* }
|
|
50
|
-
* → whatever the transport returned
|
|
51
|
-
*
|
|
52
|
-
* When attachments are present the SMTP transport wraps the body in
|
|
53
|
-
* multipart/mixed; text+html bodies still use multipart/alternative
|
|
54
|
-
* inside. Resend's http preset forwards attachments via the Resend API
|
|
55
|
-
* shape (base64 content + content_id for inline). Operators wiring
|
|
56
|
-
* other vendors against httpTransport include attachments in their
|
|
57
|
-
* own serialize() per-vendor.
|
|
58
|
-
*
|
|
59
|
-
* Validation surface uses MailError (FrameworkError subclass) with
|
|
60
|
-
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
61
|
-
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
62
|
-
* resend-*. Vendor-specific presets carry their own code prefix so
|
|
63
|
-
* diagnostic logs identify the provider that rejected the message.
|
|
55
|
+
* @card
|
|
56
|
+
* SMTP / HTTP-API email send with multipart RFC 5322 message composition, DKIM signing on the way out, and full inbound mail- authentication parsing on the way in.
|
|
64
57
|
*/
|
|
65
58
|
var C = require("./constants");
|
|
66
59
|
var crypto = require("./crypto");
|
|
@@ -119,9 +112,26 @@ function _isAscii(s) {
|
|
|
119
112
|
return !NON_ASCII_RE.test(s);
|
|
120
113
|
}
|
|
121
114
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
115
|
+
/**
|
|
116
|
+
* @primitive b.mail.toAscii
|
|
117
|
+
* @signature b.mail.toAscii(domain)
|
|
118
|
+
* @since 0.7.16
|
|
119
|
+
* @status stable
|
|
120
|
+
* @related b.mail.toUnicode, b.mail.create
|
|
121
|
+
*
|
|
122
|
+
* RFC 3492 Punycode encode an IDN domain to its ASCII-compatible form.
|
|
123
|
+
* `domain` MUST be the part after `@` — pass the local part separately.
|
|
124
|
+
* Returns the encoded ASCII string, or `null` when the input isn't a
|
|
125
|
+
* valid IDN-encodable domain. Used internally by `send()` to convert
|
|
126
|
+
* IDN domain parts before the pre-SMTPUTF8 ASCII regex check; surfaced
|
|
127
|
+
* publicly so operators wiring custom transports can apply the same
|
|
128
|
+
* normalization.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* var b = require("@blamejs/core");
|
|
132
|
+
* var ascii = b.mail.toAscii("münchen.de");
|
|
133
|
+
* // → "xn--mnchen-3ya.de"
|
|
134
|
+
*/
|
|
125
135
|
function toAscii(domain) {
|
|
126
136
|
if (typeof domain !== "string" || domain.length === 0) return null;
|
|
127
137
|
var ascii;
|
|
@@ -131,6 +141,24 @@ function toAscii(domain) {
|
|
|
131
141
|
return ascii;
|
|
132
142
|
}
|
|
133
143
|
|
|
144
|
+
/**
|
|
145
|
+
* @primitive b.mail.toUnicode
|
|
146
|
+
* @signature b.mail.toUnicode(domain)
|
|
147
|
+
* @since 0.7.16
|
|
148
|
+
* @status stable
|
|
149
|
+
* @related b.mail.toAscii, b.mail.create
|
|
150
|
+
*
|
|
151
|
+
* Decode an ASCII-Compatible-Encoding (Punycode `xn--…`) domain back
|
|
152
|
+
* to its Unicode form. Returns `null` when the input isn't a valid
|
|
153
|
+
* IDN domain. Operators rendering received-from / authentication-
|
|
154
|
+
* results trace lines use this to display the human-readable form
|
|
155
|
+
* alongside the on-the-wire ASCII representation.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* var b = require("@blamejs/core");
|
|
159
|
+
* var u = b.mail.toUnicode("xn--mnchen-3ya.de");
|
|
160
|
+
* // → "münchen.de"
|
|
161
|
+
*/
|
|
134
162
|
function toUnicode(domain) {
|
|
135
163
|
if (typeof domain !== "string" || domain.length === 0) return null;
|
|
136
164
|
try { return nodeUrl.domainToUnicode(domain); }
|
|
@@ -212,6 +240,87 @@ function _normalizeRecipientList(value, label) {
|
|
|
212
240
|
return arr;
|
|
213
241
|
}
|
|
214
242
|
|
|
243
|
+
// CAN-SPAM postal-address validation. Accepts either a 5-field object
|
|
244
|
+
// shape (street/city/region/postalCode/country) or a non-empty string
|
|
245
|
+
// (operators with an irregular address layout — e.g. EU multi-line —
|
|
246
|
+
// pass a pre-rendered string and the framework appends it as-is).
|
|
247
|
+
//
|
|
248
|
+
// Returns null on valid; a description string on invalid. The framework
|
|
249
|
+
// converts the description into a MailError code at the call-site.
|
|
250
|
+
function _validatePostalAddress(addr) {
|
|
251
|
+
if (addr == null) return "postalAddress is required";
|
|
252
|
+
if (typeof addr === "string") {
|
|
253
|
+
if (addr.trim().length === 0) return "postalAddress (string) must be non-empty";
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (typeof addr !== "object") {
|
|
257
|
+
return "postalAddress must be an object or non-empty string";
|
|
258
|
+
}
|
|
259
|
+
var REQUIRED = ["street", "city", "region", "postalCode", "country"];
|
|
260
|
+
for (var i = 0; i < REQUIRED.length; i += 1) {
|
|
261
|
+
var k = REQUIRED[i];
|
|
262
|
+
var v = addr[k];
|
|
263
|
+
if (typeof v !== "string" || v.trim().length === 0) {
|
|
264
|
+
return "postalAddress." + k + " is required (non-empty string)";
|
|
265
|
+
}
|
|
266
|
+
if (/[\r\n\0]/.test(v)) { // allow:regex-no-length-cap — short typo-surfacing check; address fields are operator config not network bytes
|
|
267
|
+
return "postalAddress." + k + " contains forbidden control characters (CR/LF/NUL)";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Pull a single field out of the address shape (object or string).
|
|
274
|
+
// Returns "" when the field isn't present (string-shape addresses don't
|
|
275
|
+
// carry structured fields).
|
|
276
|
+
function _addressField(addr, field) {
|
|
277
|
+
if (addr && typeof addr === "object" && typeof addr[field] === "string") {
|
|
278
|
+
return addr[field];
|
|
279
|
+
}
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Render the structured address as a single text block for the
|
|
284
|
+
// CAN-SPAM footer. String-shape inputs render verbatim.
|
|
285
|
+
function _renderPostalAddressText(addr) {
|
|
286
|
+
if (typeof addr === "string") return addr;
|
|
287
|
+
if (!addr || typeof addr !== "object") return "";
|
|
288
|
+
var line2 = [addr.city, addr.region, addr.postalCode].filter(Boolean).join(", ");
|
|
289
|
+
return [addr.street, line2, addr.country].filter(Boolean).join("\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _renderPostalAddressHtml(addr) {
|
|
293
|
+
if (typeof addr === "string") {
|
|
294
|
+
return _htmlEscape(addr).replace(/\n/g, "<br>");
|
|
295
|
+
}
|
|
296
|
+
if (!addr || typeof addr !== "object") return "";
|
|
297
|
+
var parts = [];
|
|
298
|
+
if (addr.street) parts.push(_htmlEscape(addr.street));
|
|
299
|
+
var line2 = [addr.city, addr.region, addr.postalCode].filter(Boolean).join(", ");
|
|
300
|
+
if (line2) parts.push(_htmlEscape(line2));
|
|
301
|
+
if (addr.country) parts.push(_htmlEscape(addr.country));
|
|
302
|
+
return parts.join("<br>");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _htmlEscape(s) {
|
|
306
|
+
return String(s)
|
|
307
|
+
.replace(/&/g, "&")
|
|
308
|
+
.replace(/</g, "<")
|
|
309
|
+
.replace(/>/g, ">")
|
|
310
|
+
.replace(/"/g, """);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function _hasUnsubscribe(message) {
|
|
314
|
+
if (message.unsubscribe && typeof message.unsubscribe === "object") return true;
|
|
315
|
+
var headers = message.headers;
|
|
316
|
+
if (!headers || typeof headers !== "object") return false;
|
|
317
|
+
var keys = Object.keys(headers);
|
|
318
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
319
|
+
if (keys[i].toLowerCase() === "list-unsubscribe") return true;
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
215
324
|
function _validateMessage(message) {
|
|
216
325
|
if (!message || typeof message !== "object") {
|
|
217
326
|
throw new MailError("mail/missing-message", "send() requires a message object", true);
|
|
@@ -1029,10 +1138,45 @@ function resendTransport(opts) {
|
|
|
1029
1138
|
|
|
1030
1139
|
// ---- Engine instance ----
|
|
1031
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* @primitive b.mail.create
|
|
1143
|
+
* @signature b.mail.create(opts)
|
|
1144
|
+
* @since 0.1.0
|
|
1145
|
+
* @status stable
|
|
1146
|
+
* @compliance gdpr, soc2, hipaa
|
|
1147
|
+
* @related b.mail.toAscii, b.mail.toUnicode
|
|
1148
|
+
*
|
|
1149
|
+
* Build a mail instance bound to a transport + defaults. Returns
|
|
1150
|
+
* `{ send, transport, defaults }`: `send(message)` validates the
|
|
1151
|
+
* merged message against the framework contract, applies CAN-SPAM
|
|
1152
|
+
* footer + unsubscribe enforcement when `commercial: true`, runs
|
|
1153
|
+
* RFC 8058 List-Unsubscribe header expansion when the message carries
|
|
1154
|
+
* `unsubscribe`, then delegates to the transport. Audit rows record
|
|
1155
|
+
* recipient counts only (addresses are PII).
|
|
1156
|
+
*
|
|
1157
|
+
* @opts
|
|
1158
|
+
* transport: function (message) | { send(message), name? }, // default: console
|
|
1159
|
+
* defaults: { from, replyTo, headers, ... }, // merged into every message
|
|
1160
|
+
* audit: boolean, // default true
|
|
1161
|
+
* commercial: boolean, // CAN-SPAM §7704 enforcement
|
|
1162
|
+
* regulated: boolean, // alias for commercial:true
|
|
1163
|
+
* postalAddress: { street, city, region, postalCode, country } | string,
|
|
1164
|
+
* footerSeparator: string, // default "\n\n----\n" / "<hr>"
|
|
1165
|
+
* footerHtml: string, // override for html-part footer
|
|
1166
|
+
*
|
|
1167
|
+
* @example
|
|
1168
|
+
* var b = require("@blamejs/core");
|
|
1169
|
+
* var mail = b.mail.create({
|
|
1170
|
+
* transport: b.mail.transports.memory(),
|
|
1171
|
+
* defaults: { from: "Acme <noreply@acme.test>" },
|
|
1172
|
+
* });
|
|
1173
|
+
* // → { send, transport, defaults }
|
|
1174
|
+
*/
|
|
1032
1175
|
function create(opts) {
|
|
1033
1176
|
opts = opts || {};
|
|
1034
1177
|
validateOpts(opts, [
|
|
1035
1178
|
"transport", "defaults", "audit",
|
|
1179
|
+
"commercial", "postalAddress", "footerSeparator", "footerHtml", "regulated",
|
|
1036
1180
|
], "mail");
|
|
1037
1181
|
var transport = opts.transport || consoleTransport();
|
|
1038
1182
|
if (typeof transport === "function") {
|
|
@@ -1045,6 +1189,55 @@ function create(opts) {
|
|
|
1045
1189
|
var defaults = opts.defaults || {};
|
|
1046
1190
|
var auditOn = opts.audit !== false;
|
|
1047
1191
|
|
|
1192
|
+
// CAN-SPAM Act §7704(a)(5) — every commercial-content message MUST
|
|
1193
|
+
// include the sender's valid physical postal address. Validate the
|
|
1194
|
+
// address shape at create() so a typo / blank field surfaces at boot,
|
|
1195
|
+
// not silently on first send. Operators marking an instance
|
|
1196
|
+
// commercial:true also opt every send() into the unsubscribe-required
|
|
1197
|
+
// posture (CAN-SPAM §7704(a)(3) — RFC 8058 List-Unsubscribe header
|
|
1198
|
+
// already wired via b.mail.unsubscribe).
|
|
1199
|
+
var commercial = opts.commercial === true || opts.regulated === true;
|
|
1200
|
+
var postalAddress = opts.postalAddress != null ? opts.postalAddress : null;
|
|
1201
|
+
if (commercial) {
|
|
1202
|
+
var addrError = _validatePostalAddress(postalAddress);
|
|
1203
|
+
if (addrError) {
|
|
1204
|
+
throw new MailError("mail/missing-postal-address",
|
|
1205
|
+
"mail.create({ commercial: true }): " + addrError +
|
|
1206
|
+
" — CAN-SPAM Act §7704(a)(5) requires a valid physical postal address.",
|
|
1207
|
+
true);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
var footerSeparator = (typeof opts.footerSeparator === "string")
|
|
1211
|
+
? opts.footerSeparator : null;
|
|
1212
|
+
var footerHtml = (typeof opts.footerHtml === "string") ? opts.footerHtml : null;
|
|
1213
|
+
if (footerHtml && commercial) {
|
|
1214
|
+
var addrText = _renderPostalAddressText(postalAddress);
|
|
1215
|
+
// Country + postal-code presence check — operator-supplied HTML
|
|
1216
|
+
// overrides MUST still carry the address bytes. We don't lex HTML
|
|
1217
|
+
// here; substring match against the rendered address is enough to
|
|
1218
|
+
// catch "operator forgot to interpolate the address into their
|
|
1219
|
+
// override template" without parsing markup.
|
|
1220
|
+
var country = _addressField(postalAddress, "country");
|
|
1221
|
+
var postalCode = _addressField(postalAddress, "postalCode");
|
|
1222
|
+
if (country && footerHtml.indexOf(country) === -1) {
|
|
1223
|
+
throw new MailError("mail/bad-footer-html",
|
|
1224
|
+
"mail.create({ footerHtml }): override must contain the postalAddress.country '" +
|
|
1225
|
+
country + "' (CAN-SPAM §7704(a)(5)). Got: " + footerHtml.slice(0, 200), // allow:raw-byte-literal — diagnostic clamp characters, not bytes
|
|
1226
|
+
true);
|
|
1227
|
+
}
|
|
1228
|
+
if (postalCode && footerHtml.indexOf(postalCode) === -1) {
|
|
1229
|
+
throw new MailError("mail/bad-footer-html",
|
|
1230
|
+
"mail.create({ footerHtml }): override must contain the postalAddress.postalCode '" +
|
|
1231
|
+
postalCode + "' (CAN-SPAM §7704(a)(5))",
|
|
1232
|
+
true);
|
|
1233
|
+
}
|
|
1234
|
+
// Suppress the "unused-variable" lint signal for addrText — the
|
|
1235
|
+
// sanity-render establishes the address shape is renderable before
|
|
1236
|
+
// we trust the operator override; the rendered text isn't itself
|
|
1237
|
+
// injected when footerHtml overrides.
|
|
1238
|
+
void addrText;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1048
1241
|
function _emit(action, info) {
|
|
1049
1242
|
if (!auditOn) return;
|
|
1050
1243
|
audit().safeEmit({
|
|
@@ -1079,6 +1272,50 @@ function create(opts) {
|
|
|
1079
1272
|
merged.headers = Object.assign({}, merged.headers || {}, unsubHeaders);
|
|
1080
1273
|
delete merged.unsubscribe;
|
|
1081
1274
|
}
|
|
1275
|
+
|
|
1276
|
+
// CAN-SPAM §7704(a) — every commercial-content message must carry
|
|
1277
|
+
// a valid physical postal address (auto-appended to the body) and
|
|
1278
|
+
// a clear opt-out path. The address shape was validated at
|
|
1279
|
+
// create(); this block (a) re-asserts the unsubscribe path is
|
|
1280
|
+
// present, (b) appends the formatted footer to text + html parts,
|
|
1281
|
+
// and (c) emits a structured audit row when the send refuses.
|
|
1282
|
+
if (commercial) {
|
|
1283
|
+
if (!_hasUnsubscribe(merged)) {
|
|
1284
|
+
try {
|
|
1285
|
+
audit().safeEmit({
|
|
1286
|
+
action: "mail.canspam.refused",
|
|
1287
|
+
outcome: "denied",
|
|
1288
|
+
metadata: {
|
|
1289
|
+
reason: "missing-unsubscribe",
|
|
1290
|
+
transport: transport.name || "custom",
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
} catch (_e) { /* audit best-effort */ }
|
|
1294
|
+
throw new MailError("mail/canspam-no-unsubscribe",
|
|
1295
|
+
"mail.send: commercial:true requires either message.unsubscribe = " +
|
|
1296
|
+
"{ url|mailto, oneClick? } OR a List-Unsubscribe header. CAN-SPAM " +
|
|
1297
|
+
"§7704(a)(3)/(4) — every commercial message must give recipients a " +
|
|
1298
|
+
"clear opt-out mechanism.", true);
|
|
1299
|
+
}
|
|
1300
|
+
var sepText = footerSeparator != null ? footerSeparator : "\n\n----\n";
|
|
1301
|
+
var sepHtml = footerSeparator != null ? footerSeparator : "<hr>";
|
|
1302
|
+
var addrText = _renderPostalAddressText(postalAddress);
|
|
1303
|
+
var addrHtml = footerHtml || _renderPostalAddressHtml(postalAddress);
|
|
1304
|
+
// Append-only — operators who want the address in a different
|
|
1305
|
+
// location render it themselves and disable commercial:true (or
|
|
1306
|
+
// pass footerHtml with the operator-controlled layout).
|
|
1307
|
+
if (typeof merged.text === "string" && merged.text.length > 0 &&
|
|
1308
|
+
merged.text.indexOf(addrText) === -1) {
|
|
1309
|
+
merged.text = merged.text + sepText + addrText + "\n";
|
|
1310
|
+
} else if (merged.text == null && addrText) {
|
|
1311
|
+
merged.text = addrText + "\n";
|
|
1312
|
+
}
|
|
1313
|
+
if (typeof merged.html === "string" && merged.html.length > 0 &&
|
|
1314
|
+
merged.html.indexOf(addrHtml) === -1) {
|
|
1315
|
+
merged.html = merged.html + sepHtml + addrHtml;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1082
1319
|
_validateMessage(merged);
|
|
1083
1320
|
|
|
1084
1321
|
var t0 = Date.now();
|
package/lib/mcp.js
CHANGED
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @module b.mcp
|
|
4
|
+
* @featured true
|
|
5
|
+
* @nav AI
|
|
6
|
+
* @title Model Context Protocol
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* consent-redirect with attacker-controlled redirect_uri.
|
|
11
|
-
* - Confused-deputy class — static client IDs combined with
|
|
12
|
-
* dynamic-client-registration AND opaque consent cookies.
|
|
8
|
+
* @intro
|
|
9
|
+
* Model Context Protocol server hardening — input validation, OAuth
|
|
10
|
+
* integration per RFC 9728, scope enforcement, audit emission.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
12
|
+
* The guard is the secure-by-default front door for an HTTP endpoint
|
|
13
|
+
* that speaks MCP. Every default refuses; operators opt into
|
|
14
|
+
* capabilities (dynamic client registration, specific tools, specific
|
|
15
|
+
* resources) deliberately. The 2025-2026 CVE class — auth-bypass on
|
|
16
|
+
* unauthenticated tool / resource invocations (CVE-2026-33032 class)
|
|
17
|
+
* plus OAuth redirect_uri abuse (CVE-2025-6514 class) plus the
|
|
18
|
+
* confused-deputy pattern when static client IDs combine with
|
|
19
|
+
* dynamic registration — is what the guard's defaults exist to
|
|
20
|
+
* close.
|
|
15
21
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* registerClientAllowlist — function(body) -> bool.
|
|
23
|
-
* toolAllowlist — Array<string> | null.
|
|
24
|
-
* resourceAllowlist — Array<string> | null.
|
|
25
|
-
* maxBodyBytes — default 1 MiB.
|
|
26
|
-
* errorClass — McpError by default.
|
|
27
|
-
* audit — bool, default true.
|
|
22
|
+
* Wire format is JSON-RPC 2.0; `parseRequest` is the envelope
|
|
23
|
+
* validator (jsonrpc version, method shape, id type, params type)
|
|
24
|
+
* and `refuse` is the matching error responder so handlers stay
|
|
25
|
+
* in the same shape the guard rejects with. OAuth redirect_uris
|
|
26
|
+
* are exact-match against an allowlist and required to be HTTPS
|
|
27
|
+
* (or localhost) per RFC 9700 §4.1.1.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* The guard is the secure-by-default front door. Every default
|
|
33
|
-
* refuses; operators opt into capabilities deliberately.
|
|
29
|
+
* @card
|
|
30
|
+
* Model Context Protocol server hardening — input validation, OAuth integration per RFC 9728, scope enforcement, audit emission.
|
|
34
31
|
*/
|
|
35
32
|
|
|
36
33
|
var C = require("./constants");
|
|
@@ -57,6 +54,26 @@ var JSONRPC_AUTH_REQUIRED = -32001;
|
|
|
57
54
|
var TOOL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
|
58
55
|
var RESOURCE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._/-]{0,255}$/;
|
|
59
56
|
|
|
57
|
+
/**
|
|
58
|
+
* @primitive b.mcp.parseRequest
|
|
59
|
+
* @signature b.mcp.parseRequest(body, opts)
|
|
60
|
+
* @since 0.7.68
|
|
61
|
+
* @related b.mcp.serverGuard, b.mcp.refuse
|
|
62
|
+
*
|
|
63
|
+
* Validate a JSON-RPC 2.0 envelope. Accepts a raw string (parsed via
|
|
64
|
+
* `b.safeJson.parse` with a 1 MiB cap) or an already-parsed object.
|
|
65
|
+
* Throws an `McpError` with a code matching the violation
|
|
66
|
+
* (`BAD_JSON` / `BAD_ENVELOPE` / `BAD_VERSION` / `BAD_METHOD` /
|
|
67
|
+
* `BAD_ID` / `BAD_PARAMS`). Returns the parsed envelope on success.
|
|
68
|
+
*
|
|
69
|
+
* @opts
|
|
70
|
+
* errorClass: Function, // default McpError; inject for custom error classes
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* var envelope = b.mcp.parseRequest('{"jsonrpc":"2.0","method":"tools/list","id":1}', {});
|
|
74
|
+
* envelope.method;
|
|
75
|
+
* // → "tools/list"
|
|
76
|
+
*/
|
|
60
77
|
function parseRequest(body, opts) {
|
|
61
78
|
opts = opts || {};
|
|
62
79
|
var errorClass = opts.errorClass || McpError;
|
|
@@ -93,6 +110,29 @@ function parseRequest(body, opts) {
|
|
|
93
110
|
return parsed;
|
|
94
111
|
}
|
|
95
112
|
|
|
113
|
+
/**
|
|
114
|
+
* @primitive b.mcp.refuse
|
|
115
|
+
* @signature b.mcp.refuse(res, code, message, id)
|
|
116
|
+
* @since 0.7.68
|
|
117
|
+
* @related b.mcp.parseRequest, b.mcp.serverGuard
|
|
118
|
+
*
|
|
119
|
+
* Write a JSON-RPC 2.0 error reply to `res`. The `code` is the
|
|
120
|
+
* negative JSON-RPC error code (-32700 parse error, -32600 invalid
|
|
121
|
+
* request, -32601 method not found, -32602 invalid params, -32603
|
|
122
|
+
* internal error, -32001 auth required); HTTP status is mapped from
|
|
123
|
+
* it (parse / invalid-request -> 400, method-not-found -> 404,
|
|
124
|
+
* internal -> 500, default -> 400). `id` defaults to `null` when
|
|
125
|
+
* undefined per the spec for unidentifiable requests.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* var http = require("http");
|
|
129
|
+
* var srv = http.createServer(function (req, res) {
|
|
130
|
+
* b.mcp.refuse(res, -32601, "method not found", 7);
|
|
131
|
+
* });
|
|
132
|
+
* srv.listen(0);
|
|
133
|
+
* // → writes { jsonrpc: "2.0", error: { code: -32601, message: "method not found" }, id: 7 }
|
|
134
|
+
* srv.close();
|
|
135
|
+
*/
|
|
96
136
|
function refuse(res, code, message, id) {
|
|
97
137
|
var body = JSON.stringify({
|
|
98
138
|
jsonrpc: "2.0",
|
|
@@ -160,6 +200,47 @@ function _checkRedirectUri(uri, allowlist, errorClass) {
|
|
|
160
200
|
}
|
|
161
201
|
}
|
|
162
202
|
|
|
203
|
+
/**
|
|
204
|
+
* @primitive b.mcp.serverGuard
|
|
205
|
+
* @signature b.mcp.serverGuard(opts)
|
|
206
|
+
* @since 0.7.68
|
|
207
|
+
* @related b.mcp.parseRequest, b.mcp.refuse, b.middleware.bearerAuth
|
|
208
|
+
*
|
|
209
|
+
* Build the MCP request-lifecycle middleware. Bearer-required by
|
|
210
|
+
* default (operator supplies `verifyBearer` to validate the token);
|
|
211
|
+
* dynamic-client-registration refused by default; redirect_uris
|
|
212
|
+
* exact-match an HTTPS-or-localhost allowlist; tool / resource names
|
|
213
|
+
* are shape-validated and optionally allowlist-gated; the body is
|
|
214
|
+
* read through a bounded chunk collector. Every refusal emits an
|
|
215
|
+
* audit event (`mcp.auth.missing-bearer` / `mcp.tool.refused` / etc.)
|
|
216
|
+
* unless `audit:false`. Returns a `(req, res, next)` middleware
|
|
217
|
+
* function that attaches `req.mcpRequest` + `req.mcpClaims` on
|
|
218
|
+
* success.
|
|
219
|
+
*
|
|
220
|
+
* @opts
|
|
221
|
+
* requireBearer: boolean, // default true
|
|
222
|
+
* verifyBearer: function, // (token, req) -> Promise<claims | null>
|
|
223
|
+
* redirectUriAllowlist: Array<string>, // exact-match URIs
|
|
224
|
+
* allowDynamicRegister: boolean, // default false
|
|
225
|
+
* registerClientAllowlist: function, // (body) -> bool — required when allowDynamicRegister
|
|
226
|
+
* toolAllowlist: Array<string>, // null = allow any shape-valid tool
|
|
227
|
+
* resourceAllowlist: Array<string>, // null = allow any shape-valid resource
|
|
228
|
+
* maxBodyBytes: number, // default 1 MiB
|
|
229
|
+
* errorClass: Function, // default McpError
|
|
230
|
+
* audit: boolean, // default true
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* var guard = b.mcp.serverGuard({
|
|
234
|
+
* requireBearer: true,
|
|
235
|
+
* verifyBearer: function (token, _req) {
|
|
236
|
+
* return token === "operator-issued-bearer-token-32-chars-min" ? { sub: "ops" } : null;
|
|
237
|
+
* },
|
|
238
|
+
* toolAllowlist: ["search.docs", "search.tickets"],
|
|
239
|
+
* resourceAllowlist: ["mcp://docs/handbook"],
|
|
240
|
+
* });
|
|
241
|
+
* typeof guard;
|
|
242
|
+
* // → "function"
|
|
243
|
+
*/
|
|
163
244
|
function serverGuard(opts) {
|
|
164
245
|
opts = opts || {};
|
|
165
246
|
var errorClass = opts.errorClass || McpError;
|