@blamejs/core 0.9.17 → 0.9.19

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.9.x
10
10
 
11
+ - v0.9.19 (2026-05-14) — **First slice of the blamepost mail-stack sequence — `b.mailStore` + `b.safeMime` + `b.guardMessageId` substrates.** Byte-level mail-store foundation that every above-the-wire mail primitive composes (agent at v0.9.20, MX listener at v0.9.23, submission listener at v0.9.24, JMAP/IMAP/POP3 at v0.9.26-29, ManageSieve at v0.9.30, DAV at v0.9.32). (1) **`b.safeMime`** — RFC 5322 + 2045/2046/2047/EAI MIME parser. Bounded: total parts cap (default 64), nesting-depth cap (default 16), boundary length cap (default 70 per RFC 2046 §5.1.1), header-bytes cap (default 64 KiB), header-line cap (default 998 per RFC 5322 §2.1.1), body-bytes cap (default 25 MiB), message cap (default 50 MiB), charset allowlist (UTF-8 / US-ASCII / common legacy 8-bit), transfer-encoding allowlist (7bit/8bit/binary/qp/base64). Surface: `parse(bytes, opts) → tree`, `walk(tree, visitor)`, `findFirst(tree, predicate)`, `extractText(tree, opts)` (RFC 2046 §5.1.4 last-wins for `multipart/alternative`), `extractAttachments(tree, opts)`. Includes RFC 2047 Q + B encoded-word decoding for `Subject:` / `From:` etc. + RFC 2231 charset'lang'value filename decoding. Throws `safe-mime/<code>` on every cap exceeded / malformed boundary / unknown charset / unknown CTE / control chars in headers / NUL bytes. **Defends CVE-2024-39929** (Exim MIME multipart parser) and **CVE-2025-30258** (gnumail truncated-MIME-tree class). Fuzz harness ships in `fuzz/safe-mime.fuzz.js`. (2) **`b.guardMessageId`** — RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id / In-Reply-To / References at the mail-store append boundary, the MX inbound boundary (v0.9.23), and the submission outbound path (v0.9.24). Refuses oversized (>998 bytes), bare CR/LF/NUL/C0-control/DEL (header-injection defense — defends `From:` / `Bcc:` smuggling via folded Message-Id continuation), unbracketed under strict profile, empty value, missing `@`, nested angle brackets, bidi codepoints (CVE-2021-42574 RTLO class in mail-header context). Profile family: strict (default) / balanced / permissive. Posture family: hipaa / pci-dss / gdpr / soc2 → all pin profile to strict. Surface: `validate(value, opts)`, `validateList(value, opts)` (References-chain cap = 100), `compliancePosture(posture)`. Fuzz harness ships in `fuzz/guard-message-id.fuzz.js`. (3) **`b.mailStore`** — byte-level mail-store substrate with pluggable backend (sqlite default; operator's `b.externalDb` Postgres or any `{ prepare(sql) → { run, get, all } }`-shaped object). Surface: `create(opts)` returning `{ appendMessage, fetchByObjectId, queryByModseq, setFlags, createFolder, listFolders, threadFor, quota, setLegalHold }`. **Sealed by default** via `b.cryptoField.sealRow` — `subject` / `from_addr` / `to_addrs` / `body_text` / `body_html` route through vault-managed AEAD envelope on insert + unseal on fetch. Plaintext (forensic-queryable without unsealing): `objectid` / `modseq` / `internal_date` / `received_at` / `size_bytes` / `flags` / `legal_hold` / `from_hash` / `message_id_hash`. Per-folder monotonic `modseq` counter (RFC 7162 CONDSTORE substrate). Per-message `objectid` (RFC 8474 JMAP cross-protocol identity). Threading at append time via In-Reply-To + References chain walk (cryptoField.lookupHash for hash-aware threading on sealed columns). Quota substrate (per-folder `used_bytes` + `used_count` maintained atomically). Legal-hold flag composes existing `b.legalHold`. Schema bootstraps at construction with six IMAP4rev2 default folders (INBOX / Sent / Drafts / Trash / Junk / Archive) and JMAP role mapping. Append composes `b.safeMime.parse` (bounded inbound) + `b.guardMessageId.validate` (header-injection gate). **Per the operator-confirmed blamepost roadmap** (`memory/specs/blamepost-roadmap.md`); next slice v0.9.20 wires `b.mail.agent` on top of this substrate.
12
+ - v0.9.18 (2026-05-14) — **18 CodeQL alerts closed across 4 rule classes + SECURITY.md hardening checklist additions for v0.9.13+ primitives + MIGRATING.md out-of-band breaking-changes section.** Post-v0.9.17 audit identified 18 pre-existing CodeQL security findings on `main` — accumulated over many releases, surfaced explicitly when v0.9.15's rename sweep changed line content. v0.9.18 closes them all. (1) **`js/file-system-race` (6 sites)** — TOCTOU between `fs.existsSync()` / `fs.statSync()` and a subsequent file op. Fixed via the framework's canonical TOCTOU-safe-read scaffold (open fd first → `fstatSync` → `readSync` loop → `closeSync` in `finally`) at `lib/atomic-file.js` (`_readSyncCore`), `lib/restore-rollback.js` (marker write switched to exclusive-create `wx` + EEXIST-tolerant), `lib/network-tls.js` (`_readPathFile` extraction with per-file ENOENT tolerance), `lib/backup/bundle.js` (open-fd-first plus required-vs-skip branch routing), `lib/static.js` (request-serve hot path narrowed to single fd). `lib/vault/seal-pem-file.js` retained as-is with a CodeQL suppression — the site has an in-line `lstat.ino === fstat.ino` inode-equality defense (line 290) that refuses with `seal-pem-file/toctou-detected` if an attacker swaps the file between `lstat` and `open`. (2) **`js/insecure-temporary-file` (6 sites)** — predictable temp paths. `lib/vault/rotate.js` now uses `mkdtempSync` for a per-rotation random scratch dir + plain filenames inside (replaces the predictable `_blamejs_rotate.tmp.db` / `_blamejs_verify.tmp.db` paths in `stagingDir`). `lib/mtls-ca.js` switched to exclusive-create `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` so an attacker pre-creating the path is refused at `EEXIST`. `lib/atomic-file.js` (`fsyncDir`), `lib/vault/rotate.js` (`_fsyncFileByPath`), `lib/http-client.js` (atomic tmp path) retained as-is with suppressions — `dirPath` / `p` are operator-supplied framework data paths (not `os.tmpdir`-reachable), and `tmpPath` carries 16 hex chars of crypto-random suffix (line 1802 `dest + ".tmp-" + bCrypto.generateToken(8)`). (3) **`js/path-injection` (2 sites in `lib/static.js`)** — `nodeFs.createReadStream(absPath)` in `_readMeta` (line 161) and the request-serve hot path (line 1115). Suppression comments added referencing the upstream `_resolveSafe` lexical-resolve + `startsWith(rootResolved + nodePath.sep)` + realpath escape check at lines 181-207 — `absPath` is sandbox-validated against `root` before reaching these lines. (4) **`js/remote-property-injection` (4 sites)** — `lib/websocket.js` (`ext.params: {}` → `Object.create(null)`), `lib/middleware/csrf-protect.js` (`var out = {}` → `Object.create(null)` for cookie-parse output). `lib/middleware/body-parser.js` (multipart `fields[currentField] = ...`) retained as-is with suppression — `currentField` is gated upstream at line 867 by `POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"])` refusing the field BEFORE assignment with a 400 BodyParserError. **Plus: SECURITY.md hardening checklist** gains 5 lines covering `b.middleware.idempotencyKey.dbStore` (hash + seal defaults), `b.metrics.snapshot` (out-of-process metrics export), `b.selfUpdate.standaloneVerifier` (zero-dep install-pipeline verifier), `b.pqcAgent.reload` (TLS-posture refresh without restart), `b.crypto.hashFilesParallel` (parallel SBOM/integrity-sweep hashing). **Plus: MIGRATING.md** now carries an "Out-of-band breaking changes" section (the v0.9.15 dbStore schema break is the first entry); `scripts/gen-migrating.js` extended with an `OUT_OF_BAND_BREAKS` table so future schema/on-disk format breaks land in MIGRATING.md without operators needing to grep CHANGELOG.
11
13
  - v0.9.17 (2026-05-14) — **Two new `codebase-patterns` detectors + 192-site cleanup sweep — `node:` prefix consistency + internal-binding leak prevention.** Post-v0.9.16 audit surfaced two enforceable invariants the existing detectors didn't cover. (1) **`node-builtin-prefix` detector** — every `require("<X>")` of a Node built-in (`fs`, `path`, `crypto`, `stream`, `tls`, `url`, `os`, `net`, `http`, `http2`, `https`, `zlib`, `dgram`, `events`, `child_process`, `readline`, …) must use the modern `require("node:<X>")` form. Three reasons: (a) userland packages on npm CAN be named after built-ins, so without the `node:` prefix a typo or `npm install` accident could shadow the built-in; (b) the prefix is a clearer at-a-glance signal that the dependency is on Node, not on a userland module; (c) bundler / SEA static-trace passes treat `node:` prefix as an unambiguous Node-builtin marker. Sweep: 153 `require()` rewrites across 79 framework files (2 parallel agents). The detector skips JSDoc `@example` block continuation lines (`*`-prefixed), so operator-facing examples that show `var fs = require("fs")` aren't rewritten — operators write their own bindings however they prefer. (2) **`internal-binding-in-prose` detector** — internal binding names (`nodeFs` / `nodePath` / `nodeCrypto` / `nodeStream` / `nodeTls` / `nodeUrl` / `bCrypto` / `retryHelper`) must NOT appear in operator-facing surface: JSDoc/comment continuation lines or string literals (error messages, audit metadata). Operators see the public API name (`path` / `fs` / `crypto` / `retry` / …), never the framework's internal alias. Sweep: 39 prose-leak fixes across 16 files — comments rewritten to use the operator-facing word (`nodePath` → `path`, `nodeFs.watch failed` → `fs.watch failed`, debug-log `"op": "nodeFs.unlinkSync"` → `"op": "fs.unlinkSync"`). (3) **2 follow-on require-binding canonicalizations** surfaced by the node-prefix sweep — `lib/ws-client.js` now destructures `var { EventEmitter } = require("node:events")` (was binding the entire `events` module to a class-shaped name) and `lib/process-spawn.js` renames inline `nodeChild` → `childProcess` (matches the module-level `childProcess` lazyRequire in `lib/dev.js`).
12
14
  - v0.9.16 (2026-05-14) — **Operator-facing prose cleanup + `require-binding-name` detector now covers `lazyRequire` wrappers + dbStore seal round-trip test added.** Post-v0.9.15 audit surfaced three classes of follow-up. (1) **Operator-facing prose leaks (7 sites)** — the v0.9.15 mechanical rename pattern `<OLD>.` → `<NEW>.` also caught occurrences inside JSDoc `@opts` comments and error-message string literals, so operators reading `b.keychain.create(opts)` saw `// absolute nodePath; required if file fallback may engage` instead of `// absolute path`. Fixed: `lib/db.js` (stream-limit error), `lib/keychain.js` (fallback-file error + 3 JSDoc lines), `lib/restore-bundle.js` (staging-dir error), `lib/watcher.js` (fs.watch failure error). Operators see plain English; internal binding names stay internal. (2) **`require-binding-name` detector extended to cover `lazyRequire`** — the v0.9.15 detector only matched plain `var X = require("M")` and missed the framework's `var X = lazyRequire(function () { return require("M"); })` pattern (used to break load cycles). 34 additional inconsistencies surfaced (`auditFwk` / `auditMod` / `auditModule` / `lazyAudit` → `audit`, `crypto` / `fwCrypto` → `bCrypto`, `dbMod` / `dbModule` → `db`, etc.) — every minority site renamed per the same canonical-name map. (3) **dbStore seal round-trip test** — the v0.9.15 test suite covered seal-falls-back-when-vault-not-ready and cross-process-sealed-row-preserved, but did NOT exercise the actual default-ON seal/unseal path because the test environment didn't `b.vault.init(...)`. New `testDbStoreSealRoundTripWithVault` bootstraps a plaintext vault, builds a dbStore with `seal: true`, writes a record + reads it back, and asserts (a) `headers` + `body` columns carry the `vault:` envelope on disk, (b) the round-trip restores the original values, and (c) `status_code` stays plaintext so forensic SELECTs still work without unsealing.
13
15
  - v0.9.15 (2026-05-13) — **`b.middleware.idempotencyKey.dbStore` hardening + framework-wide `require()` binding-name consistency.** Two operator-surfaced gaps closed: (1) **dbStore now hashes keys + seals body/headers by default.** Operator-supplied idempotency keys sometimes carry PII (order numbers, emails, vendor prefixes); the `k` column previously stored them raw, leaving every DB dump as a PII surface. v0.9.15 sha3-512 namespace-hashes the key via `b.crypto.namespaceHash("idempotency-key", key)` before insert/lookup — round-trips are transparent (operators still pass raw keys), but the DB never sees the original. The schema also splits the previous single-`v` JSON-envelope column into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` (vault-managed AEAD envelope) when vault is initialized, so a DB dump leaks neither cached response bodies nor headers. Non-sealed columns (`status_code`, `fingerprint`, `expires_at`) stay forensic-queryable. Both defaults are operator-opt-out via `opts.hashKeys: false` and `opts.seal: false`; the seal path silently falls back to plaintext + emits an `idempotency.seal_skipped_no_vault` audit warning on first use when vault isn't ready, so test fixtures and boot scripts still work. **Schema migration**: v0.9.15's split columns are incompatible with v0.9.14's single-`v` column — operators with a v0.9.14 idempotency table `DROP TABLE <tableName>;` (or pick a fresh `tableName`) before upgrading. Pre-v1 framework breaks across patch versions for security correctness. (2) **New `require-binding-name` codebase-patterns detector enforces consistent `var X = require("M")` names framework-wide.** Inconsistent names (`fs` vs `nodeFs`, `crypto` vs `nodeCrypto`, `path` vs `nodePath`, `nb` vs `numericBounds`) made `grep` across the lib unreliable and let reviewers miss shadowing bugs (`var crypto = require("crypto")` collides with the framework's own `b.crypto`). The detector carries a `CANONICAL_REQUIRE_BINDINGS` map with safety-first defaults: Node built-ins get a `node<X>` prefix (`nodeFs` / `nodePath` / `nodeCrypto` / `nodeStream` / `nodeTls` / `nodeUrl`) so a local var named `fs` / `path` / `crypto` can never shadow them; the framework's own `lib/crypto.js` binds as `bCrypto` (matches the `b.crypto` public-namespace shape and doesn't shadow node:crypto). Modules without a declared canonical fall back to majority-wins (most-sites name wins, alphabetical tiebreak). Fix is rename, not allowlist — every minority site was updated. **Sweep**: 184 require-binding renames across 108 framework files in this release; primitives + tests unchanged in surface. (3) **`b.graphqlFederation` internal `_timingSafeEqual` now routes through `b.crypto.timingSafeEqual`** (was re-implementing the length-tolerant wrapper inline; the new require-binding-name detector surfaced this; same-patch fix per the audit-existing-code rule).
package/MIGRATING.md CHANGED
@@ -1,7 +1,29 @@
1
1
  # Migrating
2
2
 
3
- Operator-facing migration recipes per breaking change. Each entry below is a `deprecate()`-marked surface in the framework — the running app will warn about it (with `BLAMEJS_DEPRECATIONS=warn` set, or by default outside production) before the noted removal version. Re-run `node scripts/gen-migrating.js` before each release; the file is committed so operators can diff it against the prior tag.
3
+ Operator-facing migration recipes per breaking change. The bulk of this file is auto-generated from `deprecate()`-marked surface in the framework — the running app warns about each (with `BLAMEJS_DEPRECATIONS=warn` set, or by default outside production) before the noted removal version. Re-run `node scripts/gen-migrating.js` before each release; the file is committed so operators can diff it against the prior tag.
4
+
5
+ **Out-of-band breaking changes** (schema breaks, config-shape changes, on-disk format breaks) cannot be expressed as `deprecate()` calls because there's no in-process runtime to warn from. They're hardcoded in the OUT_OF_BAND_BREAKS table inside `scripts/gen-migrating.js` so the operator sees the full upgrade path here without needing to grep CHANGELOG.
4
6
 
5
7
  ## No active deprecations
6
8
 
7
9
  The framework has no `deprecate()`-marked surface awaiting removal.
10
+
11
+ ---
12
+
13
+ ## Out-of-band breaking changes
14
+
15
+ Listed newest-first.
16
+
17
+ ### v0.9.15 — `b.middleware.idempotencyKey.dbStore — table schema`
18
+
19
+ Single `v` JSON-envelope column split into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` when vault is initialized; `k` column carries the sha3-512 namespace-hash of the operator-supplied key.
20
+
21
+ Operators with a v0.9.14 (or earlier) idempotency table on disk:
22
+
23
+ ```sql
24
+ DROP TABLE <tableName>; -- default: blamejs_idempotency_keys
25
+ ```
26
+
27
+ Or pick a fresh `tableName` in v0.9.15+ `dbStore({ tableName: "..." })`. The init step (`init: true`, default) creates the new split-column schema. `CREATE TABLE IF NOT EXISTS` does NOT migrate column layout on an existing table, so the drop-and-recreate is required.
28
+
29
+ Cached records in the existing table are not recoverable across the schema break — operators who care about replay continuity warm the new table by retrying the in-flight requests under the new dbStore.
package/index.js CHANGED
@@ -86,6 +86,8 @@ var session = require("./lib/session");
86
86
  var storage = require("./lib/storage");
87
87
  var safeJson = require("./lib/safe-json");
88
88
  var safeJsonPath = require("./lib/safe-jsonpath");
89
+ var safeMime = require("./lib/safe-mime");
90
+ var mailStore = require("./lib/mail-store");
89
91
  var ntpCheck = require("./lib/ntp-check");
90
92
  var auditSign = require("./lib/audit-sign");
91
93
  var objectStore = require("./lib/object-store");
@@ -157,6 +159,7 @@ var guardCsv = require("./lib/guard-csv");
157
159
  var guardHtml = require("./lib/guard-html");
158
160
  var guardSvg = require("./lib/guard-svg");
159
161
  var guardFilename = require("./lib/guard-filename");
162
+ var guardMessageId = require("./lib/guard-message-id");
160
163
  var guardArchive = require("./lib/guard-archive");
161
164
  var guardJson = require("./lib/guard-json");
162
165
  var guardYaml = require("./lib/guard-yaml");
@@ -394,6 +397,7 @@ module.exports = {
394
397
  guardHtml: guardHtml,
395
398
  guardSvg: guardSvg,
396
399
  guardFilename: guardFilename,
400
+ guardMessageId: guardMessageId,
397
401
  guardArchive: guardArchive,
398
402
  guardJson: guardJson,
399
403
  guardYaml: guardYaml,
@@ -473,6 +477,8 @@ module.exports = {
473
477
  flag: flag,
474
478
  safeJson: safeJson,
475
479
  safeJsonPath: safeJsonPath,
480
+ safeMime: safeMime,
481
+ mailStore: mailStore,
476
482
  safeSchema: safeSchema,
477
483
  pagination: pagination,
478
484
  metrics: metrics,
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMessageId
4
+ * @nav Guards
5
+ * @title Guard Message-Id
6
+ * @order 420
7
+ *
8
+ * @intro
9
+ * RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id /
10
+ * In-Reply-To / References header values at the entry to
11
+ * `b.mailStore.appendMessage` (v0.9.19), `b.mail.server.mx` (v0.9.23),
12
+ * and the outbound submission path (v0.9.25).
13
+ *
14
+ * Refuses:
15
+ *
16
+ * - oversized (default 998-byte cap per RFC 5322 §2.1.1 line cap)
17
+ * - bare CR / LF / NUL / C0 control chars (header-injection
18
+ * defense — defends `From:` / `Bcc:` smuggling via folded
19
+ * Message-Id continuation)
20
+ * - DEL (0x7F) anywhere
21
+ * - unbracketed under `strict` profile (the wire form per RFC
22
+ * 5322 §3.6.4 is `<unique-token@domain>` — operator with
23
+ * legacy mail can opt down to `balanced` to accept bare tokens)
24
+ * - empty value
25
+ * - bidi codepoints in the local-part / domain (RFC 5322 + EAI
26
+ * allow non-ASCII per RFC 6532 + RFC 5335 but bidi-marker
27
+ * codepoints are operator-unfriendly and refused outright)
28
+ *
29
+ * Profile vocabulary follows the existing guard-family convention:
30
+ *
31
+ * - `strict` (default) — bracketed `<token@domain>`, length cap,
32
+ * no control chars, no bidi
33
+ * - `balanced` — accepts unbracketed tokens (legacy mail compat)
34
+ * - `permissive` — minimal validation (NUL + CR/LF refused; rest
35
+ * passes); use only for forensic-only flows
36
+ *
37
+ * Posture vocabulary:
38
+ *
39
+ * - `hipaa` / `pci-dss` / `gdpr` / `soc2` — each pins the
40
+ * active profile to `strict` regardless of operator's profile
41
+ * opt; refuses to relax under regulated postures.
42
+ *
43
+ * Composes the framework's existing guard-family pattern via
44
+ * `b.gateContract` (the same shape `b.guardEmail` / `b.guardCsv` /
45
+ * `b.guardArchive` use). Registers in `b.guardAll`'s
46
+ * `STANDALONE_GUARDS` map.
47
+ *
48
+ * @card
49
+ * RFC 5322 §3.6.4 Message-Id validator — bounded length, no CRLF/NUL/control chars, bracketed shape under strict profile. Gates header-injection at the mail-store / MX / submission entry points.
50
+ */
51
+
52
+ var { defineClass } = require("./framework-error");
53
+
54
+ var GuardMessageIdError = defineClass("GuardMessageIdError", { alwaysPermanent: true });
55
+
56
+ var DEFAULT_PROFILE = "strict";
57
+
58
+ var PROFILES = Object.freeze({
59
+ strict: { requireBrackets: true, maxBytes: 998 }, // allow:raw-byte-literal
60
+ balanced: { requireBrackets: false, maxBytes: 998 }, // allow:raw-byte-literal
61
+ permissive: { requireBrackets: false, maxBytes: 4096 }, // allow:raw-byte-literal — permissive cap, not bytes-as-storage
62
+ });
63
+
64
+ var COMPLIANCE_POSTURES = Object.freeze({
65
+ hipaa: "strict",
66
+ "pci-dss": "strict",
67
+ gdpr: "strict",
68
+ soc2: "strict",
69
+ });
70
+
71
+ // Bidi codepoints refused — same set the framework's address-bidi
72
+ // defense uses (RFC 5322 §3.6.4 doesn't speak EAI codepoints, but RTL
73
+ // codepoints in Message-Ids are operator-unfriendly + defend the
74
+ // CVE-2021-42574 RTLO class in mail header context).
75
+ var BIDI_RE = /[؜‎‏‪-‮⁦-⁩]/;
76
+
77
+ /**
78
+ * @primitive b.guardMessageId.validate
79
+ * @signature b.guardMessageId.validate(value, opts?)
80
+ * @since 0.9.19
81
+ * @status stable
82
+ * @related b.guardMessageId.validateList, b.safeMime.parse, b.guardEmail
83
+ *
84
+ * Validate a Message-Id / In-Reply-To / References header value.
85
+ * Returns the input value on success; throws `GuardMessageIdError`
86
+ * on refusal.
87
+ *
88
+ * @opts
89
+ * profile: "strict" | "balanced" | "permissive", // default "strict"
90
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // pins profile to strict
91
+ * maxBytes: number, // per-profile default
92
+ *
93
+ * @example
94
+ * b.guardMessageId.validate("<abc@example.com>");
95
+ * // → "<abc@example.com>"
96
+ *
97
+ * try { b.guardMessageId.validate("abc@example.com"); }
98
+ * catch (e) { e.code; }
99
+ * // → "message-id/unbracketed" (strict profile)
100
+ */
101
+ function validate(value, opts) {
102
+ opts = opts || {};
103
+ var profileName = _resolveProfile(opts);
104
+ var profile = PROFILES[profileName];
105
+ var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : profile.maxBytes;
106
+
107
+ if (typeof value !== "string") {
108
+ throw new GuardMessageIdError("message-id/bad-input",
109
+ "guardMessageId.validate: value must be a string (got " + typeof value + ")");
110
+ }
111
+ if (value.length === 0) {
112
+ throw new GuardMessageIdError("message-id/empty",
113
+ "guardMessageId.validate: empty Message-Id refused");
114
+ }
115
+ if (Buffer.byteLength(value, "utf8") > maxBytes) {
116
+ throw new GuardMessageIdError("message-id/oversize",
117
+ "guardMessageId.validate: " + Buffer.byteLength(value, "utf8") +
118
+ " bytes exceeds maxBytes=" + maxBytes + " (RFC 5322 §2.1.1)");
119
+ }
120
+
121
+ // C0 control chars + NUL + DEL — always refused at every profile
122
+ // (defends mail-header-injection class — operator can't smuggle
123
+ // CR/LF into a Message-Id to fold an attacker-chosen From: line).
124
+ for (var i = 0; i < value.length; i += 1) {
125
+ var c = value.charCodeAt(i);
126
+ if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
127
+ throw new GuardMessageIdError("message-id/control-char",
128
+ "guardMessageId.validate: control char 0x" + c.toString(16) + " at offset " + i);
129
+ }
130
+ }
131
+
132
+ // Bidi codepoints — refused at strict + balanced; permissive lets
133
+ // them through. Length-bounded by the maxBytes check above so a
134
+ // hostile input can't burn regex-engine CPU; the bidi codepoint set
135
+ // is tiny so the test is constant-time anyway.
136
+ if (profileName !== "permissive" && BIDI_RE.test(value)) { // allow:regex-no-length-cap — value length-bounded by Buffer.byteLength check above
137
+ throw new GuardMessageIdError("message-id/bidi",
138
+ "guardMessageId.validate: bidi codepoint refused (CVE-2021-42574 RTLO class in mail-header context)");
139
+ }
140
+
141
+ // Bracketed shape — required under strict.
142
+ if (profile.requireBrackets) {
143
+ if (value.charAt(0) !== "<" || value.charAt(value.length - 1) !== ">") {
144
+ throw new GuardMessageIdError("message-id/unbracketed",
145
+ "guardMessageId.validate: strict profile requires `<token@domain>` shape (RFC 5322 §3.6.4)");
146
+ }
147
+ var inner = value.slice(1, -1);
148
+ var at = inner.indexOf("@");
149
+ if (at <= 0 || at === inner.length - 1) {
150
+ throw new GuardMessageIdError("message-id/no-at",
151
+ "guardMessageId.validate: Message-Id must contain `@` between local-part and domain");
152
+ }
153
+ if (inner.indexOf("<") >= 0 || inner.indexOf(">") >= 0) {
154
+ throw new GuardMessageIdError("message-id/nested-brackets",
155
+ "guardMessageId.validate: nested angle brackets refused");
156
+ }
157
+ }
158
+
159
+ return value;
160
+ }
161
+
162
+ /**
163
+ * @primitive b.guardMessageId.validateList
164
+ * @signature b.guardMessageId.validateList(value, opts?)
165
+ * @since 0.9.19
166
+ * @status stable
167
+ * @related b.guardMessageId.validate
168
+ *
169
+ * Validate a Message-Id-list header value (References / In-Reply-To
170
+ * may carry multiple ids separated by whitespace per RFC 5322 §3.6.4).
171
+ * Returns the array of validated Message-Ids; throws on any single
172
+ * refusal.
173
+ *
174
+ * @opts
175
+ * profile: same as validate
176
+ * posture: same as validate
177
+ * maxBytes: per-id cap
178
+ * maxIds: number, // default 100 — References-chain cap
179
+ *
180
+ * @example
181
+ * b.guardMessageId.validateList("<a@x> <b@x> <c@x>");
182
+ * // → ["<a@x>", "<b@x>", "<c@x>"]
183
+ */
184
+ function validateList(value, opts) {
185
+ opts = opts || {};
186
+ var maxIds = typeof opts.maxIds === "number" ? opts.maxIds : 100; // allow:raw-byte-literal — References-chain cap, not bytes
187
+ if (typeof value !== "string") {
188
+ throw new GuardMessageIdError("message-id/bad-input",
189
+ "guardMessageId.validateList: value must be a string");
190
+ }
191
+ var ids = value.split(/\s+/).filter(function (s) { return s.length > 0; });
192
+ if (ids.length > maxIds) {
193
+ throw new GuardMessageIdError("message-id/chain-too-long",
194
+ "guardMessageId.validateList: " + ids.length + " ids exceeds maxIds=" + maxIds);
195
+ }
196
+ for (var i = 0; i < ids.length; i += 1) {
197
+ validate(ids[i], opts);
198
+ }
199
+ return ids;
200
+ }
201
+
202
+ /**
203
+ * @primitive b.guardMessageId.compliancePosture
204
+ * @signature b.guardMessageId.compliancePosture(posture)
205
+ * @since 0.9.19
206
+ * @status stable
207
+ *
208
+ * Return the effective profile for a given compliance posture.
209
+ * Composed by `b.compliance.set` to surface "what posture is active
210
+ * for which guard" in audit rows.
211
+ *
212
+ * @example
213
+ * b.guardMessageId.compliancePosture("hipaa"); // → "strict"
214
+ * b.guardMessageId.compliancePosture("unknown"); // → null
215
+ */
216
+ function compliancePosture(posture) {
217
+ return COMPLIANCE_POSTURES[posture] || null;
218
+ }
219
+
220
+ function _resolveProfile(opts) {
221
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
222
+ return COMPLIANCE_POSTURES[opts.posture];
223
+ }
224
+ var p = opts.profile || DEFAULT_PROFILE;
225
+ if (!PROFILES[p]) {
226
+ throw new GuardMessageIdError("message-id/bad-profile",
227
+ "guardMessageId: unknown profile '" + p + "' (use strict / balanced / permissive)");
228
+ }
229
+ return p;
230
+ }
231
+
232
+ module.exports = {
233
+ validate: validate,
234
+ validateList: validateList,
235
+ compliancePosture: compliancePosture,
236
+ PROFILES: PROFILES,
237
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
238
+ GuardMessageIdError: GuardMessageIdError,
239
+ NAME: "messageId",
240
+ KIND: "identifier",
241
+ };