@blamejs/core 0.8.4 → 0.8.6

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - **0.8.6** (2026-05-06) — New primitives: `b.middleware.dailyByteQuota` + `b.appShutdown` extensions. **`b.middleware.dailyByteQuota`** — per-IP rolling 24-hour byte budget (24 hourly bins, slides per-second so a peer can't reset by waiting past midnight). Memory backend single-node by default; `opts.cache` wires `b.cache` for cluster-shared accounting. Refuses with 429 + `Retry-After` when peers exceed the quota; emits `middleware.daily_byte_quota.refused` audit + `middleware.daily_byte_quota.refused` metric. Inbound + outbound bytes counted (header bytes + content-length + outbound write/end byte counts). Fail-open on cache backend errors with audited reason — a flaky cache no longer takes the framework down. **`b.appShutdown` extensions** — `onUncaught` hook fires on `uncaughtException` / `unhandledRejection`; default is graceful-shutdown with exitCode=1, operators can wire a hook for relay to PagerDuty / observability before exit. `opts.signals` now accepts a custom signal list (defaults to `["SIGTERM","SIGINT"]`); operators add `SIGUSR2` (nodemon restart), `SIGHUP` (terminal disconnect), `SIGQUIT` (`kill -3`) without subclassing. `b.appShutdown.pidLock(lockPath)` — single-instance file lock that writes `process.pid`, refuses to acquire when another live process holds the lock, reaps stale lock files (PID gone), and releases on shutdown. **`b.observability.otlpExporter`** — new `system.observability.otlp_exporter.post_failed` audit emission distinguishes timeout / abort from generic network failure (operators can route timeout-rooted exporter degradation to a different alert channel). `stats()` now reports `droppedTotal` (queue overflow + export failed) and emits a `dropped_total` metric on every call so dashboards chart the running drop count. **`b.auth.oauth.fetchUserInfo`** — refuses on OIDC IdPs unless the caller threads `ufiOpts.idTokenSub` (the verified `sub` claim from `exchangeCode`'s `id_token`); cross-checks userinfo `sub === idTokenSub` to defend against token-substitution where a hostile IdP returns a different user's profile. Non-OIDC OAuth 2.0 deployments mis-flagged as `isOidc` opt out via `{ skipSubCheck: true }` with audited reason.
12
+
13
+ - **0.8.5** (2026-05-06) — Vendor-currency CI gate + `b.middleware.requireMtls` primitive. **Vendor-currency check** — `scripts/check-vendor-currency.js` + new CI job in `ci.yml` assert every npm-mapped vendored bundle in `lib/vendor/MANIFEST.json` matches the latest published version on the npm registry. Per-component check on meta-bundles (e.g. `peculiar-pki` → `@peculiar/x509` + `pkijs`). Master-branch corpus entries (`SecLists`) are checked against the GitHub Commits API for the bundled file's path on the source repo's default branch — if the upstream has commits newer than the manifest's `bundledAt` date, the gate fails. Registry errors stay advisory unless `BLAMEJS_VENDOR_CURRENCY_STRICT=1`. Operators run locally with `npm run check:vendor-currency`. **`b.middleware.requireMtls`** — new soft-enforcement middleware that rejects requests without an authenticated client certificate. Composes with `b.crypto.hashCertFingerprint` / `isCertRevoked` (added in v0.8.4) so operators pass `fingerprintAllowList: [...]` and `denyList: [...]` and the middleware does the constant-time match. `req.peerCert` + `req.peerFingerprint` are attached for downstream handlers. Audits `mtls.required.allowed` / `mtls.required.refused` with reason metadata.
14
+
11
15
  - **0.8.4** (2026-05-06) — Supply-chain scanner findings + outbound HTTP posture + npm-publish unblock. **Outbound network surface** — `b.observability.otlpExporter` no longer defaults to `globalThis.fetch`; the default transport is now `b.httpClient` (`node:https` through the framework's PQC-hybrid agent + cert-pinning + SSRF guard). The prior default leaked an outbound network surface that supply-chain scanners flagged because it sat outside the framework's TLS posture; operators on fetch-only edge runtimes still override via `opts.fetchImpl`. **Dev-mode child-process isolation** — `b.dev.create()` now lazy-requires `child_process` (was top-level — flagged on every install regardless of whether `b.dev` was used) and refuses to construct when `NODE_ENV=production` unless the operator passes `opts.allowProduction:true` with an audited reason. A misconfigured production deploy that accidentally wires the dev-mode restart loop now crashes loudly at boot rather than spawning shells on every save. **Outbound posture additions** — `b.httpClient.request` adds `responseMode: "always-resolve"` (every response resolves with `{statusCode, headers, body}` regardless of HTTP status) plus `onRedirect({from, to, hop, headersStripped, statusCode, method})` hook (operators can throw to abort or rewrite the redirect chain). `b.wsClient.connect` adds `urlFor(attempt)` and `tlsOptsFor(attempt)` per-dial overrides for between-reconnect URL / TLS rotation; the new URL is re-validated through `ssrfGuard` so a hostile upstream can't redirect a reconnecting client at a private address. `b.wsClient` swallows post-`close()` `ECONNRESET` / `EPIPE` so a clean shutdown doesn't surface a noisy unhandled-error event. `b.pqcAgent.create({ ecdhCurve })` accepts a caller-supplied stricter list — operators can drop a group from the framework default but cannot widen with non-PQ groups (the prior hardcoded value blocked legitimate per-deployment narrowing). **Crypto helpers** — `b.crypto.hashCertFingerprint(pem|der)` returns `{ hex, colon }` SHA3-512 digests and `b.crypto.isCertRevoked(pemOrDer, denyList)` does a constant-time match. **Scheduler surface** — `b.scheduler.register(name, intervalMs, fn)` shorthand for the every-N-ms registration shape; `b.scheduler.getStatus()` returns an aggregate health surface for probes / dashboards (started flag, isLeader, per-task list, totals). **npm-publish unblock** — `test/layer-0-primitives/sd-jwt-vc.test.js` was asserting `DEFAULT_ALG === "ES256"` after v0.8.1 flipped the default to `ML-DSA-87`; the assertion now matches the lib (and `DEFAULT_HASH_ALG` for `sha3-512`). v0.8.1 / v0.8.2 / v0.8.3 all failed the npm-publish gate on this single test; this release re-enables the publish workflow.
12
16
 
13
17
  - **0.8.3** (2026-05-06) — Release-gate fixes + post-v0.8.2 hardening. Wiki primitive-section validator gate flagged `b.middleware.csrfProtect` opts-key drift — the `requireOrigin` opt added in v0.8.1 wasn't documented in the wiki seeder; now listed alongside `checkOrigin` / `allowedOrigins`. Gitleaks secret-scan gate flagged `{ privateKey, cipherText }` KEM-envelope shapes in `lib/crypto.js` error messages + `CHANGELOG.md` v0.8.0 entry as generic-api-key false positives — `.gitleaks.toml` adds an explicit regex allowlist for the parameter-name shape and pins the v0.8.0 commit fingerprints. Functional additions: `b.httpClient.request` adds `responseMode: "always-resolve"` opt — every request resolves with `{ statusCode, headers, body }` regardless of HTTP status (operators using the framework as an inbound-proxy upstream no longer have to wrap each call in a try/catch to recover the body of a 4xx/5xx). `b.wsClient` swallows post-close `ECONNRESET` / `EPIPE` errors so a clean `close()` doesn't surface a noisy unhandled-error event when the kernel races the FIN with an in-flight write. `SECURITY.md` documents the `allowInternal: true` test-pattern (legitimate same-host integration tests opt in explicitly with audited reason — never as a production default).
@@ -239,21 +239,65 @@ function create(opts) {
239
239
  };
240
240
  }
241
241
 
242
+ // Operator-supplied uncaught-exception / unhandled-rejection hook.
243
+ // Default behaviour mirrors Node's: log + initiate graceful shutdown.
244
+ // Operators wire a custom hook to relay to PagerDuty / observability /
245
+ // crash-reporters before the process exits. A sync-throw in the hook
246
+ // is caught and logged but does NOT prevent the shutdown.
247
+ var onUncaught = typeof opts.onUncaught === "function" ? opts.onUncaught : null;
248
+ var uncaughtHandler = null;
249
+ var unhandledRejHandler = null;
250
+
251
+ // Operator-supplied set of signals that initiate shutdown. Defaults
252
+ // to ["SIGTERM", "SIGINT"]. SIGUSR2 (nodemon's restart signal),
253
+ // SIGHUP (terminal disconnect), SIGQUIT (graceful from kill -3) are
254
+ // common operator requests. Each signal still routes through the
255
+ // same _signalCallback so the shutdown semantics are identical.
256
+ var operatorSignals = Array.isArray(opts.signals) && opts.signals.length > 0
257
+ ? opts.signals.slice() : ["SIGTERM", "SIGINT"];
258
+
259
+ function _installUncaught() {
260
+ if (uncaughtHandler || unhandledRejHandler) return;
261
+ uncaughtHandler = function (err, origin) {
262
+ log.error("uncaught " + (origin || "exception") + ": " + ((err && err.message) || String(err)));
263
+ if (onUncaught) {
264
+ try { onUncaught(err, origin); }
265
+ catch (e) { log.error("onUncaught hook threw: " + ((e && e.message) || String(e))); }
266
+ }
267
+ shutdown().finally(function () { process.exitCode = process.exitCode || 1; });
268
+ };
269
+ unhandledRejHandler = function (reason) {
270
+ uncaughtHandler(reason instanceof Error ? reason : new Error(String(reason)), "unhandledRejection");
271
+ };
272
+ process.on("uncaughtException", uncaughtHandler);
273
+ process.on("unhandledRejection", unhandledRejHandler);
274
+ }
275
+ function _uninstallUncaught() {
276
+ if (uncaughtHandler) { process.removeListener("uncaughtException", uncaughtHandler); uncaughtHandler = null; }
277
+ if (unhandledRejHandler) { process.removeListener("unhandledRejection", unhandledRejHandler); unhandledRejHandler = null; }
278
+ }
279
+
242
280
  function installSignals() {
243
281
  if (signalsInstalled) return;
244
282
  signalsInstalled = true;
245
- signalHandlers.SIGTERM = _signalCallback("SIGTERM");
246
- signalHandlers.SIGINT = _signalCallback("SIGINT");
247
- process.on("SIGTERM", signalHandlers.SIGTERM);
248
- process.on("SIGINT", signalHandlers.SIGINT);
283
+ for (var si = 0; si < operatorSignals.length; si++) {
284
+ var sig = operatorSignals[si];
285
+ if (typeof sig !== "string" || sig.length === 0) continue;
286
+ signalHandlers[sig] = _signalCallback(sig);
287
+ process.on(sig, signalHandlers[sig]);
288
+ }
289
+ if (onUncaught || opts.installUncaught === true) _installUncaught();
249
290
  }
250
291
 
251
292
  function uninstallSignals() {
252
293
  if (!signalsInstalled) return;
253
- process.removeListener("SIGTERM", signalHandlers.SIGTERM);
254
- process.removeListener("SIGINT", signalHandlers.SIGINT);
294
+ var keys = Object.keys(signalHandlers);
295
+ for (var ki = 0; ki < keys.length; ki++) {
296
+ process.removeListener(keys[ki], signalHandlers[keys[ki]]);
297
+ }
255
298
  signalsInstalled = false;
256
299
  signalHandlers = {};
300
+ _uninstallUncaught();
257
301
  }
258
302
 
259
303
  if (installSignalHandlers) installSignals();
@@ -377,9 +421,101 @@ function standardPhases(components) {
377
421
  return phases;
378
422
  }
379
423
 
424
+ // pidLock — single-instance file lock for processes that must run
425
+ // exactly once on a host. Writes process.pid to lockPath atomically
426
+ // (open+lock+write) and refuses to acquire if another live process
427
+ // already holds it. Stale lock files (PID gone or different exe) are
428
+ // reaped automatically. The lock is released on shutdown via an
429
+ // addPhase, so operators wire it like:
430
+ //
431
+ // var pidLock = b.appShutdown.pidLock("/var/run/blamejs.pid");
432
+ // pidLock.acquire(); // throws if locked elsewhere
433
+ // appShutdownInstance.addPhase({ name: "pidLock", run: pidLock.release });
434
+ //
435
+ // On Windows the underlying flock() call is unavailable; the pidLock
436
+ // falls back to "open with exclusive create" semantics (O_EXCL via
437
+ // fs.openSync) which gives the same single-instance guarantee but
438
+ // without the cross-process advisory lock — the lock file presence
439
+ // IS the lock.
440
+ var nodeFs = require("fs");
441
+ var nodePath = require("path");
442
+
443
+ function pidLock(lockPath) {
444
+ if (typeof lockPath !== "string" || lockPath.length === 0) {
445
+ throw new AppShutdownError("app-shutdown/bad-pidlock-path",
446
+ "pidLock(lockPath): lockPath must be a non-empty string (absolute path recommended)");
447
+ }
448
+ var fd = null;
449
+ var ownsLock = false;
450
+
451
+ function _isLivePid(pid) {
452
+ if (!pid || pid <= 0) return false;
453
+ try { process.kill(pid, 0); return true; } // signal 0 = existence-check
454
+ catch (e) { return e.code === "EPERM"; } // EPERM means process exists, just no rights
455
+ }
456
+
457
+ function _readExisting() {
458
+ try {
459
+ var raw = nodeFs.readFileSync(lockPath, "utf8");
460
+ var pid = parseInt(String(raw).trim(), 10);
461
+ return isFinite(pid) && pid > 0 ? pid : null;
462
+ } catch (_e) { return null; }
463
+ }
464
+
465
+ function acquire() {
466
+ if (ownsLock) return;
467
+ nodeFs.mkdirSync(nodePath.dirname(lockPath), { recursive: true });
468
+ var existing = _readExisting();
469
+ if (existing && _isLivePid(existing) && existing !== process.pid) {
470
+ throw new AppShutdownError("app-shutdown/pidlock-held",
471
+ "pidLock: '" + lockPath + "' already held by live PID " + existing);
472
+ }
473
+ if (existing) {
474
+ // Stale lock — owner is dead. Reap.
475
+ try { nodeFs.unlinkSync(lockPath); } catch (_e) { /* race: someone else reaped it */ }
476
+ }
477
+ try {
478
+ fd = nodeFs.openSync(lockPath, nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT | nodeFs.constants.O_EXCL, 0o600);
479
+ } catch (e) {
480
+ if (e.code === "EEXIST") {
481
+ // Race: another process took the lock between our reap and create.
482
+ var winner = _readExisting();
483
+ throw new AppShutdownError("app-shutdown/pidlock-held",
484
+ "pidLock: '" + lockPath + "' acquired by PID " + (winner || "<unknown>") + " between read and write");
485
+ }
486
+ throw new AppShutdownError("app-shutdown/pidlock-open-failed",
487
+ "pidLock: failed to open '" + lockPath + "': " + e.message);
488
+ }
489
+ nodeFs.writeSync(fd, String(process.pid) + "\n");
490
+ nodeFs.fsyncSync(fd);
491
+ ownsLock = true;
492
+ }
493
+
494
+ function release() {
495
+ if (!ownsLock) return;
496
+ try { nodeFs.closeSync(fd); } catch (_e) { /* best-effort close */ }
497
+ fd = null;
498
+ try {
499
+ var current = _readExisting();
500
+ if (current === process.pid) nodeFs.unlinkSync(lockPath);
501
+ } catch (_e) { /* lock already gone — fine */ }
502
+ ownsLock = false;
503
+ }
504
+
505
+ function held() { return ownsLock; }
506
+
507
+ return {
508
+ acquire: acquire,
509
+ release: release,
510
+ held: held,
511
+ path: lockPath,
512
+ };
513
+ }
514
+
380
515
  module.exports = {
381
516
  create: create,
382
517
  standardPhases: standardPhases,
518
+ pidLock: pidLock,
383
519
  AppShutdownError: AppShutdownError,
384
520
  DEFAULT_GRACE_MS: DEFAULT_GRACE_MS,
385
521
  };
package/lib/auth/oauth.js CHANGED
@@ -526,19 +526,45 @@ function create(opts) {
526
526
  return await _normalizeTokens(tokens, { skipNonceCheck: true });
527
527
  }
528
528
 
529
- async function fetchUserInfo(accessToken) {
529
+ // OIDC requires fetchUserInfo to be called AFTER the id_token has
530
+ // been verified and its sub claim is known — otherwise the
531
+ // userinfo response can't be cross-checked against the id_token's
532
+ // sub, and a hostile IdP could swap the userinfo for a different
533
+ // user. RFC 7662 §3 doesn't mandate the cross-check but every OIDC
534
+ // conformance suite requires it. We refuse to call userinfo when
535
+ // isOidc=true unless the caller threaded the verified idTokenSub
536
+ // (or explicitly opted out via skipSubCheck for a non-OIDC OAuth
537
+ // 2.0 server presented as isOidc=false).
538
+ async function fetchUserInfo(accessToken, ufiOpts) {
539
+ ufiOpts = ufiOpts || {};
530
540
  if (!accessToken) {
531
541
  throw new OAuthError("auth-oauth/no-access-token",
532
542
  "fetchUserInfo: access token is required");
533
543
  }
544
+ if (isOidc && ufiOpts.idTokenSub === undefined && ufiOpts.skipSubCheck !== true) {
545
+ throw new OAuthError("auth-oauth/userinfo-no-id-token-sub",
546
+ "fetchUserInfo: OIDC providers require ufiOpts.idTokenSub " +
547
+ "(the verified sub claim from the id_token returned by " +
548
+ "exchangeCode) so the userinfo response can be cross-checked. " +
549
+ "Pass { idTokenSub: tokens.idToken.payload.sub } or, for non-" +
550
+ "OIDC OAuth 2.0 deployments mis-flagged as isOidc, opt out " +
551
+ "explicitly with { skipSubCheck: true } and an audited reason.");
552
+ }
534
553
  var endpoint = await _resolveEndpoint("userinfoEndpoint");
535
- return await _fetchJson(endpoint, {
554
+ var profile = await _fetchJson(endpoint, {
536
555
  headers: {
537
556
  "Authorization": "Bearer " + accessToken,
538
557
  "Accept": "application/json",
539
558
  "User-Agent": "blamejs",
540
559
  },
541
560
  });
561
+ if (isOidc && ufiOpts.idTokenSub !== undefined && profile && profile.sub !== ufiOpts.idTokenSub) {
562
+ throw new OAuthError("auth-oauth/userinfo-sub-mismatch",
563
+ "fetchUserInfo: userinfo.sub (" + profile.sub + ") does not match " +
564
+ "the id_token sub (" + ufiOpts.idTokenSub + ") — possible token " +
565
+ "substitution attack");
566
+ }
567
+ return profile;
542
568
  }
543
569
 
544
570
  async function revokeToken(token, ropts) {
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ /**
3
+ * dailyByteQuota middleware — per-IP rolling 24-hour byte budget.
4
+ *
5
+ * Tracks request + response bytes per peer IP across a rolling 24-hour
6
+ * window. When a peer exceeds the operator-configured quota, further
7
+ * requests are rejected with 429 + Retry-After. The window slides per-
8
+ * second so a peer that hammers the framework for 23 hours and 59
9
+ * minutes can't reset by waiting an instant past midnight.
10
+ *
11
+ * var quota = b.middleware.dailyByteQuota({
12
+ * bytesPerDay: b.constants.BYTES.gib(2), // 2 GiB / IP / day
13
+ * getKey: function (req) {
14
+ * // default: req.ip — operator overrides for tenant-id / api-key
15
+ * return req.ip;
16
+ * },
17
+ * cache: null, // single-node memory by default
18
+ * audit: b.audit,
19
+ * onExceeded: function (req, res, info) {
20
+ * res.setHeader("Retry-After", info.retryAfterSec);
21
+ * res.statusCode = 429;
22
+ * res.end(JSON.stringify({ error: "quota-exceeded", info: info }));
23
+ * },
24
+ * });
25
+ * router.use(quota);
26
+ *
27
+ * The middleware fires twice per request:
28
+ * - On entry: peek the running counter, refuse if already past quota
29
+ * - On res.end / res.write: account both directions of byte transfer
30
+ *
31
+ * Single-node memory backend uses a Map<ip, { bins: Uint32Array(24),
32
+ * windowStartHour: number }>. Each bin holds bytes for one rolling hour;
33
+ * sweeping happens on every account() call so cold storage doesn't grow
34
+ * unbounded. Cluster-aware operators wire opts.cache (b.cache instance)
35
+ * and the same pattern runs in the shared backend.
36
+ *
37
+ * Failure modes:
38
+ * - cache backend unreachable → fail-open (count drops, request
39
+ * proceeds), audit emitted to operator alerting; the alternative
40
+ * fail-closed would let a flaky cache take down the framework
41
+ * - peer key resolution returns null → request bypasses the quota
42
+ * (operator's getKey decided this IP is out-of-scope)
43
+ */
44
+
45
+ var C = require("../constants");
46
+ var defineClass = require("../framework-error").defineClass;
47
+ var lazyRequire = require("../lazy-require");
48
+ var validateOpts = require("../validate-opts");
49
+
50
+ var audit = lazyRequire(function () { return require("../audit"); });
51
+ var observability = lazyRequire(function () { return require("../observability"); });
52
+ var requestHelpers = lazyRequire(function () { return require("../request-helpers"); });
53
+
54
+ var DailyByteQuotaError = defineClass("DailyByteQuotaError", { alwaysPermanent: true });
55
+
56
+ var BINS_PER_DAY = 24; // allow:raw-byte-literal — 24 hours in a day
57
+ var BIN_MS = C.TIME.hours(1);
58
+
59
+ // Default getKey — req.ip OR the trusted-proxy-resolved peer address
60
+ // when the operator wired b.middleware.requestId or similar earlier in
61
+ // the chain. We don't try to be clever here: req.ip is the canonical
62
+ // shape every other middleware reads.
63
+ function _defaultGetKey(req) {
64
+ return requestHelpers().clientIp(req, { trustProxy: false });
65
+ }
66
+
67
+ function _hourBin(nowMs) { return Math.floor(nowMs / BIN_MS); }
68
+ function _newEntry() { return { bins: new Array(BINS_PER_DAY).fill(0), startHour: 0 }; }
69
+
70
+ // Shared sliding-window helper — both backends call this so the
71
+ // per-bin shift / zero / total math lives in one place. Returns the
72
+ // (possibly mutated) entry; caller persists if the entry is shared
73
+ // state (cache backend writes back).
74
+ function _slideAndSum(entry, nowHour) {
75
+ if (entry.startHour === 0) entry.startHour = nowHour - (BINS_PER_DAY - 1);
76
+ var advance = nowHour - (entry.startHour + (BINS_PER_DAY - 1));
77
+ var moved = false;
78
+ if (advance > 0) {
79
+ moved = true;
80
+ if (advance >= BINS_PER_DAY) {
81
+ for (var i = 0; i < BINS_PER_DAY; i++) entry.bins[i] = 0;
82
+ } else {
83
+ for (var j = 0; j < BINS_PER_DAY - advance; j++) entry.bins[j] = entry.bins[j + advance];
84
+ for (var k = BINS_PER_DAY - advance; k < BINS_PER_DAY; k++) entry.bins[k] = 0;
85
+ }
86
+ entry.startHour = nowHour - (BINS_PER_DAY - 1);
87
+ }
88
+ var total = 0;
89
+ for (var t = 0; t < BINS_PER_DAY; t++) total += entry.bins[t];
90
+ return { entry: entry, total: total, moved: moved };
91
+ }
92
+
93
+ function _memoryBackend() {
94
+ var store = new Map();
95
+ function _get(key) {
96
+ var entry = store.get(key);
97
+ if (!entry) { entry = _newEntry(); store.set(key, entry); }
98
+ return entry;
99
+ }
100
+ return {
101
+ async total(key, nowMs) {
102
+ return _slideAndSum(_get(key), _hourBin(nowMs)).total;
103
+ },
104
+ async account(key, bytes, nowMs) {
105
+ var slid = _slideAndSum(_get(key), _hourBin(nowMs));
106
+ slid.entry.bins[BINS_PER_DAY - 1] += bytes;
107
+ },
108
+ _resetForTest: function () { store.clear(); },
109
+ };
110
+ }
111
+
112
+ function _cacheBackend(cache) {
113
+ function _key(k) { return "dailyByteQuota:" + k; }
114
+ async function _read(key) {
115
+ var raw = await cache.get(_key(key));
116
+ return raw && typeof raw === "object" && Array.isArray(raw.bins) ? raw : _newEntry();
117
+ }
118
+ return {
119
+ async total(key, nowMs) {
120
+ var entry = await _read(key);
121
+ var slid = _slideAndSum(entry, _hourBin(nowMs));
122
+ if (slid.moved) await cache.set(_key(key), slid.entry, { ttlMs: BIN_MS * BINS_PER_DAY });
123
+ return slid.total;
124
+ },
125
+ async account(key, bytes, nowMs) {
126
+ var entry = await _read(key);
127
+ var slid = _slideAndSum(entry, _hourBin(nowMs));
128
+ slid.entry.bins[BINS_PER_DAY - 1] += bytes;
129
+ await cache.set(_key(key), slid.entry, { ttlMs: BIN_MS * BINS_PER_DAY });
130
+ },
131
+ };
132
+ }
133
+
134
+ function create(opts) {
135
+ opts = opts || {};
136
+ validateOpts(opts, [
137
+ "bytesPerDay", "cache", "getKey", "audit",
138
+ "onExceeded", "skipPaths", "now",
139
+ ], "middleware.dailyByteQuota");
140
+
141
+ if (typeof opts.bytesPerDay !== "number" || !isFinite(opts.bytesPerDay) || opts.bytesPerDay <= 0) {
142
+ throw new DailyByteQuotaError("daily-byte-quota/bad-quota",
143
+ "middleware.dailyByteQuota: opts.bytesPerDay must be a positive finite number; " +
144
+ "use b.constants.BYTES.gib(N) / mib(N) for readable values");
145
+ }
146
+ var bytesPerDay = opts.bytesPerDay;
147
+ var getKey = typeof opts.getKey === "function" ? opts.getKey : _defaultGetKey;
148
+ var auditOn = opts.audit !== false;
149
+ var onExceeded = typeof opts.onExceeded === "function" ? opts.onExceeded : null;
150
+ var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
151
+ var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
152
+ var backend = opts.cache && typeof opts.cache.get === "function"
153
+ ? _cacheBackend(opts.cache)
154
+ : _memoryBackend();
155
+
156
+ function _shouldSkip(req) {
157
+ if (skipPaths.length === 0) return false;
158
+ var p = req.url || req.originalUrl || "";
159
+ var qpos = p.indexOf("?");
160
+ if (qpos !== -1) p = p.slice(0, qpos);
161
+ for (var i = 0; i < skipPaths.length; i++) {
162
+ if (typeof skipPaths[i] === "string" && p === skipPaths[i]) return true;
163
+ if (skipPaths[i] instanceof RegExp && skipPaths[i].test(p)) return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function _emitAudit(action, outcome, metadata) {
169
+ if (!auditOn) return;
170
+ try {
171
+ audit().safeEmit({
172
+ action: "middleware.daily_byte_quota." + action,
173
+ outcome: outcome,
174
+ metadata: metadata || {},
175
+ });
176
+ } catch (_e) { /* drop-silent — audit is best-effort */ }
177
+ }
178
+
179
+ function _emitMetric(verb, n, labels) {
180
+ try { observability().safeEvent("middleware.daily_byte_quota." + verb, n || 1, labels || {}); }
181
+ catch (_e) { /* drop-silent */ }
182
+ }
183
+
184
+ return async function dailyByteQuotaMiddleware(req, res, next) {
185
+ if (_shouldSkip(req)) return next();
186
+ var key;
187
+ try { key = getKey(req); }
188
+ catch (e) {
189
+ _emitAudit("get_key_failed", "failure", { error: (e && e.message) || String(e) });
190
+ return next(); // fail-open on operator-supplied key resolution
191
+ }
192
+ if (!key) return next();
193
+
194
+ var nowMs = now();
195
+ var total;
196
+ try { total = await backend.total(key, nowMs); }
197
+ catch (e) {
198
+ _emitAudit("backend_error", "failure", { phase: "total", error: (e && e.message) || String(e) });
199
+ return next(); // fail-open on cache miss
200
+ }
201
+ if (total >= bytesPerDay) {
202
+ _emitMetric("refused", 1, { reason: "quota-exceeded" });
203
+ _emitAudit("refused", "denied", { key: key, total: total, quota: bytesPerDay });
204
+ var info = {
205
+ quota: bytesPerDay,
206
+ total: total,
207
+ retryAfterSec: Math.max(C.TIME.seconds(1) / C.TIME.seconds(1) | 0, Math.ceil(BIN_MS / C.TIME.seconds(1))),
208
+ };
209
+ if (onExceeded) {
210
+ try { return onExceeded(req, res, info); }
211
+ catch (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); }
212
+ }
213
+ if (!res.writableEnded) {
214
+ res.writeHead(429, {
215
+ "Content-Type": "application/json; charset=utf-8",
216
+ "Retry-After": String(info.retryAfterSec),
217
+ "Cache-Control": "no-store",
218
+ });
219
+ res.end(JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }));
220
+ }
221
+ return;
222
+ }
223
+
224
+ // Account both inbound + outbound bytes. Inbound is roughly the
225
+ // header bytes (we don't proxy the body buffer to count). Outbound
226
+ // is observed via writableLength as res.write / res.end fire.
227
+ var inboundBytes = 0;
228
+ if (req.headers && typeof req.headers === "object") {
229
+ // Approximate: each header line is "Name: Value\r\n". Sum the
230
+ // string lengths; the actual byte count differs only on multi-
231
+ // byte UTF-8, which is uncommon in standard headers.
232
+ var keys = Object.keys(req.headers);
233
+ for (var hi = 0; hi < keys.length; hi++) {
234
+ var v = req.headers[keys[hi]];
235
+ inboundBytes += keys[hi].length + 2 + (typeof v === "string" ? v.length : 0) + 2; // allow:raw-byte-literal — ": " + "\r\n" overhead
236
+ }
237
+ }
238
+ if (req.headers && req.headers["content-length"]) {
239
+ var clen = parseInt(req.headers["content-length"], 10);
240
+ if (isFinite(clen) && clen > 0) inboundBytes += clen;
241
+ }
242
+
243
+ // Patch res.write / res.end to account outbound bytes.
244
+ var outboundBytes = 0;
245
+ var origWrite = res.write.bind(res);
246
+ var origEnd = res.end.bind(res);
247
+ res.write = function (chunk, encoding, cb) {
248
+ if (chunk) {
249
+ outboundBytes += Buffer.isBuffer(chunk) ? chunk.length :
250
+ Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8");
251
+ }
252
+ return origWrite(chunk, encoding, cb);
253
+ };
254
+ res.end = function (chunk, encoding, cb) {
255
+ if (chunk) {
256
+ outboundBytes += Buffer.isBuffer(chunk) ? chunk.length :
257
+ Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8");
258
+ }
259
+ // Account on response end so a slow long-poll doesn't block the
260
+ // accounting until the client drops.
261
+ backend.account(key, inboundBytes + outboundBytes, now())
262
+ .catch(function (e) { _emitAudit("backend_error", "failure", { phase: "account", error: (e && e.message) || String(e) }); });
263
+ return origEnd(chunk, encoding, cb);
264
+ };
265
+
266
+ return next();
267
+ };
268
+ }
269
+
270
+ module.exports = {
271
+ create: create,
272
+ DailyByteQuotaError: DailyByteQuotaError,
273
+ _memoryBackend: _memoryBackend, // exported for tests
274
+ BINS_PER_DAY: BINS_PER_DAY,
275
+ };
@@ -29,6 +29,7 @@ var botGuard = require("./bot-guard");
29
29
  var compression = require("./compression");
30
30
  var cookies = require("./cookies");
31
31
  var cors = require("./cors");
32
+ var dailyByteQuota = require("./daily-byte-quota");
32
33
  var cspNonce = require("./csp-nonce");
33
34
  var csrfProtect = require("./csrf-protect");
34
35
  var dbRoleFor = require("./db-role-for");
@@ -47,6 +48,7 @@ var requireAal = require("./require-aal");
47
48
  var requireAuth = require("./require-auth");
48
49
  var requireContentType = require("./require-content-type");
49
50
  var requireMethods = require("./require-methods");
51
+ var requireMtls = require("./require-mtls");
50
52
  var requireStepUp = require("./require-step-up");
51
53
  var securityHeaders = require("./security-headers");
52
54
  var securityTxt = require("./security-txt");
@@ -63,6 +65,7 @@ module.exports = {
63
65
  errorHandler: errorHandler.create,
64
66
  botGuard: botGuard.create,
65
67
  cors: cors.create,
68
+ dailyByteQuota: dailyByteQuota.create,
66
69
  rateLimit: rateLimit.create,
67
70
  attachUser: attachUser.create,
68
71
  bearerAuth: bearerAuth.create,
@@ -70,6 +73,7 @@ module.exports = {
70
73
  requireAuth: requireAuth.create,
71
74
  requireContentType: requireContentType.create,
72
75
  requireMethods: requireMethods.create,
76
+ requireMtls: requireMtls.create,
73
77
  requireStepUp: requireStepUp.create,
74
78
  csrfProtect: csrfProtect.create,
75
79
  fetchMetadata: fetchMetadata.create,
@@ -106,6 +110,7 @@ module.exports = {
106
110
  errorHandler: errorHandler,
107
111
  botGuard: botGuard,
108
112
  cors: cors,
113
+ dailyByteQuota: dailyByteQuota,
109
114
  rateLimit: rateLimit,
110
115
  attachUser: attachUser,
111
116
  bearerAuth: bearerAuth,
@@ -113,6 +118,7 @@ module.exports = {
113
118
  requireAuth: requireAuth,
114
119
  requireContentType: requireContentType,
115
120
  requireMethods: requireMethods,
121
+ requireMtls: requireMtls,
116
122
  requireStepUp: requireStepUp,
117
123
  csrfProtect: csrfProtect,
118
124
  fetchMetadata: fetchMetadata,
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ /**
3
+ * requireMtls middleware — soft-enforcement gate for routes that
4
+ * require a client certificate.
5
+ *
6
+ * Operators terminate TLS at the framework's HTTPS server with
7
+ * `requestCert: true` (the framework already wires this when
8
+ * `b.app({ tlsOptions: { requestCert: true, ca: [...] } })` is
9
+ * configured). For routes that MUST receive an authenticated peer
10
+ * cert — e.g. the inbound side of an mTLS service mesh, OAuth 2.0
11
+ * mTLS Client Authentication (RFC 8705), or operator-specific
12
+ * service-to-service endpoints — wire this middleware in front of
13
+ * the route to reject any request that didn't present a valid
14
+ * client cert.
15
+ *
16
+ * var requireMtls = b.middleware.requireMtls({
17
+ * fingerprintAllowList: [
18
+ * "AB:CD:EF:...", // colon-separated SHA3-512 hex
19
+ * ],
20
+ * denyList: [], // explicit revocations
21
+ * onAuthenticated: function (req, res, next) {
22
+ * req.peerSubject = req.peerCert.subject;
23
+ * next();
24
+ * },
25
+ * audit: b.audit,
26
+ * });
27
+ * router.use("/internal", requireMtls);
28
+ *
29
+ * Failure modes (all reject 401):
30
+ * - No peer cert presented (client did not negotiate mTLS)
31
+ * - Peer cert present but unauthorized at TLS layer
32
+ * (req.client.authorized === false)
33
+ * - Fingerprint not on the operator-supplied allow-list
34
+ * - Fingerprint on the operator-supplied deny-list
35
+ *
36
+ * Audit shape (when audit is wired): emits `mtls.required.allowed`
37
+ * (success) or `mtls.required.refused` (denied) with the peer-cert
38
+ * fingerprint + subject + reason in metadata. Drop-silent if no
39
+ * audit is wired.
40
+ *
41
+ * The fingerprint allow / deny comparison routes through
42
+ * b.crypto.isCertRevoked — both forms (lowercase hex / uppercase
43
+ * colon-separated) match. Allow-list of empty / null = "any
44
+ * peer cert authorized at the TLS layer"; specifying a non-empty
45
+ * allow-list ALSO requires the fingerprint to match.
46
+ */
47
+
48
+ var defineClass = require("../framework-error").defineClass;
49
+ var lazyRequire = require("../lazy-require");
50
+ var validateOpts = require("../validate-opts");
51
+
52
+ var crypto = lazyRequire(function () { return require("../crypto"); });
53
+ var audit = lazyRequire(function () { return require("../audit"); });
54
+
55
+ var RequireMtlsError = defineClass("RequireMtlsError", { alwaysPermanent: true });
56
+
57
+ function _normalizeFingerprintEntry(entry) {
58
+ if (typeof entry !== "string" || entry.length === 0) {
59
+ throw new RequireMtlsError("require-mtls/bad-fingerprint",
60
+ "fingerprint allow/deny entries must be non-empty strings " +
61
+ "(SHA3-512 hex or colon-separated form)");
62
+ }
63
+ return entry;
64
+ }
65
+
66
+ function create(opts) {
67
+ opts = opts || {};
68
+ validateOpts(opts, [
69
+ "fingerprintAllowList", "denyList",
70
+ "onAuthenticated", "audit",
71
+ "auditAction", "errorMessage",
72
+ ], "middleware.requireMtls");
73
+
74
+ var allowList = Array.isArray(opts.fingerprintAllowList)
75
+ ? opts.fingerprintAllowList.map(_normalizeFingerprintEntry) : null;
76
+ var denyList = Array.isArray(opts.denyList)
77
+ ? opts.denyList.map(_normalizeFingerprintEntry) : [];
78
+ var onAuthenticated = typeof opts.onAuthenticated === "function" ? opts.onAuthenticated : null;
79
+ var auditOn = opts.audit !== false;
80
+ var actionBase = typeof opts.auditAction === "string" && opts.auditAction.length > 0
81
+ ? opts.auditAction : "mtls.required";
82
+ var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
83
+ ? opts.errorMessage : "client certificate required";
84
+
85
+ function _emit(outcome, metadata) {
86
+ if (!auditOn) return;
87
+ try {
88
+ audit().safeEmit({
89
+ action: actionBase + (outcome === "success" ? ".allowed" : ".refused"),
90
+ outcome: outcome,
91
+ metadata: metadata || {},
92
+ });
93
+ } catch (_e) { /* drop-silent — audit is best-effort, never blocks the request */ }
94
+ }
95
+
96
+ function _refuse(res, reason, metadata) {
97
+ _emit("denied", Object.assign({ reason: reason }, metadata || {}));
98
+ if (typeof res.writeHead === "function") {
99
+ res.writeHead(401, {
100
+ "Content-Type": "application/json; charset=utf-8",
101
+ "WWW-Authenticate": "Mutual",
102
+ "Cache-Control": "no-store",
103
+ });
104
+ res.end(JSON.stringify({ error: errorMessage, reason: reason }));
105
+ }
106
+ }
107
+
108
+ return function requireMtlsMiddleware(req, res, next) {
109
+ // Node's TLSSocket exposes:
110
+ // req.client.authorized — boolean, peer cert chain valid
111
+ // req.client.authorizationError — string when authorized=false
112
+ // req.socket.getPeerCertificate() — the cert (raw + parsed fields)
113
+ // Behind a TLS-terminating proxy (e.g. nginx, envoy) operators
114
+ // pass the peer cert as a header (X-Client-Cert) and pre-populate
115
+ // req.peerCert before this middleware fires. We don't inject a
116
+ // proxy-header parser here — that's an operator-side decision tied
117
+ // to the chosen proxy's signing model.
118
+ var sock = req.socket || req.connection || null;
119
+ var authorized = sock && sock.authorized === true;
120
+ var peerCert = req.peerCert || null;
121
+ if (!peerCert && sock && typeof sock.getPeerCertificate === "function") {
122
+ try { peerCert = sock.getPeerCertificate(true) || null; }
123
+ catch (_e) { peerCert = null; }
124
+ }
125
+
126
+ if (!authorized) {
127
+ var authzError = (sock && sock.authorizationError) || "no-peer-cert";
128
+ return _refuse(res, "tls-unauthorized", { authorizationError: String(authzError) });
129
+ }
130
+ if (!peerCert || !peerCert.raw) {
131
+ return _refuse(res, "no-peer-cert", {});
132
+ }
133
+
134
+ // Compute fingerprint via the framework's SHA3-512 helper. Buffer
135
+ // form: peerCert.raw is the DER. Hex/colon both available for
136
+ // allow/deny matching.
137
+ var fp;
138
+ try {
139
+ fp = crypto().hashCertFingerprint(peerCert.raw);
140
+ } catch (e) {
141
+ return _refuse(res, "fingerprint-failed", { error: (e && e.message) || String(e) });
142
+ }
143
+
144
+ if (denyList.length > 0 && crypto().isCertRevoked(peerCert.raw, denyList)) {
145
+ return _refuse(res, "fingerprint-on-deny-list", {
146
+ fingerprint: fp.colon,
147
+ subject: (peerCert.subject && peerCert.subject.CN) || null,
148
+ });
149
+ }
150
+ if (allowList && allowList.length > 0 && !crypto().isCertRevoked(peerCert.raw, allowList)) {
151
+ return _refuse(res, "fingerprint-not-allowed", {
152
+ fingerprint: fp.colon,
153
+ subject: (peerCert.subject && peerCert.subject.CN) || null,
154
+ });
155
+ }
156
+
157
+ // Authenticated — attach the parsed peer cert + fingerprint to
158
+ // the request so downstream handlers don't have to re-parse, then
159
+ // emit success and call next (or operator's onAuthenticated hook).
160
+ req.peerCert = peerCert;
161
+ req.peerFingerprint = fp;
162
+ _emit("success", {
163
+ fingerprint: fp.colon,
164
+ subject: (peerCert.subject && peerCert.subject.CN) || null,
165
+ });
166
+ if (onAuthenticated) {
167
+ try { return onAuthenticated(req, res, next); }
168
+ catch (e) {
169
+ return _refuse(res, "on-authenticated-threw", { error: (e && e.message) || String(e) });
170
+ }
171
+ }
172
+ return next();
173
+ };
174
+ }
175
+
176
+ module.exports = {
177
+ create: create,
178
+ RequireMtlsError: RequireMtlsError,
179
+ };
@@ -41,6 +41,7 @@ var { defineClass } = require("./framework-error");
41
41
  var OtlpExporterError = defineClass("OtlpExporterError", { alwaysPermanent: true });
42
42
 
43
43
  var observability = lazyRequire(function () { return require("./observability"); });
44
+ var audit = lazyRequire(function () { return require("./audit"); });
44
45
  var httpClient = lazyRequire(function () { return require("./http-client"); });
45
46
 
46
47
  // Default OTLP transport — uses the framework's own b.httpClient
@@ -251,10 +252,21 @@ function create(opts) {
251
252
  var inFlight = false;
252
253
  var stopping = false;
253
254
 
255
+ var auditOn = opts.audit !== false;
254
256
  function _emitMetric(verb, n, labels) {
255
257
  try { observability().safeEvent("otlp.exporter." + verb, n || 1, labels || {}); }
256
258
  catch (_e) { /* drop-silent */ }
257
259
  }
260
+ function _emitAudit(action, outcome, metadata) {
261
+ if (!auditOn) return;
262
+ try {
263
+ audit().safeEmit({
264
+ action: "system.observability.otlp_exporter." + action,
265
+ outcome: outcome,
266
+ metadata: metadata || {},
267
+ });
268
+ } catch (_e) { /* drop-silent — audit is best-effort, never crashes the exporter */ }
269
+ }
258
270
 
259
271
  function queue_(span) {
260
272
  if (stopping) { droppedExportFailed += 1; return; }
@@ -302,7 +314,19 @@ function create(opts) {
302
314
  }
303
315
  return { ok: false, status: status, retryable: retryable };
304
316
  } catch (e) {
305
- // Network error / abort
317
+ // Network error / abort. AbortController abort surfaces with
318
+ // name=AbortError; tag the audit so operators can distinguish
319
+ // a genuine network drop from "we timed out reaching the
320
+ // collector". Both are retryable but the audit metadata helps
321
+ // root-cause when collector latency is the issue.
322
+ var abortReason = e && (e.name === "AbortError" || /aborted|timeout/i.test(e.message || ""));
323
+ _emitAudit("post_failed", "failure", {
324
+ attempt: attempt,
325
+ retryable: attempt < maxAttempts,
326
+ reason: abortReason ? "timeout" : "network",
327
+ error: (e && e.message) || String(e),
328
+ });
329
+ if (abortReason) _emitMetric("export_timeout", 1, { attempt: String(attempt) });
306
330
  if (attempt < maxAttempts) {
307
331
  await _sleep(_backoffMs(attempt));
308
332
  return await _post(payload, attempt + 1);
@@ -354,10 +378,22 @@ function create(opts) {
354
378
  }
355
379
 
356
380
  function stats() {
381
+ var totalDropped = droppedQueueOverflow + droppedExportFailed;
382
+ // Operator-facing dropped-count metric — fires every stats() call
383
+ // so dashboards / probes that scrape stats can chart the running
384
+ // total even when individual drop sites already emit per-event
385
+ // metrics. The metric is monotonic for the lifetime of the
386
+ // exporter; a process restart resets it (intended).
387
+ _emitMetric("dropped_total", 0, {
388
+ queue_overflow: String(droppedQueueOverflow),
389
+ export_failed: String(droppedExportFailed),
390
+ total: String(totalDropped),
391
+ });
357
392
  return {
358
393
  queueLength: queue.length,
359
394
  droppedQueueOverflow: droppedQueueOverflow,
360
395
  droppedExportFailed: droppedExportFailed,
396
+ droppedTotal: totalDropped,
361
397
  };
362
398
  }
363
399
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -70,7 +70,8 @@
70
70
  ],
71
71
  "scripts": {
72
72
  "test": "node test/smoke.js",
73
- "prepack": "node scripts/check-pack-against-gitignore.js"
73
+ "prepack": "node scripts/check-pack-against-gitignore.js",
74
+ "check:vendor-currency": "node scripts/check-vendor-currency.js"
74
75
  },
75
76
  "dependencies": {},
76
77
  "devDependencies": {}
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:91e8b760-7fe3-4fef-a317-8b1e4f8cc238",
5
+ "serialNumber": "urn:uuid:88336eab-78a7-45f2-bc73-faf15cf6bbfb",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T22:02:35.725Z",
8
+ "timestamp": "2026-05-06T22:51:45.376Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.4",
22
+ "bom-ref": "@blamejs/core@0.8.6",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.4",
25
+ "version": "0.8.6",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.4",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.6",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.4",
57
+ "ref": "@blamejs/core@0.8.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]