@blamejs/core 0.18.43 → 0.18.45
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 +142 -0
- package/NOTICE +1 -1
- package/README.md +3 -2
- package/index.js +2 -0
- package/lib/chain-writer.js +103 -4
- package/lib/file-type.js +19 -2
- package/lib/framework-error.js +10 -0
- package/lib/gate-contract.js +255 -12
- package/lib/guard-all.js +1 -0
- package/lib/guard-archive.js +14 -0
- package/lib/guard-auth.js +1 -3
- package/lib/guard-country.js +480 -0
- package/lib/guard-csv.js +5 -0
- package/lib/guard-email.js +7 -7
- package/lib/guard-filename.js +11 -9
- package/lib/guard-graphql.js +1 -3
- package/lib/guard-html.js +6 -7
- package/lib/guard-image.js +15 -1
- package/lib/guard-json.js +3 -7
- package/lib/guard-jwt.js +8 -3
- package/lib/guard-markdown.js +946 -2
- package/lib/guard-oauth.js +1 -3
- package/lib/guard-pdf.js +8 -0
- package/lib/guard-regex.js +5 -4
- package/lib/guard-smtp-command.js +4 -2
- package/lib/guard-sql.js +10 -0
- package/lib/guard-svg.js +2 -1
- package/lib/guard-text.js +10 -0
- package/lib/guard-yaml.js +13 -2
- package/lib/mail-auth.js +12 -14
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/shared-secret-header.js +334 -0
- package/lib/public-suffix.js +57 -0
- package/lib/time.js +282 -0
- package/lib/vendor/MANIFEST.json +13 -13
- package/lib/vendor/blamejs-pki.cjs +4069 -3138
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,148 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.45 (2026-08-21) — **`b.publicSuffix` returned names that cannot exist — a 64-octet label, and `ex*ample.com`.** `canonicalDomain` is where the framework decides what a domain IS, and two kinds of non-domain got through it. A label longer than the 63 octets RFC 1035 allows came back as an organizational domain, and 17 of 31 non-LDH characters survived because `domainToASCII` maps a name without validating it as a host. Both answers then travelled: into DMARC alignment, cookie-domain comparisons and residency routing, where a name no resolver will ever return is compared against one that exists. **Changed:** *Vendored `@blamejs/pki` 0.5.23 to 0.5.25* — The toolkit behind `b.mtlsCa` and `b.auth.passkey`. Two releases land together.
|
|
12
|
+
|
|
13
|
+
0.5.24 binds an ACME certificate download to the order that asked for it. The end-entity certificate that comes back must carry the public key the order's CSR asked to have certified, and its identifier set must equal the order's — its dNSName and iPAddress subject alternative names, with the subject common name read only where it asserts none. `downloadCertificate` now requires `expectedSpki`, `identifiers`, or both, and refuses a call supplying neither rather than returning a certificate nothing looked at. An identifier that maps to no certificate name is refused rather than dropped from the comparison, which is what let an order for a name plus one other identifier be satisfied by a certificate covering only the name.
|
|
14
|
+
|
|
15
|
+
The same release gives case-insensitive name comparison one definition that folds with ASCII rules, so U+212A KELVIN SIGN is no longer read as the `k` its Unicode lowercase produces — the mapping that made two separately registrable domains compare equal. And every guard module freezes its exports: a boundary reads its check off the module object at the moment of the call, and the module registry hands every caller the same object, so a single property assignment would have replaced a constant-time comparison or a size cap in every module at once.
|
|
16
|
+
|
|
17
|
+
0.5.25 gives a key that cannot sign a way to prove possession. `pki.crmf.build` emits a `POPOPrivKey` proof under `keyEncipherment` or `keyAgreement`, so an ML-KEM certificate request has a proof to carry instead of no option at all, and a request whose proof requires it announces cmp2021(3) rather than a version its own reader refuses.
|
|
18
|
+
|
|
19
|
+
The framework calls `pki.crl.sign`, `pki.key.export`, `pki.webcrypto.subtle` and the WebAuthn verification surface. None of those changed shape, so nothing changes for callers going through `b.mtlsCa` or `b.auth.passkey`. The ACME requirement is a breaking change for code calling the vendored toolkit directly; the toolkit's own `MIGRATING.md` carries the route. **Fixed:** *A label longer than 63 octets is no longer a domain* — RFC 1035 §2.3.4 bounds a label at 63 octets and the whole name at 253. `canonicalDomain` enforced the second and not the first, so `<64 a's>.com` came back as a canonical domain and `b.publicSuffix.organizationalDomain` handed it to the caller — a name that cannot be put on the wire, and one the caller cannot tell apart from a name that can.
|
|
20
|
+
|
|
21
|
+
That is the argument the 253-octet check was already making. Its own comment describes the failure as handing back a name that "cannot be put on the wire, and a caller cannot tell", which is exactly as true of a label. The rest of the framework — `b.guardDomain`, `b.guardEmail`, `b.mailAuth` — enforces both bounds; this was the one place that enforced one and not the other.
|
|
22
|
+
|
|
23
|
+
Measured on the CONVERTED form for the same reason the total is: a 30-character internationalized label is well inside 63 as typed and can exceed it as an `xn--` A-label.
|
|
24
|
+
|
|
25
|
+
`b.mailAuth` had been compensating with its own per-label loop, and the comment there argued a label cap was a DNS wire rule rather than a naming one. That did not survive the observation that the 253 cap is equally a wire rule and was enforced anyway. The loop is gone; the bound now applies wherever a domain is canonicalized rather than at the one caller that remembered. · *A character that cannot appear in a host is no longer a domain* — `domainToASCII` is a UTS #46 MAPPER, not a hostname validator. It passes through characters that no DNS name may contain, so `ex*ample.com`, `ex(ample.com`, `ex'ample.com` and `ex"ample.com` all came back from `canonicalDomain` looking canonical — 17 of the 31 non-LDH characters tested. A caller then compared one against a cookie domain, an SPF record or a residency table.
|
|
26
|
+
|
|
27
|
+
A canonical domain is now letters, digits, hyphens and dots, checked on the converted form where the question has one answer: an internationalized label has already become its `xn--` spelling by then.
|
|
28
|
+
|
|
29
|
+
Underscore is deliberately still accepted. `_dmarc`, `_domainkey`, `_acme-challenge` and `_sip._tcp` are real DNS names, and whether to accept a service label is a policy question the framework already answers elsewhere — `b.guardDomain`'s `underscorePolicy` refuses it at strict and allows it at permissive. `canonicalDomain` normalizes a name; it does not judge one.
|
|
30
|
+
|
|
31
|
+
The check composes `b.codepointClass.isRunOf` rather than hand-rolling a character loop, so the set reads as the rule it enforces and the range table is built once. **Detectors:** *The gates that read source with comments removed now share one lexer, and it reads regular expressions* — Checks that assert a construct is PRESENT read the source with comments stripped, because a check that finds its construct in a commented-out line concludes it is there and stays silent — the state it exists to catch. Two gates did that stripping, each its own way, and each lost source the other kept.
|
|
32
|
+
|
|
33
|
+
One removed comments with two pattern replaces. Those cannot tell a comment from the same characters inside a string: an `Accept` header of `*/*` contains the two characters `/*`, which opened a block comment that ran to the next `*/` anywhere in the file and deleted an audit emission from the scan. That gate checks every emitted namespace is registered, so anything it cannot see is a namespace that goes unregistered with nothing reporting it.
|
|
34
|
+
|
|
35
|
+
The other tracked strings but not regular expressions, so a literal holding `//` read as a line comment. Across `lib/` that changed what the gates read for 101 files, in both directions: `/^curl\//i` in a blocked-agent list took the rest of its line with it, and elsewhere a quote inside a pattern desynchronized the scan far enough that whole comment blocks survived into what the gates then read as code.
|
|
36
|
+
|
|
37
|
+
Both now call one implementation, beside the tokenizer that resolves the same ambiguity for the detector matchers. · *Stripping comments may not change whether a file parses* — Deciding whether a slash opens a pattern or divides means naming what may precede one, and that list is open-ended — the regex-literal gate was rewritten for exactly that reason, and the same list rebuilt here was missing a different entry each time it was checked: a postfix increment, a control-flow header, a statement block, an object literal, `break`, a non-ASCII identifier, a function expression, a trailing-dot number.
|
|
38
|
+
|
|
39
|
+
So the list is no longer what the gates rest on. Removing comments cannot change whether source parses, and anything deleted beyond a comment takes syntax with it. Every file under `lib/` and `test/` is now compiled twice — as written, and with comments removed — and the two must agree. Alongside it, three generated sweeps ask the same question of the language rather than of this codebase: every reserved word, and every block-bearing construct, must survive a pattern written after it — and every word must be read in the role its POSITION gives it rather than the one it spells. That last one matters because a word is not always the keyword it looks like: after a dot it is a property name, so `o.default / 2` divides, and after `break` or `continue` it is a label that ends the statement, so a pattern on the next line opens rather than divides.
|
|
40
|
+
|
|
41
|
+
What that covers is exact. It catches a slash read as opening a pattern where it divided, which deletes code and stops the file parsing. The opposite mistake leaves a real comment behind and still parses, so it is pinned by cases instead, and it is bounded — a pattern scan stops at the newline, where a phantom block comment runs to the next `*/` or to the end of the file.
|
|
42
|
+
|
|
43
|
+
It is not a check that passes by looking nowhere: the stripper as it stood before it lexed patterns breaks parsing in ten `lib/` files, and the check reports if its own walk reaches fewer files than the tree holds, if a generated fixture stops being valid source, or if a case no longer contains the text it searches for. · *A removed comment leaves a separator only where the tokens would fuse* — A block comment can be the only thing between two tokens. Deleting it outright joins them: `key/* note */in obj` becomes `keyin obj`, and `a +/* note */+b` becomes `a ++b` — different programs, and the second is not one at all.
|
|
44
|
+
|
|
45
|
+
A separator now goes in exactly where the pair would fuse: two words, or two punctuation characters that spell one of the language's multi-character operators. Everywhere else nothing is inserted, because the checks match source shapes and a space between a name and its parenthesis makes a pattern stop matching — which is a miss, and misses are silent. **References:** [RFC 1035 §2.3.4 — size limits](https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4) · [UTS #46 — Unicode IDNA Compatibility Processing](https://www.unicode.org/reports/tr46/)
|
|
46
|
+
|
|
47
|
+
- 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.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
`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.
|
|
56
|
+
|
|
57
|
+
`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.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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"`.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
Escaping and generated markup are the other places a document grows, and no cap bounded either. `'` renders as `'` and `"` as `"` — 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.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
`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.
|
|
96
|
+
|
|
97
|
+
`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.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
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`.
|
|
128
|
+
|
|
129
|
+
`.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.
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
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)
|
|
152
|
+
|
|
11
153
|
- 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.
|
|
12
154
|
|
|
13
155
|
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.
|
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.
|
|
71
|
+
Version: 0.5.25
|
|
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
|
@@ -160,10 +160,11 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
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.
|
|
326
|
+
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.25 | [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,
|
package/lib/chain-writer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
@@ -366,6 +373,16 @@ SIGNATURES.forEach(function (row) {
|
|
|
366
373
|
if (row.extension && !(row.extension.toLowerCase() in _EXT_TO_MIME)) {
|
|
367
374
|
_EXT_TO_MIME[row.extension.toLowerCase()] = row.mime;
|
|
368
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
|
+
}
|
|
369
386
|
});
|
|
370
387
|
|
|
371
388
|
/**
|
package/lib/framework-error.js
CHANGED
|
@@ -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,
|