@blamejs/core 0.18.54 → 0.18.56

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 (48) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/NOTICE +5 -5
  3. package/README.md +9 -9
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/audit-sign.js +24 -5
  6. package/lib/audit.js +26 -24
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/chain-writer.js +17 -0
  9. package/lib/db-file-lifecycle.js +14 -3
  10. package/lib/db.js +505 -49
  11. package/lib/guard-filename.js +8 -1
  12. package/lib/guard-html.js +10 -2
  13. package/lib/guard-list-unsubscribe.js +6 -1
  14. package/lib/guard-managesieve-command.js +24 -3
  15. package/lib/guard-smtp-command.js +20 -4
  16. package/lib/guard-svg.js +6 -1
  17. package/lib/http-client.js +17 -3
  18. package/lib/mail-agent.js +6 -4
  19. package/lib/mail-auth.js +59 -2
  20. package/lib/mail-server-imap.js +65 -34
  21. package/lib/mail-server-managesieve.js +65 -42
  22. package/lib/mail-server-mx.js +313 -40
  23. package/lib/mail-server-net.js +155 -1
  24. package/lib/mail-server-pop3.js +16 -19
  25. package/lib/mail-server-rate-limit.js +104 -6
  26. package/lib/mail-server-submission.js +162 -33
  27. package/lib/mail-server-tls.js +71 -11
  28. package/lib/mcp.js +11 -3
  29. package/lib/middleware/csrf-protect.js +37 -20
  30. package/lib/middleware/require-mtls.js +8 -1
  31. package/lib/network-tls.js +18 -0
  32. package/lib/safe-mount-info.js +39 -6
  33. package/lib/safe-smtp.js +96 -1
  34. package/lib/safe-url.js +8 -2
  35. package/lib/self-update.js +4 -1
  36. package/lib/session-stores.js +6 -3
  37. package/lib/vendor/MANIFEST.json +34 -34
  38. package/lib/vendor/blamejs-pki.cjs +396 -37
  39. package/lib/vendor/browser/noble-ciphers.mjs +15 -1
  40. package/lib/vendor/browser/noble-hashes.mjs +12 -4
  41. package/lib/vendor/browser/noble-post-quantum.mjs +78 -37
  42. package/lib/vendor/noble-ciphers.cjs +15 -1
  43. package/lib/vendor/noble-curves.cjs +46 -21
  44. package/lib/vendor/noble-post-quantum.cjs +184 -75
  45. package/lib/watcher.js +31 -6
  46. package/lib/ws-client.js +17 -2
  47. package/package.json +1 -1
  48. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -8,6 +8,152 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.56 (2026-08-27) — **A CSRF exemption turned on a header the attacker writes, and a certificate renewal reached four mail listeners but not the fifth.** `csrfProtect`'s `skipStateless` waived the token check for any request carrying an `Authorization` header. Presence is not authenticity: an attacker composing a cross-site request writes their own headers. Worse, the header says nothing about which credential authenticated the request, and `attachUser` with `tokenFrom: "both"` reads the cookie first — so a request carrying both was authenticated by exactly the ambient credential the gate protects, and skipped the gate on a header nobody read. The exemption now turns on the absence of a cookie, and it no longer waives the origin check.
12
+
13
+ The mail listeners had two cases of the same shape: a value read once at construction that the operator changes while the server runs. ManageSieve captured `opts.tlsContext`, so a certificate renewal reached its four siblings and not it — and nothing reported that, because the watcher fired and the context rebuilt exactly as expected. `mail.server.mx` captured `localDomains`, so a withdrawn domain kept drawing 250 at RCPT until a restart while every management surface agreed it was gone.
14
+
15
+ Also: an audit checkpoint read the chain tip outside the lock that makes appends atomic, so it could sign a counter and a hash that were never the tip together. **Changed:** *Vendored `@noble` cryptography refreshed* — `@noble/ciphers` and `@noble/curves` to 2.4.0, `@noble/post-quantum` to 0.7.1, `@noble/hashes` to 2.4.0. The four move together because 0.7.1 pins its siblings to exactly 2.4.0, which keeps the browser hash bundle and the copy inlined in the server's PQC bundle the same code.
16
+
17
+ No output bytes change on anything blamejs computes or has stored. ML-KEM, ML-DSA and SLH-DSA known-answer vectors are byte-identical across the bump, in both directions, and so are XChaCha20-Poly1305 ciphertexts; no constant, domain-separation string, nonce construction or serialization order moved. Nothing already encrypted, wrapped or signed needs re-doing.
18
+
19
+ Two behaviour changes are reachable only through the raw primitives `b.pqcSoftware` exposes, and are worth knowing if you call them directly. `prehash(shake256)` on the 128-bit parameter sets now throws rather than producing a non-conformant signature. And an options object with an unknown or misspelled key — or one that is a class instance rather than a plain object — is now refused instead of silently ignored. **Fixed:** *A checkpoint reads the chain tip under the lock that makes appends atomic (#673)* — A checkpoint is a signed statement about a specific tip, and the value of that signature is that the pair it names was real. The tip read that feeds one ran outside the mutex the chain writer holds across its own read-tip → insert, so it could land in the middle of an append and pair a counter with a hash that were never the tip together. The signature over that pair is valid, the arithmetic is self-consistent, and it describes a state the chain was never in.
20
+
21
+ The read now takes the same lock, through `withChainLock` on the writer handle. It is held for the read only: signing is post-quantum and slow, every concurrent append queues behind that lock, and holding it across the signature would charge that cost to unrelated writers without adding a guarantee — a checkpoint claims a prefix, not that nothing has been appended since. A lock is per chain key, so an append to a different partition is never blocked by one.
22
+
23
+ A second, unreachable insert path into `audit_log` is removed. It took no lock and computed no row hash, and a future reader finding two insert paths into the chain table would reasonably have assumed both were live. · *A certificate renewal reaches the ManageSieve listener (#671)* — `b.mail.server.mx`, `.submission`, `.imap` and `.pop3` read `opts.tlsContext` at the point they need a context, so a consumer supplying an accessor gets the renewed certificate on the next connection. `.managesieve` captured it at construction, calling that accessor exactly once and serving the boot certificate for the life of the process.
24
+
25
+ Certificates renew every 60 to 90 days on any automated CA, so the frozen one expires while every piece of surrounding evidence says rotation is working: the watcher fired, the context rebuilt, and it simply never reached this one listener. That is worse than a listener that never supported rotation, because nothing reports it.
26
+
27
+ It now reads the option per connection like its siblings.
28
+
29
+ The rotation example in `b.mail.server.tls.context` was also wrong. It showed `mx.replaceTlsContext(newCtx)`, a method no listener implements and none needs: pass `get tlsContext() { return tls.secureContext; }` and a reload reaches the next connection with nothing to swap. Passing `tlsContext: tls.secureContext` instead copies the context current at boot, which is the frozen form. · *The hosted-domain set can change while the server runs (#661)* — `b.mail.server.mx.create` captured `localDomains` at construction, so the answer to "do we host this domain" was frozen at boot. Hosting a domain is administrative state, not configuration — operators add and withdraw them while the process runs.
30
+
31
+ Withdrawing is the case that bit: the operator disables a domain, every management surface agrees it is gone, and the listener keeps answering 250 at RCPT for it until a restart, with nothing signalling that mail is still arriving. Adding is milder but still wrong — a new domain draws 550 5.7.1, which a sending queue reads as permanent and drops rather than retrying.
32
+
33
+ `localDomains` now also accepts a function, answered per recipient. The neighbouring `recipientPolicy` already was, which is what made the frozen half odd: two parts of one question answered a line apart with different currency. The array form is unchanged and still validated once at boot. A set held in one array and mutated in place — pushed on add, spliced on withdraw — is read correctly, because the result is cached on the set's contents rather than on the array's identity.
34
+
35
+ An entry a live set returns that `b.guardDomain` refuses is dropped, with an audit event naming it, rather than thrown on — this is the request path, and a throw there turns a typo in an admin form into a mail outage. Mail for that entry is refused; the rest of the set keeps serving. The same holds for a set that cannot be read at all, an entry that cannot be coerced to a string, and anything else the resolver may raise: the recipients are refused and the connection survives.
36
+
37
+ `authservId` follows the same set rather than pinning to whichever domain was first at boot, and is resolved once per message so the header written and the forged headers stripped always name the same identity. **Security:** *`csrfProtect` `skipStateless` turns on the absence of a cookie, not on a header (#663)* — The exemption exists because a request with no ambient credential cannot be forged on a victim's behalf: CSRF spends a cookie the browser attaches by itself. It tested for an `Authorization` header as well.
38
+
39
+ Presence is not authenticity. An attacker composing a cross-site request writes their own headers, so `Authorization: Bearer nonsense` met the condition by being typed. And the header says nothing about which credential authenticated the request: `b.middleware.attachUser` with `tokenFrom: "both"` reads the cookie FIRST, so a request carrying a session cookie and a junk bearer header was authenticated by exactly the ambient credential the gate protects, and skipped the gate because of a header nothing had read. The two middlewares disagreed about which credential was in play, and the disagreement is what opened it.
40
+
41
+ The test is now the absence of a Cookie header, and nothing else. A bearer client that also sends an unrelated cookie is validated like any other request; deciding otherwise needs the auth layer's verdict about which credential authenticated it, which header presence cannot supply.
42
+
43
+ The exemption also sits below the origin cross-check now instead of above it, so `checkOrigin` is no longer waived by it. A consumer that asked for an origin check asked for something the token compare does not provide, and there is no reading of "stateless" under which a cross-origin state change becomes acceptable.
44
+
45
+ **Upgrading.** A caller relying on the header to skip the token check now needs to send the token, or to send no cookie. This is the shape the option was documented to cover; the header was never evidence of anything.
46
+
47
+ - 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.
48
+
49
+ 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.
50
+
51
+ 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.
52
+
53
+ 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.
54
+
55
+ 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.
56
+
57
+ 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.
58
+
59
+ 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.
60
+
61
+ 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.
62
+
63
+ 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.
64
+
65
+ 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.
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ `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.
72
+
73
+ 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.
74
+
75
+ 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.
76
+
77
+ `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.
78
+
79
+ 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.
80
+
81
+ 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.
82
+
83
+ 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.
84
+
85
+ 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.
86
+
87
+ 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.
88
+
89
+ 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.
90
+
91
+ 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.
92
+
93
+ 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.
94
+
95
+ `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.
96
+
97
+ `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.
98
+
99
+ 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.
100
+
101
+ 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.
102
+
103
+ 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.
104
+
105
+ `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.
106
+
107
+ 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.
108
+
109
+ 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.
110
+
111
+ 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.
112
+
113
+ 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.
114
+
115
+ 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.
116
+
117
+ 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.
118
+
119
+ `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.
120
+
121
+ `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.
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ 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.
128
+
129
+ 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.
130
+
131
+ 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.
132
+
133
+ 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.
134
+
135
+ 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.
136
+
137
+ 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.
138
+
139
+ 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.
140
+
141
+ 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.
142
+
143
+ 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.
144
+
145
+ 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.
146
+
147
+ 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.
148
+
149
+ 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.
150
+
151
+ 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.
152
+
153
+ 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.
154
+
155
+ 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.
156
+
11
157
  - 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.
12
158
 
13
159
  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.
package/NOTICE CHANGED
@@ -17,7 +17,7 @@ lib/vendor/MANIFEST.json.
17
17
 
18
18
  --------------------------------------------------------------------------------
19
19
  Component: @noble/ciphers
20
- Version: 2.3.0
20
+ Version: 2.4.0
21
21
  Source: https://github.com/paulmillr/noble-ciphers
22
22
  License: MIT
23
23
  Copyright: Copyright (c) 2023 Paul Miller (https://paulmillr.com)
@@ -26,7 +26,7 @@ Used for: XChaCha20-Poly1305 authenticated encryption (lib/crypto.js,
26
26
  Thank you to Paul Miller for the audited noble-ciphers suite.
27
27
  --------------------------------------------------------------------------------
28
28
  Component: @noble/curves
29
- Version: 2.3.0
29
+ Version: 2.4.0
30
30
  Source: https://github.com/paulmillr/noble-curves
31
31
  License: MIT
32
32
  Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
@@ -36,7 +36,7 @@ Used for: RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF)
36
36
  audited noble-curves suite.
37
37
  --------------------------------------------------------------------------------
38
38
  Component: @noble/hashes
39
- Version: 2.3.0
39
+ Version: 2.4.0
40
40
  Source: https://github.com/paulmillr/noble-hashes
41
41
  License: MIT
42
42
  Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
@@ -53,7 +53,7 @@ Used for: The client half of a hybrid exchange, as a browser build
53
53
  Miller for the audited noble-hashes suite.
54
54
  --------------------------------------------------------------------------------
55
55
  Component: @noble/post-quantum
56
- Version: 0.7.0
56
+ Version: 0.7.1
57
57
  Source: https://github.com/paulmillr/noble-post-quantum
58
58
  License: MIT
59
59
  Copyright: Copyright (c) 2024 Paul Miller (https://paulmillr.com)
@@ -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.30
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`)
@@ -132,7 +132,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
132
132
  - CSP nonce — generated per request, merged into the CSP (`b.middleware.cspNonce`)
133
133
  - Fetch-metadata resource-isolation guard (`b.middleware.fetchMetadata`)
134
134
  - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
135
- - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
135
+ - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips cookieless requests, which carry no ambient credential to abuse and so are not CSRF-able. A request that sends a cookie is validated whatever else it carries, and the Origin check is never skipped (`b.middleware.csrfProtect`)
136
136
  - CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
137
137
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
138
138
  - Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
@@ -178,7 +178,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
178
178
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
179
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`)
@@ -314,16 +314,16 @@ All runtime dependencies are committed to the repo — no transitive npm install
314
314
  ```bash
315
315
  ./scripts/vendor-update.sh --check # see what's outdated
316
316
  ./scripts/vendor-update.sh --diff @noble/ciphers # see changelog before bumping
317
- ./scripts/vendor-update.sh @noble/ciphers 2.3.0 # bundle + commit a new version
317
+ ./scripts/vendor-update.sh @noble/ciphers 2.4.0 # bundle + commit a new version
318
318
  ```
319
319
 
320
320
  | Package | Version | Author | Purpose |
321
321
  |---|---|---|---|
322
- | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
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
- | [`@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
- | [`@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.30 | [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` |
322
+ | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.4.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
323
+ | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.4.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
+ | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.4.0 (bundles @noble/hashes 2.4.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
+ | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.1 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.4.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.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
  };
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");
package/lib/audit.js CHANGED
@@ -190,23 +190,12 @@ async function _readAuditRowHashAtCounter(counter) {
190
190
  );
191
191
  }
192
192
 
193
- async function _insertAuditRow(allCols, values) {
194
- // No retry non-idempotent. Timeout only. Map each column to its
195
- // positional value and bind as a row object (the unambiguous b.sql form;
196
- // a flat value array whose first element is a Buffer would be misread as
197
- // an array-of-rows). BARE logical table name clusterStorage rewrites.
198
- var rowObj = {};
199
- for (var i = 0; i < allCols.length; i++) rowObj[allCols[i]] = values[i];
200
- var built = sql.insert("audit_log", _sqlOpts())
201
- .columns(allCols)
202
- .values(rowObj)
203
- .toSql();
204
- return await safeAsync.withTimeout(
205
- clusterStorage.execute(built.sql, built.params),
206
- FRAMEWORK_SQL_TIMEOUT_MS,
207
- { name: "audit.insertRow" }
208
- );
209
- }
193
+ // A second insert path into audit_log used to live here, unreachable. It held
194
+ // this file's only sql.insert("audit_log") and nothing called it: every append
195
+ // goes through chain-writer, which is what serializes read-tip hash → insert.
196
+ // Removed rather than left, because a future reader finding two insert paths
197
+ // into the chain table would reasonably assume both were live, and the dead one
198
+ // took no lock and computed no row hash.
210
199
 
211
200
  var _CHECKPOINT_COLS = [
212
201
  "_id", "createdAt", "atMonotonicCounter", "atRowHash",
@@ -1090,13 +1079,26 @@ async function _checkpointOnDatabase(opts, dbGenAtEntry) {
1090
1079
  .orderBy("monotonicCounter", "desc")
1091
1080
  .limit(1)
1092
1081
  .toSql();
1093
- var tip = await safeAsync.withTimeout(
1094
- safeAsync.asyncRetry(function () {
1095
- return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
1096
- }),
1097
- FRAMEWORK_SQL_TIMEOUT_MS,
1098
- { name: "audit.checkpoint.readTip" }
1099
- );
1082
+ // Read the tip under the SAME lock append() holds across its own
1083
+ // read-tip → insert. A checkpoint is a signed statement about a tip, so it
1084
+ // has to observe one that existed: read unlocked, it can land mid-append and
1085
+ // pair a counter with a hash that were never the tip together, and the
1086
+ // signature over that pair is valid, self-consistent, and describes a state
1087
+ // the chain was never in.
1088
+ //
1089
+ // The lock covers the READ only. Signing is post-quantum and slow, and every
1090
+ // concurrent append queues behind this lock; holding it across the signature
1091
+ // would charge that to unrelated writers without adding anything, because a
1092
+ // checkpoint claims a prefix rather than claiming nothing was appended after.
1093
+ var tip = await _chainWriter.withChainLock(null, function () {
1094
+ return safeAsync.withTimeout(
1095
+ safeAsync.asyncRetry(function () {
1096
+ return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
1097
+ }),
1098
+ FRAMEWORK_SQL_TIMEOUT_MS,
1099
+ { name: "audit.checkpoint.readTip" }
1100
+ );
1101
+ });
1100
1102
 
1101
1103
  if (!tip) return null; // empty audit log; nothing to anchor
1102
1104
 
@@ -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
  }
@@ -481,6 +481,23 @@ function create(opts) {
481
481
  table: table,
482
482
  chainKey: chainKey,
483
483
  append: append,
484
+ // Run `fn` under the same lock append() holds across read-tip → insert.
485
+ //
486
+ // A checkpoint signs a statement about the tip, so it has to observe a tip
487
+ // that actually existed. Reading it outside this lock can land in the
488
+ // middle of an append — between the row being written and the counter
489
+ // advancing — and pair a counter with a hash that were never the tip
490
+ // together. The signature over that pair is valid and self-consistent and
491
+ // describes a state the chain was never in.
492
+ //
493
+ // Callers hold it for the READ only and sign afterwards. The signature is
494
+ // post-quantum and slow, and every concurrent append waits on this lock;
495
+ // signing under it would charge that cost to unrelated writers for no
496
+ // added guarantee, since a checkpoint claims a prefix rather than claiming
497
+ // nothing has been appended since.
498
+ withChainLock: function (keyValue, fn) {
499
+ return _mutexFor(keyValue).runExclusive(fn);
500
+ },
484
501
  _resetForTest: _resetForTest,
485
502
  // Expose for diagnostic introspection — the lock for a given key (or the
486
503
  // single-chain lock when no chainKey is configured).
@@ -90,12 +90,17 @@ function _aad(dataDir, label) {
90
90
  return Buffer.from("blamejs.db-file-lifecycle.v1\0" + label + "\0" + (dataDir || ""), "utf8");
91
91
  }
92
92
 
93
- function _resolveTmpDir(operatorTmpDir, allowDiskFallback) {
93
+ // Platform and stat are parameters so the non-Linux branch is reachable from a
94
+ // Linux CI host. They also keep the "/dev/shm" literal off a direct fs call:
95
+ // the path is only meaningful on Linux, and a leading slash on Windows is
96
+ // drive-relative, so an unguarded probe there asks about C:\dev\shm and can be
97
+ // answered yes by any directory an unprivileged user creates.
98
+ function _resolveTmpDirFrom(operatorTmpDir, allowDiskFallback, platform, stat) {
94
99
  if (operatorTmpDir) return operatorTmpDir;
95
100
  // Linux: /dev/shm is the standard tmpfs mount.
96
- if (process.platform === "linux") {
101
+ if (platform === "linux") {
97
102
  try {
98
- var st = nodeFs.statSync("/dev/shm");
103
+ var st = stat("/dev/shm");
99
104
  if (st && st.isDirectory()) return "/dev/shm";
100
105
  } catch (_e) { /* fall through */ }
101
106
  }
@@ -108,6 +113,11 @@ function _resolveTmpDir(operatorTmpDir, allowDiskFallback) {
108
113
  "OR set opts.allowDiskFallback: true to accept disk-backed temporary storage.");
109
114
  }
110
115
 
116
+ function _resolveTmpDir(operatorTmpDir, allowDiskFallback) {
117
+ return _resolveTmpDirFrom(operatorTmpDir, allowDiskFallback,
118
+ process.platform, nodeFs.statSync);
119
+ }
120
+
111
121
  /**
112
122
  * @primitive b.db.fileLifecycle
113
123
  * @signature b.db.fileLifecycle(opts)
@@ -342,4 +352,5 @@ function fileLifecycle(opts) {
342
352
  module.exports = {
343
353
  fileLifecycle: fileLifecycle,
344
354
  DbFileLifecycleError: DbFileLifecycleError,
355
+ _resolveTmpDirFromForTest: _resolveTmpDirFrom,
345
356
  };