@blamejs/core 0.6.11 → 0.6.12

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **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
12
  - **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
13
  - **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
14
  - **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
@@ -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. Best-effort —
276
- // a flush failure logs but doesn't fail the whole backup; the bundle
277
- // will just snapshot whatever's on disk.
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: (e && e.message) || String(e) },
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
 
@@ -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 the dropped counter.
126
- // Caller's audit hook recorded the drop already at the dispatcher.
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) lines.push("[mail.console] Bcc: " + (Array.isArray(message.bcc) ? message.bcc.join(", ") : 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: application/json, or X-Requested-With:
9
- * XMLHttpRequest, or content-type starts application/json):
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/queue.js CHANGED
@@ -188,16 +188,20 @@ function consume(queueName, handler, opts) {
188
188
  // accounting keeps it cheap (just a sliding deque of timestamps).
189
189
  var rateLimit = null;
190
190
  if (opts.rateLimit) {
191
- if (!opts.rateLimit.max || !opts.rateLimit.perSeconds ||
192
- typeof opts.rateLimit.max !== "number" ||
193
- typeof opts.rateLimit.perSeconds !== "number") {
191
+ var rlMax = opts.rateLimit.max;
192
+ var rlPer = opts.rateLimit.perSeconds;
193
+ // Strict positive-finite on both: NaN, Infinity, 0, and negatives
194
+ // all produce undefined throttling math (NaN deque comparisons,
195
+ // perma-locked queues, perma-open windows). Reject at config time.
196
+ if (typeof rlMax !== "number" || !isFinite(rlMax) || rlMax <= 0 || Math.floor(rlMax) !== rlMax ||
197
+ typeof rlPer !== "number" || !isFinite(rlPer) || rlPer <= 0) {
194
198
  throw _err("BAD_RATE_LIMIT",
195
- "consume({ rateLimit }): expected { max: number, perSeconds: number }, got " +
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: opts.rateLimit.max,
200
- windowMs: C.TIME.seconds(opts.rateLimit.perSeconds),
203
+ max: rlMax,
204
+ windowMs: C.TIME.seconds(rlPer),
201
205
  timestamps: [],
202
206
  };
203
207
  }
package/lib/restore.js CHANGED
@@ -89,6 +89,7 @@ function create(opts) {
89
89
  opts = opts || {};
90
90
  validateOpts(opts, [
91
91
  "dataDir", "storage", "passphrase", "rollbackRoot", "audit",
92
+ "maxPulledBytes", "maxPulledFiles",
92
93
  ], "restore");
93
94
  if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
94
95
  throw new RestoreError("restore/no-datadir",
@@ -106,6 +107,47 @@ function create(opts) {
106
107
  var rollbackRoot = opts.rollbackRoot || (dataDir + ".rollbacks");
107
108
  var auditOn = opts.audit !== false;
108
109
 
110
+ // Preflight footprint caps. Defended against storage that returns a
111
+ // tampered or oversized bundle: we cap both the storage-reported size
112
+ // (cheap, before pull) AND the actually-pulled bytes/file-count
113
+ // (defense-in-depth in case the backend lied). Default 4 GiB / 100K
114
+ // files keeps the small-bundle path uncapped while bounding the
115
+ // pathological case.
116
+ var maxPulledBytes = typeof opts.maxPulledBytes === "number" && isFinite(opts.maxPulledBytes) && opts.maxPulledBytes > 0
117
+ ? opts.maxPulledBytes : 4 * 1024 * 1024 * 1024;
118
+ var maxPulledFiles = typeof opts.maxPulledFiles === "number" && isFinite(opts.maxPulledFiles) && opts.maxPulledFiles > 0
119
+ ? opts.maxPulledFiles : 100000;
120
+
121
+ function _walkPullDirFootprint(dir) {
122
+ var totalBytes = 0, fileCount = 0;
123
+ var stack = [dir];
124
+ while (stack.length > 0) {
125
+ var current = stack.pop();
126
+ var entries;
127
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); }
128
+ catch (_e) { continue; }
129
+ for (var i = 0; i < entries.length; i++) {
130
+ var entry = entries[i];
131
+ var full = path.join(current, entry.name);
132
+ if (entry.isDirectory()) {
133
+ stack.push(full);
134
+ } else if (entry.isFile()) {
135
+ fileCount++;
136
+ if (fileCount > maxPulledFiles) {
137
+ return { tooManyFiles: true, fileCount: fileCount };
138
+ }
139
+ try {
140
+ totalBytes += fs.statSync(full).size;
141
+ if (totalBytes > maxPulledBytes) {
142
+ return { tooManyBytes: true, totalBytes: totalBytes };
143
+ }
144
+ } catch (_e) { /* file vanished mid-walk */ }
145
+ }
146
+ }
147
+ }
148
+ return { totalBytes: totalBytes, fileCount: fileCount };
149
+ }
150
+
109
151
  function _emitAudit(action, info, outcome) {
110
152
  if (!auditOn) return;
111
153
  audit().safeEmit({
@@ -118,6 +160,32 @@ function create(opts) {
118
160
 
119
161
  async function list() { return await storage.listBundles(); }
120
162
 
163
+ // Find a bundle in storage.listBundles() output and check its
164
+ // reported size against maxPulledBytes BEFORE pulling. listBundles()
165
+ // returns the storage-reported size; cheap to scan and rejects an
166
+ // oversized object before any bytes hit local disk. Returns the
167
+ // bundle metadata when within bounds, throws when oversized, returns
168
+ // null when listBundles doesn't surface the bundle (e.g. listing is
169
+ // truncated by the backend).
170
+ async function _preflightBundleSize(bundleId) {
171
+ var listed;
172
+ try { listed = await storage.listBundles(); }
173
+ catch (_e) { return null; }
174
+ if (!Array.isArray(listed)) return null;
175
+ for (var i = 0; i < listed.length; i++) {
176
+ var entry = listed[i];
177
+ if (entry && entry.bundleId === bundleId) {
178
+ if (typeof entry.size === "number" && entry.size > maxPulledBytes) {
179
+ throw new RestoreError("restore/bundle-too-large",
180
+ "bundle '" + bundleId + "' reports size " + entry.size +
181
+ " bytes, exceeds maxPulledBytes " + maxPulledBytes);
182
+ }
183
+ return entry;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
121
189
  async function inspect(bundleId) {
122
190
  if (typeof bundleId !== "string" || bundleId.length === 0) {
123
191
  throw new RestoreError("restore/bad-bundle-id", "inspect: bundleId is required");
@@ -127,10 +195,22 @@ function create(opts) {
127
195
  throw new RestoreError("restore/bundle-not-found",
128
196
  "inspect: bundle '" + bundleId + "' not in storage");
129
197
  }
198
+ await _preflightBundleSize(bundleId);
130
199
  var pullDir = path.join(os.tmpdir(),
131
200
  "blamejs-restore-inspect-" + crypto.generateToken(4));
132
201
  try {
133
202
  await storage.readBundle(bundleId, pullDir);
203
+ var pulled = _walkPullDirFootprint(pullDir);
204
+ if (pulled.tooManyBytes) {
205
+ throw new RestoreError("restore/pulled-too-large",
206
+ "bundle '" + bundleId + "' pulled " + pulled.totalBytes +
207
+ " bytes (caught mid-pull), exceeds maxPulledBytes " + maxPulledBytes);
208
+ }
209
+ if (pulled.tooManyFiles) {
210
+ throw new RestoreError("restore/pulled-too-many-files",
211
+ "bundle '" + bundleId + "' pulled " + pulled.fileCount +
212
+ " files, exceeds maxPulledFiles " + maxPulledFiles);
213
+ }
134
214
  return restoreBundle.inspect({ bundleDir: pullDir });
135
215
  } finally {
136
216
  try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
@@ -160,6 +240,17 @@ function create(opts) {
160
240
  }
161
241
 
162
242
  // 1. Pull bundle out of storage
243
+ // Preflight: reject an oversized bundle BEFORE pulling bytes to
244
+ // disk. Cheap when the backend lists size; no-op when it doesn't.
245
+ try {
246
+ await _preflightBundleSize(bundleId);
247
+ } catch (e) {
248
+ _cleanupTmp();
249
+ _emitAudit("restore.failure",
250
+ { bundleId: bundleId, reason: (e && e.message) || String(e) },
251
+ "failure");
252
+ throw e;
253
+ }
163
254
  try {
164
255
  await storage.readBundle(bundleId, pullDir);
165
256
  } catch (e) {
@@ -170,6 +261,19 @@ function create(opts) {
170
261
  throw new RestoreError("restore/storage-read-failed",
171
262
  "pulling bundle from storage failed: " + ((e && e.message) || String(e)));
172
263
  }
264
+ // Defense-in-depth: walk the pulled bundle and re-check footprint.
265
+ // Catches a backend that under-reported size in listBundles or that
266
+ // doesn't surface size at all.
267
+ var pulled = _walkPullDirFootprint(pullDir);
268
+ if (pulled.tooManyBytes || pulled.tooManyFiles) {
269
+ _cleanupTmp();
270
+ var capCode = pulled.tooManyBytes ? "restore/pulled-too-large" : "restore/pulled-too-many-files";
271
+ var capMsg = pulled.tooManyBytes
272
+ ? "bundle '" + bundleId + "' pulled " + pulled.totalBytes + " bytes, exceeds maxPulledBytes " + maxPulledBytes
273
+ : "bundle '" + bundleId + "' pulled " + pulled.fileCount + " files, exceeds maxPulledFiles " + maxPulledFiles;
274
+ _emitAudit("restore.failure", { bundleId: bundleId, reason: capMsg }, "failure");
275
+ throw new RestoreError(capCode, capMsg);
276
+ }
173
277
 
174
278
  // 2. Decrypt + verify into stagingDir
175
279
  var extracted;
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
- if (typeof opts.extendBy === "number" && opts.extendBy > 0) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",