@blamejs/core 0.18.41 → 0.18.43

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,36 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - 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
+
13
+ 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.
14
+
15
+ 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.
16
+
17
+ 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.
18
+
19
+ `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.
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ `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.
28
+
29
+ `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)
30
+
31
+ - 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.
32
+
33
+ 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.
34
+
35
+ Requests are still refused on positive evidence: the `User-Agent` deny-list (curl, wget, python-requests, axios, Go-http-client and the rest) is unchanged, and operators can extend it with `blockedAgents`. Separating a crawler from an abuser is a rate-limiting question rather than a header one, and `b.rateLimit` answers it.
36
+
37
+ If you were relying on the old behaviour, `mode: "tag"` surfaces the same signal on `req.suspectedBot` for you to act on. **Detectors:** *No single absent header may block, asserted against the middleware* — `bot-guard.test.js` now starts from a complete browser request and removes one header at a time, asserting that no single omission refuses the request. It covers the headers the middleware inspects — `User-Agent`, `Accept`, `Accept-Language`, `Accept-Encoding` and the three `Sec-Fetch-*` — so the rule holds for whichever one a future change reaches for, not only the one that was wrong.
38
+
39
+ It is a behavioural check rather than a source-pattern one deliberately. Written as a pattern check first, it was wrong six different ways: anchored to the first expression in the condition, bounded at the first `)`, bounded to a single line, defeated by a statement before the return, by a guard that was OR'd rather than AND'd, and by a `)` inside a string literal. Each fix was a step further into parsing JavaScript with text matching. Running the middleware decides all six, because it asks what the code does rather than how it is written. **References:** [How Google crawls locale-adaptive pages — Googlebot and Accept-Language](https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages)
40
+
11
41
  - v0.18.41 (2026-08-20) — **Logout can now clear the session cookie on a plain-HTTP origin, and every Set-Cookie the framework writes goes through one validating writer.** `b.session.logout` built its expiry cookie by string concatenation with `Secure` hardcoded. A browser refuses a `Secure` cookie that arrives over plain HTTP, so on a cleartext deployment the header was discarded and the session cookie the logout existed to clear stayed in the jar. `b.middleware.csrfProtect` had its own cookie formatter with the same shape and a different consequence: it interpolated the configured `cookie.path` into the response header with no CRLF scrub. Both now compose `b.cookies`, which is the framework's single Set-Cookie writer. **Added:** *`b.cookies.assertAppendable(res)`* — Throws unless a response can carry an appended `Set-Cookie` — the same check `appendSetCookie` performs, exposed so a caller can run it before doing something it cannot undo. A response has to be readable as well as writable to be appended to: without `appendHeader` the merge happens in the framework and needs to see what is already queued, and a response carrying only `setHeader` would have its existing cookies silently replaced. `b.session.logout` calls it up front, because it revokes the session row before queueing the expiry cookie — a refusal at queue time would leave the session destroyed, the browser still holding its cookie, and the request failing. · *`b.cookies.appendSetCookie(res, header)`* — Queues one `Set-Cookie` header without discarding the ones already queued. `res.setHeader("Set-Cookie", value)` replaces the header, so a route that issues a session cookie and then a CSRF cookie sends only the second; `Set-Cookie` is the one response header that is legitimately repeated. The appender uses `res.appendHeader` where the runtime offers it and array-merges where it does not.
12
42
 
13
43
  It pairs with `b.cookies.serialize`, which validates and builds the header string. `b.cookies.create().write()` and `.clear()` compose both; reach for the two separately when you need to build the header at one point in a flow and queue it at another — validating early, emitting only once the side effect it accompanies has succeeded, which is what `b.session.logout` does with the session row. **Changed:** *`b.session.logout` queues its expiry cookie rather than overwriting the header* — It called `res.setHeader("Set-Cookie", ...)`, which discarded any cookie the route had already queued — a rotated CSRF token, a locale. It now appends. Code reading `res.getHeader("Set-Cookie")` after a logout sees an array of header strings rather than a single string; Node accepts either form on the way out.
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.16
71
+ Version: 0.5.17
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,7 +156,7 @@ 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
@@ -322,7 +322,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
322
322
  | [`@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
323
  | [`@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
324
  | [`@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.16 | [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` |
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
326
  | [`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
327
  | [`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
328
 
package/lib/file-type.js CHANGED
@@ -353,9 +353,89 @@ function assertOneOf(buf, allowlist, opts) {
353
353
  return detected;
354
354
  }
355
355
 
356
+ // Both directions of the signature table, built once at load. Every row is
357
+ // 1:1 in both directions, so neither map loses an entry; a row added with a
358
+ // duplicate mime or extension would silently shadow, which the test asserts
359
+ // against by round-tripping every row.
360
+ var _MIME_TO_EXT = Object.create(null);
361
+ var _EXT_TO_MIME = Object.create(null);
362
+ SIGNATURES.forEach(function (row) {
363
+ if (row.mime && !(row.mime.toLowerCase() in _MIME_TO_EXT)) {
364
+ _MIME_TO_EXT[row.mime.toLowerCase()] = row.extension;
365
+ }
366
+ if (row.extension && !(row.extension.toLowerCase() in _EXT_TO_MIME)) {
367
+ _EXT_TO_MIME[row.extension.toLowerCase()] = row.mime;
368
+ }
369
+ });
370
+
371
+ /**
372
+ * @primitive b.fileType.extensionFor
373
+ * @signature b.fileType.extensionFor(mime)
374
+ * @since 0.18.43
375
+ * @status stable
376
+ * @related b.fileType.detect, b.fileType.mimeFor
377
+ *
378
+ * The canonical file extension for a MIME type, with no leading dot, or `null`
379
+ * when the framework does not recognise the type. Never a guess: a caller
380
+ * minting an object-store key or a `Content-Disposition` filename needs to
381
+ * know when the answer is unknown rather than receive a plausible-looking one.
382
+ *
383
+ * `detect` answers "what is this"; the next question is usually "what should I
384
+ * call it", and the table that answers it is the same one. Reading it here
385
+ * rather than keeping a private copy is what stops the two drifting when a
386
+ * signature is added.
387
+ *
388
+ * The lookup is case-insensitive and ignores content-type parameters, so the
389
+ * `Content-Type` header value a request arrived with can be passed straight in.
390
+ *
391
+ * @example
392
+ * b.fileType.extensionFor("image/png");
393
+ * // → "png"
394
+ */
395
+ function extensionFor(mime) {
396
+ if (typeof mime !== "string" || mime.length === 0) return null;
397
+ // MIME types are case-insensitive (RFC 2045 §5.1) and may carry parameters.
398
+ var bare = mime.split(";")[0].trim().toLowerCase();
399
+ if (bare.length === 0) return null;
400
+ return Object.prototype.hasOwnProperty.call(_MIME_TO_EXT, bare) ? _MIME_TO_EXT[bare] : null;
401
+ }
402
+
403
+ /**
404
+ * @primitive b.fileType.mimeFor
405
+ * @signature b.fileType.mimeFor(extension)
406
+ * @since 0.18.43
407
+ * @status stable
408
+ * @related b.fileType.detect, b.fileType.extensionFor
409
+ *
410
+ * The MIME type for a file extension, or `null` when the framework does not
411
+ * recognise it. The inverse of `b.fileType.extensionFor`, over the same table.
412
+ *
413
+ * A leading dot is accepted, because that is the form `path.extname` returns
414
+ * and therefore the form a caller usually has in hand. The lookup is
415
+ * case-insensitive.
416
+ *
417
+ * This answers a naming question, not a trust question. It reports what an
418
+ * extension claims; it does not establish what a file IS. Only
419
+ * `b.fileType.detect` does that, by reading the bytes — an attacker controls
420
+ * the name, never the magic.
421
+ *
422
+ * @example
423
+ * b.fileType.mimeFor(".png");
424
+ * // → "image/png"
425
+ */
426
+ function mimeFor(extension) {
427
+ if (typeof extension !== "string" || extension.length === 0) return null;
428
+ var ext = extension.charAt(0) === "." ? extension.slice(1) : extension;
429
+ ext = ext.trim().toLowerCase();
430
+ if (ext.length === 0) return null;
431
+ return Object.prototype.hasOwnProperty.call(_EXT_TO_MIME, ext) ? _EXT_TO_MIME[ext] : null;
432
+ }
433
+
356
434
  module.exports = {
357
435
  detect: detect,
358
436
  assertOneOf: assertOneOf,
437
+ extensionFor: extensionFor,
438
+ mimeFor: mimeFor,
359
439
  FileTypeError: FileTypeError,
360
440
  // Internal — exposed so tests can introspect the registry shape.
361
441
  _SIGNATURES: SIGNATURES,
@@ -7,7 +7,13 @@
7
7
  * authentication, but catches drive-by scrapers and most low-effort bots.
8
8
  *
9
9
  * Heuristics (all combined):
10
- * - Missing Accept-Language header (real browsers always send one)
10
+ * - Missing Accept-Language header ADVISORY ONLY (never blocks). Tagged
11
+ * in mode:"tag". It cannot block because the header is absent for entire
12
+ * client families: every major search-engine crawler omits it (Google
13
+ * documents that Googlebot "sends HTTP requests without setting
14
+ * Accept-Language"), as do uptime monitors, link previewers and feed
15
+ * readers — a 403 on it alone made a site's every page unreachable to
16
+ * Googlebot while a browser sailed through.
11
17
  * - Missing Sec-Fetch-Mode header — ADVISORY ONLY (never blocks). Tagged
12
18
  * in mode:"tag" on secure-context HTML GETs where a modern browser
13
19
  * would have sent it. It cannot block because the header is absent for
@@ -90,13 +96,15 @@ function _coerceAgentPattern(r, where) {
90
96
  * Cheap fingerprint-based detection of obviously-non-browser requests.
91
97
  * Constructed via `b.middleware.botGuard(opts)`; the resulting
92
98
  * middleware has the `(req, res, next)` shape shown above.
93
- * Two blocking heuristicsmissing `Accept-Language` and a User-Agent
94
- * regex match against a default list (curl / wget / python-requests /
95
- * axios / etc.) plus one advisory signal: a missing `Sec-Fetch-Mode`
96
- * on a secure-context HTML GET sets `req.suspectedBot` in `mode: "tag"`
97
- * but NEVER blocks (the header is absent for Safari < 16.4 and every
98
- * plain-HTTP non-localhost origin, so blocking on it refuses real
99
- * users). Not
99
+ * One blocking heuristica User-Agent regex match against a default
100
+ * list (curl / wget / python-requests / axios / etc.) — plus two
101
+ * advisory signals that set `req.suspectedBot` in `mode: "tag"` and
102
+ * NEVER block: a missing `Accept-Language` (absent from every major
103
+ * search-engine crawler, so blocking on it makes a site unreachable to
104
+ * them) and a missing `Sec-Fetch-Mode` on a secure-context HTML GET
105
+ * (absent for Safari < 16.4 and every plain-HTTP non-localhost origin).
106
+ * A header a whole client family omits is not evidence of automation.
107
+ * Not
100
108
  * a substitute for proper authentication — catches drive-by scrapers
101
109
  * and low-effort bots. In `mode: "block"` (default) the request is
102
110
  * refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
@@ -206,14 +214,29 @@ function create(opts) {
206
214
  // Skip browser-fingerprint checks for API routes
207
215
  return null;
208
216
  }
209
- if (!headers["accept-language"]) return "missing-accept-language";
217
+ // Missing Accept-Language NEVER blocks, for the reason the Sec-Fetch-Mode
218
+ // check below does not either: the header is absent for entire CLIENT
219
+ // families, so a 403 on it alone refuses them wholesale. Every major
220
+ // search-engine crawler omits it — Google documents that Googlebot "sends
221
+ // HTTP requests without setting Accept-Language in the request header",
222
+ // and bingbot behaves the same — so blocking on it made every content page
223
+ // of a blamejs site answer 403 to a crawler while a browser sailed
224
+ // through. Measured on a live deployment: a 337-URL sitemap of which only
225
+ // the cached homepage was reachable. Uptime monitors, link previewers and
226
+ // feed readers are refused the same way.
227
+ //
228
+ // It survives as an advisory TAG, so an operator can still rate-limit or
229
+ // log on it. Automation libraries remain blocked by the User-Agent
230
+ // deny-list, which is what actually distinguishes them; separating a
231
+ // crawler from an abuser is a rate-limiting question, not a header one.
232
+ if (mode === "tag" && !headers["accept-language"]) return "missing-accept-language";
210
233
  // Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
211
234
  // browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
212
235
  // and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
213
236
  // reverse proxies), so a 403 on it alone refuses real users. It survives
214
237
  // only as an advisory TAG in mode:"tag", and even then only in a secure
215
238
  // context where a modern browser would have sent it. Drive-by bots are
216
- // still blocked by missing Accept-Language + the User-Agent deny-list.
239
+ // still blocked by the User-Agent deny-list.
217
240
  if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
218
241
  return null;
219
242
  }
package/lib/money.js CHANGED
@@ -444,22 +444,77 @@ function roundMinor(minor, step, mode) {
444
444
  // method: floor each share, then hand out the leftover units to the
445
445
  // shares with the largest fractional remainder. Total preserved by
446
446
  // construction; deterministic across runtimes.
447
- Money.prototype.allocate = function (weights) {
447
+ /**
448
+ * @primitive b.money.splitUnits
449
+ * @signature b.money.splitUnits(total, weights)
450
+ * @since 0.18.43
451
+ * @status stable
452
+ * @related b.money.fromMinorUnits, b.money.roundMinor
453
+ *
454
+ * Split an integer into parts proportional to `weights`, distributing every
455
+ * unit. Largest-remainder method: floor each share, then hand the leftover
456
+ * units to the shares with the largest fractional remainder, ties broken by
457
+ * index. The total is preserved by construction and the result is identical
458
+ * across runtimes — no floating point is involved at any step.
459
+ *
460
+ * This is the algorithm behind `Money.allocate`, reachable without a Money.
461
+ * `Money` requires a currency from `b.money.CURRENCIES`, which carries the
462
+ * codes the framework has minor-unit data for rather than all of ISO 4217, so
463
+ * a caller holding a currency outside that set could not reach the split at
464
+ * all. Nor could a caller splitting something that is not money — seats,
465
+ * quota, shard weights, a rate limit across workers — which is the same
466
+ * arithmetic with the same requirement that nothing be lost to rounding.
467
+ *
468
+ * `total` may be negative: shares floor toward negative infinity so the
469
+ * leftover pass stays positive and the parts still sum to `total`.
470
+ *
471
+ * @example
472
+ * b.money.splitUnits(100n, [1, 1, 1]);
473
+ * // → [34n, 33n, 33n]
474
+ */
475
+ function splitUnits(total, weights) {
476
+ return _largestRemainder(_toBigIntUnits(total, "total"), weights, "splitUnits");
477
+ }
478
+
479
+ // Coerce a BigInt or SAFE integer Number; anything else is a caller error.
480
+ //
481
+ // Number.isInteger is not enough. Beyond Number.MAX_SAFE_INTEGER a literal has
482
+ // already been rounded to a representable neighbour before this function ever
483
+ // sees it — 9007199254740993 arrives as 9007199254740992, isInteger says yes,
484
+ // and BigInt() faithfully preserves the wrong value. The split would then be
485
+ // exact arithmetic over a total the caller never asked for, which breaks the
486
+ // one guarantee this function makes. Above that range the caller has to pass a
487
+ // BigInt, which carries the value exactly.
488
+ function _toBigIntUnits(v, label) {
489
+ if (typeof v === "bigint") return v;
490
+ if (typeof v === "number" && Number.isSafeInteger(v)) return BigInt(v);
491
+ throw new MoneyError("money/bad-units",
492
+ label + " must be a BigInt or a safe integer Number (beyond " +
493
+ "Number.MAX_SAFE_INTEGER a Number has already lost the value — pass a BigInt); got " +
494
+ (typeof v === "number" ? String(v) : typeof v));
495
+ }
496
+
497
+ // The shared largest-remainder split. `label` names the caller in refusals so
498
+ // an operator sees the API they actually invoked.
499
+ function _largestRemainder(total, weights, label) {
448
500
  if (!Array.isArray(weights) || weights.length === 0) {
449
501
  throw new MoneyError("money/bad-weights",
450
- "allocate requires a non-empty array of weights");
502
+ label + " requires a non-empty array of weights");
451
503
  }
452
504
  var sum = 0n;
453
505
  var w = new Array(weights.length);
454
506
  for (var i = 0; i < weights.length; i++) {
455
507
  var wi = weights[i];
456
508
  var wBig;
509
+ // Safe-integer, not merely integer — see _toBigIntUnits. A weight past the
510
+ // safe range has already been rounded, and the shares would be exact
511
+ // arithmetic over proportions the caller did not give.
457
512
  if (typeof wi === "bigint") wBig = wi;
458
- else if (typeof wi === "number" && Number.isInteger(wi)) wBig = BigInt(wi);
513
+ else if (typeof wi === "number" && Number.isSafeInteger(wi)) wBig = BigInt(wi);
459
514
  else {
460
515
  throw new MoneyError("money/bad-weight",
461
- "weight[" + i + "] must be BigInt or integer Number; got " +
462
- (typeof wi));
516
+ "weight[" + i + "] must be a BigInt or a safe integer Number; got " +
517
+ (typeof wi === "number" ? String(wi) : typeof wi));
463
518
  }
464
519
  if (wBig < 0n) {
465
520
  throw new MoneyError("money/bad-weight",
@@ -470,9 +525,8 @@ Money.prototype.allocate = function (weights) {
470
525
  }
471
526
  if (sum === 0n) {
472
527
  throw new MoneyError("money/bad-weights",
473
- "allocate weights sum to zero");
528
+ label + " weights sum to zero");
474
529
  }
475
- var total = this._minor;
476
530
  var shares = new Array(weights.length);
477
531
  var remainders = new Array(weights.length);
478
532
  var allocated = 0n;
@@ -508,6 +562,14 @@ Money.prototype.allocate = function (weights) {
508
562
  leftover = leftover - 1n;
509
563
  k = k + 1;
510
564
  }
565
+ return shares;
566
+ }
567
+
568
+ // allocate -- split `this` into `weights.length` parts proportional to the
569
+ // weights, distributing every minor unit. The arithmetic is b.money.splitUnits;
570
+ // this wraps each share back into a Money of the same currency.
571
+ Money.prototype.allocate = function (weights) {
572
+ var shares = _largestRemainder(this._minor, weights, "allocate");
511
573
  var out = new Array(shares.length);
512
574
  for (var s2 = 0; s2 < shares.length; s2++) {
513
575
  out[s2] = new Money(shares[s2], this.currency);
@@ -800,6 +862,7 @@ module.exports = {
800
862
  zero: zero,
801
863
  convert: convert,
802
864
  roundMinor: roundMinor,
865
+ splitUnits: splitUnits,
803
866
  CURRENCIES: CURRENCIES,
804
867
  Money: Money,
805
868
  MoneyError: MoneyError,