@blamejs/core 0.18.42 → 0.18.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,132 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.44 (2026-08-20) — **Four things every consumer was writing itself, and one of them was a wrong timestamp in the audit chain.** A Markdown renderer, a country-code check, a shared-secret header gate and a monotonic clock. Each was being hand-rolled downstream, and in each case the copies got the same detail wrong: author text reaching HTML unescaped, `UK` accepted as a country code, a length check running after a constant-time compare that throws on a length mismatch, and a timestamp that moves backwards when NTP corrects the clock. The last one was not only a downstream problem — `b.chainWriter` had it too, and it made a compliance export refuse itself. **Added:** *`b.time.monotonicNow()` and `b.time.monotonicClock(opts?)`* — A clock whose `now()` never returns a value less than or equal to the one before it. `monotonicNow()` is the shared process-wide sequence; `monotonicClock(opts)` builds an isolated one.
12
+
13
+ The four-line version of this is easy to write and leaves out three things. `opts.source` makes the clock testable, which a closure over `Date.now` is not. `observeFloor(ms)` seeds the guarantee from a value read back out of storage, so it survives a restart or failover — precisely when a fresh node is syncing NTP and a backwards step is most likely, and precisely when a process-memory floor has just reset to zero. And `maxDriftMs` (default one second) bounds how far ahead of the underlying source a burst may walk the returned value.
14
+
15
+ Passing the cap REPORTS — through `onDrift` when given, otherwise as the `time.monotonic.drift_exceeded` observability event — and keeps returning monotonic values. For an append-only chain a timestamp a few milliseconds optimistic is a smaller harm than a dropped row, and a dropped row is what an attacker who can step the clock would be aiming for. A caller whose property is timestamp accuracy rather than completeness passes `strict: true` and gets a `time/monotonic-drift-cap` throw instead.
16
+
17
+ Two inputs are refused rather than coerced, and the reason is the same in both cases: a clock that guesses is worse than one that says no.
18
+
19
+ `opts.source` must return a SAFE-INTEGER count of milliseconds. A fractional clock such as `performance.timeOrigin + performance.now()` is refused rather than rounded, because the monotonic promise is kept by handing out `last + 1` when the source has not moved, and that arithmetic needs an integer to stay exact.
20
+
21
+ `observeFloor(ms)` takes a number and refuses anything else — including the decimal string a Postgres `BIGINT` arrives as through most drivers. Write `clock.observeFloor(Number(tipRow.recordedAt))`. Coercing silently would also accept `"abc"` as `NaN`, after which every comparison against the floor is false and the guarantee is gone with nothing said.
22
+
23
+ The drift report never goes through `b.audit`: a clock that stamps audit rows and audits its own drift would call itself. · *`b.guardMarkdown.render(source, opts?)` — Markdown to HTML, escaping by default* — The module could validate and sanitise Markdown but not render it, so every consumer that accepts Markdown from an author and shows it to a visitor wrote the HTML emitter itself, by string concatenation. The three things those copies get wrong are the three that turn a CMS or a help centre into a stored-XSS hole: author text reaching the output unescaped, a `javascript:` or `data:` URL surviving into an `href`, and raw HTML passed through in the hope of sanitising it downstream.
24
+
25
+ Every text node leaves through the shared markup escaper. Link targets are limited to `http`, `https`, `mailto` and relative references, screened by the same detector the validator uses — the one that folds entity and whitespace obfuscation before testing the scheme, so `java	script:` and `javascript:` are refused along with the plain spelling. The allowlist is applied over the whole RFC 3986 §3.1 scheme grammar (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`), not the leading letters: a check that reads only letters stops at the `+` in `web+evil:`, concludes there is no scheme, treats the target as relative, and emits it — and browsers hand registered `xxx+yyy:` schemes to protocol handlers. A target carrying any other scheme, an attribute-breaking character or a control byte is refused, and the link's TEXT still renders, so a refusal never silently deletes the author's words. Anchors carry `rel="nofollow noopener noreferrer"`.
26
+
27
+ Raw HTML is emitted as escaped text rather than sanitised, which makes the split-tag bypass class (`<scr<script>ipt>`) structurally impossible rather than a matter of getting the rewrite right.
28
+
29
+ The subset is paragraphs, ATX headings, bullet and ordered lists, fenced and indented code, blockquotes, thematic breaks, emphasis, strong, code spans and links. Anything outside it — images, tables, reference links, footnotes — renders as escaped text. That is a display limitation on purpose: an unrecognised construct that shows its own source is a formatting bug, while one that becomes markup is a vulnerability.
30
+
31
+ BIDI, zero-width, C0-control and NUL characters are stripped before parsing whatever the profile says. Unlike validation, where an operator may want to be told and decide, an invisible character reaching rendered HTML is never what the author meant.
32
+
33
+ Hostile input is bounded rather than fatal: the profile's `maxBytes` and `maxLines` are enforced before anything is parsed (measured in BYTES, so a non-ASCII document is not silently allowed several times the stated size), blockquote nesting is capped by the same profile's `maxBlockquoteDepth`, inline nesting degrades to escaped text past its bound, and delimiter scanning is linear — 200,000 unmatched brackets render in 9ms.
34
+
35
+ Nesting depth does not multiply the cost per character either, which is a distinct property from the caps and had to be built for rather than assumed. The bracket-match index is derived once for an inline run and read at an offset by the nested spans, because both the link-label and emphasis paths recurse on a slice of what they were given. Deriving it per level made memory scale with depth as well as length: a 6.68 MiB document nested 24 deep — inside the balanced profile's 8 MiB cap — grew the heap by 1,334 MiB, and permissive accepts eight times that input. It now costs 7 MiB.
36
+
37
+ The index itself is also no longer sized by the document, but by the delimiters in it. It was a dense array with one slot per character, allocated whether or not the document contained a single bracket, so 60 MiB of ordinary prose carrying one link spent hundreds of megabytes indexing six brackets' worth of information — memory that had nothing to do with anything hostile in the input. Opener positions are now counted in one pass and stored in exactly that many slots; a document with no brackets allocates nothing, and 60 MiB of prose plus one link costs 60 MiB, which is the rendered output. Sizing by the delimiters needs a bound of its own, because a document that is NOTHING but brackets has as many as it has characters and satisfies the byte and line caps while doing it — a single 64 MiB line of `[` passes both. Past two million bracket delimiters the source is refused with `markdown/too-many-delimiters`, decided during the counting pass so nothing document-scale is allocated first. Refused rather than truncated: ignoring delimiters past a bound would make link detection depend on how far into the document a link happens to sit.
38
+
39
+ Escaping and generated markup are the other places a document grows, and no cap bounded either. `'` renders as `&#39;` and `"` as `&quot;` — five and six characters for one — and a six-character `[a](x)` emits a fifty-character anchor carrying its `rel` list. A document of nothing but apostrophes satisfies `maxBytes`, `maxLines` and the delimiter cap and still renders to five times its size: 16 MiB of them produced 80 MiB of output and retained 528 MiB, and a page of compact links reached 7.6x.
40
+
41
+ Rendered output is now bounded at four times the source bytes, refused with `markdown/output-amplification`. The check is a PREDICTION, not a measurement — the escaped length is counted before the escaped string is built, since one 64 MiB line would otherwise allocate its 320 MiB expansion before any check on the result could run.
42
+
43
+ The ratio applies to sources of 64 KiB or more. A short document is dominated by fixed markup — `hi` becomes `<p>hi</p>`, five times its source — so a ratio would refuse every small document while defending nothing: 20 KiB of apostrophes renders to 100 KiB, which is not a threat. Both sides are measured in UTF-8 bytes, so a document of non-ASCII text is held to the same limit as an ASCII one.
44
+
45
+ Four comes from measurement. Real documents are nowhere near it — this project's own README, SECURITY.md and CHANGELOG.md render at 1.22x, 1.13x and 1.13x — but SHORT constructs carry a fixed markup cost a small source cannot absorb: a page of brief fenced samples reaches 3.53x, because `<pre><code class="language-js">` is thirty characters against a fifteen-character source, and a page of one-line paragraphs reaches 3.00x. A tighter bound would have refused a documentation page of code examples, which is a worse outcome than the difference between a 192 MiB and a 256 MiB ceiling on a 64 MiB input. Each scan carries a cursor into that index rather than searching it, so resolving delimiters stays linear in their number.
46
+
47
+ Blockquotes had the same shape at the block level: stripping a quote level built a new array of new strings for every line in the run, at every level, with each parent's still live. A 3.94 MiB document nested 256 deep — inside permissive's limits — grew the heap by 85 MiB. A level now records where each line's content starts instead of copying it, and the same document costs 1 MiB. Satisfying every advertised cap and still exhausting memory is not a bound worth advertising.
48
+
49
+ What the profile does and does not change is worth stating plainly, because the two halves differ. The SAFETY floor is profile-independent: escaping, the link-target allowlist, and raw HTML rendered as text are identical at `strict`, `balanced` and `permissive`, because there is no safe way to loosen them. What the profile varies is the SIZE budget — `maxBytes` at 1 MiB / 8 MiB / 64 MiB, `maxLines` at 4,096 / 32,768 / 262,144, and `maxBlockquoteDepth` at 16 / 64 / 256. Pick the profile for the document sizes you intend to accept, not for how much escaping you want.
50
+
51
+ One bound is not an operator setting: `maxBlockquoteDepth` raised past 512 is capped at 512, because the renderer recurses once per level and what the call stack survives is a property of the implementation. Nesting past the effective bound is refused with `markdown/blockquote-depth` — a verdict a caller can handle — rather than becoming the stack overflow it would otherwise be. No shipped profile reaches the ceiling.
52
+
53
+ The output is a fragment: no wrapper, no doctype, and no substitute for the page's own Content-Security-Policy. · *`b.guardCountry` — an ISO 3166-1 alpha-2 guard that does not consult `Intl`* — The framework routes on country codes — data residency, tax nexus, geo-restriction, DSR jurisdiction — and shipped nothing to check one, so consumers hand-rolled a two-letter shape test plus an `Intl.DisplayNames` echo.
54
+
55
+ That hand-roll accepts codes that are not countries. `UK` is not an ISO 3166-1 code at all (the United Kingdom is `GB`). `ZZ` is the CLDR unknown-region sentinel. `EU`, `EZ` and `UN` are exceptionally reserved and name a union, a currency area and an organisation. `AA`, `XA`, `XB`, `XK`, `QM` and `QO` are user-assigned, which means they mean whatever the system that wrote them decided. Each of those reaching a residency decision is a wrong answer wearing the shape of a right one.
56
+
57
+ It also fails OPEN on a stripped-ICU runtime. A `small-icu` or `no-icu` Node build has no `Intl.DisplayNames`, so the `catch` returns true and every two-letter string becomes a country — a deployment-shaped behaviour change with no signal. This guard answers from a bundled table and never touches `Intl`; removing `Intl` from the process does not move a single answer.
58
+
59
+ `reservedPolicy`, `userAssignedPolicy` and `formerlyUsedPolicy` each take `reject`, `audit` or `allow`, and a value outside those three is refused where options are resolved — so a typo is a boot error rather than a refusal that quietly became an audit entry. `allow` means allow: no finding is emitted, so the gate serves. That distinction matters because ANY finding dispositions a gate to audit-only, which would have made `allow` and `audit` the same setting for an operator who routes on `EU` deliberately.
60
+
61
+ `validate` / `sanitize` / `gate` follow the guard-family contract, plus `isValid(code)` for the call site that wants a predicate. Refusals name only what a record can be pointed at: `country-user-assigned`, `country-exceptionally-reserved`, `country-formerly-used` (with the replacement its registry record carries, or none where the record names none — `NT`, `AN`, `CS` and `YU` dissolved into several successors), and `country-not-assigned` for anything else. There is deliberately no "unassigned" verdict: ISO reserves codes this table has no machine-readable source for, so the stronger claim would be unsupported.
62
+
63
+ The 249 codes were derived by reading two independent sources and diffing them — the IANA language-subtag-registry and Unicode CLDR's region validity data, each minus its own enumerated non-country entries. Both land on the same 249. Neither carries ISO assignment status by itself: in the IANA registry the record for `AC` (exceptionally reserved) is byte-identical in shape to the one for `GB`. · *`b.middleware.sharedSecretHeader(opts)` — the third request-authentication shape* — `Authorization: Bearer` has `b.middleware.bearerAuth` and a signed webhook has `b.webhookHmac`. A named header carrying a fixed shared secret — what internal service-to-service calls, cron triggers and platform bridges use — had nothing, so it was hand-rolled, and hand-rolling it puts four conditions in one expression that have to be in the right order.
64
+
65
+ The LENGTH check comes first, because `b.crypto.timingSafeEqual` throws on a length mismatch rather than returning false; a compare-first ordering turns a short header into a 500. An UNCONFIGURED secret refuses every request rather than accepting them — a deployment that forgot its environment variable is otherwise wide open and looks configured. The compare does not short-circuit, which is the whole reason `timingSafeEqual` is there and the first thing a `===` "optimisation" removes.
66
+
67
+ And an AVAILABILITY failure is not an authentication failure. `opts.secret` may be a function, for a secrets manager or a rotating value, and may be async. A resolver that returns nothing is the unconfigured case: 401. A resolver that THROWS could not fetch the secret, and the caller may well be holding the right one — that still denies, but answers 503, so an operator reading logs sees a dependency outage rather than a flood of bad credentials.
68
+
69
+ Every refusal — absent header, wrong length, wrong value, repeated header, unconfigured secret — produces the same body, so the gate cannot be used as an oracle for which condition failed.
70
+
71
+ A request carrying no credential is refused BEFORE the secret is resolved. That matters when `opts.secret` is a resolver backed by a secrets manager: awaiting it first would let an unauthenticated client drive that dependency once per request, and amplify an outage, while presenting nothing. The verdict is the same either way; only its cost changes. During a resolver outage such a request answers 401 rather than 503, which is the more accurate of the two — the caller brought no credential, so the dependency's health is not what decided it.
72
+
73
+ The repeated-header refusal reads `req.rawHeaders`, not the parsed header value, because Node joins duplicate custom headers into one comma-separated string rather than exposing an array. Checking only for an array would never fire on a real request, and a secret equal to the joined value (`"alpha, beta"` against header lines `alpha` and `beta`) would authenticate a request in which no single header carried the secret. That detection reaches as far as this process can see: a reverse proxy that MERGES duplicates before Node receives them leaves one header on the wire and nothing to detect, so a deployment relying on this refusal should configure the proxy to reject duplicate occurrences rather than fold them.
74
+
75
+ Two configuration mistakes are separated from credential failures rather than buried in them. A resolver that returns something which is not a secret (a `Buffer` from a secrets-manager SDK, a number, a parsed JSON envelope) takes the 503 rather than the 401 — no usable secret was obtained, which is the resolver-threw fact, not a caller fact. And `headerName` must be an RFC 9110 §5.1 token: a name carrying a space or a colon can never match an incoming header, so it is refused at construction instead of producing permanent 401s that look exactly like an attack. **Fixed:** *`recordedAt` could move backwards in the audit chain, and the export then refused itself* — `b.chainWriter` stamped `recordedAt` from `Date.now()` while `monotonicCounter` advanced under a mutex. `Date.now()` is not monotonic: it repeats when two writes land in the same millisecond, and it moves BACKWARDS when NTP steps the clock — the correction `b.ntpCheck` exists to detect. So the two columns could disagree on order.
76
+
77
+ That is not cosmetic, because readers cross between them. `b.auditTools.exportSlice` selects rows by `recordedAt` and then requires the selection to be contiguous in `monotonicCounter`. One backwards step drops a row out of the window its neighbours are in, and the export is refused with `audit-tools/non-contiguous` — advising the operator to widen the date range, which cannot help, because the row is not where its counter says it is. A five-second step reproduced it on a four-row chain: counters 1, 2 and 4 selected, 3 skipped, export refused. `b.legalHold` orders hold history by `recordedAt` from the same table and could present a release before the hold it released.
78
+
79
+ The writer now reads the chain tip before stamping rather than after, and takes that tip's `recordedAt` as the floor for the row it is about to write. The floor is therefore durable across a restart or a failover — it comes from storage, not process memory — and costs no extra round trip, because the tip was already being read for the previous row's hash. Each partition of a keyed writer carries its own clock, so an excursion on one chain cannot push another chain's timestamps into the future.
80
+
81
+ Affects `audit_log`, `consent_log`, and every operator-registered chain table with a `recordedAt` column. · *A refused chain append no longer burns its `monotonicCounter`* — `b.chainWriter` took the next counter before doing the work, and never gave it back when the append failed. Any throw between taking it and the insert landing — a sealed-field failure, a row-hash failure, an insert that exhausted its retries — consumed a counter for a row that was never written.
82
+
83
+ That leaves a permanent hole in `monotonicCounter`, and a hole is precisely what `b.auditTools.exportSlice` refuses as `audit-tools/non-contiguous`. So one transient write failure could make every later export of any range spanning it fail, with nothing in the chain to explain why and no way to repair it short of rewriting counters.
84
+
85
+ The counter is now returned when the row does not land. This is safe because the append holds that chain's mutex, so no other writer can have taken a counter in between, and because the insert is the last awaited step. In the rare case where an insert timed out after the write had actually committed, the next append reuses the counter and the unique index on `monotonicCounter` refuses it — a loud failure rather than a silent duplicate.
86
+
87
+ Affects `audit_log`, `consent_log`, and every operator-registered chain table. · *`b.fileType.mimeFor` answers for both spellings of a format* — The extension table took one canonical spelling per format, so every alias answered `null`: `.jpg` resolved and `.jpeg` did not, though they are the same format and `.jpeg` is what Windows and most cameras write. The gap was symmetric — `.tiff` resolved and `.tif` did not.
88
+
89
+ It matters because of what the answer is FOR. A consumer comparing a declared extension against the MIME its detector reports gets `null` for an ordinary image and has to decide what `null` means; a table that answers for one spelling and not the other is wrong in the direction that produces a wrong decision.
90
+
91
+ A signature may now declare `extensionAliases`, and `mimeFor` resolves them. `b.fileType.extensionFor` is unchanged: it still names ONE canonical extension per MIME type, so `image/jpeg` still answers `jpg`.
92
+
93
+ `.ico` still answers `null`, and deliberately. Nothing in the registry detects the icon magic, so a MIME mapping for it would have `mimeFor` claim a format `detect` cannot recognise. · *A guard gate no longer serves an empty value its own validator refuses* — A gate reads its value out of the request context, and the reader could not tell a field that was ABSENT from one PRESENT as an empty string — an `ctx.a || ctx.b || ""` chain collapses both. The gate then short-circuited to `serve` on the falsy result, so `b.guardCountry.gate().check({ country: "" })` served while `b.guardCountry.validate("")` reported the value as empty. The gate disagreed with the validator it exists to enforce, at the request boundary where residency and jurisdiction decisions are actually made.
94
+
95
+ An absent field still serves — a context carrying none of a guard's fields has nothing for that guard to look at, which is what the short-circuit was written for. A field present as an empty string is now a value, and the validator decides what it is worth.
96
+
97
+ This changes eight guards that take an identifier — `b.guardCidr`, `b.guardCountry`, `b.guardDomain`, `b.guardJsonpath`, `b.guardJwt`, `b.guardMime`, `b.guardTime`, `b.guardUuid` — plus `b.guardFilename`, `b.guardRegex` and `b.guardSmtpCommand`, whose gates are hand-written and had the same reader. If a deployment passes an empty string to mean "no value", omit the field instead. Guards that take a structured bag rather than a string are unaffected.
98
+
99
+ A validator that throws on the value now produces a refusal rather than propagating: a validator that cannot parse an input has not approved it, and letting the throw escape would fail the request the gate exists to decide. · *A dangerous autolink is no longer hidden by wrapping it in a harmless one* — `b.guardMarkdown.validate` scanned an autolink body to the closing `>` without stopping at a nested `<`, so an outer candidate swallowed everything inside it. `<a:<javascript:alert(1)>>` recorded a single URL beginning `a:` — a scheme nothing objects to — and the scan resumed past the inner candidate, which was therefore never examined. The scheme filter did not fail; it was never asked. Reported alone, `<javascript:alert(1)>` was flagged as expected.
100
+
101
+ The body now stops at `<`, which is what CommonMark's grammar already says an autolink body may not contain. The scan stays linear: it advances to the character that ended it, and that character begins the next candidate instead of being skipped.
102
+
103
+ This matters for what `validate` REPORTS rather than for what `b.guardMarkdown.render` emits — the renderer escapes autolinks rather than emitting them, so its output was never affected. The report is what an operator consults before handing author Markdown to a renderer that does support autolinks, and a finding that goes missing there is a decision made on wrong information. The link, image and reference-definition extractors were checked for the same shape and do not have it. · *A malformed guard limit is refused instead of silently switching the limit off* — Every numeric limit in the `b.guard*` family is applied as `measured > opts.maxThing`. Hand that comparison a string, an `Infinity` or a fraction and it is false for all input — so a malformed value did not fall back to the default, it DISABLED the cap, on exactly the untrusted input the cap exists to bound.
104
+
105
+ The check for this existed, but only inside the generated `validate()`, and the set of keys it covered was maintained by hand for each guard. That list had drifted from the defaults it was meant to mirror. `maxRuntimeMs` ships in all 27 guards' defaults and was named in none of them, so `{ maxRuntimeMs: "5s" }` removed the parse-runtime budget family-wide. `b.guardCsv` declared no list at all, leaving `maxRows`, `maxColumns`, `maxCellBytes` and `maxTotalBytes` unchecked; `b.guardSql`'s `maxBytes` and `b.guardSvg`'s `maxAttrsPerTag` and `maxAttrValueBytes` were unlisted the same way. Hand-written entry points — `b.guardMarkdown.render` and its peers in the email, html, json, filename and archive guards — resolved their own options and never reached the check at all. 136 combinations of guard, limit and malformed value were accepted.
106
+
107
+ The limits are now checked where options are resolved, which is the one point every entry point passes through, and the set is derived from each guard's own defaults rather than listed per guard. A limit added to any guard is covered without anyone remembering to name it, and a new entry point cannot opt out of the guard's own bounds by resolving options itself.
108
+
109
+ Zero remains a setting where it is one. A limit a guard DECLARES as a cap still refuses it — `maxBytes: 0` would refuse every input. Everything else derived from the defaults is held only to being a non-negative integer, because the derivation cannot tell a cap from a tolerance: `maxRuntimeMs: 0` means "no runtime budget" and `b.guardJwt`'s `nbfFutureSlackMs: 0` and `iatFutureSlackMs: 0` mean "allow no clock slack" — a stricter setting an operator may want, and one that requiring a positive integer would have taken away. Those two are no longer declared as caps, which is what they always were. The other side of the same line: every `max*` limit is now declared as a cap, including in `b.guardCsv`, `b.guardHtml`, `b.guardEmail`, `b.guardFilename` and `b.guardSvg`, which declared none or only some. Setting one of those to `0` previously meant "refuse everything" silently; it now says so at the call.
110
+
111
+ An option present with the value `undefined` is now treated as one that was not supplied, so it keeps the profile's default instead of erasing it. `{ maxBytes: process.env.MAX_BYTES && Number(process.env.MAX_BYTES) }` with the variable unset is the ordinary way to arrive here, and the merge previously copied that `undefined` over the default — after which the cap was gone by the same mechanism as a malformed value, on 119 caps across the family.
112
+
113
+ What this changes for you: a limit passed as a string, `Infinity`, `NaN`, a fraction or a negative number now throws `<guard>/bad-opt` at the call that sets it, where it previously took effect as "no limit". If a deployment is passing one, it has been running without that bound. `maxRuntimeMs: 0` is unaffected — zero remains the documented way to run uncapped, and is the only limit for which zero is a setting rather than a malformed value. · *An unreadable chain-tip timestamp refuses the append instead of being skipped* — The tip's `recordedAt` is now the floor for the row being written (see above). A tip whose timestamp cannot serve as one — not a safe integer, negative, or at the `Number.MAX_SAFE_INTEGER` ceiling where no next value is representable — refuses the append with `chain-writer/bad-tip-timestamp`, naming the table.
114
+
115
+ Skipping the floor and appending anyway would put the new row beneath the row it links to, which is the disorder the floor exists to prevent, and would do it silently on a chain that is already damaged. A row like that was written outside the framework; verify the chain before appending to it. **References:** [RFC 9562 — UUID and time-ordered identifiers](https://www.rfc-editor.org/rfc/rfc9562) · [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) · [Unicode CLDR region validity data](https://github.com/unicode-org/cldr/blob/main/common/validity/region.xml) · [OWASP — Cross Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
116
+
117
+ - v0.18.43 (2026-08-20) — **Three things the framework already knew how to do, reachable from outside it.** Each of these existed as working machinery with no public door: the largest-remainder split was locked behind a currency table that carries 43 of the ~180 ISO 4217 codes, the durable queue could only be driven by a resident consumer, and the MIME/extension table was reachable only through a test hook. In each case the predictable consequence downstream was a partial reimplementation of the part that is hardest to get right. **Added:** *`b.queue.tick(opts)` — drain what is due, then return* — Leases at most `max` available jobs, runs the handler over them, applies the same leasing, deterministic backoff and dead-letter transitions `b.queue.consume` applies, and settles. Nothing is left running when the promise resolves: no resident loop, no timer, no consumer for shutdown to wait on. Returns `{ leased, succeeded, failed, stale, unsettled, movedToDlq, mayHaveMore, queueDepth }`. Loop on `mayHaveMore` — the batch came back full, so more may be due right now. Not on `queueDepth`: that is the queue's total active depth and counts jobs whose `availableAt` is still in the future, so a caller looping on it spins through empty ticks waiting for a delayed job to come due. `queueDepth` is `null` when the backend could not be asked, because unknown is not empty. Two counts are not terminal states and mean different things. `unsettled` is the one to alert on: the backend REFUSED to record the outcome — complete or fail rejecting after its own retries — so the job is still inflight and the tick does not know whether the handler succeeded. A later sweep recovers it, and if the handler had already succeeded its side effects run again. `stale` is benign by comparison and counts jobs the tick ran but did not settle — the handler outlived the lease, the job was swept and re-leased, and the backend's attempt fence refused the completion. A non-zero `stale` means `leaseMs` is too short for the handler or the batch too large to finish inside it.
118
+
119
+ Pair it with `b.queue.init({ sweepIntervalMs: 0 })`, new in this release. `init` otherwise starts a 30-second expired-lease sweep timer, and in a frozen isolate that timer either never fires or fires inside a LATER invocation, touching the backend outside any request the platform is accounting for. `tick` sweeps once per invocation itself, so the timer buys nothing there. The option also lets a resident deployment tune the period; the default is unchanged.
120
+
121
+ It sweeps expired leases before taking a batch. A job whose holder died between leasing and settling stays `inflight` until something re-pends it, and the thing that normally does is the 30-second sweep timer started at `init` — a timer that never fires in the runtime this driver exists for. Without the sweep an invocation killed mid-lease would strand its job permanently, in exactly the deployment `tick` serves.
122
+
123
+ This is the driver a scheduled invocation needs — a Cloudflare `scheduled()` handler, a Lambda on EventBridge, a Kubernetes CronJob. `consume` cannot serve those: it is a resident loop and it starts a background sweep, and such a runtime freezes between invocations, so the timer never fires and the consumer is either blocking the handler or killed mid-lease. Without a per-tick entry point the durable half gets reimplemented by the caller — attempt accounting, backoff schedule, DLQ transition — which is precisely the part that is hard to get right.
124
+
125
+ `consume` and `tick` now run jobs through one shared path, so the retry schedule and the DLQ transition have exactly one implementation rather than two that can drift.
126
+
127
+ It drives the `local` and `redis` protocols, the same boundary `consume` has. An `sqs` backend is refused with `TICK_UNSUPPORTED`: SQS completes and fails by `receiptHandle` and owns redelivery and the dead-letter queue through its own RedrivePolicy, so driving it here would complete without the receipt and let SQS redeliver messages the handler had already processed. Drive SQS directly — lease, handle, complete or fail. · *`b.money.splitUnits(total, weights)` — the largest-remainder split without a currency* — Splits an integer into parts proportional to `weights`, distributing every unit: floor each share, then hand the leftovers to the largest remainders, ties broken by index. The total is preserved by construction and no floating point is involved, so the result is identical across runtimes. Negative totals floor toward negative infinity, so the parts still sum to the total.
128
+
129
+ A `Number` total or weight must be a SAFE integer. Past `Number.MAX_SAFE_INTEGER` the value has already been rounded before the function sees it — `9007199254740993` arrives as `...992` — so exact arithmetic there would faithfully split a total the caller never asked for. Those are refused with a message pointing at BigInt, which carries the value exactly.
130
+
131
+ The algorithm was already there as `Money.allocate`, but reaching it required a `Money`, which requires a currency from `b.money.CURRENCIES` — a table carrying the codes the framework has minor-unit data for rather than all of ISO 4217. A caller holding a currency outside that set could not reach the split at all, and neither could a caller splitting something that is not money: seats, quota, shard weights, a rate limit across workers. `Money.allocate` now composes it, so both doors open onto one implementation. · *`b.fileType.extensionFor(mime)` and `b.fileType.mimeFor(extension)`* — Read-only accessors over the signature table `b.fileType.detect` already consults, in both directions. Unknown returns `null` rather than a guess — a caller minting an object-store key or a `Content-Disposition` filename needs to know when the framework does not recognise the type. Lookups are case-insensitive, `extensionFor` ignores content-type parameters so a `Content-Type` header value can be passed straight in, and `mimeFor` accepts a leading dot because that is what `path.extname` returns.
132
+
133
+ `detect` answers what a file is; the next question is usually what to call it, and until now that had no answer, so consumers kept partial copies of a table the framework maintains — copies that drift the moment a signature is added.
134
+
135
+ `mimeFor` answers a naming question, not a trust question: it reports what an extension claims. Only `detect` establishes what a file IS, by reading the bytes, because an attacker controls the name and never the magic. **References:** [RFC 2045 §5.1 — Content-Type is case-insensitive](https://www.rfc-editor.org/rfc/rfc2045#section-5.1)
136
+
11
137
  - v0.18.42 (2026-08-20) — **`b.middleware.botGuard` no longer refuses a request for omitting Accept-Language, which was returning 403 to every search-engine crawler.** `b.middleware.botGuard` treated a missing `Accept-Language` header as grounds for a 403 in its default blocking mode. Google documents that Googlebot sends requests without setting that header, and bingbot behaves the same, so every content page of a site running the default middleware chain answered 403 to a crawler while a browser was served normally. The header's absence is now an advisory signal, matching how the same middleware already treats a missing `Sec-Fetch-Mode`. **Changed:** *Vendored `@blamejs/pki` 0.5.16 to 0.5.17* — A failed integrity check now destroys the plaintext it had recovered rather than returning it, `AuthEnvelopedData` validates the attributes it is asked to authenticate, and the refusal for a content cipher in the wrong container cites the rule it actually applies — RFC 5083 §2 defines `AuthEnvelopedData` in terms of authenticated encryption and never names GCM, so the previous message sent operators to argue with a specification that says the opposite. **Fixed:** *A missing `Accept-Language` tags a request instead of refusing it* — The check was `if (!headers["accept-language"]) return "missing-accept-language"`, and `mode` defaults to `"block"`, so the request was answered 403. Whole client families omit the header — every major search-engine crawler, and with them uptime monitors, link previewers and feed readers — which made a site unreachable to all of them. On a deployment fronted by a cache this is easy to miss from the outside: the cached home page still answers 200, so the site looks partly indexed while every other URL is refused.
12
138
 
13
139
  It now sets `req.suspectedBot` in `mode: "tag"` and never blocks, which is exactly how the middleware already treats a missing `Sec-Fetch-Mode` — absent for Safari before 16.4 and for every plain-HTTP non-localhost origin — and how `b.middleware.fetchMetadata` already treats a missing `Sec-Fetch-Site` through its `allowMissing` default. A header a whole client family omits is not evidence of automation.
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.17
71
+ Version: 0.5.23
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
@@ -67,7 +67,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
67
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`
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
- - **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; cluster-shared cache (`b.cache`)
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`)
71
71
  ### Identity & access
72
72
 
73
73
  - **Passwords** — Argon2id + policy primitive (`b.auth.password`); NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles; HaveIBeenPwned k-anonymity breach check; length / context / dictionary / complexity rules; rotation + history
@@ -156,14 +156,15 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
156
156
  - **Mobile credentials (mDL)** — `b.mdoc` ISO/IEC 18013-5 verification: `verifyIssuerSigned` checks the COSE_Sign1 IssuerAuth (issuer cert from the `x5chain` header), the MSO validity window, and every disclosed element's digest against the MSO `valueDigests` (selective-disclosure integrity), with optional issuer-chain verification; `verifyDeviceAuth` proves holder binding (§9.1.3 signature variant) — the device COSE_Sign1 over the `DeviceAuthentication` structure with the MSO device key + protocol `sessionTranscript`. The ISO credential ecosystem alongside `b.vc` and `b.auth.sdJwtVc`. Composes `b.cose` + `b.cbor`
157
157
  - **Decentralized Identifiers** — `b.did` W3C DID resolution (DID Core 1.0): `resolve` a `did:key` / `did:jwk` (deterministic, offline — Ed25519 / P-256 / P-384 / secp256k1) or `did:web` (operator-fetched document) to `node:crypto` verification keys, so a credential's issuer DID resolves to the key that verifies it (`b.vc` / `b.mdoc` / `b.scitt`). `keyToDid` names a key as a `did:key` or `did:jwk`; document/JWK keys are kty/crv-allowlisted before import
158
158
  - **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
159
- - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
159
+ - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.); `extensionFor` / `mimeFor` read the same table in both directions, so naming a detected file does not need a second copy of it
160
160
  ### Content-safety gates
161
161
 
162
162
  - **Composition contract** — `b.gateContract` uniform mode posture / hooks / forensic snapshot / decision cache / runtime cap
163
- - **Document guards** — `b.guardCsv` (formula injection, dangerous-function denylist, bidi / homoglyph / dialect ambiguity, CSV-bombs); `b.guardHtml` (XSS / mXSS / DOM-clobbering, dangerous-tag + event-handler family, URL-scheme with entity-decode bypass, CSS-injection in style); `b.guardSvg` (script / foreignObject / animation href hijack / DOCTYPE / XXE / SVGZ / cross-origin `<use>` SSRF); `b.guardMarkdown` (URL schemes pre-render, CVE-2026-30838 dangerous-tag, ReDoS emphasis runs)
163
+ - **Document guards** — `b.guardCsv` (formula injection, dangerous-function denylist, bidi / homoglyph / dialect ambiguity, CSV-bombs); `b.guardHtml` (XSS / mXSS / DOM-clobbering, dangerous-tag + event-handler family, URL-scheme with entity-decode bypass, CSS-injection in style); `b.guardSvg` (script / foreignObject / animation href hijack / DOCTYPE / XXE / SVGZ / cross-origin `<use>` SSRF); `b.guardMarkdown` (URL schemes pre-render, CVE-2026-30838 dangerous-tag, ReDoS emphasis runs), which also RENDERS — `b.guardMarkdown.render` emits HTML escape-by-default over a conservative subset, with every link target screened against the same entity-and-whitespace-folding scheme check and raw HTML escaped rather than sanitised
164
164
  - **Structured data** — `b.guardJson` (prototype-pollution, dup keys, JSON5, depth/breadth caps); `b.guardYaml` (deserialization-tag RCE, billion-laughs aliases, Norway-problem); `b.guardXml` (XXE / billion-laughs / xi:include / signature wrapping; DOCTYPE refused at all profile levels)
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
+ - **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)
167
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
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
169
170
  - **Profiles + postures** — every member ships strict / balanced / permissive plus hipaa / pci-dss / gdpr / soc2
@@ -322,7 +323,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
322
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 |
323
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` |
324
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 |
325
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.17 | [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.23 | [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
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 |
327
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) |
328
329
 
package/index.js CHANGED
@@ -234,6 +234,7 @@ var guardText = require("./lib/guard-text");
234
234
  var guardDomain = require("./lib/guard-domain");
235
235
  var guardUuid = require("./lib/guard-uuid");
236
236
  var guardCidr = require("./lib/guard-cidr");
237
+ var guardCountry = require("./lib/guard-country");
237
238
  var guardTime = require("./lib/guard-time");
238
239
  var guardMime = require("./lib/guard-mime");
239
240
  var guardJwt = require("./lib/guard-jwt");
@@ -630,6 +631,7 @@ module.exports = {
630
631
  guardDomain: guardDomain,
631
632
  guardUuid: guardUuid,
632
633
  guardCidr: guardCidr,
634
+ guardCountry: guardCountry,
633
635
  guardTime: guardTime,
634
636
  guardMime: guardMime,
635
637
  guardJwt: guardJwt,
@@ -48,6 +48,7 @@ var safeSql = require("./safe-sql");
48
48
  var sql = require("./sql");
49
49
  var C = require("./constants");
50
50
  var boundedMap = require("./bounded-map");
51
+ var time = require("./time");
51
52
  var { FrameworkError } = require("./framework-error");
52
53
 
53
54
  // Allowlist of chain table names. The two framework chains ship registered; a
@@ -238,6 +239,30 @@ function create(opts) {
238
239
  var _nextCounterByKey = new Map();
239
240
  var _counterInitByKey = new Map();
240
241
 
242
+ // `recordedAt` has to order the same way `monotonicCounter` does. Readers
243
+ // depend on it crossing between the two: b.auditTools.exportSlice selects a
244
+ // slice by recordedAt and then requires that slice to be CONTIGUOUS in
245
+ // monotonicCounter. Date.now() cannot carry that — it repeats inside a
246
+ // millisecond and steps backwards when NTP corrects the clock — so one
247
+ // backwards step drops a row out of the window its neighbours are in and
248
+ // refuses the operator's export.
249
+ //
250
+ // One clock per partition, seeded from that partition's own persisted tip on
251
+ // every append: the floor therefore survives a restart or a failover, and a
252
+ // clock excursion on one chain cannot push another chain's timestamps into
253
+ // the future.
254
+ var _clockByKey = new Map();
255
+ function _clockFor(keyValue) {
256
+ var k = chainKey !== null ? String(keyValue) : _SINGLE_CHAIN_KEY;
257
+ return boundedMap.getOrInsert(_clockByKey, k, function () {
258
+ return time.monotonicClock({ label: table + ":" + k });
259
+ });
260
+ }
261
+
262
+ // Only carry a timestamp when the table actually has the column — a
263
+ // consumer's chain table is free not to.
264
+ var _hasRecordedAt = columnsForInsert.indexOf("recordedAt") !== -1;
265
+
241
266
  function _ensureCounterInit(keyValue) {
242
267
  var k = chainKey !== null ? String(keyValue) : _SINGLE_CHAIN_KEY;
243
268
  var once = boundedMap.getOrInsert(_counterInitByKey, k, function () {
@@ -264,8 +289,10 @@ function create(opts) {
264
289
  }
265
290
 
266
291
  async function _readChainTipRow(keyValue) {
292
+ // recordedAt rides along with rowHash: the tip is this partition's durable
293
+ // clock floor, and reading it here costs no extra round trip.
267
294
  var tipQ = sql.select(table, _sqlOpts())
268
- .columns(["rowHash"])
295
+ .columns(_hasRecordedAt ? ["rowHash", "recordedAt"] : ["rowHash"])
269
296
  .orderBy("monotonicCounter", "desc")
270
297
  .limit(1);
271
298
  // Scope the tip to the partition so per-key chains link correctly —
@@ -330,9 +357,77 @@ function create(opts) {
330
357
 
331
358
  async function _appendInsideMutex(logical, keyValue) {
332
359
  var _ck = chainKey !== null ? String(keyValue) : _SINGLE_CHAIN_KEY;
360
+
361
+ // Re-prime if the counter is gone. append() awaits _ensureCounterInit
362
+ // BEFORE taking this mutex, so a call already queued behind one that
363
+ // failed has cleared that gate while the failure handler below deleted the
364
+ // primed value — this read would then be `undefined` and stamp NaN.
365
+ //
366
+ // Today that is masked: the tip is screened before `counter` is first
367
+ // used, so a pre-insert throw hides it, and monotonicCounter is INTEGER
368
+ // NOT NULL with a unique index in every schema. Relying on the masking
369
+ // would make correctness here an argument about which throw happens first,
370
+ // and that is precisely the reasoning that produced the permanent write
371
+ // wedge this handler now exists to prevent. Under the mutex the answer
372
+ // cannot change underneath us, and _ensureCounterInit takes no lock, so
373
+ // calling it here cannot deadlock.
374
+ if (!_nextCounterByKey.has(_ck)) await _ensureCounterInit(keyValue);
375
+
333
376
  var counter = _nextCounterByKey.get(_ck);
334
377
  _nextCounterByKey.set(_ck, counter + 1);
335
- var nowMs = Date.now();
378
+ try {
379
+ return await _buildAndInsert(logical, keyValue, _ck, counter);
380
+ } catch (e) {
381
+ // The append did not complete — but WHETHER THE ROW LANDED is unknown. A
382
+ // timeout cannot distinguish "the insert never committed" from "it
383
+ // committed and the acknowledgement was lost", and both arrive here as
384
+ // the same throw.
385
+ //
386
+ // Restoring the counter unconditionally is right in the first case and
387
+ // catastrophic in the second: the next append would reuse a counter that
388
+ // IS already in the table, hit the unique index, land back here, restore
389
+ // it again, and wedge this chain permanently — every audit or consent
390
+ // write failing until the process restarts. That is far worse than the
391
+ // contiguity hole the rollback exists to avoid.
392
+ //
393
+ // So discard the in-memory counter instead of guessing, and let the next
394
+ // append re-read MAX(monotonicCounter) from storage. That answers both
395
+ // cases with the same mechanism: if the row never landed the maximum is
396
+ // unchanged and the counter is reclaimed with no gap; if it did land the
397
+ // maximum includes it and the next append continues after it.
398
+ _nextCounterByKey.delete(_ck);
399
+ _counterInitByKey.delete(_ck);
400
+ throw e;
401
+ }
402
+ }
403
+
404
+ async function _buildAndInsert(logical, keyValue, _ck, counter) {
405
+ void _ck;
406
+
407
+ // Read the tip BEFORE stamping: it supplies both the hash this row links
408
+ // to and the clock floor this row's timestamp must clear.
409
+ var tipRow = await _readChainTipRow(keyValue);
410
+ var clock = _clockFor(keyValue);
411
+ if (_hasRecordedAt && tipRow && tipRow.recordedAt !== undefined && tipRow.recordedAt !== null) {
412
+ // Postgres hands a BIGINT back as a string; the floor is a number.
413
+ var tipMs = Number(tipRow.recordedAt);
414
+ // A tip that carries a timestamp MUST yield a usable floor. Skipping an
415
+ // unreadable one would append beneath the row it links to and put the
416
+ // chain back in the state this floor exists to prevent, so an unusable
417
+ // tip refuses the append and says which chain it came from - rather than
418
+ // surfacing a bare clock error from two frames down.
419
+ if (!Number.isSafeInteger(tipMs) || tipMs < 0 || tipMs >= Number.MAX_SAFE_INTEGER) {
420
+ throw new ChainWriterError(
421
+ "append: the chain tip for " + table + " carries an unusable recordedAt (" +
422
+ String(tipRow.recordedAt) + "), so the next row's timestamp cannot be " +
423
+ "ordered against it. The row is corrupt or was written outside the " +
424
+ "framework; verify the chain before appending.",
425
+ "chain-writer/bad-tip-timestamp"
426
+ );
427
+ }
428
+ clock.observeFloor(tipMs);
429
+ }
430
+ var nowMs = clock.now();
336
431
  var nonce = generateBytes(C.BYTES.bytes(16));
337
432
 
338
433
  // Caller-supplied logical row: spread + add framework-managed fields.
@@ -353,8 +448,8 @@ function create(opts) {
353
448
  if (!(hashableColumns[hci] in sealed)) sealed[hashableColumns[hci]] = null;
354
449
  }
355
450
 
356
- // Compute rowHash over the sealed content fields, linking to THIS key's tip.
357
- var tipRow = await _readChainTipRow(keyValue);
451
+ // Compute rowHash over the sealed content fields, linking to THIS key's
452
+ // tip the same read that supplied the clock floor above.
358
453
  var prevHash = tipRow ? tipRow.rowHash : auditChain.ZERO_HASH;
359
454
  var rowHash = auditChain.computeRowHash(prevHash, sealed, nonce);
360
455
 
@@ -376,6 +471,10 @@ function create(opts) {
376
471
  _mutexByKey = new Map();
377
472
  _counterInitByKey = new Map();
378
473
  _nextCounterByKey = new Map();
474
+ // The clocks go too: a test that tears down and reseeds a table would
475
+ // otherwise carry the previous run's floor into the new chain and stamp
476
+ // every row far ahead of wall clock.
477
+ _clockByKey = new Map();
379
478
  }
380
479
 
381
480
  return {
package/lib/file-type.js CHANGED
@@ -131,7 +131,13 @@ var SIGNATURES = [
131
131
  // ---- Images ----
132
132
  { name: "png", mime: "image/png", extension: "png", category: "image",
133
133
  offset: 0, magic: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) },
134
- { name: "jpeg", mime: "image/jpeg", extension: "jpg", category: "image",
134
+ // `extension` is the CANONICAL spelling, the one extensionFor() answers with.
135
+ // `extensionAliases` are the other spellings of the same format, which
136
+ // mimeFor() must also resolve: `.jpeg` is what Windows and most cameras
137
+ // write, and a table answering for one spelling and not the other hands a
138
+ // caller `null` for an ordinary image.
139
+ { name: "jpeg", mime: "image/jpeg", extension: "jpg", extensionAliases: ["jpeg"],
140
+ category: "image",
135
141
  offset: 0, magic: Buffer.from([0xFF, 0xD8, 0xFF]) },
136
142
  { name: "gif", mime: "image/gif", extension: "gif", category: "image",
137
143
  offset: 0, magic: [Buffer.from("GIF87a", "ascii"), Buffer.from("GIF89a", "ascii")] },
@@ -144,7 +150,8 @@ var SIGNATURES = [
144
150
  } },
145
151
  { name: "bmp", mime: "image/bmp", extension: "bmp", category: "image",
146
152
  offset: 0, magic: Buffer.from([0x42, 0x4D]) },
147
- { name: "tiff", mime: "image/tiff", extension: "tiff", category: "image",
153
+ { name: "tiff", mime: "image/tiff", extension: "tiff", extensionAliases: ["tif"],
154
+ category: "image",
148
155
  offset: 0, magic: [
149
156
  Buffer.from([0x49, 0x49, 0x2A, 0x00]), // little-endian
150
157
  Buffer.from([0x4D, 0x4D, 0x00, 0x2A]), // big-endian
@@ -353,9 +360,99 @@ function assertOneOf(buf, allowlist, opts) {
353
360
  return detected;
354
361
  }
355
362
 
363
+ // Both directions of the signature table, built once at load. Every row is
364
+ // 1:1 in both directions, so neither map loses an entry; a row added with a
365
+ // duplicate mime or extension would silently shadow, which the test asserts
366
+ // against by round-tripping every row.
367
+ var _MIME_TO_EXT = Object.create(null);
368
+ var _EXT_TO_MIME = Object.create(null);
369
+ SIGNATURES.forEach(function (row) {
370
+ if (row.mime && !(row.mime.toLowerCase() in _MIME_TO_EXT)) {
371
+ _MIME_TO_EXT[row.mime.toLowerCase()] = row.extension;
372
+ }
373
+ if (row.extension && !(row.extension.toLowerCase() in _EXT_TO_MIME)) {
374
+ _EXT_TO_MIME[row.extension.toLowerCase()] = row.mime;
375
+ }
376
+ // Alias spellings resolve to the same MIME. They deliberately do NOT feed
377
+ // _MIME_TO_EXT: extensionFor() names ONE canonical extension per type, and an
378
+ // alias winning that slot would change what the framework calls the format.
379
+ if (Array.isArray(row.extensionAliases)) {
380
+ row.extensionAliases.forEach(function (alias) {
381
+ if (alias && !(alias.toLowerCase() in _EXT_TO_MIME)) {
382
+ _EXT_TO_MIME[alias.toLowerCase()] = row.mime;
383
+ }
384
+ });
385
+ }
386
+ });
387
+
388
+ /**
389
+ * @primitive b.fileType.extensionFor
390
+ * @signature b.fileType.extensionFor(mime)
391
+ * @since 0.18.43
392
+ * @status stable
393
+ * @related b.fileType.detect, b.fileType.mimeFor
394
+ *
395
+ * The canonical file extension for a MIME type, with no leading dot, or `null`
396
+ * when the framework does not recognise the type. Never a guess: a caller
397
+ * minting an object-store key or a `Content-Disposition` filename needs to
398
+ * know when the answer is unknown rather than receive a plausible-looking one.
399
+ *
400
+ * `detect` answers "what is this"; the next question is usually "what should I
401
+ * call it", and the table that answers it is the same one. Reading it here
402
+ * rather than keeping a private copy is what stops the two drifting when a
403
+ * signature is added.
404
+ *
405
+ * The lookup is case-insensitive and ignores content-type parameters, so the
406
+ * `Content-Type` header value a request arrived with can be passed straight in.
407
+ *
408
+ * @example
409
+ * b.fileType.extensionFor("image/png");
410
+ * // → "png"
411
+ */
412
+ function extensionFor(mime) {
413
+ if (typeof mime !== "string" || mime.length === 0) return null;
414
+ // MIME types are case-insensitive (RFC 2045 §5.1) and may carry parameters.
415
+ var bare = mime.split(";")[0].trim().toLowerCase();
416
+ if (bare.length === 0) return null;
417
+ return Object.prototype.hasOwnProperty.call(_MIME_TO_EXT, bare) ? _MIME_TO_EXT[bare] : null;
418
+ }
419
+
420
+ /**
421
+ * @primitive b.fileType.mimeFor
422
+ * @signature b.fileType.mimeFor(extension)
423
+ * @since 0.18.43
424
+ * @status stable
425
+ * @related b.fileType.detect, b.fileType.extensionFor
426
+ *
427
+ * The MIME type for a file extension, or `null` when the framework does not
428
+ * recognise it. The inverse of `b.fileType.extensionFor`, over the same table.
429
+ *
430
+ * A leading dot is accepted, because that is the form `path.extname` returns
431
+ * and therefore the form a caller usually has in hand. The lookup is
432
+ * case-insensitive.
433
+ *
434
+ * This answers a naming question, not a trust question. It reports what an
435
+ * extension claims; it does not establish what a file IS. Only
436
+ * `b.fileType.detect` does that, by reading the bytes — an attacker controls
437
+ * the name, never the magic.
438
+ *
439
+ * @example
440
+ * b.fileType.mimeFor(".png");
441
+ * // → "image/png"
442
+ */
443
+ function mimeFor(extension) {
444
+ if (typeof extension !== "string" || extension.length === 0) return null;
445
+ var ext = extension.charAt(0) === "." ? extension.slice(1) : extension;
446
+ ext = ext.trim().toLowerCase();
447
+ if (ext.length === 0) return null;
448
+ return Object.prototype.hasOwnProperty.call(_EXT_TO_MIME, ext) ? _EXT_TO_MIME[ext] : null;
449
+ }
450
+
356
451
  module.exports = {
357
452
  detect: detect,
358
453
  assertOneOf: assertOneOf,
454
+ extensionFor: extensionFor,
455
+ mimeFor: mimeFor,
359
456
  FileTypeError: FileTypeError,
360
457
  // Internal — exposed so tests can introspect the registry shape.
361
458
  _SIGNATURES: SIGNATURES,
@@ -313,6 +313,15 @@ var GuardUuidError = defineClass("GuardUuidError", { alwaysPermane
313
313
  // IPv6 dual-stack confusion, BIDI / zero-width / control / null-byte
314
314
  // universal refuse. alwaysPermanent.
315
315
  var GuardCidrError = defineClass("GuardCidrError", { alwaysPermanent: true });
316
+ // GuardCountryError covers ISO 3166-1 alpha-2 identifier violations: shape
317
+ // malformation (anything but two ASCII letters, so a fullwidth or homoglyph
318
+ // spelling refuses), user-assigned ranges (AA, QM-QZ, XA-XZ, ZZ),
319
+ // exceptionally reserved codes that name a union / organisation / territory
320
+ // rather than a country (EU, EZ, UN, AC, CP, CQ, DG, EA, IC, TA), formerly
321
+ // used codes withdrawn from the standard (AN, BU, CS, DD, FX, NT, SU, TP,
322
+ // YD, YU, ZR), codes that are not assigned at all (UK - the code is GB), and
323
+ // BIDI / zero-width / control / null-byte universal refuse. alwaysPermanent.
324
+ var GuardCountryError = defineClass("GuardCountryError", { alwaysPermanent: true });
316
325
  // GuardTimeError covers RFC 3339 / ISO 8601 datetime identifier
317
326
  // violations: shape malformation, year-window overflow (pre-epoch /
318
327
  // far-future), naive datetime (no offset), non-UTC offset, leap-second
@@ -747,6 +756,7 @@ module.exports = {
747
756
  GuardDomainError: GuardDomainError,
748
757
  GuardUuidError: GuardUuidError,
749
758
  GuardCidrError: GuardCidrError,
759
+ GuardCountryError: GuardCountryError,
750
760
  GuardTimeError: GuardTimeError,
751
761
  GuardMimeError: GuardMimeError,
752
762
  GuardJwtError: GuardJwtError,