@blamejs/core 0.8.43 → 0.8.50

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.
Files changed (222) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
package/lib/mail.js CHANGED
@@ -1,66 +1,59 @@
1
1
  "use strict";
2
2
  /**
3
- * mail — message contract + pluggable transports.
3
+ * @module b.mail
4
+ * @featured true
5
+ * @nav Communication
6
+ * @title Mail
4
7
  *
5
- * Both the contract and the transport surface ship together. Operators
6
- * can also pass any function or `{ send }` object as a custom transport.
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.console — logs message to stderr (dev default)
9
- * mail.transports.memory captures into a `sent[]` array (tests)
10
- * mail.transports.smtp — raw RFC 5321 over net/tls with STARTTLS,
11
- * AUTH LOGIN, and PQC-friendly TLS opts
12
- * mail.transports.http generic HTTP-API transport: operator
13
- * supplies endpoint, headers, serialize(),
14
- * and interpret() works with any vendor
15
- * that speaks JSON-over-HTTPS (Postmark,
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
- * Public API:
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
- * mail.create({ transport?, defaults?, audit? }) instance
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
- * transport function(message) | { send(message) }; default: console.
25
- * defaults — { from, replyTo, headers, ... } merged into every
26
- * message unless the message overrides.
27
- * audit — emit mail.send.success / .failure audit events
28
- * (default true).
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
- * await instance.send(message)
31
- * message: {
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
- // IDN domain encode — domain MUST be the part after '@'. Returns the
123
- // Punycode-encoded ASCII domain, OR null if the input isn't a valid
124
- // IDN-encodable domain.
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, "&amp;")
308
+ .replace(/</g, "&lt;")
309
+ .replace(/>/g, "&gt;")
310
+ .replace(/"/g, "&quot;");
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
- * Model Context Protocol server-guard primitive — hardens an HTTP
4
- * endpoint that speaks MCP against the three CVE classes published in
5
- * 2025-2026:
3
+ * @module b.mcp
4
+ * @featured true
5
+ * @nav AI
6
+ * @title Model Context Protocol
6
7
  *
7
- * - CVE-2026-33032 (CVSS 9.8, nginx-ui) — auth-bypass class:
8
- * unauthenticated tool/resource invocations.
9
- * - CVE-2025-6514 (CVSS 9.6, mcp-remote) OAuth RCE class:
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
- * Public API:
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
- * mcp.serverGuard(opts) -> middleware(req, res, next)
17
- * opts:
18
- * requireBearer — bool, default true.
19
- * verifyBearer — async (token, req) -> claims | null.
20
- * redirectUriAllowlist — Array<string> exact-match URIs.
21
- * allowDynamicRegister bool, default false.
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
- * mcp.parseRequest(body, opts) — JSON-RPC 2.0 envelope validator.
30
- * mcp.refuse(res, code, message, id) JSON-RPC error responder.
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;