@blamejs/core 0.8.6 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
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");
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.6",
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:88336eab-78a7-45f2-bc73-faf15cf6bbfb",
5
+ "serialNumber": "urn:uuid:c89193b4-c13d-4dd9-882f-4581a77a92d9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T22:51:45.376Z",
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.6",
22
+ "bom-ref": "@blamejs/core@0.8.7",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.6",
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.6",
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.6",
57
+ "ref": "@blamejs/core@0.8.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]