@blamejs/core 0.18.53 → 0.18.55

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 (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -8,6 +8,234 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.55 (2026-08-25) — **Opening an encrypted database twice destroyed the first process's data, and four mail-listener limits were documented but never applied.** `b.db.init` in encrypted mode reclaimed temporary working copies by filename alone, so a second process opening the same volume unlinked a running process's live database. On Linux the unlink succeeds against an open file, so the first process kept reading and writing normally while every flush from then on wrote nothing and reported success — losing everything back to its last flush, not merely the overlap. It does not reproduce on Windows, where unlinking an open file fails, so a deployment developed on a desktop and shipped to a container met it in production first.
12
+
13
+ The mail listeners had a matching shape in smaller form: `minBytesPerSecond` was validated, defaulted and exposed as a getter no listener called, so a peer trickling a few bytes at a time held a connection and its slot in the per-address cap for as long as it liked. `mail.server.imap` released a peer's rate-limit slot on disconnect but never removed it from the live-connection set, so an unauthenticated peer could grow that set without bound by connecting and dropping. No listener set a ceiling of its own, leaving the per-address cap as the only limit and the process-wide total at that cap times however many addresses a caller could speak from.
14
+
15
+ Separately, three places asked the filesystem about a path that only exists on Linux without first asking what platform they were on. A leading slash is drive-relative on Windows, so `/dev/shm` names `C:\dev\shm` there — an ordinary directory any authenticated user can create. Encrypted-at-rest resolved it as its in-memory mount and wrote every decrypted working copy to persistent disk, while the check that exists to catch that sat in a Linux-only branch and could not fire. `b.safeMountInfo` and the working-copy owner record read two more such paths, each of which an unprivileged local user could plant to decide what the framework believed.
16
+
17
+ Also: `UID SEARCH` could not be supplied by a consumer who had supplied `SEARCH`, `mail.server.mx` computed an ARC verdict and dropped it before handing the message on, and ManageSieve answered one `PUTSCRIPT` twice while discarding a conforming client's inline credential. **Added:** *`immutable` on `b.db.init`* — Pairs with `readOnly` under `atRest: "plain"`, and declares that nothing writes the volume while it is open.
18
+
19
+ SQLite needs a `-shm` file to read a database whose header says WAL, and creates one on open even for a reader that changes nothing. On a read-only mount that fails, so `readOnly` alone could not be used in the setting it was built for. Told the volume is immutable, SQLite reads it with no sidecars at all.
20
+
21
+ It is opt-in because only the operator can make that claim: the framework can see its own handle, not another process or another host sharing the mount. Getting it wrong is worse than a stale read, since a concurrent writer makes the reader's view undefined rather than merely out of date. So the default open is unchanged, and `immutable` is refused without `readOnly`, and under `atRest: "encrypted"` where the file opened is a working copy this process just decrypted and there is nothing to declare.
22
+
23
+ It is also refused, with `db/immutable-pending-wal`, when the volume has a non-empty `-wal` beside it. Not consulting the write-ahead log is exactly what lets the open create no sidecars, but a volume whose writer crashed — or one captured mid-write — can hold committed transactions only there. Reading past it would not give the stale view `immutable` asks the operator to accept; it would give a partial one that looks whole, missing precisely the newest rows. A clean close removes the file, so its presence says the volume was not closed cleanly. Open it once writable to checkpoint, or drop `immutable` and open read-only on a mount that permits the sidecar pair. · *`readOnly` on `b.db.init`* — Opens a volume for reading without ever writing back: no periodic flush, no flush on `close()`, no flush at exit, and SQLite opened read-only so a write fails where it is issued. Under `atRest: "encrypted"` this is what lets a second process read a volume another process is writing without becoming the newest writer. · *`maxConnections` on every mail listener* — A per-listener ceiling on concurrently accepted sockets, default 1024. The per-address rate limit bounds one peer; this bounds the listener. **Fixed:** *Opening an encrypted database in a second process no longer destroys the first process's data (#653)* — `db.init` under `atRest: "encrypted"` keeps its working copy in a temporary directory and sweeps that directory at boot to reclaim copies left by processes that crashed. The sweep used the filename as its only evidence: every `blamejs-*.db` that was not the caller's own was unlinked, with nothing asked about the owner.
24
+
25
+ On Linux and in any container the unlink succeeds against a file another process holds open. The first process kept a valid descriptor and carried on reading and writing with nothing to see. Its flushes then found no source path and returned as though they had written, so nothing reached `db.enc` again for the life of that process. The loss window is not the second process's lifetime and not the flush interval: it runs back to the last successful flush and forward until restart.
26
+
27
+ Sharing a temporary directory is the ordinary case for a container, because the documented way to reach a command line inside a running image is to exec into it.
28
+
29
+ Each working copy now records the process that owns it, and the sweep asks the operating system whether that process is still running, through `b.pidProbe`. A copy whose owner is alive is left alone. A copy with no ownership record — written by an earlier version, or one that cannot be read — is also left alone: deleting on a guess is what caused the loss, so the unprovable case keeps the file.
30
+
31
+ The record names the PID namespace that issued the id, because a process id only means something inside it. Two containers sharing a temporary directory are each process 1, so one container probing the other's id either finds nothing or finds an unrelated local process; both answers are noise, and reading the first as "dead owner" would unlink a live database — the very loss the record exists to prevent. A record from a namespace the reader cannot compare against is left alone. Where the platform has no PID namespaces every id on the host is comparable, and the sweep proceeds as normal. Where the platform does have them but the kernel will not say which one this process is in — `/proc` unmounted, or that entry restricted — the sweep reclaims nothing at all, since a token shared by every process that cannot see its namespace would tell two containers their ids are comparable when that is exactly what is unknown.
32
+
33
+ A flush whose working copy has vanished under a live handle now throws instead of returning. It could never have succeeded, and one error at the first flush would have surfaced every symptom above at once. After `close()` the same absence is expected and stays silent, because close removes the copy on purpose having flushed first.
34
+
35
+ `readOnly: true` opens a volume without ever writing back, at both ends of its life. `init` normally anchors a fresh audit checkpoint when the chain has moved since the last one, and a volume left by a crash is exactly that shape, so a reader would have been refused by its own read-only handle on the snapshots it most exists to inspect; `close()` normally anchors a final one so the tip sidecar names the last state. A read-only handle now asks for neither. Existing checkpoint signatures are still verified; only creating one is skipped. Two boot pragmas that rewrite the database header, `journal_mode` and `auto_vacuum`, are skipped as well; the rest configure the connection and touch nothing on disk, so a reader keeps them. The same applies to the key sidecar: a `db.key.enc` in the pre-binding format is still unsealed and used, but not re-sealed in place, because that rewrite is a convenience the next writer performs anyway and it would fail outright on a read-only mount. A read-only open of a directory with no key at all is refused rather than generating one, since there is no volume to read and creating a key would return an empty database indistinguishable from a successful read.
36
+
37
+ The contract covers what `init` calls, not only what it does itself. Audit-signing bootstrap was the remaining write: on a volume with no `audit-sign.key`, one written before signing was enabled or with `auditSigning: false`, a boot generated a keypair, wrote it under `dataDir`, and swept orphaned temp files on the way. `b.auditSign.init` now takes `readOnly`, which loads an existing key and does none of those; a volume with no key leaves signing uninitialized rather than acquiring one a reader chose. Two processes sharing one encrypted volume each decrypt their own working copy, so whichever flushes last overwrites what the other wrote since; a reader takes no part in that. SQLite is opened read-only as well, so a stray write fails where it is issued rather than succeeding into a copy nobody will persist. · *A server hosting no domains refuses every recipient instead of accepting them (#652)* — `b.mail.server.mx` had no spelling for "this server hosts no domains yet", and the two available spellings failed in opposite directions. `localDomains: []` was refused at construction, while omitting it built a listener whose relay check was nested inside a non-empty test and therefore never ran. The only spelling that started a server was the one that turned the check off, and every `RCPT TO` was accepted.
38
+
39
+ Delivery still failed later, so nothing was relayed. What a relay prober sees is `250` at MAIL FROM and `250` at RCPT TO for a sender and recipient the server has no relationship with, which is what a blocklist operator acts on; and the eventual refusal is a transient `451` that arrives only after the peer has sent the whole message, so it retries indefinitely. · *An empty allowlist permits nothing, everywhere it is read* — An allowlist that disappears when empty is a firewall rule set that opens when the last rule is deleted. Eight primitives read an explicitly empty list as "no restriction" and widened it to something more permissive than the caller asked for. Omitting the option is still how "no restriction" is spelled in every case; what changes is that a list you supply and that comes back empty is now honoured as the refusal it is.
40
+
41
+ `b.middleware.requireMtls` is the one to upgrade for: a `fingerprintAllowList` that computed to empty admitted EVERY client certificate rather than none. `b.mcp.toolResult.sanitize` permitted every URL in a tool result — attacker-influenced content, where that allowlist is what stops a result pointing at a host of the attacker's choosing. `b.safeUrl.parse` widened an empty `allowedProtocols` back to `https:`, and `canonicalize` widened an empty `allowedSchemes` to all four of http / https / ws / wss; both were measured accepting a URL under a list that permitted nothing. `b.selfUpdate.poll` and the MCP elicitation schema-type gate did the same against their own documented defaults, and a documented default is the value for the OMITTED case, not for a list a caller built and got nothing back from. `b.wsClient` turned an SSRF gate's empty address pin into no pin at all, handing the connect back to the ordinary resolver and re-opening the DNS-rebinding window the pin exists to close. Honouring an empty pin then has to fail cleanly rather than crash: the dial now answers its own name lookup with an error, so the connection is refused through the socket's ordinary error path instead of leaving a TCP handle that is never connected and never torn down.
42
+
43
+ Two places that look like this are deliberately unchanged, because their declaration and behaviour already agree: `b.fileUpload`'s `allowedFileTypes` documents an empty default as "no whitelist, operator opts in", and `b.middleware.requireBoundKey`'s `peerCertFingerprints` is a per-record binding a resolver returns "when set", not an operator policy list.
44
+
45
+ A gate now covers the shape, so an allow-ish list that decides policy on its length fails the build rather than shipping. · *`b.watcher` in `auto` mode polls when it cannot identify the filesystem* — Auto mode reads `/proc/self/mountinfo` to find out what carries the watched root, because `fs.watch` does not receive inotify events across a container bind mount or from a network filesystem. When that read returned nothing, or described no mount covering the root, it chose `fs.watch` anyway.
46
+
47
+ The two answers are not symmetric. Polling costs stat calls, which an operator can see and budget for. `fs.watch` on a filesystem whose inotify chain does not reach the process drops events with no error and no callback, so a consumer waiting on a change waits forever with nothing to read. Not knowing now resolves to polling.
48
+
49
+ What makes the read fail is `/proc` being unmounted or that entry restricted, which is a hardened container: the environment the probe was written for, and the one where inotify is least likely to work. A mount table that does describe the root still selects the native backend, so an ordinary host is unaffected. Setting `mode` explicitly overrides the probe as before. · *The BDAT path runs the same smuggling check as DATA (#650)* — `b.mail.server.submission` advertises CHUNKING, and the bare-LF screen that refuses a smuggled body ran on the DATA path only. A body DATA refuses was accepted when sent in chunks and relayed intact.
50
+
51
+ RFC 3030 §3 makes a BDAT payload an opaque byte stream, so it is not dot-unstuffed — but the screen still applies, because the body is RELAYED and the next hop is usually DATA. Both BDAT exits now run it. · *Accepting a message costs time linear in its size (#646)* — Both SMTP listeners re-derived the whole accumulated body on every chunk and scanned it twice, so acceptance was quadratic in message size. The byte cap bounds bytes, not processor time: a message well inside `maxMessageBytes` cost 4949 ms at 8 MiB against 143 ms at 1 MiB, and on the MX listener that is reachable unauthenticated.
52
+
53
+ The body is now scanned incrementally, each chunk once plus a four-byte overlap so a pattern spanning a chunk boundary is still found, and the full body is assembled once when the terminator arrives.
54
+
55
+ Overlap alone is not enough to classify a line boundary. Deciding whether an `\n` is bare means reading the byte before it, and a window has that byte at every offset except its first — so a window opening on the `\n` of a canonical `\r\n` read the boundary as bare and reported a conforming message as smuggling. Widening the overlap does not help; it only changes which byte is stranded at the front. `b.guardSmtpCommand.detectBodySmuggling` therefore takes an optional third argument naming what preceded the buffer, and the scanner supplies it. Whole-buffer callers are unaffected: at a true body start there is no preceding byte, and a leading `\n` there really is bare. · *An agent-emitted audit row names its actor (#657)* — `b.mail.agent` reshaped an actor to `{ id }` while `audit.record` reads `userId`, so every audit row an agent emitted was unattributable even when the consumer passed an identity. The shape is now produced in one place, which also carries the address, user agent and session id the audit record has fields for. · *A first line dot-stuffed per RFC 5321 §4.5.2 is stored without the stuffing dot (#651)* — `safeSmtp.dotUnstuff` treated only a line following CRLF as a line start, so a leading dot at offset 0 was left in place. §4.5.2 has the sender stuff a dot on ANY body line beginning with one, the first included, so a message whose first line legitimately began with a dot was stored with an extra one. · *`b.mail.server.tls.context` applies a key-agreement policy (#656)* — The context was built as `{ cert, key }` and set no group list at all, so a listener speaking STARTTLS to the public internet negotiated whatever the runtime defaulted to. An `ecdhCurve` a consumer passed was accepted and dropped, which made a failed attempt to set a policy indistinguishable from a successful one.
56
+
57
+ The list now comes from `b.network.tls.keyAgreementGroups`, where the framework's PQC-first preference lives, and RFC 8879 certificate compression is set on the context — a TLSSocket wrapping a pre-built context ignores that option, so setting it at the wrap site was inert and the server kept writing the full uncompressed chain. · *The DATA slow-loris floor is applied (#647)* — `b.mail.server.rateLimit` has documented `minBytesPerSecond` as a floor on the DATA body since it shipped: validated at construction, defaulted to 100, and exposed through a getter. No listener ever called it.
58
+
59
+ `idleTimeoutMs` cuts a connection that stops entirely. It does not cut one that sends a few bytes at a time, because each arrival pushes the deadline out again — so a peer could hold a connection, and its slot in the per-address cap, for as long as it cared to.
60
+
61
+ `b.mail.server.mx` and `b.mail.server.submission` now ask the limiter as the body arrives and answer `421 4.7.0` below the floor, after a grace window so that a sender pausing between chunks is not judged on its first arrival. The policy lives with the number, so both listeners ask the same question.
62
+
63
+ The rate is measured over bounded windows rather than across the whole body, and that distinction is the defence rather than a detail: a lifetime average lets an early burst pay for an arbitrarily slow tail, and at the default 100 B/s an 8 MiB burst buys about a day of credit while 50 MiB buys six. Each window has to meet the floor on its own, so nothing a peer sent earlier pays for what it sends now. The window rolls on an interval the limiter supplies, so a custom limiter that judges over a longer stretch is asked at its own interval rather than being reset underneath it.
64
+
65
+ The check runs on every inbound byte rather than inside the DATA and BDAT handlers, because a check reached only from a body handler is one the peer chooses whether to reach: a client could skip it by using BDAT instead of DATA, then by sending a zero-length chunk, then by interleaving `NOOP`, each of which keeps the socket idle timer alive without passing through a body handler. What a peer cannot do is hold a connection without sending bytes, so that is where the floor is measured — on both listeners, for both body paths.
66
+
67
+ The same reasoning applies to the transport. On `b.mail.server.submission` the byte count was kept on the plaintext socket listener, and an explicit STARTTLS upgrade replaces that listener with a callback carrying decrypted chunks — so the count stopped exactly when a connection became the shape submission is normally deployed as. The reading froze at zero, and once the grace window elapsed a client sending well above the floor was told `421`. Both transports now feed one counting funnel, which is what `b.mail.server.mx` already did.
68
+
69
+ `b.mail.server.pop3` documented the same option as bounding a trickle on `RETR` / `TOP`. It never called it either, and the option measures arriving bytes, which is the other direction. What bounds a peer that stops taking a response is `idleTimeoutMs`, measured, and the module now says so. · *A custom rate limiter is checked against the whole interface at boot* — `b.mail.server.rateLimit.resolve` recognised a custom limiter by the presence of `admitConnection` and passed it straight to a listener that calls eight methods on it. A limiter missing any of the others was accepted at construction and then failed from inside a connection handler, on whichever request first reached a method it did not have.
70
+
71
+ That was already true before this release — `releaseConnection` has been called on every socket close for as long as the listeners have tracked connections — so an incomplete limiter was already breaking, just later and more quietly. Adding the DATA-body floor moved the failure onto every message.
72
+
73
+ A custom limiter must now implement `admitConnection`, `releaseConnection`, `checkAuthAdmit`, `noteAuthFailure`, `checkRcptAdmit`, `noteRcptFailure`, `minBytesPerSecond`, `bodyRateStarved` and `bodyRateWindowMs`. `resolve` refuses at config time and names what is missing, so the gap surfaces at boot instead of mid-transaction. To customise part of the behaviour, build `create({...})` and override the parts you want to change: a method added in a later release then cannot silently go unimplemented. That is the recommended shape precisely because a hand-built object has to track this list, and a list written in prose beside a list enforced in code is two statements of one fact. · *`mail.server.imap` releases a connection when the client disconnects (#648)* — Every accepted socket was tracked in a live-connection set and removed only on the paths where the SERVER ends a session. A peer that opened a connection, took the greeting and dropped TCP was never removed. Its rate-limit slot WAS released on the same event, so it could reconnect immediately and repeat, leaving an entry behind each time. Nothing authenticates before that point.
74
+
75
+ The two ledgers a connection occupies — the limiter's per-address count and the listener's set — are released by the same event, and are now entered and released together in one place that every listener shares. Four listeners had written that pairing by hand correctly and one had not; writing it once is what makes it impossible to half-write.
76
+
77
+ The three mailbox listeners also report `connectionCount()`, which the two transfer listeners already did. · *Mail listeners take a ceiling of their own (#649)* — The per-address cap in `b.mail.server.rateLimit` bounds ONE peer and says nothing about how many peers there are, so the process-wide total was that cap times however many source addresses a caller could speak from — a number a botnet, a NAT pool or a single IPv6 /64 makes large. Each accepted socket costs a descriptor and a parser state machine before any authentication.
78
+
79
+ Every listener now accepts `maxConnections`, defaulting to 1024, held in one place so the five cannot drift on what "too many" means. The runtime closes the excess socket before the connection handler sees it, so a refusal costs nothing. · *`UID SEARCH` reaches the handler a consumer supplied (#655)* — `b.mail.server.imap` ships no search, copy or move of its own — those are operator-domain, and a consumer supplies them through `opts.overrides`. The `UID` verb dispatched its sub-commands itself and never went back through the registry, so that seam was reachable from one side only: a consumer who supplied `SEARCH` got the sequence form served and the UID form refused, and the only way to supply the UID form was to replace the whole `UID` verb, taking the working `UID FETCH` and `UID STORE` down with it.
80
+
81
+ RFC 9051 §6.4.9 is the form a client with a cross-session cache asks for, because a sequence number is only meaningful inside the session that issued it. So the refusal fell on the clients doing the durable thing.
82
+
83
+ `UID SEARCH`, `UID COPY` and `UID MOVE` now dispatch to the same registry entry as the unprefixed verb with `parsed.useUid` set, which also keeps the tenant check, the guard validation and the audit emission on the UID path. With no handler supplied they answer `NO <verb> not configured` rather than `BAD`: the command is understood, and this server has no handler for it.
84
+
85
+ `UID EXPUNGE` is refused unless a consumer supplies an EXPUNGE handler that reads the uid-set. RFC 4315 §2.1 expunges only the named messages, while the shipped EXPUNGE takes no set and expunges everything flagged `\Deleted` — forwarding to it would have deleted messages the client did not name. · *The ARC verdict reaches `agent.handoff`, and a resolver outage no longer reads as a forgery (#658)* — `b.mail.inbound.verify` evaluates the ARC chain and returns it. The MX listener wrote it into the message's `Authentication-Results` header and into its audit event, then dropped it: the `auth` object handed to `agent.handoff` carried `spf`, `dkim` and `dmarc` and no `arc`. A consumer wanting to act on the chain had to re-parse a header the pipeline had just written.
86
+
87
+ The header is also lossier than the verdict. RFC 8601 has one `arc=fail` token, while the verdict separates a chain that is structurally incomplete from one whose seal did not verify.
88
+
89
+ A chain a hop sealed as `cv=fail` is terminal and is never reported transient, whatever else went wrong alongside it: RFC 8617 §5.2 makes it unrecoverable, so retrying DNS cannot turn it into a pass, and calling it transient would have a consumer defer and re-deliver mail that will never validate. That reading requires the hop to have proved the claim is its own, because `cv=` is a token in a header the sender wrote and only the covering ARC-Seal attributes it. So terminality is conditional on the last hop's seal verifying; where its own key lookup was unavailable the declaration is unauthenticated text, the verdict stays transient, and anyone who can stall one DNS query cannot stamp a permanent rejection onto a chain.
90
+
91
+ Separately, `chainStatus: "fail"` covered both a seal that did not verify and a key that could not be looked up, and reported `signature-verification-failed` for both. The first says something about the sender; the second says the resolver was busy. A verdict that did not validate because a lookup failed now carries `reason: "key-lookup-unavailable"` and `transient: true`, and a genuine failure carries neither. `chainStatus` stays within the none / pass / fail vocabulary RFC 8617 §5.2 gives the chain, so the wire token is unchanged. · *ManageSieve answers `PUTSCRIPT` once, and accepts an inline credential (#659)* — A literal owes a CRLF after its payload, outside the declared octet count. The SASL path consumed it; the `PUTSCRIPT` path did not, so the next pass read it as an empty line and refused it — after the script had already been accepted. One command drew two replies, and a client that reads one reply per command spends the rest of the session attributing each answer to the command before it. Both literal paths now consume the terminator through the same function, which is what stops one of them having it and the other not.
92
+
93
+ RFC 5804 §2.1 allows the initial response inline. The listener parsed and validated the quoted form and then discarded the value, so a client that used it was answered as though it had sent no initial response at all. The parsed value is now returned and used, bounded by the same SASL token cap the literal form already carried. · *A snapshot no longer copies past a pending write-ahead log* — `b.db.snapshot()` copies the main database file, which is the whole database only once the write-ahead log has been folded into it. It ran a checkpoint first to make that true, and caught any failure quietly.
94
+
95
+ A `readOnly` handle cannot checkpoint. The attempt failed into that catch, the copy went ahead, and the result was a snapshot missing exactly the transactions the log still held — one that decrypts, opens and restores cleanly while being short of the most recent writes. Nothing downstream could tell, which is what makes it worse than a snapshot that fails.
96
+
97
+ Whether the log drained is now established rather than assumed. The checkpoint reports how many frames the log held and how many reached the database file, and that report is the answer — not the length of the log, which settles nothing: a passive or automatic checkpoint copies every frame across and leaves the file allocated behind it, so refusing on size would reject snapshots that are perfectly complete.
98
+
99
+ Where the answer cannot be established the snapshot is refused with `db/snapshot-pending-wal`. That covers a checkpoint that reported frames left behind or could not run for a busy database, a log that cannot be read at all — an unreadable log is unknown, not absent — and a `readOnly` handle, which cannot checkpoint and so cannot ask. The read-only refusal names the two ways out: snapshot from a handle that can write, or checkpoint the volume before opening it read-only.
100
+
101
+ The flush to `db.enc` carried the same shape and now refuses with `db/flush-pending-wal` rather than storing a volume short of its newest commits and reporting success. **Security:** *Encrypted-at-rest no longer resolves a persistent directory as its in-memory mount on Windows* — `atRest: "encrypted"` decrypts the database into a working copy and promises that copy lives in memory. Finding the mount was a single probe for `/dev/shm`, run on every platform.
102
+
103
+ A path beginning with a slash is absolute only on POSIX. On Windows it is drive-relative, so that probe asked whether `C:\dev\shm` exists — an ordinary NTFS directory, on a drive whose default permissions let any authenticated user create it. Where it existed, encrypted mode resolved it and wrote every decrypted working copy to persistent disk: reachable by backup, by replication, and by anyone imaging the drive. The check that exists to catch exactly this compares a resolved path against Linux mount points, and it sat in a Linux-only branch with nothing on the other side, so it could not fire on the one platform where the resolution was wrong.
104
+
105
+ The probe is now Linux-only. Nothing is inferred elsewhere, so encrypted mode off Linux fails closed with `db/no-tmpfs` unless the operator names a mount through `opts.tmpDir` or `BLAMEJS_TMPDIR` — a guess about where memory-backed storage lives is not one the framework can make on Windows or macOS.
106
+
107
+ Where the operator does name a path, the residency check now reports on every platform instead of only Linux. It distinguishes two findings that are not the same. On Linux the mount table can be compared against, so a path outside the known tmpfs mounts is a determination that the copy lands on disk, and that is refused with `db/tmpdir-not-tmpfs` as before. Off Linux there is nothing to compare against, so the finding is that the mount is unclassified, and it is logged as a warning naming the path rather than refused. Nothing was emitted there previously, which read as approval.
108
+
109
+ The asymmetry is deliberate. Refusing the unclassified case would mean every macOS and Windows deployment setting `allowNonTmpfsTmpDir: true` to boot at all, and that option travels: set once in shared configuration, it also switches off the Linux check, which is the one that can actually tell.
110
+
111
+ If you have run encrypted mode on Windows, `C:\dev\shm` may hold decrypted database copies written by earlier versions. Upgrading does not remove them; delete them once no process is using them. · *`b.safeMountInfo.read` has no default path off Linux* — The default was `/proc/self/mountinfo` on every platform. Drive-relative on Windows, that names `C:\proc\self\mountinfo`, which an unprivileged local user can create. Whoever wrote that file authored the entries `bestMatch()` and `isBindMount()` answer from — which is to say they decided what the framework believed about which filesystem a path was on, and `b.watcher` uses that to choose between native filesystem events and polling.
112
+
113
+ Off Linux there is now no default: `read()` returns `opts.fallback` without touching the filesystem, emitting `safe-mount-info.refused` with code `no-default-path`. A caller holding a mountinfo file elsewhere still passes `opts.path`, which is honored on every platform.
114
+
115
+ A read failure on an explicitly named path also names that path in its audit event. It previously reported `/proc/self/mountinfo unreadable` whatever had been asked for, sending anyone debugging one to a file nothing had read. · *The database working-copy owner reads its PID namespace only where namespaces exist* — Each encrypted working copy records the namespace and process id that own it, and the sweep reclaims a copy only when it can compare that record against a running process. The namespace came from reading `/proc/self/ns/pid`, attempted before the platform was consulted and falling back to a fixed token when the read failed.
116
+
117
+ On Windows that path is drive-relative, so a local user able to create `C:\proc\self\ns\pid` could decide what this process reported as its own namespace. A record matching it is what authorizes unlinking a working copy, so a planted answer aimed the sweep at a database another process was using; a mismatching one stopped the sweep reclaiming anything, leaving decrypted copies to accumulate.
118
+
119
+ The platform is consulted first. Where PID namespaces do not exist there is one namespace by definition and the platform says so, with nothing read. On Linux the link is read and used as before, and where it cannot be read the sweep still reclaims nothing rather than assuming ids are comparable.
120
+
121
+ - v0.18.54 (2026-08-24) — **Outbound mail could not be sent, and DANE refused exactly the peers that publish TLSA.** `b.mail.send.deliver` reached for a transport name nothing exports, so without an explicit `transportFactory` every recipient deferred 4.4.4 with no socket ever opened. The transport it should have reached discarded `message.raw` and rebuilt the body, so a message that did get through arrived empty. Under `policy.dane: "enforce"`, a peer that published TLSA records was refused while a peer that published none was delivered to, and the records that were fetched authenticated nothing. Alongside those: `b.mailStore` over `b.db` committed a write and then threw, POP3 and ManageSieve treated a SASL challenge as an authentication failure, `mx` gained the RCPT-time recipient check that made `550 5.1.1` reachable, and `b.mail.inbound.verify` now evaluates the ARC chain its own documentation had been promising.
122
+
123
+ Three of these are worth upgrading for ahead of the rest. A pipelined SASL response was refused and the round already in flight was left running, so it could still authenticate the connection whose response had just been refused — on all four listeners. The SMTP client decided how to frame and whether to send a message from the capabilities advertised before STARTTLS, and never discarded them, so an extension line injected on the cleartext leg still steered decisions made under TLS. And DKIM and ARC signed a UTF-8 re-encoding of the message rather than its own octets, which for ordinary 8-bit mail meant a valid signature over bytes the sender never wrote, returned to the caller in place of the message. **Added:** *`b.codepointClass.hasPairWhere`* — The scan for a two-character construct — a path traversal opening on `..`, a regex inline-flag group on `(?`, a YAML merge key on `<<` — was written out by hand in each screen that needed one. The literals and the follow-on tests differ, the scan does not, and three copies of a scan are three places for an off-by-one to live.
124
+
125
+ `hasPairWhere(text, first, second, accept)` calls `accept(i)` at every position where `first` is followed by `second`, and answers whether any of them counted. It reads the string one character at a time, which is what a guard needs: asking the question with a pattern would be a screen running the construct it screens over hostile text.
126
+
127
+ `b.guardFilename`, `b.guardRegex` and `b.guardYaml` now route through it. · *`b.codepointClass.firstLineInjectionCharOffset`* — Returns the index of the first CR, LF or NUL in a string, or `-1`. These three end a line-oriented protocol record, so a value carrying one splits the record it is written into and the remainder is read as a second, attacker-chosen line. Four call sites had hand-rolled the same scan, which is how a fifth could go without it; they now share this one.
128
+
129
+ Deliberately narrower than `firstControlCharOffset`, which refuses every C0 control and DEL: a caller adopting this does not silently start refusing values it used to accept. **Changed:** *Sample tokens and key placeholders no longer look like secrets to a default scanner* — A default-ruleset secret scan of `lib/` reported eleven findings, all inside comment blocks or documentation fixtures, none of them key material. The cost landed on anyone vendoring the tree: either a blanket allowlist over the whole library, which then hides a real finding, or a hand-triage repeated on every refresh.
130
+
131
+ Sample JWTs are now built from their header and claims rather than pasted as literals, which also shows a reader what the token says instead of an opaque blob; the PEM placeholder is written without the `BEGIN` framing; and the two-key recipient shape is named in prose. CI now also scans `lib/` with the default ruleset and no configuration, which is the scan a consumer actually runs. **Fixed:** *`b.mail.send.deliver` can open a connection* — The default transport was read as `b.mail.smtpTransport`, a name nothing defines. The export is `b.mail.transports.smtp`. So the default was `undefined`, calling it threw, the throw was classified as a transient peer problem, and an operator who did not pass `transportFactory` got every recipient deferred `4.4.4` forever with no socket ever opened.
132
+
133
+ The option is documented as an override, which means the default is the path most callers take. · *The SMTP transport sends the message it was given* — `send()` discarded `message.raw` and rebuilt the body from the structured fields. A message composed elsewhere and handed over as bytes therefore arrived as an empty `multipart/alternative`, and the peer answered `250`, so the sender had every reason to believe it had been delivered.
134
+
135
+ Raw bytes now reach the wire verbatim, which took three things beyond keeping the buffer: the message is no longer decoded as UTF-8 on the way through (that replaced every invalid sequence, corrupting exactly the binary payloads this transport detects), the DATA terminator no longer appends a CRLF the message already ended with (which inserted a blank line and broke any signature over the body), and a body requiring BINARYMIME is refused rather than sent under DATA framing, which cannot carry one.
136
+
137
+ BINARYMIME was in fact unreachable: the transport identifies a binary body by the NUL in it, and the outbound smuggling screen refused the message for containing a NUL. That screen now covers the header block for such a message, where the injection class actually lives.
138
+
139
+ The wire-capability negotiation reads the raw bytes too: `SMTPUTF8` is decided from the raw header block, `8BITMIME` from the whole message, and `BINARYMIME` from a NUL anywhere in it, so a raw message still negotiates the extensions its content requires instead of being sent under the wrong ones. · *DANE authenticates the peer instead of refusing it* — Two defects on one path, and together they inverted what DANE is for.
140
+
141
+ The TLSA lookup never passed the DNSSEC acknowledgement that `dane.tlsa` requires under RFC 7672 §1.3, and `deliver` exposed no option that could supply it. A peer publishing no records took an earlier return and was delivered to; a peer that published records reached the check and was refused. The operator who did the work of publishing TLSA was the one who stopped receiving mail.
142
+
143
+ Separately, `dane.verifyChain` shipped and nothing called it, so on the path where the lookup did succeed the certificate the peer presented was never compared against the records just retrieved. `dane: "enforce"` meant "fail if the lookup did not go well", not "authenticate the peer".
144
+
145
+ `policy.dnssecValidated: true` is now the operator's statement that their resolver validates, and `dane: "enforce"` requires it at `create()` rather than failing per-peer later: an un-asserted resolver is a property of the sender, not of any recipient. Under `"opportunistic"` without it the records are unusable, so none are fetched. The records that are fetched go to the transport, which matches the peer's certificate chain against them once the handshake completes and refuses the send on a mismatch, on both the implicit-TLS and STARTTLS paths.
146
+
147
+ A DANE failure now defers instead of bouncing. It says the HOST could not be authenticated, and the ordinary cause is a certificate rollover part-way through DNS propagation, which resolves itself. Classified permanent it also skipped every remaining MX, so one host mid-rollover turned deliverable mail into a report. MTA-STS and REQUIRETLS refusals stay permanent.
148
+
149
+ The lookup goes through the resolver the operator configured on `deliver`, because `dnssecValidated` is a statement about that resolver: fetching the records through the system resolver instead would let a non-validating path supply them under a validated banner. A configured resolver that cannot answer TLSA is refused rather than bypassed.
150
+
151
+ Under DANE-EE and DANE-TA the TLSA record is the trust anchor and PKIX path validation is not performed (RFC 7672 §3.1.1), which is what lets a self-signed MX certificate work at all. Leaving the WebPKI check on rejected such a peer during the handshake, before its certificate could be compared against the records that authenticate it, so every DANE-only MX was deferred while matching its own records exactly. PKIX-TA and PKIX-EE record sets keep the WebPKI check.
152
+
153
+ `b.mail.transports.smtp` takes the records directly as `dane: [tlsaRecord, ...]` for operators driving it themselves. · *`b.guardYaml` no longer reads a block scalar's contents as structure (#642)* — Two lines inside a block scalar that merely look like `key: value` were reported as a duplicate key. A `script: |` block holding JavaScript with `owner: ctx.owner` on two lines was refused, and there is no mapping inside a block scalar for those keys to collide in.
154
+
155
+ 0.18.53 introduced a lexer for exactly this and moved the tag, anchor and alias scans onto it. The duplicate-key screen was left reading the raw source, so the same root survived one release in a different screen.
156
+
157
+ Sweeping that root found a second: the merge-key screen also read raw source, so `<<: *base` written in a comment, in a quoted value, inside a block scalar, or on a plain scalar's continuation line was reported as a merge key. Both screens now ask the lexer, which gained `lexLines` — the mask and the node-start offsets from one pass.
158
+
159
+ The question it asks is "could a node begin here?", not "is this well-formed structure?", because `<<:*d` with no space after the colon is not a valid mapping entry and is still reported: that is the shape being smuggled past a parser variant that reads it as a merge. Asking it that way covers every region that is literal text at once. Enumerating those regions instead was tried first and left six of the eight open, which is why the lexer answers it rather than the screen. · *DKIM and ARC sign the octets they were given (#644)* — RFC 6376 §3.4 signs a canonicalized octet stream. `b.mail.dkim` took the message as a JavaScript string, canonicalized characters, and hashed the UTF-8 re-encoding of the result — so the octets it signed were `utf8(decode_utf8(original))`, equal to the original only when the original was already valid UTF-8. 128 of the 256 single-octet values do not survive that round trip.
160
+
161
+ Signing was the worse half. `sign()` returns the message with the signature prepended, so a caller relaying the return value relayed corrupted bytes carrying a signature that verifies at the receiver: the corruption arrived authenticated, and the octets the sender wrote were gone from the relayed copy. On the receive side a legitimate 8-bit message verified as a DKIM fail, leaving DMARC to fall back to the SPF identity — and to nothing at all once the message had been forwarded.
162
+
163
+ `sign()` and `verify()` now accept a `Buffer` and hash its octets, returning what they were given: a Buffer for a Buffer, a string for a string. A string argument keeps its present meaning, that the octets are its UTF-8 encoding, so existing signatures are unchanged. `b.mail.arc.sign` and the ARC verifier take the same treatment, since a seal covers a message the same way a signature does, and `b.mail.inbound.verify` passes a Buffer message through to both instead of decoding it. Both signers resolve the message through one shared function, which is what stops them answering "which bytes is this message" differently.
164
+
165
+ §3.4 canonicalization touches only SP, HTAB, CR and LF, none of which can appear inside a multi-byte UTF-8 sequence, so this is byte-for-byte identical to the previous behaviour on every valid-UTF-8 message.
166
+
167
+ The message is two views and they are not interchangeable, which is the part worth carrying to the next module that holds mail: a signature covers the bytes on the wire, and an address is characters. `b.mail.inbound.verify` reads the From header as text so an RFC 6531 local part still aligns against a DMARC record, and reads the same message as octets for DKIM and ARC. · *`b.mail.server.imap` returns a message's own octets (#645)* — Every FETCH response was assembled as a string and written with `socket.write`'s string overload, which encodes as UTF-8. RFC 9051 §4.3 makes a literal a counted sequence of octets and §6.4.5 makes `BODY[]` the message, so a message octet that is not valid UTF-8 could not survive: a `BODY[]` of a 69-octet message announced 71 and sent 71, none of them the stored bytes.
168
+
169
+ The two numbers in one response disagreeing is what a client cannot work around — the literal count is what tells it where the response ends, so a count that does not match the payload leaves it reading the next response as part of this one. A `<start.count>` window was affected on ordinary UTF-8 mail too, since §6.4.5 puts no alignment condition on the window and a boundary falling inside a character took three octets to deliver two, none of them the ones requested.
170
+
171
+ `fetchRange` may now return a row's `payload` as a Buffer, and should whenever the row carries message content; the response is framed and written as those octets. A string payload is unchanged and remains right for rows carrying only attributes. Response strings keep their UTF-8 encoding, which is what §5.1 asks for in the one place a response string is not ASCII: a mailbox name under UTF8=ACCEPT.
172
+
173
+ A NOTIFY push carries message content too, and built its FETCH response from a second copy of the same code — so it corrupted identically. Both now go through one builder, which is what stops the two answers from diverging again.
174
+
175
+ The same mailbox over POP3 already returned the message intact, so which protocol an account holder used decided whether they received their own mail. · *Raw 8-bit mail is refused when the peer does not offer 8BITMIME* — A body carrying octets with the high bit set needs 8BITMIME (RFC 6152 §3). When the peer did not advertise it, the framing choice fell through to `7BIT` and the transport wrote those octets anyway — declaring one thing and sending another. A receiver is then free to reject the transaction, or to strip the eighth bit and deliver a corrupted message, which for a signed message means a signature that no longer matches what arrived.
176
+
177
+ The send is now refused with `mail/8bitmime-not-advertised` before DATA or BDAT opens, naming the two ways out: encode the body as quoted-printable or base64, or deliver through a peer that supports the extension. A peer that does advertise 8BITMIME is unaffected and still receives `BODY=8BITMIME`.
178
+
179
+ The BINARYMIME sibling of this already refused. Marking a body as needing an extension and then shipping it without one is the same defect either way, and only one of the two was closed. · *`b.mail.arc.evaluate` agrees with `b.mail.arc.verify`* — `evaluate` answers `verify`'s question plus a trust decision, so the two must never disagree about whether a chain passes. They did, for any message containing a non-ASCII character: `evaluate` resolved the message to its wire form and then handed that form to `verify`, which resolved it again. The second pass read the wire representation as text and re-encoded it, so the octets checked were no longer the octets sealed — the chain passed `verify` and failed `evaluate`, and a trusted sealer went unrecognised.
180
+
181
+ The conversion is idempotent on octets and not on text, so the internal hand-off now passes octets. That contract is written at the function both paths share, because the two views of a message are easy to mix up and the mistake is invisible on ASCII. · *A refused pipelined SASL response can no longer authenticate the connection* — A client can put several lines in one TCP segment, so two SASL responses can arrive together. The shared exchange refuses the second one, because a client that answers before it has been asked is not following the protocol — but it refused it and left the verifier already running for the first response alone.
182
+
183
+ That verifier could then resolve `{ ok: true }` and authenticate the connection whose response had just been refused, so the refusal decided nothing. It could also emit a challenge after the listener had answered and cleared its state.
184
+
185
+ The round in flight is now abandoned rather than merely reported: its completion, success or throw, reaches no callback. The exchange also stays dead afterwards, so a listener that does not tear the connection down cannot have it become authenticable again once the violation has scrolled past. IMAP, submission, POP3 and ManageSieve all drive their SASL exchange through this, so all four were exposed. · *SMTP capabilities are read only from the EHLO that follows STARTTLS* — `b.mail.smtpTransport` decided whether a message could be sent, and how it would be framed, from the capabilities advertised on the cleartext leg. The pre-upgrade extension list was also never discarded, so the set in force after the handshake was the union of both legs.
186
+
187
+ RFC 3207 §4.2 requires the opposite: a client MUST discard every service extension learned before the upgrade, because the cleartext leg is exactly what a network attacker can rewrite. An injected extension line steered decisions made under TLS — a `SIZE` cap that refuses every message, a `CHUNKING` that selects a framing the real peer never offered.
188
+
189
+ It read as an over-refusal too. Servers commonly advertise `CHUNKING` and `BINARYMIME` only once TLS is up, so a binary message was refused before `STARTTLS` was even sent, naming an extension the peer does advertise.
190
+
191
+ Every capability-dependent decision now runs on the post-upgrade EHLO alone: the SMTPUTF8 and BINARYMIME requirement checks, the `SIZE` pre-check, and the BDAT-versus-DATA choice. Implicit-TLS transports are unaffected, since their first EHLO is already inside TLS. · *A deferred recipient names the receiver that refused (#643)* — `b.mail.send.deliver` returned deferred recipients with a reason and a retry budget and no `mxHost`, while delivered ones carried it. A queue view therefore read "deferred, retry in 15 minutes" with no peer attached, and a domain with several MX hosts gives an operator no way to tell which one refused — which is what decides whether to wait, to contact that receiver, or to look at their own transport security against that host.
192
+
193
+ Deferred recipients now carry `mxHost`. It is `null`, rather than absent, when the deferral happened before any host was reached (an MX lookup that failed), so a consumer can tell "no peer was reached" from "the peer was not recorded". · *`b.guardFilename` honours `adsPolicy: "allow"` (#623, reopened)* — `sanitize`, `validate` and the gate accepted `adsPolicy: "allow"`, documented it as opting out of the NTFS alternate-data-stream check, and refused anyway. An operator who set every documented opt-out still could not store `12:30 notes.txt` — a timestamped note, not an attack.
194
+
195
+ 0.18.52 addressed the same report by making the refusal message explain why the option did not apply there. That reads as a considered boundary until you meet a filename like that one: the check is lexical, a colon is an ordinary filename character on Linux and macOS, and the shape of an attack is identical to the shape of a timestamp. Only the operator knows which filesystem the name will be written to, which is why the option exists.
196
+
197
+ The option now opts out at every door that reads it, including strip mode and the validate finding. The default is unchanged and still refuses, in all three profiles — reaching the name requires setting `reservedCharPolicy: "allow"` as well, because a colon is also a Windows reserved character.
198
+
199
+ This also corrects where the rule sits: an ADS suffix was treated as a floor no configuration could lower, alongside NUL bytes, `..` traversal and UNC prefixes. Those three are unsafe on every filesystem. A colon is not, and the rule belongs with `reservedCharPolicy` and `reservedNamePolicy`, the framework's other Windows-specific checks, all default-on with an opt-out.
200
+
201
+ Note for anyone tracking versions: 0.18.52 was tagged but never published — its publish workflow failed and the tag is immutable, so npm goes 0.18.51 to 0.18.53. Nothing from it was lost; 0.18.53 carries it. · *`b.network.dns.resolver` queries the DoH endpoint you configured* — `b.network.dns.useDnsOverHttps({ url, ca })` sets where DNS goes, and `b.network.dns.resolver.create()` ignored it: its wire-format lookup addressed a public provider that was hardcoded in the source. A deployment that had pointed DoH at its own resolver — for split horizon, for privacy, or because that resolver validates DNSSEC — still had every `resolver.query()` leave for the public one.
202
+
203
+ That also hollowed out the DANE assertion above: `policy.dnssecValidated: true` describes the operator's resolver, and the queries were not reaching it. The endpoint and its CA are now read per lookup, so a later `useDnsOverHttps` reaches the next query rather than a handle staying pinned to whatever was configured when it was built.
204
+
205
+ The configured request method travels with it. RFC 8484 §4.1 allows GET and POST, an endpoint may accept only POST, and `useDnsOverHttps({ method: "POST" })` says so — honouring the URL while sending GET leaves that endpoint unusable. With no method configured the choice falls to URL length, using the same threshold the rest of the DNS module uses rather than a second copy of the number.
206
+
207
+ `b.network.dns.activeDohEndpoint()` reports the endpoint in force, or `null` when the deployment is on DoT or the system resolver. · *`b.mail.send.deliver` reads MX records from the framework's own resolver* — The MX normalisation accepted `{ exchange, priority }` on each record, and `b.network.dns` decodes an MX RR into `{ ..., decoded: { preference, exchange } }`. Composed with the framework's own resolver — the one the DANE documentation points operators at — every MX host came out `undefined`, and the first thing to touch one failed the delivery.
208
+
209
+ A comment above the normalisation asserted the flat shape, and every test agreed with it, because every test supplied a hand-written resolver returning exactly what the comment claimed. The disagreement is only visible against a real DNS response, which is what the new `mail-dane-authentication` integration test uses. · *`b.mailStore` over `b.db` no longer commits a write and then throws* — `appendMessage` and `hardExpunge` selected a transaction shape by testing `typeof backend.transaction === "function"`, which is true of both conventions and distinguishes neither. `b.db.transaction(fn)` runs `fn` and returns its result; better-sqlite3's returns a wrapper to call. Against `b.db` the callback ran, the write committed, and the framework then tried to call the returned value and threw. The caller saw a failure over a completed write.
210
+
211
+ Both conventions are now honoured by observing what actually happened: if the callback ran, the result stands; if a function came back instead, it is called. A backend that does neither is refused rather than left to commit unatomically. · *`deliver` accepts the null reverse path a DSN requires* — RFC 5321 §4.5.5 requires a bounce to be sent from the null reverse path, `MAIL FROM:<>`, and the SMTP transport already turns `""` into exactly that. `deliver` refused `envelope.from: ""` as missing, so the one value a DSN must use was the one value it would not accept. Its own documented DSN example sent the report back with the original sender's address, which is the loop RFC 5321 §4.5.5 exists to prevent.
212
+
213
+ `""` is now a value rather than an absence, and no DSN is generated for a message that already carries the null sender. · *POP3 and ManageSieve honour a SASL challenge* — An operator verifier may answer `{ pending: true, challenge }` to ask for another round trip, which IMAP and submission honoured. POP3 and ManageSieve called the verifier once, passed no `step`, and dropped a pending verdict into the branch that writes an authentication failure. That branch also spent the client's authentication-failure budget, so a normal protocol round trip consumed the defence that exists to slow down credential guessing.
214
+
215
+ Both now run the exchange the way the other two listeners do, through one shared step runner rather than a fourth copy of the loop. The client's reply reaches the verifier instead of the command table, `*` cancels without costing budget, and a genuinely failed exchange still charges it.
216
+
217
+ ManageSieve also accepts the literal form of a response. RFC 5804 §1.2 makes a string either quoted or a literal (`{N}` or `{N+}` followed by N bytes), and a SASL response is base64 and can be long, which is exactly when a client reaches for the literal. The initial response accepted both; the continuation accepted only the quoted form, so the literal marker itself was read as the response and its bytes as another one. · *`b.mail.server.mx` can refuse an unknown mailbox* — Recipients were decided from `localDomains` alone, so a local domain accepted every local part and the application first met the recipient at the agent handoff, after `354` and after the whole message body. From there the only answers were `250`, which tells the peer the message arrived and leaves the receiver owing a report, or `451`, which tells a peer holding a permanent condition to keep retrying. `550 5.1.1` was not expressible.
218
+
219
+ `opts.recipientPolicy` runs at RCPT TO, in the same shape the submission listener already had: `{ ok: false, reason }` refuses `550 5.1.1` with the reason, and a throw defers `451 4.7.1`, because a directory that cannot be reached is not a verdict about the mailbox. Refusals charge the same per-IP recipient-failure budget as a relay refusal, since the difference between `250` and `550` is a mailbox-existence oracle. · *A recipient-refusal reason can no longer forge a second SMTP reply* — Both mail listeners wrote an operator policy's refusal reason straight into the `550` line. That reason is rarely the operator's own words: a directory wrapper answers "No such user: <address>", and the address came from the peer. A CR or LF in it ended the reply line early and everything after was read by the peer as a second server response, so a refusal could deliver a forged acceptance.
220
+
221
+ The reason now goes through a check that returns it when it cannot split a line and a fixed fallback when it can, so the recipient is still refused and only the prose is replaced. This covers the submission listener's existing `recipientPolicy` as well as the MX listener's new one, and a detector keeps a third from appearing.
222
+
223
+ The same class is closed on the SASL challenge that the IMAP, POP3, ManageSieve and submission listeners write during a multi-step exchange. There the exchange fails instead of substituting, because a challenge that cannot be sent verbatim cannot authenticate anyone. · *`b.mail.inbound.verify` evaluates ARC* — The module documented `b.mail.arc.verify` among the calls the receiver pipeline composes, and the pipeline never made it. A consumer wiring `b.mail.server.mx` as documented got SPF, DKIM and DMARC, no `arc` field in the result, no `arc=` method in the emitted `Authentication-Results`, and nothing at run time to say so.
224
+
225
+ The chain is now evaluated and reported. It is treated as evidence rather than as a gate: a malformed chain is a `fail` verdict and the pipeline continues, because refusing mail over a defect in an intermediary's headers punishes the wrong party. "none" is reported like any other verdict, so a receiver reading the header can tell a chain that was checked and found absent from a receiver that does not check. · *`safeResolveTxt` reports absence as absence* — The guard listed the resolver's `ENOTFOUND` and `ENODATA` codes, and the DNS resolver reported every non-zero RCODE through one error code, so NXDOMAIN — the commonest form of "no record published" — arrived as a failure and threw.
226
+
227
+ RCODE 3 now carries `resolver/nxdomain` and everything else stays `resolver/query-failed`. The distinction matters beyond this call: `b.mail.rbl` treated any lookup error as "not listed", so a SERVFAIL from a blocklist read as a clean verdict. It now accepts only genuine absence. · *`b.mail.helo` FCrDNS can pass* — The check calls `resolver.queryPtr`, which `b.network.dns.resolver.create()` did not expose. The call returned `undefined`, the resulting TypeError landed in a catch written for NXDOMAIN, and the check reported a clean "no reverse name" for every address it ever ran on. Every test passed throughout, because the only object with a `queryPtr` was the one in the test file.
228
+
229
+ The resolver now provides `queryPtr`, and a resolver that does not is refused rather than read as a miss: "no reverse name" and "I could not ask" are different findings. · *`b.mail.agent.expunge` refuses a posture it cannot resolve* — The retention floor was resolved by indexing the floor table with a fallback to zero, so a misspelled posture, a capitalised one, or one the table does not carry all permitted an unbounded hard delete with no error. It also read through the prototype: posture `"constructor"` returned a function, which is truthy, so the fallback kept it and every age comparison against it was meaningless. A typo was refused where retention windows are computed and accepted at the one call that destroys mail permanently.
230
+
231
+ It now resolves through `b.retention.complianceFloor`. No posture at all remains a legitimate zero-floor configuration; a posture that was supplied and is not understood is refused. · *`b.retention.complianceFloor` stops calling valid postures typos* — Absence from the retention-floor table was read as "not a posture", but that table holds only the subset of regimes carrying a regulator-mandated minimum. Most do not: GDPR Art. 5(1)(e) is storage limitation, the opposite of a floor. So 158 of the 169 postures `b.compliance.set` accepts were reported as misspellings, and an operator who set one could not compute a TTL at all.
232
+
233
+ A posture the framework knows now resolves, to zero when it imposes no minimum. A name outside `b.compliance.KNOWN_POSTURES` still throws, and says which vocabulary it was measured against. · *`b.mail.server.imap` answers a literal with a real continuation* — RFC 9051 §7.5 defines a command continuation request as a line beginning with `+`. The listener wrote it as an untagged response, so the wire carried `* + Ready for literal data`. A conforming client waits for a `+` line, and `APPEND` never completed. The non-synchronizing route was closed too: the strict profile's guard refuses `LITERAL+` and `CAPABILITY` does not advertise it. · *`b.mail.server.jmap` can decline the WebSocket transport* — The RFC 8887 capability was advertised whether or not the consumer had wired the upgrade handler. The check that looked like an opt-out was not one: omit the key and the default added it, supply the key and the merge put it straight back. The only value that suppressed it was `undefined`, and only because `JSON.stringify` drops such keys.
234
+
235
+ `webSocket: false` is now the answer, and it removes both the capability and the top-level `webSocketUrl` alias, so a client is not sent to an endpoint that cannot upgrade. · *`b.credentialHash.needsRehash` refuses an algorithm name it does not know* — `hash()` validated `opts.algo` against the algorithm table and threw on a name it did not recognise. `needsRehash()` read the same table with a fallback to the framework default, so a misspelling silently answered a different question than the caller asked: "is this row still the default?" rather than "is this row argon2id?", on a credential-rotation decision. It also read through the prototype, where `"constructor"` yielded a function that compares unequal to every real algorithm id. · *`b.ai.adverseDecision.wrap` refuses a legal basis it has no deadlines for* — `legalBasis` selects the statutory deadlines stamped on every adverse notice, and an unrecognised value fell back to `operator-defined`, whose deadlines are all null. One missing character in `gdpr-22` produced a notice asserting the subject has no right to an explanation, no right to human review and no right to appeal — the exact obligations Article 22 imposes, switched off silently, while the notice still recorded the misspelled basis as though it had been honoured.
236
+
237
+ An operator whose regime the framework does not carry asks for `operator-defined` by name. · *`b.redact.classifyDefaults` refuses a prototype member as a classifier name* — The unknown-pattern check read `opts.extra[name]` through the prototype, so once a consumer supplied any extra pattern, `constructor` / `toString` / `valueOf` resolved to inherited functions and passed as known names. The scanner then met a function where a pattern spec belongs and threw an untyped error at scan time: a data-loss classifier failing at the moment it was asked to look, rather than refusing at boot. · *Two middleware counters no longer discard their own failures* — `b.middleware.bearerAuth` and `b.middleware.fetchMetadata` recorded their metrics through a call that throws on a misconfigured observability backend, inside the request path. Both now use the drop-silent emitter the rest of the framework's hot paths use, so a metrics problem cannot fail the request that triggered it.
238
+
11
239
  - v0.18.53 (2026-08-24) — **The YAML screens refused ordinary documents: a list of records, and an exclamation mark in a sentence.** `b.guardYaml.parse` refused any sequence whose items carry more than one key, which is the shape of nearly every list of records anyone writes. Separately, both `b.guardYaml` and `b.parsers.yaml` decided whether a `!`, `&` or `*` opened a tag, anchor or alias by looking at the character in front of it, so they reported one inside a quoted string, inside a block scalar's shell script, in the middle of a value, and inside a comment. `x: 1 # note !bang` was refused. Both screens now read the document's structure, through one scanner shared by the two modules. **Fixed:** *`b.guardYaml` no longer reports a duplicate key for a list of records* — Key uniqueness was tracked per indentation level, and a sequence-item line never reset that tracking. The second item's keys were therefore checked against the first item's, so every key except the one written inline with the dash was reported as a duplicate:
12
240
 
13
241
  ```yaml
package/NOTICE CHANGED
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
68
68
  reference implementation.
69
69
  --------------------------------------------------------------------------------
70
70
  Component: @blamejs/pki
71
- Version: 0.5.28
71
+ Version: 0.5.31
72
72
  Source: https://github.com/blamejs/pki
73
73
  License: Apache-2.0
74
74
  Copyright: Copyright (c) blamejs contributors
package/README.md CHANGED
@@ -64,7 +64,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
64
64
  - **SQLite with sealed-by-default columns** — `b.db`, migrations, seeders, atomic-file writes; the db handle constructs with a SQLITE_LIMIT_LENGTH parse-time cap (a >1 MiB statement is rejected) as a DoS floor on the raw-SQL surface
65
65
  - **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
66
66
  - **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
67
- - **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
67
+ - **DB lifecycle** — `readOnly: true` opens an encrypted volume without ever writing back (no periodic flush, no flush on close or at exit, SQLite opened read-only), so a second process can read one another process is writing; the encrypted-mode working copy records its owning process, and the boot sweep that reclaims copies left by crashed processes asks whether that process is still running rather than inferring it from the filename; in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
68
68
  - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes. The framework's own data layer — the signed audit chain, cluster leadership and lease fencing, sessions, break-glass, and the local queue / cache / scheduler — is composed through the dialect-aware `b.sql` builder (every identifier quoted by construction, every value bound as a placeholder, dialect-correct SQLite / Postgres / MySQL output), so the framework's tables run on a Postgres or MySQL backend, not only local SQLite; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total-size boundaries
69
69
  - **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads, with versioned delete + `listVersions` for right-to-erasure / crypto-shred against an Object-Lock bucket (`b.storage`, `b.objectStore`)
70
70
  - **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`) — the local backend can target an operator-supplied database / table / schema; driven either by a resident consumer (`b.queue.consume`) or one batch at a time for a scheduled runtime that cannot host one (`b.queue.tick`); cluster-shared cache (`b.cache`)
@@ -165,7 +165,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
165
165
  - **Archive + filename** — `b.guardArchive` (zip-slip, symlink + hardlink escape, decompression bombs, duplicate-entry); `b.guardFilename` (path traversal raw + percent-encoded + overlong-UTF-8, null-byte, Windows reserved, NTFS ADS, RTLO bidi)
166
166
  - **Email** — `b.guardEmail` (SMTP smuggling per CVE-2023-51764 / 51765 / 51766 class, CRLF header injection, IDN homograph, IP-literals, RFC 5321 length caps)
167
167
  - **Identifiers** — `b.guardUuid` (RFC 9562 form / version / variant, nil + max sentinels); `b.guardCidr` (octet overflow, mask range, reserved-range membership); `b.guardCountry` (ISO 3166-1 alpha-2 from a bundled table, never `Intl` — the 249 officially assigned codes only, so `UK`, `ZZ`, `EU`, `EZ`, `UN` and the user-assigned ranges are refused rather than routed on, and a stripped-ICU build changes no answer)
168
- - **Character catalog** — `b.codepointClass` bidi / C0-control / zero-width / Unicode-Tags / whitespace range tables plus the codepoint scanners the guards screen with (`firstInRanges` / `stripRanges` / `replaceRanges` / `indexOfAny` / `replaceAny` / `trimChars` / `trimRanges` / `containsFolded` / `indexOfFolded` / `matchesAtFolded` / `isRunOf` / `isRunOfRanges` / `splitLines` / `splitLinesAny` / `splitOnWhitespace`), and UTS #39 confusable-script classification
168
+ - **Character catalog** — `b.codepointClass` bidi / C0-control / zero-width / Unicode-Tags / whitespace range tables plus the codepoint scanners the guards screen with (`firstInRanges` / `stripRanges` / `replaceRanges` / `indexOfAny` / `replaceAny` / `trimChars` / `trimRanges` / `containsFolded` / `indexOfFolded` / `matchesAtFolded` / `isRunOf` / `isRunOfRanges` / `hasPairWhere` / `splitLines` / `splitLinesAny` / `splitOnWhitespace`), and UTS #39 confusable-script classification
169
169
  - **No regular expressions** — every screen in every `b.guard*` and `b.safe*` primitive is a character walk, so its cost is the length of the input and its rule is the one the source states. A build gate refuses a new pattern in the family; a pattern that must be RUN — an operator's JSON-schema `pattern`, an operator's SQL identifier shape — goes through `b.regexLinear` or is refused by `b.guardRegex.assertSafe` first
170
170
  - **Profiles + postures** — every member ships strict / balanced / permissive plus hipaa / pci-dss / gdpr / soc2
171
171
  - **Aggregator** — `b.guardAll` registry; every shipped guard ON by default; opt-out per guard with audited reason via `exceptFor: { name: { reason } }`. `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` + `b.guardFilename.gate({ profile: "strict" })` automatically — operator opts out via `contentSafety: null` / `filenameSafety: null` (audited)
@@ -176,9 +176,9 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
176
176
  - **Pub/sub + events** — distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); framework-emitted signal bus for breach / integrity events (`b.events`)
177
177
  - **CloudEvents + SSE** — CloudEvents 1.0.2 for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF: `wrap` / `parse` envelopes, non-throwing `validate` / `isValid`, the JSON event + batch formats (`toJSON` / `fromJSON` / `toJSONBatch` / `fromJSONBatch`), and the HTTP binding in both binary and structured content modes with auto-detecting `http.decode` (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
178
178
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
179
- - **Mail (outbound delivery)** — MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
179
+ - **Mail (outbound delivery)** — MX-lookup → MTA-STS-fetch → DANE-TLSA lookup and per-peer certificate-chain verification (RFC 7672) → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
180
180
  - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays, plus DMARC aggregate (RUA) + forensic (RUF) report parsing (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
181
- - **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) and a DATA-phase SPF/DKIM/DMARC gate that refuses policy-failing mail before storage (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
181
+ - **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) and a DATA-phase SPF/DKIM/DMARC gate that refuses policy-failing mail before storage (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`). Every listener takes a `maxConnections` ceiling of its own alongside the per-address rate limit, reports `connectionCount()`, and releases a peer's rate-limit slot and its tracking-set entry together when the socket closes; the two SMTP listeners enforce the `minBytesPerSecond` floor on an arriving DATA body
182
182
  - **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
183
183
  - **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
184
184
  - **Mail-stack agent** — multi-threaded worker pool + queue dispatch + sealed mail-store backed by SQLite FTS5 (`b.mail.agent`, `b.mailStore`)
@@ -323,7 +323,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
323
323
  | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
324
324
  | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
325
325
  | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
326
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.28 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
326
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.31 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
327
327
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
328
328
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
329
329
 
@@ -16,11 +16,35 @@
16
16
  * opt; this is the implementation detail.
17
17
  */
18
18
 
19
+ // The actor as `b.audit.record` reads one. It documents the 5W shape
20
+ // `{ userId, ip, userAgent, sessionId }` and stores `actor.userId` into
21
+ // `actorUserId`; the agent substrate speaks `{ id, roles }`, so emitting that
22
+ // shape unchanged left `actorUserId` null on every row these modules produce —
23
+ // eight of them share this wrapper — even when the consumer had passed an
24
+ // identity. An audit trail that cannot say who did something is the one thing
25
+ // an audit trail is for.
26
+ //
27
+ // `id` is kept alongside `userId` because it is the substrate's own vocabulary
28
+ // and consumers read it back off the event. The remaining 5W fields are carried
29
+ // when supplied rather than dropped: they are the difference between "user u1"
30
+ // and "user u1, from this address, in this session".
31
+ // The rest of audit's 5W actor fields, carried through under their own names.
32
+ var CARRIED_ACTOR_FIELDS = ["ip", "userAgent", "sessionId"];
33
+
34
+ function actorShape(actor) {
35
+ if (!actor || typeof actor !== "object") return { id: "<system>", userId: null };
36
+ var shaped = { id: actor.id, userId: actor.id, roles: actor.roles || [] };
37
+ CARRIED_ACTOR_FIELDS.forEach(function (field) {
38
+ if (actor[field] !== undefined) shaped[field] = actor[field];
39
+ });
40
+ return shaped;
41
+ }
42
+
19
43
  function safeAudit(auditImpl, action, actor, metadata) {
20
44
  try {
21
45
  auditImpl.safeEmit({
22
46
  action: action,
23
- actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
47
+ actor: actorShape(actor),
24
48
  outcome: _outcomeFor(action),
25
49
  metadata: metadata || {},
26
50
  });
@@ -43,5 +67,6 @@ function _outcomeFor(action) {
43
67
  }
44
68
 
45
69
  module.exports = {
46
- safeAudit: safeAudit,
70
+ safeAudit: safeAudit,
71
+ actorShape: actorShape, // shared so a module with its own emitter does not re-derive it
47
72
  };
@@ -65,7 +65,9 @@ var AdverseDecisionError = defineClass("AdverseDecisionError", { alwaysPermanent
65
65
 
66
66
  // Per-regime statutory deadlines for the consumer-rights surfaces.
67
67
  // Operators select via opts.legalBasis; the framework attaches the
68
- // right deadline shape to each adverseNotice.
68
+ // right deadline shape to each adverseNotice. This table IS the accepted
69
+ // vocabulary — wrap() refuses a basis that is not a key here, because the
70
+ // deadlines it carries are not guessable from the name.
69
71
  var REGIME_DEADLINES = Object.freeze({
70
72
  "gdpr-22": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "GDPR Article 22" },
71
73
  "ai-act-86": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "EU AI Act Article 86" },
@@ -103,7 +105,21 @@ function wrap(opts) {
103
105
  var onAdverse = typeof opts.onAdverse === "function" ? opts.onAdverse : null;
104
106
  var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
105
107
 
106
- var deadlines = REGIME_DEADLINES[legalBasis] || REGIME_DEADLINES["operator-defined"];
108
+ // The basis selects the statutory deadlines stamped on every adverse notice,
109
+ // so an unrecognised one cannot fall back to "operator-defined": its
110
+ // deadlines are all null, and a single missing character in "gdpr-22" then
111
+ // produced a notice asserting the subject has no right to an explanation, to
112
+ // human review, or to appeal — the exact obligations Article 22 imposes,
113
+ // switched off silently while the notice recorded the misspelling as though
114
+ // it had been honoured. An operator whose regime is not carried here asks for
115
+ // "operator-defined" by name.
116
+ if (!Object.prototype.hasOwnProperty.call(REGIME_DEADLINES, legalBasis)) {
117
+ throw new AdverseDecisionError("ai-adverse/bad-legal-basis",
118
+ "ai.adverseDecision.wrap: unknown legalBasis '" + legalBasis + "'. The " +
119
+ "basis fixes the statutory deadlines on every adverse notice, so it cannot " +
120
+ "be guessed. Known: " + Object.keys(REGIME_DEADLINES).join(", "));
121
+ }
122
+ var deadlines = REGIME_DEADLINES[legalBasis];
107
123
 
108
124
  var _emitAudit = audit().namespaced("ai.adverse_decision", opts.audit);
109
125
  var _emitMetric = observability().namespaced("ai.adverse_decision");
package/lib/audit-sign.js CHANGED
@@ -275,7 +275,8 @@ var pendingNewKeyAlg = null;
275
275
  * @opts
276
276
  * dataDir: string, // required — directory holding the key file
277
277
  * mode: "wrapped" | "plaintext", // default "wrapped"
278
- * algorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65" // default "slh-dsa-shake-256f"; only consulted when generating a fresh key
278
+ * algorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65", // default "slh-dsa-shake-256f"; only consulted when generating a fresh key
279
+ * readOnly: boolean // default false — load an existing key but never create the directory, sweep orphaned temp files, or generate a keypair. A volume with no key leaves signing uninitialized rather than acquiring one.
279
280
  *
280
281
  * @example
281
282
  * await b.auditSign.init({
@@ -311,13 +312,31 @@ async function init(opts) {
311
312
  currentMode = mode;
312
313
  paths = resolvePaths(opts.dataDir);
313
314
 
314
- if (!nodeFs.existsSync(paths.dataDir)) nodeFs.mkdirSync(paths.dataDir, { recursive: true });
315
- // Sweep tmp files from any prior crashed write
316
- atomicFile.cleanOrphans(paths.sealed);
317
- atomicFile.cleanOrphans(paths.plaintext);
315
+ // `readOnly` callers verify with a key that is already there; they never
316
+ // establish one. Creating the directory, sweeping orphaned temp files and
317
+ // generating a first-run keypair are all writes, and a caller that promised
318
+ // not to write must make none of them — on a read-only mount they fail the
319
+ // open outright, and on a writable one they add a keypair to a volume the
320
+ // caller said it would only read, which the next writer then inherits.
321
+ var readOnly = opts.readOnly === true;
322
+
323
+ if (!readOnly) {
324
+ if (!nodeFs.existsSync(paths.dataDir)) nodeFs.mkdirSync(paths.dataDir, { recursive: true });
325
+ // Sweep tmp files from any prior crashed write
326
+ atomicFile.cleanOrphans(paths.sealed);
327
+ atomicFile.cleanOrphans(paths.plaintext);
328
+ }
318
329
 
319
330
  var hasPlaintext = nodeFs.existsSync(paths.plaintext);
320
331
  var hasSealed = nodeFs.existsSync(paths.sealed);
332
+
333
+ // No key on a read-only open is not an error and not a reason to make one:
334
+ // a volume written before signing was enabled simply has none. Signing stays
335
+ // uninitialized, so anything that needs a key says so when asked rather than
336
+ // silently operating under one this open invented. The conflict and mismatch
337
+ // checks below still run when a key IS present, because those describe the
338
+ // volume rather than this handle.
339
+ if (readOnly && !hasPlaintext && !hasSealed) return;
321
340
  if (hasPlaintext && hasSealed) {
322
341
  throw _err("KEY_FILE_CONFLICT",
323
342
  "both audit-sign.key and audit-sign.key.sealed exist; resolve manually");
@@ -211,7 +211,10 @@ function _refuseCrossOrigin(response, opts, ceremony) {
211
211
  // reports crossOrigin without naming it cannot be matched against a list, so
212
212
  // it does not pass one -- an unnamed embedder is exactly the case the list
213
213
  // was written to exclude.
214
- if (Array.isArray(allow) && allow.length > 0 &&
214
+ // No length test: this is the ADMIT condition, and `[].indexOf(x)` is already
215
+ // -1, so an empty list admits nobody either way. Spelling it without the
216
+ // length keeps it from reading like the widen-on-empty shape it is not.
217
+ if (Array.isArray(allow) &&
215
218
  typeof parsed.topOrigin === "string" && allow.indexOf(parsed.topOrigin) !== -1) {
216
219
  return;
217
220
  }