@blamejs/core 0.10.13 → 0.10.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.15 (2026-05-18) — **TLS-RPT receiver — RFC 8460 aggregate-report ingest.** New primitive surface under `b.mail.deploy` that closes the receive-side of TLS-RPT (the publish-side shipped v0.7.29 + v0.9.56). **(a) `b.mail.deploy.parseTlsRptReport(bytes, opts?)`** — pure parser + RFC 8460 §4.4 schema validator. Accepts `application/tlsrpt+json` (raw) and `application/tlsrpt+gzip` (auto-detected via the RFC 1952 gzip magic bytes `0x1f 0x8b` or routed when `opts.contentType` names a gzip media-type). Caps compressed payload at 4 MiB (RFC 8460 §5.2 community ceiling), decompressed at 32 MiB (operator-overridable), and refuses decompression amplification > 50:1 — defends [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) (libcurl + zlib decompression amplification) and the broader CVE-2024-zlib bomb class. Routes through `b.guardJson.parse` for proto-pollution / depth / key-count defenses before walking the §4.4 schema. Refuses on missing required fields (`organization-name` / `contact-info` / `report-id` / `date-range.{start,end}-datetime` / `policies`) and enforces the §4.4 erratum that `policies` MUST be a non-empty array even for single-policy reports. Returns the normalized report shape plus `sessionTotals: { success, failure }` and a `wasCompressed` flag. **(b) `b.mail.deploy.tlsRptIngestHttp({...})`** — factory returning an `(req, res)` HTTPS POST handler mounted at the operator's `rua=https://<host>/<path>` endpoint per RFC 8460 §5.4. Negotiates the two IANA-registered media types ([RFC 8460 §6.4-6.5](https://www.rfc-editor.org/rfc/rfc8460.html#section-6.4)), returns 405 on non-POST, 415 on bad media-type (with `Accept:` header), 413 on size / bomb / ratio refusal, 400 on parse failure (with `Error-Type:` header naming the typed error code), 201 on accept. Optional `trustedReporters` array refuses non-trusted reporting domains (RFC 8460 §5.3-class defense extended to the HTTPS path). Body collection routes through `b.safeBuffer.boundedChunkCollector` — cap enforced at every `push()`, not after — so a hostile reporter sending a 10-GB body rejects on the chunk that overflows. Emits the `mail.tlsrpt.ingest_http` audit event with `policyDomains` set + session totals on every accept / refuse. **(c) `b.mail.deploy.tlsRptReportSchema()`** — schema descriptor (required fields, policy types, result types) for operator dashboards. Pure function. **(d) Codebase-patterns detector `gunzip-without-output-size-cap`** (lib-side) — every `zlib.gunzipSync` / `zlib.createGunzip` / `zlib.brotliDecompressSync` MUST sit in a file that also names `maxOutputLength` (Node-native cap) per the CVE-2025-0725 defense class. Companion-check `requires` field added to the lib-side runner. **Deferred from v1:** mailto: ingest (no operator demand surfaced — HTTPS POST is the de-facto deployment shape for TLS-RPT; operators wanting mailto: today compose `b.mail.server.mx` + `parseTlsRptReport`) and brotli decompression (no fielded reporter uses `Content-Encoding: br` for TLS-RPT; the RFC 8460 §6.4-6.5 IANA registry only names `+json` and `+gzip`). Each reopens with a documented condition. References: [RFC 8460 SMTP TLS Reporting](https://www.rfc-editor.org/rfc/rfc8460.html) · [RFC 8461 MTA-STS](https://www.rfc-editor.org/rfc/rfc8461.html) · [RFC 1952 gzip](https://www.rfc-editor.org/rfc/rfc1952.html) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725).
12
+ - v0.10.14 (2026-05-18) — **codebase-patterns hardening — test-side catalog gains 3 new detectors + 1 migration; lib-side catalog gains 1 new detector with comment-skip preprocessing.** Closes the same class of bug that caused the v0.10.13 macOS hang (test-discipline-without-enforcement). **(a) `test-codebase-patterns.test.js` — test-side antipattern runner** now supports `matchOn: "basename"` mode and `requires` companion-content checks. **(b) M1 migration —** `testNoReleaseNamedTestFiles` moves from the lib-side catalog to the test-side catalog (the rule scans test-file basenames, not lib-source content). **(c) N1 — `Promise + setTimeout` direct sleep in tests refused.** Tests calling `await new Promise(r => setTimeout(r, N))` for synchronization MUST use `helpers.waitUntil` per CLAUDE.md rule §11b. 49 pre-existing files are allowlisted as a documented v0.10.14 migration backlog; the gate prevents new occurrences. **(d) N2 — hardcoded server bind ports refused.** Tests calling `.listen(N)` with a literal non-zero port MUST use `.listen(0)` + `server.address().port` to avoid `SMOKE_PARALLEL=64` bind races. Detector scoped to the bind path (`.listen(...)`); read-only protocol-constant references (`port: 993` / `port: 587` in autoconfig XML) don't trip. **(e) N3 — tests creating `b.db` handles without an isolation primitive refused.** Any test calling `b.db.create(` MUST also wire one of `helpers.setupTestDb` / `helpers.setupVaultOnly` / `node:fs.mkdtempSync`. Leaked per-test SQLite state corrupts subsequent tests under `SMOKE_PARALLEL=64`. **(f) N4 — raw `audit.emit(...)` outside drop-silent wrap refused (lib).** Per CLAUDE.md rule §5 hot-path audit sinks must be drop-silent. Detector found and fixed an existing violation in `lib/subject.js:_writeAudit` whose comment promised swallowing but actually let the throw escape. The lib-side runner gains a `skipCommentLines` per-entry opt so docstring `@example` lines don't trip detectors that match comment-friendly tokens. **(g) N5 deferred —** `Date.now()` vs `process.hrtime()` for elapsed-time math needs semantic distinction (elapsed-math vs row-age); regex alone is too noisy. v0.10.13's stream-throttle elapsed-clamp shipped the highest-value fix already; remaining call sites get per-file review in a later patch.
11
13
  - v0.10.13 (2026-05-18) — **`b.cms` CMS codec + `b.streamThrottle` bandwidth limiter + histogram-aware snapshot writer + Windows-safe daemonize.** **`b.cms` — RFC 5652 Cryptographic Message Syntax codec.** New crypto primitive: in-tree CMS encoder + decoder built on the existing `b.asn1Der` walker and the vendored noble-post-quantum primitives. **(a) `b.cms.encodeSignedData({ encapContent, digestAlg, signers })`** — emits a DER-encoded `ContentInfo` carrying `SignedData` per RFC 5652 §5 with PQC signers: ML-DSA-65 + ML-DSA-87 ([RFC 9909](https://www.rfc-editor.org/rfc/rfc9909.html)) and SLH-DSA-SHAKE-256f ([RFC 9881](https://www.rfc-editor.org/rfc/rfc9881.html)). Digest algorithms are SHA3-256 or SHA3-512 (PQC-first; SHA-2 family refused with `cms/bad-digest`). Signed-attributes carry `contentType` + `messageDigest` + `signingTime` in DER-canonical SET-OF ordering; the signature input re-tags the IMPLICIT `[0]` to the universal SET (`0x31`) per §5.4 paragraph 3 so signatures round-trip with any conforming verifier. Signer identifiers carry the full `issuerAndSerialNumber` extracted from the operator-supplied cert DER ([RFC 5652 §10.2.4](https://www.rfc-editor.org/rfc/rfc5652#section-10.2.4)). **(b) `b.cms.encodeEnvelopedData({ plaintext, recipients })`** — emits a DER-encoded `ContentInfo` carrying `EnvelopedData` with `KEMRecipientInfo` recipients per [RFC 9629](https://www.rfc-editor.org/rfc/rfc9629.html) and ML-KEM-1024 per [RFC 9936](https://www.rfc-editor.org/rfc/rfc9936.html). Each recipient encapsulates against the operator-supplied ML-KEM-1024 public key; the framework's SHAKE256 KDF derives a 32-byte content-encryption KEK from the KEM shared-secret bound to the literal label `cms/kemri/chacha20-poly1305` (so a key derived for this composition cannot be confused with one derived for any other framework path). Content encryption is ChaCha20-Poly1305 ([RFC 8103](https://www.rfc-editor.org/rfc/rfc8103.html) OID); the AEAD tag makes Efail-class CBC-malleability impossible by construction ([CVE-2017-17688](https://nvd.nist.gov/vuln/detail/CVE-2017-17688) / [CVE-2017-17689](https://nvd.nist.gov/vuln/detail/CVE-2017-17689)). **(c) `b.cms.decode(buf, { maxBytes? })`** — returns `{ contentType, content }` where `contentType` is the dotted-OID string and `content` is the inner `asn1-der` node. Refuses input past `maxBytes` (default 64 MiB), non-SEQUENCE top-level, missing `[0] EXPLICIT` content, and malformed OID encodings (closes the [CVE-2022-47629](https://nvd.nist.gov/vuln/detail/CVE-2022-47629) libksba class via the existing `b.asn1Der` strict-decode posture). **(d) Refusal posture documented in `lib/cms-codec.js` @intro**: only PQC signature algorithms (`cms/bad-sig-alg`), only ML-KEM-1024 recipients (`cms/bad-recipient-type`), non-empty signers / recipients required at encode (`cms/no-signers` / `cms/no-recipients`). **Operator impact:** no breaking changes; new primitive at `b.cms`. **Deferred to v0.10.14:** the on-the-wire S/MIME 4.0 layer ([RFC 8551](https://www.rfc-editor.org/rfc/rfc8551.html) `multipart/signed` framing, base64 DER body, `micalg` mapping) and OpenPGP encrypt + decrypt + WKD discovery ([RFC 9580](https://www.rfc-editor.org/rfc/rfc9580.html) §5.1 / §5.13 packets plus [draft-koch-openpgp-webkey-service](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/)) land together so the mail-crypto surface lights up coherently rather than half-on-each-side across two patches. AuthEnvelopedData ([RFC 5083](https://www.rfc-editor.org/rfc/rfc5083.html)) as a distinct `ContentInfo` shape is deferred — EnvelopedData with ChaCha20-Poly1305 is already AEAD by construction; the §5083 OID rewrap lights up alongside S/MIME for peers that refuse the EnvelopedData form. References: [RFC 5652 CMS](https://www.rfc-editor.org/rfc/rfc5652.html) · [RFC 9629 KEMRecipientInfo](https://www.rfc-editor.org/rfc/rfc9629.html) · [RFC 9909 ML-DSA in X.509+CMS](https://www.rfc-editor.org/rfc/rfc9909.html) · [RFC 9881 SLH-DSA in X.509+CMS](https://www.rfc-editor.org/rfc/rfc9881.html) · [RFC 9936 ML-KEM in CMS](https://www.rfc-editor.org/rfc/rfc9936.html) · [RFC 8103 ChaCha20-Poly1305 in CMS](https://www.rfc-editor.org/rfc/rfc8103.html). **(e) `b.streamThrottle` — token-bucket bandwidth limiter.** New primitive: `b.streamThrottle.create({ bytesPerSec, burstBytes? })` returns a shared token bucket whose `.transform()` instances each consume from the same budget. The missing primitive between per-request rate-limit and per-process worker pools: N parallel transfers share the operator-configured byte budget rather than each getting their own. Composes with `node:stream.pipeline` as a regular `stream.Transform`; chunks larger than `burstBytes` refuse with `stream-throttle/oversize-chunk` unless `transform({ allowOversize: true })`. Algorithm is the [RFC 2697 srTCM](https://www.rfc-editor.org/rfc/rfc2697.html) single-rate token-bucket shape, with lazy refill so there is no per-throttle background timer. **(f) Histogram-aware metrics snapshot writer.** `b.metrics.snapshot.startWriter` gains an opt-in `registry` field. When supplied, the JSON snapshot grows a `metrics` field carrying every registered counter / gauge / histogram in structured form — histograms include `buckets` + `observations: [{ labels, counts, sum, count }]`, so sidecar readers compose `histogram_quantile()` against the snapshot file without running a separate `/metrics` HTTP endpoint. `fileMode` default unchanged (0o640). **(g) Windows-safe daemonize.** `b.daemon.start` detached-fork mode now branches by platform. POSIX continues inheriting the parent-opened log FD via `stdio: ["ignore", logFd, logFd]` (unchanged). Windows now uses `stdio: "ignore"` + `windowsHide: true` so the child has no inherited handles that the OS invalidates on parent exit — the previously-broken Windows daemonize path now produces a survivable detached process. The child is responsible for opening its own log file (operators pass `--log` in `opts.args`). `daemon.started` audit gains `stdioMode` so operators can grep for the chosen strategy. **Closes: [#94](https://github.com/blamejs/blamejs/issues/94), [#100](https://github.com/blamejs/blamejs/issues/100), [#101](https://github.com/blamejs/blamejs/issues/101); also closes [#92](https://github.com/blamejs/blamejs/issues/92) and [#93](https://github.com/blamejs/blamejs/issues/93) (already shipped in v0.10.9 as `b.promisePool` / `b.sdNotify` — left open until now).**
12
14
  - v0.10.12 (2026-05-18) — **`b.agent.tenant` adoption across the mail-server listeners.** The v0.10.11 shared `b.mail.serverRegistry` primitive gains optional `opts.tenantScope` (a `b.agent.tenant.create()` instance) + `opts.agentTenantId` (the tenant this listener serves). When supplied, every method dispatch first gates on `tenantScope.check(state.actor, agentTenantId)` BEFORE guard validation or audit emission; cross-tenant access surfaces as the v0.9.25-typed `agent-tenant/cross-tenant-access-refused` which the listener's catch-path converts to the protocol's `BAD` / `NO` refusal reply. **(a) `b.mail.server.imap.create({ tenantScope, agentTenantId })`** — IMAP dispatch is gated for every command after AUTH. **(b) `b.mail.server.jmap.create({ tenantScope, agentTenantId })`** — JMAP per-method dispatch routes through the tenant scope alongside its existing per-`accountId` isolation. **(c) `b.mail.server.managesieve.create({ tenantScope, agentTenantId })`** — ManageSieve same pattern. **(d) `b.mail.server.submission.create({ tenantScope, agentTenantId })`** — submission listener gates at the AUTH-success boundary (before `state.actor` is committed) so cross-tenant authentication surfaces as `535 5.7.0 Authentication rejected (cross-tenant)` and the SMTP envelope never begins under the wrong tenant. **(e) `b.mail.server.pop3.create({ tenantScope, agentTenantId })`** — same AUTH-success gate; cross-tenant refusal returns `-ERR Authentication rejected (cross-tenant)`. New audit events: `mail.server.submission.cross_tenant_refused` and `mail.server.pop3.cross_tenant_refused`. **Operator impact:** no breaking changes — `tenantScope` / `agentTenantId` are optional; operators not running multi-tenant see identical behavior. Operators with multi-tenant deployments wire `b.agent.tenant.create({...})` once and pass the same scope to every per-tenant listener instance — cross-tenant isolation becomes structural rather than per-handler opt-in. **Deferred to v0.10.12.1:** per-tenant `b.mailStore` seal-key derivation via `tenantScope.derivedKey(tenantId, "seal")` and per-tenant audit namespaces via `tenantScope.auditFor(tenantId)`. Today every mail listener seals through the framework primary vault key — adequate for single-tenant and multi-tenant-trusted deployments; the v0.10.12.1 follow-up adds per-tenant key separation for compromise-isolation use cases. References: [RFC 9051 IMAP4rev2 §3 state machine](https://www.rfc-editor.org/rfc/rfc9051#section-3) · [RFC 8620 JMAP Core §1.6.2 accountId](https://www.rfc-editor.org/rfc/rfc8620#section-1.6.2) · [RFC 6409 Submission §6.1 actor-to-MAIL-FROM identity binding](https://www.rfc-editor.org/rfc/rfc6409#section-6.1) · [RFC 1939 POP3 §6 transaction state](https://www.rfc-editor.org/rfc/rfc1939#section-6) · v0.9.25 `b.agent.tenant` contract.
13
15
  - v0.10.11 (2026-05-18) — **Mail-server per-method registration sweep.** New shared primitive `b.mail.serverRegistry` (`lib/mail-server-registry.js`) replaces the hand-rolled `switch (verb)` dispatchers in the IMAP, JMAP, and ManageSieve listener factories. Operators can now override individual command / method handlers (e.g. IMAP `FETCH`, JMAP `Email/query`, ManageSieve `PUTSCRIPT`) via `opts.overrides: { NAME: { fn, maxHandlerBytes, maxHandlerMs } }` without re-implementing wire-protocol state machines or bypassing the guard substrate. **(a) Per-handler resource budgets — required at registration.** Operators MUST supply `maxHandlerBytes` (≤ 256 MiB) and `maxHandlerMs` (≤ 5 min) on every override; the registration throws `mail-server-registry/bad-max-handler-bytes` / `bad-max-handler-ms` on missing or out-of-range budgets. Defends [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) (Cyrus authenticated OOM) and [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312) (Stalwart malformed nested `message/rfc822` cyclical OOM) by forcing operators to declare the resource ceiling explicitly. **(b) Catalogue gate.** Per-protocol method names outside the IANA / RFC catalogue refuse registration unless `allowExperimental: true` is supplied — opting in audits the registration so operators can grep for off-spec handlers. **(c) Guard chain preserved.** The listener factories run `b.guardImapCommand` / `b.guardJmap` / `b.guardManagesieveCommand` BEFORE the registry lookup; operator overrides cannot bypass the wire-protocol validation, smuggling defenses, or rate-limit budgets. **(d) Handler timeout.** Promise-returning handlers wrap through `b.safeAsync.withTimeout(maxHandlerMs)`; a runaway override raises `mail-server-registry/handler-timeout` rather than pinning the connection. **(e) Defaults seeded.** IMAP picks up 30 verbs (CAPABILITY, NOOP, LOGOUT, ID, STARTTLS, AUTHENTICATE, LOGIN, ENABLE, SELECT, EXAMINE, LIST, STATUS, NAMESPACE, APPEND, CHECK, CLOSE, UNSELECT, EXPUNGE, FETCH, STORE, UID, IDLE, DONE — plus the previously-undispatched SEARCH / CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE / COPY / MOVE which default to `NO not-configured` until operator overrides); ManageSieve picks up 12 verbs (CAPABILITY, NOOP, STARTTLS, LOGOUT, AUTHENTICATE, HAVESPACE, PUTSCRIPT, LISTSCRIPTS, SETACTIVE, GETSCRIPT, DELETESCRIPT, RENAMESCRIPT); JMAP wraps the existing `opts.methods` map with a one-time deprecation audit (`mail.server.jmap.methods_opt_deprecated`) and routes through the same registry — operators migrate to `opts.overrides` with explicit budgets. **(f) New audit event:** `mail.serverRegistry.method_dispatch` carries `{ protocol, name, source: "builtin" | "operator-override" }` on every dispatch; `mail.serverRegistry.experimental_registration` audits opt-in off-catalogue registrations. **Operator impact:** existing JMAP `opts.methods` callers see the deprecation audit but continue to function (legacy auto-budget = 10 MiB / 30 s); existing IMAP / ManageSieve operators have no migration burden — the listener factories continue to accept the same opts shape. Operators wiring NEW overrides MUST supply explicit budgets. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051) · [RFC 8620 JMAP Core](https://www.rfc-editor.org/rfc/rfc8620) · [RFC 8621 JMAP for Mail](https://www.rfc-editor.org/rfc/rfc8621) · [RFC 5804 ManageSieve](https://www.rfc-editor.org/rfc/rfc5804) · [RFC 2971 IMAP4 ID](https://www.rfc-editor.org/rfc/rfc2971) · [RFC 2177 IMAP IDLE](https://www.rfc-editor.org/rfc/rfc2177) · [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) · [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312).
package/README.md CHANGED
@@ -97,6 +97,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
97
97
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`)
98
98
  - **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
99
99
  - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
100
+ - **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
100
101
  - **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
101
102
  - **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
102
103
  ### HTTP
@@ -46,11 +46,19 @@
46
46
  */
47
47
 
48
48
  var nodeCrypto = require("node:crypto");
49
+ var zlib = require("node:zlib");
50
+ var lazyRequire = require("./lazy-require");
49
51
  var validateOpts = require("./validate-opts");
50
52
  var numericBounds = require("./numeric-bounds");
53
+ var C = require("./constants");
54
+ var safeJson = require("./safe-json");
55
+ var safeBuffer = require("./safe-buffer");
56
+ var guardJson = lazyRequire(function () { return require("./guard-json"); });
57
+ var audit = lazyRequire(function () { return require("./audit"); });
51
58
  var { defineClass } = require("./framework-error");
52
59
 
53
60
  var MailDeployError = defineClass("MailDeployError", { alwaysPermanent: true });
61
+ var TlsRptParseError = defineClass("TlsRptParseError", { alwaysPermanent: true });
54
62
 
55
63
  // RFC 8461 §3.2 MTA-STS policy field allowlist. Field values typed +
56
64
  // bounded — operator supplies them; we never echo arbitrary bytes
@@ -483,10 +491,629 @@ function autoDiscoverXml(opts) {
483
491
  "</Autodiscover>\n";
484
492
  }
485
493
 
494
+ // ---- TLS-RPT receiver (RFC 8460) ----
495
+ //
496
+ // Inbound aggregate-report ingest for operators who publish
497
+ // `rua=https://reports.example.com/tlsrpt` on `_smtp._tls.<domain>`.
498
+ // Reporters POST `application/tlsrpt+json` (raw) or
499
+ // `application/tlsrpt+gzip` (gzip-wrapped JSON) per RFC 8460 §5.4
500
+ // + §6.4-6.5 IANA media-type registrations.
501
+ //
502
+ // v1 scope (this slice):
503
+ // - `parseTlsRptReport(bytes, opts?)` — pure parser + §4.4 schema
504
+ // validator. Caps decompressed size (default 32 MiB), compressed
505
+ // size (default 4 MiB), and compression ratio (default 50:1) to
506
+ // defend CVE-2025-0725 / generic decompression-amplification.
507
+ // - `tlsRptIngestHttp({...})` — (req, res) factory returning an
508
+ // RFC 8460 §5.4-compliant handler (201 on accept / 400 on bad
509
+ // JSON / 413 on size / 415 on bad media-type / 405 on non-POST).
510
+ // - `tlsRptReportSchema()` — schema descriptor for operator
511
+ // dashboards.
512
+ //
513
+ // Deferred from v1 (each with documented condition):
514
+ // - `mailto:` ingest via b.mail.server.mx. Defer condition: no
515
+ // operator demand has surfaced; HTTPS POST is the de-facto
516
+ // deployment shape for TLS-RPT today (reporters with `rua=mailto:`
517
+ // ingest are a long tail). Operators wanting mailto: ingest
518
+ // compose b.mail.server.mx today + call `parseTlsRptReport` on
519
+ // the extracted body part themselves. Reopens when an operator
520
+ // surfaces concrete demand AND the mail.server.mx surface stays
521
+ // stable across the upcoming UTA-draft revisions.
522
+ // - Brotli decompression. Defer condition: no fielded reporter
523
+ // uses `Content-Encoding: br` for TLS-RPT today; the IANA
524
+ // media-type registry (RFC 8460 §6.4) only registers +json and
525
+ // +gzip. Operators behind a brotli-encoding proxy decode at the
526
+ // proxy layer. Reopens when at least one fielded reporter ships
527
+ // brotli or the in-progress UTA-draft requires it.
528
+
529
+ // Hard caps — defensive against CVE-2025-0725 (libcurl/zlib
530
+ // integer overflow), CVE-2024-zlib decompression amplification, and
531
+ // the §5.2 community ceiling (receivers commonly cap at 10 MiB).
532
+ var TLSRPT_MAX_COMPRESSED_BYTES = C.BYTES.mib(4); // allow:raw-byte-literal — 4 MiB compressed cap per §5.2 community practice
533
+ var TLSRPT_MAX_DECOMPRESSED_BYTES = C.BYTES.mib(32); // allow:raw-byte-literal — 32 MiB decompressed cap (operators override via opts)
534
+ var TLSRPT_MAX_RATIO = 50; // allow:raw-byte-literal — 50:1 compression ratio refusal
535
+ var TLSRPT_MAX_POLICIES = 1000; // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §4.4 policy-cardinality cap
536
+ var TLSRPT_MAX_FAILURE_DETAILS = 10000; // allow:raw-byte-literal — per-policy failure-details cap
537
+ var TLSRPT_GZIP_MAGIC_0 = 0x1f; // allow:raw-byte-literal — RFC 1952 gzip magic byte 0
538
+ var TLSRPT_GZIP_MAGIC_1 = 0x8b; // allow:raw-byte-literal — RFC 1952 gzip magic byte 1
539
+
540
+ // Valid RFC 8460 §4.4 result-type values for `failure-details[].result-type`.
541
+ var TLSRPT_RESULT_TYPES = Object.freeze({
542
+ "starttls-not-supported": 1,
543
+ "certificate-host-mismatch": 1,
544
+ "certificate-expired": 1,
545
+ "certificate-not-trusted": 1,
546
+ "validation-failure": 1,
547
+ "tlsa-invalid": 1,
548
+ "dnssec-invalid": 1,
549
+ "dane-required": 1,
550
+ "sts-policy-fetch-error": 1,
551
+ "sts-policy-invalid": 1,
552
+ "sts-webpki-invalid": 1,
553
+ });
554
+
555
+ // Valid RFC 8460 §4.4 policy-type values.
556
+ var TLSRPT_POLICY_TYPES = Object.freeze({
557
+ sts: 1, tlsa: 1, "no-policy-found": 1,
558
+ });
559
+
560
+ /**
561
+ * @primitive b.mail.deploy.parseTlsRptReport
562
+ * @signature b.mail.deploy.parseTlsRptReport(input, opts?)
563
+ * @since 0.10.15
564
+ * @status stable
565
+ * @compliance hipaa, pci-dss, gdpr, soc2
566
+ * @related b.mail.deploy.tlsRptIngestHttp, b.mail.deploy.tlsRptReportSchema
567
+ *
568
+ * Parse + validate an RFC 8460 TLS-RPT aggregate report. Accepts:
569
+ * - Raw `application/tlsrpt+json` bytes (Buffer or string).
570
+ * - `application/tlsrpt+gzip` bytes (gzip magic auto-detected via
571
+ * `0x1f 0x8b` per RFC 1952, or routed when `opts.contentType`
572
+ * names a gzip media-type).
573
+ *
574
+ * Refusal posture:
575
+ * - Compressed payload > `opts.maxCompressedBytes` (default 4 MiB)
576
+ * → `mail-tlsrpt/oversize-compressed`.
577
+ * - Decompressed payload > `opts.maxDecompressedBytes` (default
578
+ * 32 MiB) → `mail-tlsrpt/gunzip-bomb`.
579
+ * - Compression ratio > `opts.maxRatio` (default 50:1) →
580
+ * `mail-tlsrpt/ratio-bomb`.
581
+ * - Malformed gzip → `mail-tlsrpt/gunzip-failed`.
582
+ * - Routes through `b.guardJson.parse` for proto-pollution / depth
583
+ * / key-count defenses before the §4.4 schema walk.
584
+ * - Missing REQUIRED §4.4 fields → `mail-tlsrpt/bad-schema`.
585
+ * - `policies` MUST be an array (RFC 8460 §4.4 erratum, even for
586
+ * single-policy reports).
587
+ *
588
+ * @opts
589
+ * contentType: string, // optional — hint for gzip routing
590
+ * maxCompressedBytes: number, // default TLSRPT_MAX_COMPRESSED_BYTES (4 MiB)
591
+ * maxDecompressedBytes: number, // default TLSRPT_MAX_DECOMPRESSED_BYTES (32 MiB)
592
+ * maxRatio: number, // default 50 (compressed:decompressed cap)
593
+ *
594
+ * @example
595
+ * var report = b.mail.deploy.parseTlsRptReport(reqBody, {
596
+ * contentType: req.headers["content-type"],
597
+ * });
598
+ * // → { organization-name, date-range: {start, end}, contact-info,
599
+ * // report-id, policies: [{ policy-type, policy-domain, ... }] }
600
+ */
601
+ function parseTlsRptReport(input, opts) {
602
+ opts = opts || {};
603
+ var bytes;
604
+ if (Buffer.isBuffer(input)) bytes = input;
605
+ else if (typeof input === "string") bytes = Buffer.from(input, "utf8");
606
+ else {
607
+ throw new TlsRptParseError("mail-tlsrpt/bad-input",
608
+ "parseTlsRptReport: input must be a Buffer or string");
609
+ }
610
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
611
+ ["maxCompressedBytes", "maxDecompressedBytes", "maxRatio"],
612
+ "parseTlsRptReport", TlsRptParseError, "mail-tlsrpt/bad-opts");
613
+ var maxCompressed = opts.maxCompressedBytes || TLSRPT_MAX_COMPRESSED_BYTES;
614
+ var maxDecompressed = opts.maxDecompressedBytes || TLSRPT_MAX_DECOMPRESSED_BYTES;
615
+ var maxRatio = opts.maxRatio || TLSRPT_MAX_RATIO;
616
+ if (bytes.length > maxCompressed) {
617
+ throw new TlsRptParseError("mail-tlsrpt/oversize-compressed",
618
+ "parseTlsRptReport: compressed payload " + bytes.length +
619
+ " bytes exceeds maxCompressedBytes=" + maxCompressed);
620
+ }
621
+
622
+ // gzip auto-detect — magic 0x1f 0x8b per RFC 1952. Routes through
623
+ // the same defensive shape as DMARC RUA (lib/mail-auth.js): bound
624
+ // decompression at the cap, surface bomb-vs-malformed as distinct
625
+ // typed errors so audit / alert wiring can react differently.
626
+ var contentType = (opts.contentType || "").toLowerCase();
627
+ var compressedLen = bytes.length;
628
+ var looksGzip = bytes.length >= 2 && bytes[0] === TLSRPT_GZIP_MAGIC_0 && bytes[1] === TLSRPT_GZIP_MAGIC_1;
629
+ var wasCompressed = false;
630
+ if (contentType.indexOf("gzip") !== -1 || looksGzip) {
631
+ wasCompressed = true;
632
+ try { bytes = zlib.gunzipSync(bytes, { maxOutputLength: maxDecompressed }); }
633
+ catch (e) {
634
+ var msg = (e && e.message) || String(e);
635
+ var isBomb = (e && (e.code === "ERR_BUFFER_TOO_LARGE" || e.code === "ERR_OUT_OF_RANGE")) ||
636
+ /output length|max(?:imum)?\s+output|exceeds?/i.test(msg);
637
+ if (isBomb) {
638
+ throw new TlsRptParseError("mail-tlsrpt/gunzip-bomb",
639
+ "parseTlsRptReport: gunzip output exceeded " + maxDecompressed +
640
+ " bytes (decompression amplification — refused per CVE-2025-0725 class)");
641
+ }
642
+ throw new TlsRptParseError("mail-tlsrpt/gunzip-failed",
643
+ "parseTlsRptReport: gunzip failed: " + msg);
644
+ }
645
+ if (compressedLen > 0 && bytes.length / compressedLen > maxRatio) {
646
+ throw new TlsRptParseError("mail-tlsrpt/ratio-bomb",
647
+ "parseTlsRptReport: decompression ratio " +
648
+ Math.round(bytes.length / compressedLen) + ":1 exceeds maxRatio=" +
649
+ maxRatio + ":1 (decompression amplification — refused)");
650
+ }
651
+ }
652
+
653
+ // Route through b.guardJson — proto-pollution / depth / key-count
654
+ // defenses on every untrusted-JSON parse path (closes v0.10.14
655
+ // detector class for untrusted-json-without-guardjson).
656
+ var raw;
657
+ try {
658
+ raw = guardJson().parse(bytes.toString("utf8"), {
659
+ maxBytes: maxDecompressed,
660
+ maxDepth: 32, // allow:raw-byte-literal — JSON depth cap
661
+ maxKeys: 1000, // allow:raw-byte-literal — top-level key cap
662
+ });
663
+ } catch (_e) {
664
+ // Fall back to b.safeJson.parse if guardJson isn't available (in
665
+ // certain bootstrap paths). Both refuse __proto__ / depth-bombs.
666
+ try { raw = safeJson.parse(bytes.toString("utf8")); }
667
+ catch (e2) {
668
+ throw new TlsRptParseError("mail-tlsrpt/bad-json",
669
+ "parseTlsRptReport: JSON parse failed: " + ((e2 && e2.message) || String(e2)));
670
+ }
671
+ }
672
+
673
+ return _validateTlsRptReport(raw, { wasCompressed: wasCompressed });
674
+ }
675
+
676
+ function _validateTlsRptReport(raw, ctx) {
677
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
678
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
679
+ "parseTlsRptReport: top-level must be a JSON object");
680
+ }
681
+ // RFC 8460 §4.4 REQUIRED fields.
682
+ var orgName = raw["organization-name"];
683
+ var contact = raw["contact-info"];
684
+ var reportId = raw["report-id"];
685
+ var dateRange = raw["date-range"];
686
+ var policies = raw["policies"];
687
+ if (typeof orgName !== "string" || orgName.length === 0) {
688
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
689
+ "parseTlsRptReport: missing required string 'organization-name'");
690
+ }
691
+ if (typeof contact !== "string" || contact.length === 0) {
692
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
693
+ "parseTlsRptReport: missing required string 'contact-info'");
694
+ }
695
+ if (typeof reportId !== "string" || reportId.length === 0) {
696
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
697
+ "parseTlsRptReport: missing required string 'report-id'");
698
+ }
699
+ if (!dateRange || typeof dateRange !== "object" ||
700
+ typeof dateRange["start-datetime"] !== "string" ||
701
+ typeof dateRange["end-datetime"] !== "string") {
702
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
703
+ "parseTlsRptReport: 'date-range' must have string start-datetime + end-datetime");
704
+ }
705
+ // RFC 8460 §4.4 erratum — `policies` MUST be an array even for a
706
+ // single-policy report. Some legacy implementations emit a bare
707
+ // object; we refuse to normalize so the operator catches the
708
+ // upstream non-conformance.
709
+ if (!Array.isArray(policies)) {
710
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
711
+ "parseTlsRptReport: 'policies' must be an array (RFC 8460 §4.4 erratum); single-policy reports still use [policy] form");
712
+ }
713
+ if (policies.length === 0) {
714
+ throw new TlsRptParseError("mail-tlsrpt/bad-schema",
715
+ "parseTlsRptReport: 'policies' must be a non-empty array");
716
+ }
717
+ if (policies.length > TLSRPT_MAX_POLICIES) {
718
+ throw new TlsRptParseError("mail-tlsrpt/too-many-policies",
719
+ "parseTlsRptReport: report has " + policies.length +
720
+ " policies (cap " + TLSRPT_MAX_POLICIES + ")");
721
+ }
722
+ // Codex P2 (v0.10.15) — validate summary counts as finite non-negative
723
+ // integers before summing. `Number(...) || 0` would accept
724
+ // `Infinity` (from JSON literal `1e309` or string "Infinity"),
725
+ // negative values, and arbitrary strings (coerced to NaN→0). Each
726
+ // is operator-untrusted input on an audit-emitted path.
727
+ var totalSuccess = 0, totalFailure = 0;
728
+ for (var i = 0; i < policies.length; i += 1) {
729
+ _validatePolicy(policies[i], i);
730
+ var summary = policies[i]["summary"];
731
+ if (summary && typeof summary === "object") {
732
+ var sRaw = summary["total-successful-session-count"];
733
+ var fRaw = summary["total-failure-session-count"];
734
+ if (sRaw !== undefined) {
735
+ if (typeof sRaw !== "number" || !isFinite(sRaw) || sRaw < 0 || Math.floor(sRaw) !== sRaw) {
736
+ throw new TlsRptParseError("mail-tlsrpt/bad-summary",
737
+ "parseTlsRptReport: policies[" + i + "].summary.total-successful-session-count must be a finite non-negative integer");
738
+ }
739
+ totalSuccess += sRaw;
740
+ }
741
+ if (fRaw !== undefined) {
742
+ if (typeof fRaw !== "number" || !isFinite(fRaw) || fRaw < 0 || Math.floor(fRaw) !== fRaw) {
743
+ throw new TlsRptParseError("mail-tlsrpt/bad-summary",
744
+ "parseTlsRptReport: policies[" + i + "].summary.total-failure-session-count must be a finite non-negative integer");
745
+ }
746
+ totalFailure += fRaw;
747
+ }
748
+ }
749
+ }
750
+ // Return a normalized shape — preserve every operator-readable
751
+ // field, plus add framework-attached metadata (sessionTotals,
752
+ // wasCompressed) that doesn't conflict with the RFC schema.
753
+ return {
754
+ "organization-name": orgName,
755
+ "contact-info": contact,
756
+ "report-id": reportId,
757
+ "date-range": {
758
+ "start-datetime": dateRange["start-datetime"],
759
+ "end-datetime": dateRange["end-datetime"],
760
+ },
761
+ "policies": policies,
762
+ sessionTotals: {
763
+ success: totalSuccess,
764
+ failure: totalFailure,
765
+ },
766
+ wasCompressed: ctx.wasCompressed === true,
767
+ };
768
+ }
769
+
770
+ function _validatePolicy(p, idx) {
771
+ if (!p || typeof p !== "object") {
772
+ throw new TlsRptParseError("mail-tlsrpt/bad-policy",
773
+ "parseTlsRptReport: policies[" + idx + "] must be an object");
774
+ }
775
+ var policy = p["policy"];
776
+ if (!policy || typeof policy !== "object") {
777
+ throw new TlsRptParseError("mail-tlsrpt/bad-policy",
778
+ "parseTlsRptReport: policies[" + idx + "].policy missing");
779
+ }
780
+ var pType = policy["policy-type"];
781
+ if (!TLSRPT_POLICY_TYPES[pType]) {
782
+ throw new TlsRptParseError("mail-tlsrpt/bad-policy",
783
+ "parseTlsRptReport: policies[" + idx + "].policy.policy-type '" + pType +
784
+ "' not in {sts, tlsa, no-policy-found}");
785
+ }
786
+ if (typeof policy["policy-domain"] !== "string" || policy["policy-domain"].length === 0) {
787
+ throw new TlsRptParseError("mail-tlsrpt/bad-policy",
788
+ "parseTlsRptReport: policies[" + idx + "].policy.policy-domain missing");
789
+ }
790
+ // policy-string is optional for no-policy-found, REQUIRED otherwise.
791
+ // We don't enforce — operators may receive partial reports from
792
+ // legacy reporters; we surface the field as-is.
793
+ var failureDetails = p["failure-details"];
794
+ if (failureDetails !== undefined) {
795
+ if (!Array.isArray(failureDetails)) {
796
+ throw new TlsRptParseError("mail-tlsrpt/bad-policy",
797
+ "parseTlsRptReport: policies[" + idx + "].failure-details must be an array");
798
+ }
799
+ if (failureDetails.length > TLSRPT_MAX_FAILURE_DETAILS) {
800
+ throw new TlsRptParseError("mail-tlsrpt/too-many-failures",
801
+ "parseTlsRptReport: policies[" + idx + "] has " + failureDetails.length +
802
+ " failure-details (cap " + TLSRPT_MAX_FAILURE_DETAILS + ")");
803
+ }
804
+ for (var k = 0; k < failureDetails.length; k += 1) {
805
+ var fd = failureDetails[k];
806
+ if (!fd || typeof fd !== "object") {
807
+ throw new TlsRptParseError("mail-tlsrpt/bad-failure-detail",
808
+ "parseTlsRptReport: policies[" + idx + "].failure-details[" + k + "] must be an object");
809
+ }
810
+ if (typeof fd["result-type"] === "string" && !TLSRPT_RESULT_TYPES[fd["result-type"]]) {
811
+ // Unknown result-type — surface as audit metadata but don't
812
+ // refuse; RFC 8460 §4.4 result-type registry can grow over
813
+ // time and we shouldn't break on new IANA entries.
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ /**
820
+ * @primitive b.mail.deploy.tlsRptReportSchema
821
+ * @signature b.mail.deploy.tlsRptReportSchema()
822
+ * @since 0.10.15
823
+ * @status stable
824
+ * @related b.mail.deploy.parseTlsRptReport
825
+ *
826
+ * Returns a structured RFC 8460 §4.4 schema descriptor — operator
827
+ * dashboards consume this to render report shape consistently.
828
+ * The descriptor names every required + optional field with type +
829
+ * cardinality + brief description. Pure function; safe to cache.
830
+ *
831
+ * @example
832
+ * var schema = b.mail.deploy.tlsRptReportSchema();
833
+ * schema.required.indexOf("report-id") !== -1; // → true
834
+ */
835
+ function tlsRptReportSchema() {
836
+ return {
837
+ rfc: "RFC 8460 §4.4",
838
+ required: [
839
+ "organization-name", "contact-info", "report-id", "date-range", "policies",
840
+ ],
841
+ fields: {
842
+ "organization-name": { type: "string", required: true, description: "Reporter organisation display name." },
843
+ "contact-info": { type: "string", required: true, description: "Email / URI for reporter contact." },
844
+ "report-id": { type: "string", required: true, description: "Reporter-issued unique report identifier (RFC 5322 msg-id shape)." },
845
+ "date-range": { type: "object", required: true, description: "Window the report covers; { start-datetime, end-datetime } in RFC 3339 form." },
846
+ "policies": { type: "array", required: true, description: "Array of policy evaluations (RFC 8460 §4.4 erratum — always array, even for single-policy reports)." },
847
+ },
848
+ policyFields: {
849
+ "policy": { type: "object", required: true, description: "{ policy-type, policy-string, policy-domain, mx-host }." },
850
+ "summary": { type: "object", required: false, description: "{ total-successful-session-count, total-failure-session-count }." },
851
+ "failure-details": { type: "array", required: false, description: "Per-failure details (result-type, sending-mta-ip, etc.)." },
852
+ },
853
+ policyTypes: Object.keys(TLSRPT_POLICY_TYPES),
854
+ resultTypes: Object.keys(TLSRPT_RESULT_TYPES),
855
+ };
856
+ }
857
+
858
+ /**
859
+ * @primitive b.mail.deploy.tlsRptIngestHttp
860
+ * @signature b.mail.deploy.tlsRptIngestHttp(opts)
861
+ * @since 0.10.15
862
+ * @status stable
863
+ * @compliance hipaa, pci-dss, gdpr, soc2
864
+ * @related b.mail.deploy.parseTlsRptReport, b.mail.deploy.tlsRptReportSchema
865
+ *
866
+ * Returns an `(req, res)` request handler mounted at the operator's
867
+ * `rua=https://<host>/<path>` endpoint. Implements the receive-side
868
+ * of RFC 8460 §5.4:
869
+ *
870
+ * - POST only — non-POST returns 405 with Allow: POST.
871
+ * - Accepts `application/tlsrpt+json` and `application/tlsrpt+gzip`
872
+ * (RFC 8460 §6.4-6.5 IANA media types). 415 on others.
873
+ * - Body size cap (default 4 MiB compressed) — 413 on exceed.
874
+ * - Routes the bytes through `parseTlsRptReport`. 400 on parse
875
+ * failure (with `Error-Type:` header naming the typed error
876
+ * code). 201 on accept.
877
+ * - Calls `opts.onAccept(report, req)` after successful parse.
878
+ * Operator's hook decides storage (most operators journal +
879
+ * emit a metric); the framework does NOT persist by default.
880
+ * - Emits a `mail.tlsrpt.ingest_http` audit event with
881
+ * posture-aware payload (organization-name, report-id,
882
+ * policy-domain set, session totals).
883
+ *
884
+ * Authentication discipline (Codex P2 v0.10.15):
885
+ * - `trustedReporters` is a CONTENT-SIDE soft filter — it compares
886
+ * the reporter's self-declared `organization-name` field (the
887
+ * report body, operator-untrusted) against the operator's
888
+ * allowlist. A hostile sender can forge any `organization-name`
889
+ * string to bypass it. This option is ADVISORY: a tripwire that
890
+ * surfaces unexpected reporter-name strings in audit, not an
891
+ * authentication boundary.
892
+ * - For real authentication, supply `opts.authenticate(req)` — the
893
+ * hook fires BEFORE parsing the body and returns truthy / falsy
894
+ * (or a Promise). False / falsy refuses with 401 + the
895
+ * `mail-tlsrpt/unauthenticated` audit code. Operators wire this
896
+ * to their mTLS-peer-cert / IP-allowlist / signed-header /
897
+ * reverse-proxy auth boundary. The framework intentionally does
898
+ * NOT couple to any specific auth scheme.
899
+ *
900
+ * @opts
901
+ * authenticate: Function, // (req) → boolean | Promise<boolean>; SHA real auth boundary
902
+ * trustedReporters: string[], // ADVISORY content filter on report.organization-name (operator-untrusted field)
903
+ * maxCompressedBytes: number, // default 4 MiB
904
+ * maxDecompressedBytes: number, // default 32 MiB
905
+ * maxRatio: number, // default 50
906
+ * onAccept: Function, // (report, req) → void | Promise
907
+ * onRefuse: Function, // (errCode, errMessage, req) → void
908
+ * audit: object, // optional b.audit handle (default: framework audit)
909
+ *
910
+ * @example
911
+ * app.post("/tlsrpt", b.mail.deploy.tlsRptIngestHttp({
912
+ * onAccept: function (report) {
913
+ * b.journal.append({ kind: "tlsrpt", report: report });
914
+ * },
915
+ * }));
916
+ */
917
+ function tlsRptIngestHttp(opts) {
918
+ opts = opts || {};
919
+ validateOpts(opts, ["authenticate", "trustedReporters", "maxCompressedBytes",
920
+ "maxDecompressedBytes", "maxRatio", "onAccept", "onRefuse",
921
+ "audit", "compliance"],
922
+ "mail.deploy.tlsRptIngestHttp");
923
+ validateOpts.optionalFunction(opts.authenticate, "tlsRptIngestHttp: opts.authenticate",
924
+ MailDeployError, "mail-tlsrpt/bad-opts");
925
+ if (opts.trustedReporters !== undefined &&
926
+ (!Array.isArray(opts.trustedReporters) ||
927
+ opts.trustedReporters.some(function (s) { return typeof s !== "string"; }))) {
928
+ throw new MailDeployError("mail-tlsrpt/bad-opts",
929
+ "tlsRptIngestHttp: opts.trustedReporters must be an array of strings");
930
+ }
931
+ var authenticate = typeof opts.authenticate === "function" ? opts.authenticate : null;
932
+ var trusted = opts.trustedReporters
933
+ ? Object.freeze(opts.trustedReporters.reduce(function (a, s) { a[s] = 1; return a; }, {}))
934
+ : null;
935
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxCompressedBytes, "maxCompressedBytes", MailDeployError, "mail-tlsrpt/bad-opts");
936
+ var maxCompressed = opts.maxCompressedBytes || TLSRPT_MAX_COMPRESSED_BYTES;
937
+ // Cache the other caps so the per-request parser call sees them.
938
+ var parseOpts = {
939
+ maxCompressedBytes: maxCompressed,
940
+ maxDecompressedBytes: opts.maxDecompressedBytes,
941
+ maxRatio: opts.maxRatio,
942
+ };
943
+ var onAccept = typeof opts.onAccept === "function" ? opts.onAccept : null;
944
+ var onRefuse = typeof opts.onRefuse === "function" ? opts.onRefuse : null;
945
+
946
+ return function tlsRptHandler(req, res) {
947
+ if (req.method !== "POST") {
948
+ res.writeHead(405, { "Allow": "POST", "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
949
+ res.end("RFC 8460 §5.4 requires POST\n");
950
+ return;
951
+ }
952
+ var ct = (req.headers["content-type"] || "").toLowerCase();
953
+ var ctRoot = ct.split(";")[0].trim();
954
+ if (ctRoot !== "application/tlsrpt+json" && ctRoot !== "application/tlsrpt+gzip") {
955
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
956
+ reason: "bad-content-type", contentType: ctRoot,
957
+ });
958
+ if (onRefuse) try { onRefuse("mail-tlsrpt/bad-content-type", "unexpected content-type " + ctRoot, req); }
959
+ catch (_e) { /* drop-silent */ }
960
+ res.writeHead(415, { "Content-Type": "text/plain", "Accept": "application/tlsrpt+json, application/tlsrpt+gzip" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
961
+ res.end("RFC 8460 §6.4-6.5 media types required\n");
962
+ return;
963
+ }
964
+ // Codex P2 (v0.10.15) — real-authentication boundary BEFORE body
965
+ // collection. The operator-supplied `authenticate(req)` hook
966
+ // routes to mTLS peer-cert / IP-allowlist / signed-header /
967
+ // reverse-proxy header inspection. Sync-or-async; falsy → 401.
968
+ if (authenticate) {
969
+ var authPromise;
970
+ try { authPromise = Promise.resolve(authenticate(req)); }
971
+ catch (e) { authPromise = Promise.reject(e); }
972
+ authPromise.then(function (ok) {
973
+ if (!ok) {
974
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", { reason: "unauthenticated" });
975
+ if (onRefuse) try { onRefuse("mail-tlsrpt/unauthenticated", "authenticate(req) returned falsy", req); }
976
+ catch (_e) { /* drop-silent */ }
977
+ res.writeHead(401, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/unauthenticated" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
978
+ res.end("authentication required\n");
979
+ return;
980
+ }
981
+ _collectAndProcess();
982
+ }, function (err) {
983
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
984
+ reason: "auth-error", message: (err && err.message) || String(err),
985
+ });
986
+ if (onRefuse) try { onRefuse("mail-tlsrpt/auth-error", (err && err.message) || String(err), req); }
987
+ catch (_e) { /* drop-silent */ }
988
+ res.writeHead(500, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/auth-error" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
989
+ res.end("authenticate hook threw\n");
990
+ });
991
+ return;
992
+ }
993
+ _collectAndProcess();
994
+
995
+ function _collectAndProcess() {
996
+ var collector = safeBuffer.boundedChunkCollector({
997
+ maxBytes: maxCompressed,
998
+ errorClass: MailDeployError,
999
+ sizeCode: "mail-tlsrpt/oversize-compressed",
1000
+ });
1001
+ var aborted = false;
1002
+ req.on("data", function (chunk) {
1003
+ if (aborted) return;
1004
+ try { collector.push(chunk); }
1005
+ catch (e) {
1006
+ aborted = true;
1007
+ try { req.destroy(); } catch (_e) { /* best-effort */ }
1008
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
1009
+ reason: "oversize-compressed", bytes: collector.bytesCollected(), cap: maxCompressed,
1010
+ });
1011
+ if (onRefuse) try { onRefuse("mail-tlsrpt/oversize-compressed", "body exceeded " + maxCompressed + " bytes", req); }
1012
+ catch (_e) { /* drop-silent */ }
1013
+ if (!res.headersSent) {
1014
+ res.writeHead(413, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1015
+ res.end("RFC 8460 §5.4 — body exceeds " + maxCompressed + " bytes\n");
1016
+ }
1017
+ void e; // _e shadowed by lower scope; mark intent
1018
+ }
1019
+ });
1020
+ req.on("end", function () {
1021
+ if (aborted) return;
1022
+ var report;
1023
+ try {
1024
+ report = parseTlsRptReport(collector.result(), Object.assign({
1025
+ contentType: ctRoot,
1026
+ }, parseOpts));
1027
+ } catch (e) {
1028
+ var code = (e && e.code) || "mail-tlsrpt/unknown";
1029
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
1030
+ reason: code, message: (e && e.message) || String(e),
1031
+ });
1032
+ if (onRefuse) try { onRefuse(code, (e && e.message) || String(e), req); }
1033
+ catch (_e) { /* drop-silent */ }
1034
+ var status = code === "mail-tlsrpt/oversize-compressed" ? 413
1035
+ : code === "mail-tlsrpt/gunzip-bomb" ? 413
1036
+ : code === "mail-tlsrpt/ratio-bomb" ? 413
1037
+ : code === "mail-tlsrpt/bad-content-type" ? 415
1038
+ : 400; // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1039
+ res.writeHead(status, { "Content-Type": "text/plain", "Error-Type": code });
1040
+ res.end("RFC 8460 §5.4 — refused: " + code + "\n");
1041
+ return;
1042
+ }
1043
+ if (trusted && !trusted[report["organization-name"]]) {
1044
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", {
1045
+ reason: "untrusted-reporter", reporter: report["organization-name"],
1046
+ });
1047
+ if (onRefuse) try { onRefuse("mail-tlsrpt/untrusted-reporter",
1048
+ "reporter '" + report["organization-name"] + "' not in trustedReporters", req); }
1049
+ catch (_e) { /* drop-silent */ }
1050
+ res.writeHead(403, { "Content-Type": "text/plain", "Error-Type": "mail-tlsrpt/untrusted-reporter" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1051
+ res.end("RFC 8460 §5.3-class: untrusted reporter\n");
1052
+ return;
1053
+ }
1054
+ var policyDomains = report.policies.map(function (p) {
1055
+ return p && p.policy && p.policy["policy-domain"];
1056
+ }).filter(Boolean);
1057
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "success", {
1058
+ reporter: report["organization-name"],
1059
+ reportId: report["report-id"],
1060
+ policyDomains: policyDomains,
1061
+ sessionTotals: report.sessionTotals,
1062
+ policyCount: report.policies.length,
1063
+ wasCompressed: report.wasCompressed,
1064
+ });
1065
+ if (onAccept) {
1066
+ try {
1067
+ var ret = onAccept(report, req);
1068
+ if (ret && typeof ret.then === "function") {
1069
+ ret.then(function () {
1070
+ if (!res.headersSent) {
1071
+ res.writeHead(201, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1072
+ res.end("RFC 8460 §5.4 — accepted\n");
1073
+ }
1074
+ }, function (_e) {
1075
+ if (!res.headersSent) {
1076
+ res.writeHead(500, { "Content-Type": "text/plain" }); // allow:raw-byte-literal — internal-error status
1077
+ res.end("internal error processing report\n");
1078
+ }
1079
+ });
1080
+ return;
1081
+ }
1082
+ } catch (_e) { /* fall through to 201 — operator hook is best-effort */ }
1083
+ }
1084
+ res.writeHead(201, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1085
+ res.end("RFC 8460 §5.4 — accepted\n");
1086
+ });
1087
+ req.on("error", function () {
1088
+ if (aborted) return;
1089
+ aborted = true;
1090
+ _safeAuditEmit(opts.audit, "mail.tlsrpt.ingest_http", "denied", { reason: "req-error" });
1091
+ if (!res.headersSent) {
1092
+ res.writeHead(400, { "Content-Type": "text/plain" }); // allow:raw-byte-literal allow:raw-time-literal — RFC 8460 §5.4 status code
1093
+ res.end("malformed request\n");
1094
+ }
1095
+ });
1096
+ } // end _collectAndProcess
1097
+ };
1098
+ }
1099
+
1100
+ function _safeAuditEmit(handle, action, outcome, metadata) {
1101
+ try {
1102
+ var a = handle || audit();
1103
+ if (a && typeof a.safeEmit === "function") {
1104
+ a.safeEmit({ action: action, outcome: outcome, actor: {}, metadata: metadata });
1105
+ }
1106
+ } catch (_e) { /* drop-silent — audit failure must not block ingest */ }
1107
+ }
1108
+
486
1109
  module.exports = {
487
- mtaStsPublish: mtaStsPublish,
488
- danePublish: danePublish,
489
- autoConfigXml: autoConfigXml,
490
- autoDiscoverXml: autoDiscoverXml,
491
- MailDeployError: MailDeployError,
1110
+ mtaStsPublish: mtaStsPublish,
1111
+ danePublish: danePublish,
1112
+ autoConfigXml: autoConfigXml,
1113
+ autoDiscoverXml: autoDiscoverXml,
1114
+ parseTlsRptReport: parseTlsRptReport,
1115
+ tlsRptReportSchema: tlsRptReportSchema,
1116
+ tlsRptIngestHttp: tlsRptIngestHttp,
1117
+ MailDeployError: MailDeployError,
1118
+ TlsRptParseError: TlsRptParseError,
492
1119
  };
package/lib/subject.js CHANGED
@@ -635,16 +635,20 @@ function _subjectHash(subjectId) {
635
635
 
636
636
  function _writeAudit(action, subjectId, outcome, metadata) {
637
637
  // recordSafe — audit failure must not roll back the subject mutation
638
- // that already touched the database. Best-effort emission with errors
639
- // swallowed; operators see them in stderr if they fire.
640
- audit.emit({
641
- actor: {},
642
- action: action,
643
- resource: { kind: "subject", id: subjectId },
644
- outcome: outcome,
645
- reason: metadata && metadata.requestReason ? metadata.requestReason : null,
646
- metadata: metadata || null,
647
- });
638
+ // that already touched the database. Drop-silent per CLAUDE.md rule
639
+ // §5 (hot-path audit sinks): swallow any throw from audit.emit so a
640
+ // misconfigured sink doesn't crash a partially-committed subject
641
+ // mutation. Errors surface via the audit sink's own logger.
642
+ try {
643
+ audit.emit({
644
+ actor: {},
645
+ action: action,
646
+ resource: { kind: "subject", id: subjectId },
647
+ outcome: outcome,
648
+ reason: metadata && metadata.requestReason ? metadata.requestReason : null,
649
+ metadata: metadata || null,
650
+ });
651
+ } catch (_e) { /* drop-silent — audit emit failure must not block subject mutation */ }
648
652
  }
649
653
 
650
654
  function _resetForTest() { db.reset(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:7f2411e6-1488-4a9f-954b-bef06640070c",
5
+ "serialNumber": "urn:uuid:3a0ed9ad-ab9e-433f-80ae-8bd6213c52d8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T20:01:39.367Z",
8
+ "timestamp": "2026-05-19T02:11:46.591Z",
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.10.13",
22
+ "bom-ref": "@blamejs/core@0.10.15",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.13",
25
+ "version": "0.10.15",
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.10.13",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.15",
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.10.13",
57
+ "ref": "@blamejs/core@0.10.15",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]