@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 +4 -0
- package/index.js +1 -0
- package/lib/app-shutdown.js +142 -6
- package/lib/auth/access-lock.js +220 -0
- package/lib/auth/oauth.js +28 -2
- package/lib/middleware/daily-byte-quota.js +275 -0
- package/lib/middleware/index.js +3 -0
- package/lib/observability-otlp-exporter.js +37 -1
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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");
|
package/lib/app-shutdown.js
CHANGED
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -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
package/sbom.cyclonedx.json
CHANGED
|
@@ -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:
|
|
5
|
+
"serialNumber": "urn:uuid:c89193b4-c13d-4dd9-882f-4581a77a92d9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.7",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.8.7",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|