@blamejs/core 0.8.5 → 0.8.7

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.7** (2026-05-06) — `b.auth.accessLock` — three-mode access-lock primitive for stop-the-world / read-only / role-restricted operator interventions. `"open"` is normal operation; `"read-only"` refuses non-idempotent methods (POST/PUT/PATCH/DELETE) with 503 + Retry-After while letting GET/HEAD/OPTIONS pass; `"locked"` refuses everything except an operator-supplied `passthroughPaths` allowlist (status / health / unlock endpoint). Operators flip modes during incident response, schema-migration windows, or break-glass review via `lock.set("locked", { actor, reason })`; the transition emits `auth.access_lock.mode_changed` audit + metric. `unlockRoles: ["sre", ...]` lets a privileged role bypass all three modes via `getRole(req)` so a break-glass operator can always reach the unlock endpoint to flip back. The boot-time mode emits `auth.access_lock.boot` so the audit chain captures the deploy posture.
12
+
13
+ - **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.
14
+
11
15
  - **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.
12
16
 
13
17
  - **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.
package/index.js CHANGED
@@ -144,6 +144,7 @@ var auth = {
144
144
  stepUp: require("./lib/auth/step-up"),
145
145
  acr: require("./lib/auth/acr-vocabulary"),
146
146
  authTime: require("./lib/auth/auth-time-tracker"),
147
+ accessLock: require("./lib/auth/access-lock"),
147
148
  };
148
149
  var template = require("./lib/template");
149
150
  var render = require("./lib/render");
@@ -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
  };
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ /**
3
+ * b.auth.accessLock — three-mode access-lock primitive for stop-the-
4
+ * world / read-only / role-restricted operator interventions.
5
+ *
6
+ * Operators flip the framework's serving posture between modes during
7
+ * incident response, schema migration windows, security investigations,
8
+ * and break-glass review:
9
+ *
10
+ * "open" — normal operation; every request reaches its handler
11
+ * "read-only" — refuses non-idempotent methods (POST/PUT/PATCH/DELETE)
12
+ * with 503; GET/HEAD/OPTIONS pass
13
+ * "locked" — refuses every request with 503 except a small set of
14
+ * operator-specified pass-through paths (status, health,
15
+ * break-glass-unlock); useful during schema migrations or
16
+ * a hard maintenance window
17
+ *
18
+ * Mode flips audit + emit a metric so dashboards see the transition.
19
+ * The operator-supplied unlockRoles allows a privileged role
20
+ * (configured via b.permissions) to bypass all three modes — the
21
+ * break-glass operator can always reach the unlock endpoint to flip
22
+ * back to "open". Without unlockRoles, "locked" is genuinely closed
23
+ * and the operator has to redeploy with an opts.startMode override
24
+ * to recover.
25
+ *
26
+ * var lock = b.auth.accessLock.create({
27
+ * startMode: "open",
28
+ * unlockRoles: ["sre", "security-incident-response"],
29
+ * passthroughPaths: ["/healthz", "/readyz", "/admin/access-lock"],
30
+ * audit: b.audit,
31
+ * getRole: function (req) { return req.user && req.user.role; },
32
+ * });
33
+ *
34
+ * router.use(lock.middleware());
35
+ * router.post("/admin/access-lock/:mode", function (req, res) {
36
+ * await lock.set(req.params.mode, { actor: req.user.id, reason: req.body.reason });
37
+ * res.json({ mode: lock.mode() });
38
+ * });
39
+ */
40
+
41
+ var defineClass = require("../framework-error").defineClass;
42
+ var lazyRequire = require("../lazy-require");
43
+ var validateOpts = require("../validate-opts");
44
+
45
+ var audit = lazyRequire(function () { return require("../audit"); });
46
+ var observability = lazyRequire(function () { return require("../observability"); });
47
+
48
+ var AccessLockError = defineClass("AccessLockError", { alwaysPermanent: true });
49
+
50
+ var VALID_MODES = Object.freeze({ open: 1, "read-only": 1, locked: 1 });
51
+ var SAFE_METHODS = Object.freeze({ GET: 1, HEAD: 1, OPTIONS: 1 });
52
+
53
+ function _normalizeMode(mode) {
54
+ if (typeof mode !== "string") return null;
55
+ var m = mode.toLowerCase();
56
+ return VALID_MODES[m] ? m : null;
57
+ }
58
+
59
+ function create(opts) {
60
+ opts = opts || {};
61
+ validateOpts(opts, [
62
+ "startMode", "unlockRoles", "passthroughPaths",
63
+ "audit", "getRole", "errorMessage",
64
+ ], "auth.accessLock");
65
+
66
+ var startMode = _normalizeMode(opts.startMode || "open");
67
+ if (!startMode) {
68
+ throw new AccessLockError("auth-access-lock/bad-mode",
69
+ "auth.accessLock: opts.startMode must be one of " + Object.keys(VALID_MODES).join(", "));
70
+ }
71
+ var unlockRoles = Array.isArray(opts.unlockRoles) ? opts.unlockRoles.slice() : [];
72
+ for (var ri = 0; ri < unlockRoles.length; ri++) {
73
+ if (typeof unlockRoles[ri] !== "string" || unlockRoles[ri].length === 0) {
74
+ throw new AccessLockError("auth-access-lock/bad-role",
75
+ "auth.accessLock: unlockRoles[" + ri + "] must be a non-empty string");
76
+ }
77
+ }
78
+ var passthroughPaths = Array.isArray(opts.passthroughPaths)
79
+ ? opts.passthroughPaths.slice() : [];
80
+ var auditOn = opts.audit !== false;
81
+ var getRole = typeof opts.getRole === "function" ? opts.getRole : null;
82
+ var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
83
+ ? opts.errorMessage : "service in restricted access mode";
84
+
85
+ var currentMode = startMode;
86
+ var modeSetAt = Date.now();
87
+ var modeSetBy = "boot";
88
+ var modeReason = "initial mode at boot";
89
+
90
+ function _emitAudit(action, outcome, metadata) {
91
+ if (!auditOn) return;
92
+ try {
93
+ audit().safeEmit({
94
+ action: "auth.access_lock." + action,
95
+ outcome: outcome,
96
+ metadata: metadata || {},
97
+ });
98
+ } catch (_e) { /* drop-silent — audit is best-effort */ }
99
+ }
100
+ function _emitMetric(verb, n, labels) {
101
+ try { observability().safeEvent("auth.access_lock." + verb, n || 1, labels || {}); }
102
+ catch (_e) { /* drop-silent */ }
103
+ }
104
+
105
+ function _isPassthrough(req) {
106
+ if (passthroughPaths.length === 0) return false;
107
+ var p = req.url || "";
108
+ var qpos = p.indexOf("?");
109
+ if (qpos !== -1) p = p.slice(0, qpos);
110
+ for (var i = 0; i < passthroughPaths.length; i++) {
111
+ var entry = passthroughPaths[i];
112
+ if (typeof entry === "string" && (p === entry || p.indexOf(entry + "/") === 0)) return true;
113
+ if (entry instanceof RegExp && entry.test(p)) return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ function _hasUnlockRole(req) {
119
+ if (!getRole || unlockRoles.length === 0) return false;
120
+ var role;
121
+ try { role = getRole(req); }
122
+ catch (_e) { return false; }
123
+ if (!role) return false;
124
+ if (typeof role === "string") return unlockRoles.indexOf(role) !== -1;
125
+ if (Array.isArray(role)) {
126
+ for (var i = 0; i < role.length; i++) {
127
+ if (unlockRoles.indexOf(role[i]) !== -1) return true;
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
133
+ function set(mode, info) {
134
+ var next = _normalizeMode(mode);
135
+ if (!next) {
136
+ throw new AccessLockError("auth-access-lock/bad-mode",
137
+ "auth.accessLock.set: mode must be one of " + Object.keys(VALID_MODES).join(", "));
138
+ }
139
+ if (next === currentMode) return { mode: currentMode, changed: false };
140
+ var prev = currentMode;
141
+ info = info || {};
142
+ currentMode = next;
143
+ modeSetAt = Date.now();
144
+ modeSetBy = typeof info.actor === "string" ? info.actor : "unspecified";
145
+ modeReason = typeof info.reason === "string" ? info.reason : "";
146
+ _emitAudit("mode_changed", "success", {
147
+ from: prev,
148
+ to: next,
149
+ actor: modeSetBy,
150
+ reason: modeReason,
151
+ });
152
+ _emitMetric("mode_changed", 1, { from: prev, to: next });
153
+ return { mode: currentMode, changed: true, from: prev };
154
+ }
155
+
156
+ function mode() { return currentMode; }
157
+ function status() {
158
+ return {
159
+ mode: currentMode,
160
+ since: modeSetAt,
161
+ setBy: modeSetBy,
162
+ reason: modeReason,
163
+ passthroughPaths: passthroughPaths.slice(),
164
+ unlockRoles: unlockRoles.slice(),
165
+ };
166
+ }
167
+
168
+ function _refuse(res, reason) {
169
+ if (!res.writableEnded && typeof res.writeHead === "function") {
170
+ res.writeHead(503, {
171
+ "Content-Type": "application/json; charset=utf-8",
172
+ "Retry-After": "60",
173
+ "Cache-Control": "no-store",
174
+ });
175
+ res.end(JSON.stringify({ error: errorMessage, mode: currentMode, reason: reason }));
176
+ }
177
+ }
178
+
179
+ function middleware() {
180
+ return function accessLockMiddleware(req, res, next) {
181
+ // open — fast path, no checks.
182
+ if (currentMode === "open") return next();
183
+ // passthrough paths bypass all modes (status / health / unlock endpoint).
184
+ if (_isPassthrough(req)) return next();
185
+ // unlockRoles bypass all modes.
186
+ if (_hasUnlockRole(req)) return next();
187
+ if (currentMode === "read-only") {
188
+ var method = (req.method || "GET").toUpperCase();
189
+ if (SAFE_METHODS[method]) return next();
190
+ _emitAudit("refused", "denied", { mode: currentMode, method: method, path: req.url });
191
+ _emitMetric("refused", 1, { mode: currentMode, reason: "non-safe-method" });
192
+ return _refuse(res, "non-safe-method-in-read-only");
193
+ }
194
+ // locked — refuse everything that wasn't passthrough / unlockRole.
195
+ _emitAudit("refused", "denied", { mode: currentMode, method: req.method, path: req.url });
196
+ _emitMetric("refused", 1, { mode: currentMode, reason: "locked" });
197
+ return _refuse(res, "locked");
198
+ };
199
+ }
200
+
201
+ // Initial-mode audit fires once at create-time so operators see the
202
+ // boot-time posture in the audit chain (confirms the deploy started
203
+ // in the expected mode).
204
+ _emitAudit("boot", "success", { mode: currentMode });
205
+ _emitMetric("boot", 1, { mode: currentMode });
206
+
207
+ return {
208
+ middleware: middleware,
209
+ set: set,
210
+ mode: mode,
211
+ status: status,
212
+ VALID_MODES: Object.keys(VALID_MODES),
213
+ };
214
+ }
215
+
216
+ module.exports = {
217
+ create: create,
218
+ AccessLockError: AccessLockError,
219
+ VALID_MODES: Object.keys(VALID_MODES),
220
+ };
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");
@@ -64,6 +65,7 @@ module.exports = {
64
65
  errorHandler: errorHandler.create,
65
66
  botGuard: botGuard.create,
66
67
  cors: cors.create,
68
+ dailyByteQuota: dailyByteQuota.create,
67
69
  rateLimit: rateLimit.create,
68
70
  attachUser: attachUser.create,
69
71
  bearerAuth: bearerAuth.create,
@@ -108,6 +110,7 @@ module.exports = {
108
110
  errorHandler: errorHandler,
109
111
  botGuard: botGuard,
110
112
  cors: cors,
113
+ dailyByteQuota: dailyByteQuota,
111
114
  rateLimit: rateLimit,
112
115
  attachUser: attachUser,
113
116
  bearerAuth: bearerAuth,
@@ -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.5",
3
+ "version": "0.8.7",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:1f60d33c-fc3b-4da1-a753-bb1c98de9968",
5
+ "serialNumber": "urn:uuid:c89193b4-c13d-4dd9-882f-4581a77a92d9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T22:33:10.715Z",
8
+ "timestamp": "2026-05-07T01:02:55.563Z",
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.5",
22
+ "bom-ref": "@blamejs/core@0.8.7",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.5",
25
+ "version": "0.8.7",
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.5",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.7",
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.5",
57
+ "ref": "@blamejs/core@0.8.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]