@blamejs/core 0.6.11 → 0.6.13
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 +2 -0
- package/lib/api-key.js +2 -3
- package/lib/backup/index.js +21 -5
- package/lib/cache.js +3 -7
- package/lib/log-stream-webhook.js +22 -4
- package/lib/mail.js +16 -1
- package/lib/middleware/require-auth.js +9 -4
- package/lib/notify.js +2 -3
- package/lib/numeric-checks.js +40 -0
- package/lib/queue.js +10 -6
- package/lib/restore.js +106 -0
- package/lib/retry.js +3 -6
- package/lib/safe-url.js +16 -0
- package/lib/session.js +5 -2
- package/lib/slug.js +2 -3
- package/lib/testing.js +3 -7
- package/lib/webhook.js +3 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.6.x
|
|
10
10
|
|
|
11
|
+
- **0.6.13** (2026-05-02) — wiki primitive-section docs catch up to the v0.6.12 surface (`safeUrl.parse` allowUserinfo, `session.touch` extendBy ceiling, `queue.consume` rateLimit validation, `mail.transports.console` redactBcc, `logStream` webhook-sink onDrop, `backup.create` requireFlush, `restore.create` maxPulledBytes / maxPulledFiles); restore default cap stated as `C.BYTES.gib(4)` instead of a raw byte literal; numeric-check predicates (`isPositiveInt`, `isFiniteNonNegative`, `isPositiveFinite`) consolidated into `lib/numeric-checks.js` — were duplicated across api-key, cache, notify, queue, restore, retry, slug, testing, webhook; api-snapshot.json refreshed
|
|
12
|
+
- **0.6.12** (2026-05-02) — `b.safeUrl.parse` now rejects URLs with `user:pass@` userinfo by default (opt in per-call via `allowUserinfo: true`); `b.session.touch({ extendBy })` enforces the same `MAX_TTL_MS` ceiling as `create` / `rotate`; `b.queue.consume({ rateLimit })` rejects negative / zero / `NaN` / `Infinity` / fractional `max`; `b.middleware.requireAuth` no longer treats request `Content-Type: application/json` as a JSON-preference signal (only `Accept` and `X-Requested-With` count); `b.backup.create({ requireFlush: true })` opt-in fails the backup if pre-flush fails instead of producing a stale snapshot; `b.restore.create({ maxPulledBytes, maxPulledFiles })` preflight bounds bundle footprint before and after pull (defaults 4 GiB / 100K files); `b.mail.transports.console({ redactBcc: true })` opt-in prints recipient count instead of addresses; `b.logStream.transports.webhook({ onDrop })` callback fires on overflow + retry-exhausted batch drops. Wiki: admin login wired through `b.auth.lockout` (exponential-backoff after bad-cred attempts) and cookie `Secure` flag now routes through `b.requestHelpers.requestProtocol` with `WIKI_TRUST_PROXY` opt-in instead of trusting raw `x-forwarded-proto`. Wiki README documents the trust model for editable page bodies and the sanitization pattern operators should adopt before expanding the editor surface.
|
|
11
13
|
- **0.6.11** (2026-05-01) — wiki example-execution validator: fixture init no longer reaches across module realms (unblocks the npm-publish workflow's wiki-e2e gate, which `npm install --install-links` copies the framework into the wiki's node_modules — two distinct singletons before this fix)
|
|
12
14
|
- **0.6.10** (2026-05-01) — README / SECURITY / CONTRIBUTING / wiki: removed stale version stamps and an inaccurate vendored-dep list; SECURITY now points at `lib/vendor/MANIFEST.json` for the authoritative vendor list; supported-versions table no longer pins to a specific minor; wiki archive example names the digest variable correctly (was `sha256`, output is SHA3-512 hex)
|
|
13
15
|
- **0.6.9** (2026-05-01) — b.archive.zip().digest() returns a SHA3-512 hex string (was SHA-256); operators reconciling against an external SHA-256 must hash the bytes themselves
|
package/lib/api-key.js
CHANGED
|
@@ -63,6 +63,7 @@ var cryptoField = require("./crypto-field");
|
|
|
63
63
|
var requestHelpers = require("./request-helpers");
|
|
64
64
|
var validateOpts = require("./validate-opts");
|
|
65
65
|
var C = require("./constants");
|
|
66
|
+
var numericChecks = require("./numeric-checks");
|
|
66
67
|
var { ApiKeyError } = require("./framework-error");
|
|
67
68
|
|
|
68
69
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
@@ -129,9 +130,7 @@ var DEFAULTS = Object.freeze({
|
|
|
129
130
|
|
|
130
131
|
// ---- Config-time validation helpers (throw on bad input) ----
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
134
|
-
}
|
|
133
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
135
134
|
|
|
136
135
|
function _validateIdentifier(name, value) {
|
|
137
136
|
if (typeof value !== "string" || value.length === 0) {
|
package/lib/backup/index.js
CHANGED
|
@@ -238,6 +238,13 @@ function create(opts) {
|
|
|
238
238
|
var flushBeforeBackup = typeof opts.flushBeforeBackup === "function"
|
|
239
239
|
? opts.flushBeforeBackup
|
|
240
240
|
: (opts.flushBeforeBackup === false ? null : null);
|
|
241
|
+
// requireFlush — when true, a flush failure FAILS the backup instead
|
|
242
|
+
// of producing a (potentially stale) snapshot. Operators on
|
|
243
|
+
// encrypted-at-rest with hard freshness requirements (compliance,
|
|
244
|
+
// audit, point-in-time recovery) opt in. Default false preserves the
|
|
245
|
+
// long-standing best-effort posture for operators who care about
|
|
246
|
+
// backup completing more than freshness.
|
|
247
|
+
var requireFlush = opts.requireFlush === true;
|
|
241
248
|
// Default: try b.db.flushToDisk if available. Wired this way so the
|
|
242
249
|
// backup primitive doesn't take a hard dependency on b.db (operators
|
|
243
250
|
// running backup against an external db handle still work).
|
|
@@ -272,15 +279,24 @@ function create(opts) {
|
|
|
272
279
|
var stagingDir = path.join(os.tmpdir(),
|
|
273
280
|
"blamejs-backup-staging-" + bundleId.replace(/[:.]/g, "-"));
|
|
274
281
|
|
|
275
|
-
// Flush the live DB to disk so the snapshot is current.
|
|
276
|
-
// a flush failure logs but doesn't fail
|
|
277
|
-
//
|
|
282
|
+
// Flush the live DB to disk so the snapshot is current. Default
|
|
283
|
+
// posture is best-effort — a flush failure logs but doesn't fail
|
|
284
|
+
// the whole backup. With requireFlush:true the failure aborts the
|
|
285
|
+
// backup so a stale snapshot never lands in storage.
|
|
278
286
|
if (flushBeforeBackup) {
|
|
279
287
|
try { await flushBeforeBackup(); }
|
|
280
288
|
catch (e) {
|
|
289
|
+
var flushReason = (e && e.message) || String(e);
|
|
281
290
|
_emitAudit("backup.flush.failure",
|
|
282
|
-
{ bundleId: bundleId, reason:
|
|
283
|
-
"warning");
|
|
291
|
+
{ bundleId: bundleId, reason: flushReason },
|
|
292
|
+
requireFlush ? "failure" : "warning");
|
|
293
|
+
if (requireFlush) {
|
|
294
|
+
_emitAudit("backup.failure",
|
|
295
|
+
{ bundleId: bundleId, reason: "flush-required-but-failed: " + flushReason },
|
|
296
|
+
"failure");
|
|
297
|
+
throw new BackupError("backup/flush-required-failed",
|
|
298
|
+
"backup flush required but failed: " + flushReason);
|
|
299
|
+
}
|
|
284
300
|
}
|
|
285
301
|
}
|
|
286
302
|
|
package/lib/cache.js
CHANGED
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
var clusterStorage = require("./cluster-storage");
|
|
94
94
|
var C = require("./constants");
|
|
95
95
|
var lazyRequire = require("./lazy-require");
|
|
96
|
+
var numericChecks = require("./numeric-checks");
|
|
96
97
|
var requestHelpers = require("./request-helpers");
|
|
97
98
|
var safeAsync = require("./safe-async");
|
|
98
99
|
var validateOpts = require("./validate-opts");
|
|
@@ -116,13 +117,8 @@ var DEFAULTS = Object.freeze({
|
|
|
116
117
|
|
|
117
118
|
// ---- Config-time validation helpers (throw on bad input) ----
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function _isPositiveInt(n) {
|
|
124
|
-
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
125
|
-
}
|
|
120
|
+
var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
|
|
121
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
126
122
|
|
|
127
123
|
// ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
|
|
128
124
|
// or non-number is rejected.
|
|
@@ -97,6 +97,19 @@ function create(config) {
|
|
|
97
97
|
errorClass: LogStreamError,
|
|
98
98
|
});
|
|
99
99
|
var headers = Object.assign({ "Content-Type": cfg.contentType }, _authHeaders(cfg));
|
|
100
|
+
// onDrop callback: invoked when a batch is dropped, either by buffer
|
|
101
|
+
// overflow ("overflow") or by retry exhaustion ("retry-exhausted").
|
|
102
|
+
// Operator wiring this directly (without the framework's dispatcher
|
|
103
|
+
// wrapping) needs visibility into permanent-drop events; the
|
|
104
|
+
// dispatcher path emits its own audit, but a sink used in isolation
|
|
105
|
+
// would otherwise lose drops silently. The callback is invoked
|
|
106
|
+
// best-effort — a throw inside it is swallowed.
|
|
107
|
+
var onDrop = typeof cfg.onDrop === "function" ? cfg.onDrop : null;
|
|
108
|
+
function _emitDrop(reason, batch, err) {
|
|
109
|
+
if (!onDrop) return;
|
|
110
|
+
try { onDrop({ reason: reason, batch: batch, error: err || null }); }
|
|
111
|
+
catch (_e) { /* drop callback is best-effort by design */ }
|
|
112
|
+
}
|
|
100
113
|
var buffer = [];
|
|
101
114
|
var dropCount = 0;
|
|
102
115
|
var flushTimer = null;
|
|
@@ -121,9 +134,13 @@ function create(config) {
|
|
|
121
134
|
await retryHelper.withRetry(function () {
|
|
122
135
|
return _post(cfg.url, body, headers, cfg.timeoutMs, cfg.allowedProtocols, cfg.allowInternal);
|
|
123
136
|
}, cfg.retry);
|
|
124
|
-
} catch {
|
|
125
|
-
// Batch permanently rejected — surface via
|
|
126
|
-
//
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Batch permanently rejected — surface via dropCount AND the
|
|
139
|
+
// operator-supplied onDrop callback. The dispatcher path
|
|
140
|
+
// wraps its own audit hook around emit(); operators using
|
|
141
|
+
// this sink directly rely on dropCount + onDrop.
|
|
142
|
+
dropCount += batch.length;
|
|
143
|
+
_emitDrop("retry-exhausted", batch, e);
|
|
127
144
|
break;
|
|
128
145
|
}
|
|
129
146
|
}
|
|
@@ -136,8 +153,9 @@ function create(config) {
|
|
|
136
153
|
function emit(record) {
|
|
137
154
|
if (closed) return Promise.resolve({ accepted: false, reason: "sink closed" });
|
|
138
155
|
if (buffer.length >= cfg.bufferLimit) {
|
|
139
|
-
buffer.shift(); // drop oldest
|
|
156
|
+
var dropped = buffer.shift(); // drop oldest
|
|
140
157
|
dropCount += 1;
|
|
158
|
+
_emitDrop("overflow", [dropped], null);
|
|
141
159
|
}
|
|
142
160
|
buffer.push(record);
|
|
143
161
|
if (buffer.length >= cfg.batchSize) {
|
package/lib/mail.js
CHANGED
|
@@ -246,6 +246,14 @@ function _toArray(v) {
|
|
|
246
246
|
function consoleTransport(opts) {
|
|
247
247
|
opts = opts || {};
|
|
248
248
|
var stream = opts.stream || process.stderr;
|
|
249
|
+
// redactBcc: print only the recipient COUNT instead of the addresses.
|
|
250
|
+
// Default false preserves the dev-visibility purpose of this
|
|
251
|
+
// transport. Operators piping dev logs into shared / centralized
|
|
252
|
+
// sinks (Slack, log aggregator, ticket system) opt in to avoid
|
|
253
|
+
// leaking the BCC list — the property exists precisely so a recipient
|
|
254
|
+
// doesn't see who else got the message, and that promise breaks the
|
|
255
|
+
// moment the addresses land in a non-private log.
|
|
256
|
+
var redactBcc = opts.redactBcc === true;
|
|
249
257
|
return {
|
|
250
258
|
name: "console",
|
|
251
259
|
send: async function (message) {
|
|
@@ -255,7 +263,14 @@ function consoleTransport(opts) {
|
|
|
255
263
|
"[mail.console] Subject: " + (message.subject || ""),
|
|
256
264
|
];
|
|
257
265
|
if (message.cc) lines.push("[mail.console] Cc: " + (Array.isArray(message.cc) ? message.cc.join(", ") : message.cc));
|
|
258
|
-
if (message.bcc)
|
|
266
|
+
if (message.bcc) {
|
|
267
|
+
if (redactBcc) {
|
|
268
|
+
var bccCount = Array.isArray(message.bcc) ? message.bcc.length : 1;
|
|
269
|
+
lines.push("[mail.console] Bcc: <" + bccCount + " recipient" + (bccCount === 1 ? "" : "s") + " — redacted>");
|
|
270
|
+
} else {
|
|
271
|
+
lines.push("[mail.console] Bcc: " + (Array.isArray(message.bcc) ? message.bcc.join(", ") : message.bcc));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
259
274
|
var body = message.text || (message.html ? "(html body, " + message.html.length + " bytes)" : "");
|
|
260
275
|
lines.push("");
|
|
261
276
|
lines.push(body);
|
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* either lets the request through or rejects it.
|
|
6
6
|
*
|
|
7
7
|
* Rejection shape:
|
|
8
|
-
* - JSON-preferring caller (Accept
|
|
9
|
-
*
|
|
8
|
+
* - JSON-preferring caller (Accept includes application/json, or
|
|
9
|
+
* X-Requested-With: XMLHttpRequest):
|
|
10
10
|
* 401 application/json with { error: "Authentication required." }
|
|
11
11
|
* - Browser-preferring caller, when opts.redirectTo is set:
|
|
12
12
|
* 302 with Location header
|
|
13
13
|
* - Otherwise:
|
|
14
14
|
* 401 text/plain
|
|
15
15
|
*
|
|
16
|
+
* Note: the Content-Type of the REQUEST is intentionally NOT a signal.
|
|
17
|
+
* A server-to-server POST with `Content-Type: application/json` and no
|
|
18
|
+
* `Accept` header should get the same response shape as any other
|
|
19
|
+
* unauthenticated request — Content-Type describes what the client
|
|
20
|
+
* SENT, not what they want back. Operators with a non-default
|
|
21
|
+
* preference contract supply opts.prefersJson.
|
|
22
|
+
*
|
|
16
23
|
* Always emits `auth.required.denied` audit event on rejection (when
|
|
17
24
|
* opts.audit !== false). The event records request method + path +
|
|
18
25
|
* client IP — keys-only, no body content.
|
|
@@ -37,8 +44,6 @@ function _defaultPrefersJson(req) {
|
|
|
37
44
|
var h = req.headers || {};
|
|
38
45
|
if (typeof h.accept === "string" && h.accept.indexOf("application/json") !== -1) return true;
|
|
39
46
|
if (h["x-requested-with"] === "XMLHttpRequest") return true;
|
|
40
|
-
if (typeof h["content-type"] === "string" &&
|
|
41
|
-
h["content-type"].indexOf("application/json") === 0) return true;
|
|
42
47
|
return false;
|
|
43
48
|
}
|
|
44
49
|
|
package/lib/notify.js
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
var lazyRequire = require("./lazy-require");
|
|
51
51
|
var bootLog = require("./log");
|
|
52
|
+
var numericChecks = require("./numeric-checks");
|
|
52
53
|
var requestHelpers = require("./request-helpers");
|
|
53
54
|
var safeAsync = require("./safe-async");
|
|
54
55
|
var safeUrl = require("./safe-url");
|
|
@@ -76,9 +77,7 @@ var DEFAULTS = Object.freeze({
|
|
|
76
77
|
|
|
77
78
|
// ---- Call-site validation (throw on bad input) ----
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
81
|
-
}
|
|
80
|
+
var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
|
|
82
81
|
|
|
83
82
|
function _validateTransport(name, t) {
|
|
84
83
|
if (!t || typeof t !== "object") {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* numeric-checks — predicate helpers for opts / arg validation.
|
|
4
|
+
*
|
|
5
|
+
* Existed previously as private `_isPositiveInt` / `_isFiniteNonNegative`
|
|
6
|
+
* / `_isNonNegFinite` copies inside api-key, cache, notify, retry, slug,
|
|
7
|
+
* testing, webhook, and (new in v0.6.12) inline checks in queue and
|
|
8
|
+
* restore. Same shape, different file — that's the repeat-means-primitive
|
|
9
|
+
* rule. Everything routes through here now so adding (e.g.) NaN-or-
|
|
10
|
+
* Infinity-string handling is a one-file change.
|
|
11
|
+
*
|
|
12
|
+
* Predicates only — callers throw with their own framework-error class.
|
|
13
|
+
*
|
|
14
|
+
* isPositiveInt(n) n is a finite integer >= 1
|
|
15
|
+
* isFiniteNonNegative(n) n is a finite number >= 0
|
|
16
|
+
* isPositiveFinite(n) n is a finite number > 0
|
|
17
|
+
*
|
|
18
|
+
* All return false for non-numbers, NaN, Infinity, -Infinity, null,
|
|
19
|
+
* undefined, strings, etc. — operators get one consistent gate against
|
|
20
|
+
* the silent-NaN-cap class of bug regardless of which primitive they're
|
|
21
|
+
* configuring.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function isPositiveInt(n) {
|
|
25
|
+
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFiniteNonNegative(n) {
|
|
29
|
+
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPositiveFinite(n) {
|
|
33
|
+
return typeof n === "number" && isFinite(n) && n > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
isPositiveInt: isPositiveInt,
|
|
38
|
+
isFiniteNonNegative: isFiniteNonNegative,
|
|
39
|
+
isPositiveFinite: isPositiveFinite,
|
|
40
|
+
};
|
package/lib/queue.js
CHANGED
|
@@ -34,6 +34,7 @@ var C = require("./constants");
|
|
|
34
34
|
var clusterStorage = require("./cluster-storage");
|
|
35
35
|
var crypto = require("./crypto");
|
|
36
36
|
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var numericChecks = require("./numeric-checks");
|
|
37
38
|
var observability = require("./observability");
|
|
38
39
|
var protocolDispatcher = require("./protocol-dispatcher");
|
|
39
40
|
var localProto = require("./queue-local");
|
|
@@ -188,16 +189,19 @@ function consume(queueName, handler, opts) {
|
|
|
188
189
|
// accounting keeps it cheap (just a sliding deque of timestamps).
|
|
189
190
|
var rateLimit = null;
|
|
190
191
|
if (opts.rateLimit) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
var rlMax = opts.rateLimit.max;
|
|
193
|
+
var rlPer = opts.rateLimit.perSeconds;
|
|
194
|
+
// NaN, Infinity, 0, negatives, fractional max all produce undefined
|
|
195
|
+
// throttling math (NaN deque comparisons, perma-locked queues,
|
|
196
|
+
// perma-open windows). Reject at config time.
|
|
197
|
+
if (!numericChecks.isPositiveInt(rlMax) || !numericChecks.isPositiveFinite(rlPer)) {
|
|
194
198
|
throw _err("BAD_RATE_LIMIT",
|
|
195
|
-
"consume({ rateLimit }): expected { max:
|
|
199
|
+
"consume({ rateLimit }): expected { max: positive integer, perSeconds: positive finite number }, got " +
|
|
196
200
|
JSON.stringify(opts.rateLimit), true);
|
|
197
201
|
}
|
|
198
202
|
rateLimit = {
|
|
199
|
-
max:
|
|
200
|
-
windowMs: C.TIME.seconds(
|
|
203
|
+
max: rlMax,
|
|
204
|
+
windowMs: C.TIME.seconds(rlPer),
|
|
201
205
|
timestamps: [],
|
|
202
206
|
};
|
|
203
207
|
}
|
package/lib/restore.js
CHANGED
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
var fs = require("fs");
|
|
55
55
|
var os = require("os");
|
|
56
56
|
var path = require("path");
|
|
57
|
+
var C = require("./constants");
|
|
57
58
|
var crypto = require("./crypto");
|
|
59
|
+
var numericChecks = require("./numeric-checks");
|
|
58
60
|
var restoreBundle = require("./restore-bundle");
|
|
59
61
|
var restoreRollback = require("./restore-rollback");
|
|
60
62
|
var lazyRequire = require("./lazy-require");
|
|
@@ -89,6 +91,7 @@ function create(opts) {
|
|
|
89
91
|
opts = opts || {};
|
|
90
92
|
validateOpts(opts, [
|
|
91
93
|
"dataDir", "storage", "passphrase", "rollbackRoot", "audit",
|
|
94
|
+
"maxPulledBytes", "maxPulledFiles",
|
|
92
95
|
], "restore");
|
|
93
96
|
if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
94
97
|
throw new RestoreError("restore/no-datadir",
|
|
@@ -106,6 +109,47 @@ function create(opts) {
|
|
|
106
109
|
var rollbackRoot = opts.rollbackRoot || (dataDir + ".rollbacks");
|
|
107
110
|
var auditOn = opts.audit !== false;
|
|
108
111
|
|
|
112
|
+
// Preflight footprint caps. Defended against storage that returns a
|
|
113
|
+
// tampered or oversized bundle: we cap both the storage-reported size
|
|
114
|
+
// (cheap, before pull) AND the actually-pulled bytes/file-count
|
|
115
|
+
// (defense-in-depth in case the backend lied). Default 4 GiB / 100K
|
|
116
|
+
// files keeps the small-bundle path uncapped while bounding the
|
|
117
|
+
// pathological case.
|
|
118
|
+
var maxPulledBytes = numericChecks.isPositiveFinite(opts.maxPulledBytes)
|
|
119
|
+
? opts.maxPulledBytes : C.BYTES.gib(4);
|
|
120
|
+
var maxPulledFiles = numericChecks.isPositiveInt(opts.maxPulledFiles)
|
|
121
|
+
? opts.maxPulledFiles : 100000;
|
|
122
|
+
|
|
123
|
+
function _walkPullDirFootprint(dir) {
|
|
124
|
+
var totalBytes = 0, fileCount = 0;
|
|
125
|
+
var stack = [dir];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
var current = stack.pop();
|
|
128
|
+
var entries;
|
|
129
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); }
|
|
130
|
+
catch (_e) { continue; }
|
|
131
|
+
for (var i = 0; i < entries.length; i++) {
|
|
132
|
+
var entry = entries[i];
|
|
133
|
+
var full = path.join(current, entry.name);
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
stack.push(full);
|
|
136
|
+
} else if (entry.isFile()) {
|
|
137
|
+
fileCount++;
|
|
138
|
+
if (fileCount > maxPulledFiles) {
|
|
139
|
+
return { tooManyFiles: true, fileCount: fileCount };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
totalBytes += fs.statSync(full).size;
|
|
143
|
+
if (totalBytes > maxPulledBytes) {
|
|
144
|
+
return { tooManyBytes: true, totalBytes: totalBytes };
|
|
145
|
+
}
|
|
146
|
+
} catch (_e) { /* file vanished mid-walk */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { totalBytes: totalBytes, fileCount: fileCount };
|
|
151
|
+
}
|
|
152
|
+
|
|
109
153
|
function _emitAudit(action, info, outcome) {
|
|
110
154
|
if (!auditOn) return;
|
|
111
155
|
audit().safeEmit({
|
|
@@ -118,6 +162,32 @@ function create(opts) {
|
|
|
118
162
|
|
|
119
163
|
async function list() { return await storage.listBundles(); }
|
|
120
164
|
|
|
165
|
+
// Find a bundle in storage.listBundles() output and check its
|
|
166
|
+
// reported size against maxPulledBytes BEFORE pulling. listBundles()
|
|
167
|
+
// returns the storage-reported size; cheap to scan and rejects an
|
|
168
|
+
// oversized object before any bytes hit local disk. Returns the
|
|
169
|
+
// bundle metadata when within bounds, throws when oversized, returns
|
|
170
|
+
// null when listBundles doesn't surface the bundle (e.g. listing is
|
|
171
|
+
// truncated by the backend).
|
|
172
|
+
async function _preflightBundleSize(bundleId) {
|
|
173
|
+
var listed;
|
|
174
|
+
try { listed = await storage.listBundles(); }
|
|
175
|
+
catch (_e) { return null; }
|
|
176
|
+
if (!Array.isArray(listed)) return null;
|
|
177
|
+
for (var i = 0; i < listed.length; i++) {
|
|
178
|
+
var entry = listed[i];
|
|
179
|
+
if (entry && entry.bundleId === bundleId) {
|
|
180
|
+
if (typeof entry.size === "number" && entry.size > maxPulledBytes) {
|
|
181
|
+
throw new RestoreError("restore/bundle-too-large",
|
|
182
|
+
"bundle '" + bundleId + "' reports size " + entry.size +
|
|
183
|
+
" bytes, exceeds maxPulledBytes " + maxPulledBytes);
|
|
184
|
+
}
|
|
185
|
+
return entry;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
121
191
|
async function inspect(bundleId) {
|
|
122
192
|
if (typeof bundleId !== "string" || bundleId.length === 0) {
|
|
123
193
|
throw new RestoreError("restore/bad-bundle-id", "inspect: bundleId is required");
|
|
@@ -127,10 +197,22 @@ function create(opts) {
|
|
|
127
197
|
throw new RestoreError("restore/bundle-not-found",
|
|
128
198
|
"inspect: bundle '" + bundleId + "' not in storage");
|
|
129
199
|
}
|
|
200
|
+
await _preflightBundleSize(bundleId);
|
|
130
201
|
var pullDir = path.join(os.tmpdir(),
|
|
131
202
|
"blamejs-restore-inspect-" + crypto.generateToken(4));
|
|
132
203
|
try {
|
|
133
204
|
await storage.readBundle(bundleId, pullDir);
|
|
205
|
+
var pulled = _walkPullDirFootprint(pullDir);
|
|
206
|
+
if (pulled.tooManyBytes) {
|
|
207
|
+
throw new RestoreError("restore/pulled-too-large",
|
|
208
|
+
"bundle '" + bundleId + "' pulled " + pulled.totalBytes +
|
|
209
|
+
" bytes (caught mid-pull), exceeds maxPulledBytes " + maxPulledBytes);
|
|
210
|
+
}
|
|
211
|
+
if (pulled.tooManyFiles) {
|
|
212
|
+
throw new RestoreError("restore/pulled-too-many-files",
|
|
213
|
+
"bundle '" + bundleId + "' pulled " + pulled.fileCount +
|
|
214
|
+
" files, exceeds maxPulledFiles " + maxPulledFiles);
|
|
215
|
+
}
|
|
134
216
|
return restoreBundle.inspect({ bundleDir: pullDir });
|
|
135
217
|
} finally {
|
|
136
218
|
try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
|
|
@@ -160,6 +242,17 @@ function create(opts) {
|
|
|
160
242
|
}
|
|
161
243
|
|
|
162
244
|
// 1. Pull bundle out of storage
|
|
245
|
+
// Preflight: reject an oversized bundle BEFORE pulling bytes to
|
|
246
|
+
// disk. Cheap when the backend lists size; no-op when it doesn't.
|
|
247
|
+
try {
|
|
248
|
+
await _preflightBundleSize(bundleId);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
_cleanupTmp();
|
|
251
|
+
_emitAudit("restore.failure",
|
|
252
|
+
{ bundleId: bundleId, reason: (e && e.message) || String(e) },
|
|
253
|
+
"failure");
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
163
256
|
try {
|
|
164
257
|
await storage.readBundle(bundleId, pullDir);
|
|
165
258
|
} catch (e) {
|
|
@@ -170,6 +263,19 @@ function create(opts) {
|
|
|
170
263
|
throw new RestoreError("restore/storage-read-failed",
|
|
171
264
|
"pulling bundle from storage failed: " + ((e && e.message) || String(e)));
|
|
172
265
|
}
|
|
266
|
+
// Defense-in-depth: walk the pulled bundle and re-check footprint.
|
|
267
|
+
// Catches a backend that under-reported size in listBundles or that
|
|
268
|
+
// doesn't surface size at all.
|
|
269
|
+
var pulled = _walkPullDirFootprint(pullDir);
|
|
270
|
+
if (pulled.tooManyBytes || pulled.tooManyFiles) {
|
|
271
|
+
_cleanupTmp();
|
|
272
|
+
var capCode = pulled.tooManyBytes ? "restore/pulled-too-large" : "restore/pulled-too-many-files";
|
|
273
|
+
var capMsg = pulled.tooManyBytes
|
|
274
|
+
? "bundle '" + bundleId + "' pulled " + pulled.totalBytes + " bytes, exceeds maxPulledBytes " + maxPulledBytes
|
|
275
|
+
: "bundle '" + bundleId + "' pulled " + pulled.fileCount + " files, exceeds maxPulledFiles " + maxPulledFiles;
|
|
276
|
+
_emitAudit("restore.failure", { bundleId: bundleId, reason: capMsg }, "failure");
|
|
277
|
+
throw new RestoreError(capCode, capMsg);
|
|
278
|
+
}
|
|
173
279
|
|
|
174
280
|
// 2. Decrypt + verify into stagingDir
|
|
175
281
|
var extracted;
|
package/lib/retry.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
var C = require("./constants");
|
|
36
36
|
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var numericChecks = require("./numeric-checks");
|
|
37
38
|
// safe-async re-exports withRetry + CircuitBreaker from this module, so a
|
|
38
39
|
// direct top-level require would create a cycle. Lazy-require defers the
|
|
39
40
|
// resolution until the first sleep() call, by which point both modules
|
|
@@ -82,12 +83,8 @@ var DEFAULT_BREAKER = Object.freeze({
|
|
|
82
83
|
|
|
83
84
|
// ---- Call-site validation helpers (throw on bad input) ----
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
function _isNonNegFinite(n) {
|
|
89
|
-
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
90
|
-
}
|
|
86
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
87
|
+
var _isNonNegFinite = numericChecks.isFiniteNonNegative;
|
|
91
88
|
function _isAbortSignal(s) {
|
|
92
89
|
// Duck-typed: AbortSignal exposes .aborted (bool) and .addEventListener (fn)
|
|
93
90
|
return s != null && typeof s === "object" &&
|
package/lib/safe-url.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* log-stream, http-client) surface their
|
|
24
24
|
* own decorated error class. Default:
|
|
25
25
|
* SafeUrlError.
|
|
26
|
+
* allowUserinfo — accept URLs that carry user:pass@ credentials
|
|
27
|
+
* in the authority. Default: false. Userinfo in
|
|
28
|
+
* outbound URLs leaks into request logs, error
|
|
29
|
+
* messages, metric labels, and trace spans;
|
|
30
|
+
* credential placement belongs in headers /
|
|
31
|
+
* cookies / a credential store, not the URL.
|
|
32
|
+
* Operators with a legacy endpoint that
|
|
33
|
+
* REQUIRES userinfo opt in explicitly per call.
|
|
26
34
|
*
|
|
27
35
|
* Constants — pre-baked allowlists for the common caller cases:
|
|
28
36
|
*
|
|
@@ -95,6 +103,14 @@ function parse(url, opts) {
|
|
|
95
103
|
"]. Pass opts.allowedProtocols to override (e.g. safeUrl.ALLOW_HTTP_ALL for cleartext endpoints).");
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
if (opts.allowUserinfo !== true && (parsed.username !== "" || parsed.password !== "")) {
|
|
107
|
+
throw _makeError(errClass, "safe-url/userinfo-disallowed",
|
|
108
|
+
"URL contains user:pass@ credentials in the authority. These leak into " +
|
|
109
|
+
"request logs / error messages / metric labels / trace spans. Move the " +
|
|
110
|
+
"credential to an Authorization header (or a credential store the client " +
|
|
111
|
+
"reads at call time), or pass opts.allowUserinfo: true to opt this URL in.");
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
return parsed;
|
|
99
115
|
}
|
|
100
116
|
|
package/lib/session.js
CHANGED
|
@@ -212,8 +212,11 @@ async function touch(token, opts) {
|
|
|
212
212
|
// assembly) and matches the call shape clusterStorage expects.
|
|
213
213
|
// extendBy resets expiresAt relative to NOW, not relative to the
|
|
214
214
|
// current expiresAt — a soaked session with continuous traffic
|
|
215
|
-
// shouldn't accumulate unbounded expiry.
|
|
216
|
-
|
|
215
|
+
// shouldn't accumulate unbounded expiry. The same MAX_TTL_MS
|
|
216
|
+
// ceiling create() and rotate() apply gates extendBy too — repeated
|
|
217
|
+
// touch() calls cannot push expiresAt past the framework's bound.
|
|
218
|
+
if (opts.extendBy !== undefined && opts.extendBy !== null) {
|
|
219
|
+
_validateTtl(opts.extendBy, "session.touch");
|
|
217
220
|
var newExpires = nowMs + opts.extendBy;
|
|
218
221
|
var result = await clusterStorage.execute(
|
|
219
222
|
"UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
|
package/lib/slug.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* - HTML-tag stripping (sanitize textually before slugging).
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
var numericChecks = require("./numeric-checks");
|
|
37
38
|
var { SlugError } = require("./framework-error");
|
|
38
39
|
var _err = SlugError.factory;
|
|
39
40
|
|
|
@@ -63,9 +64,7 @@ var _RESERVED = Object.freeze([
|
|
|
63
64
|
|
|
64
65
|
// ---- Call-site validation helpers (throw on bad input) ----
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
|
|
68
|
-
}
|
|
67
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
69
68
|
|
|
70
69
|
function _validateOpts(name, opts) {
|
|
71
70
|
if (typeof opts.separator !== "string" || opts.separator.length !== 1) {
|
package/lib/testing.js
CHANGED
|
@@ -65,6 +65,7 @@ var os = require("node:os");
|
|
|
65
65
|
var nodePath = require("node:path");
|
|
66
66
|
var EventEmitter = require("node:events").EventEmitter;
|
|
67
67
|
var lazyRequire = require("./lazy-require");
|
|
68
|
+
var numericChecks = require("./numeric-checks");
|
|
68
69
|
var safeAsync = require("./safe-async");
|
|
69
70
|
var { TestingError } = require("./framework-error");
|
|
70
71
|
|
|
@@ -84,13 +85,8 @@ var DEFAULTS = Object.freeze({
|
|
|
84
85
|
|
|
85
86
|
// ---- Call-site validation helpers (throw on bad input) ----
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function _isFiniteNonNegative(n) {
|
|
92
|
-
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
93
|
-
}
|
|
88
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
89
|
+
var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
|
|
94
90
|
|
|
95
91
|
// ---- HTTP mocks (standalone — no framework primitive overlap) ----
|
|
96
92
|
// These mimic Node's `http` module's request/response shapes. They're
|
package/lib/webhook.js
CHANGED
|
@@ -72,6 +72,7 @@ var safeUrl = require("./safe-url");
|
|
|
72
72
|
var retry = require("./retry");
|
|
73
73
|
var C = require("./constants");
|
|
74
74
|
var lazyRequire = require("./lazy-require");
|
|
75
|
+
var numericChecks = require("./numeric-checks");
|
|
75
76
|
var requestHelpers = require("./request-helpers");
|
|
76
77
|
var { WebhookError } = require("./framework-error");
|
|
77
78
|
|
|
@@ -113,12 +114,8 @@ var DEFAULTS = Object.freeze({
|
|
|
113
114
|
|
|
114
115
|
// ---- Call-site validation helpers (throw on bad input) ----
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
function _isNonNegFinite(n) {
|
|
120
|
-
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
121
|
-
}
|
|
117
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
118
|
+
var _isNonNegFinite = numericChecks.isFiniteNonNegative;
|
|
122
119
|
function _hasOwn(obj, k) { return Object.prototype.hasOwnProperty.call(obj, k); }
|
|
123
120
|
function _objectKeys(obj) { return Object.keys(obj); }
|
|
124
121
|
|