@blamejs/core 0.4.9 → 0.4.11

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.4.x
10
10
 
11
+ - **0.4.10** (2026-04-30) — bodyParser multipart: fileFilter + per-field maxBytes/mimeTypes
12
+ - **0.4.9** (2026-04-30) — Origin-Agent-Cluster + DNS-Prefetch-Control headers; b.auth.lockout primitive
11
13
  - **0.4.8** (2026-04-30) — wiki SEO surface: per-page OG / Twitter / JSON-LD + sitemap.xml + robots.txt
12
14
  - **0.4.7** (2026-04-30) — audit-fix the welcome page's "what's in the box" table
13
15
  - **0.4.6** (2026-04-30) — wiki gets the brand-flare on every page + substantive content additions
package/lib/cache.js CHANGED
@@ -7,10 +7,13 @@
7
7
  * backend: "memory",
8
8
  * ttlMs: C.TIME.minutes(5),
9
9
  * maxEntries: 10000,
10
- * audit: b.audit, // optional
10
+ * maxBytes: C.BYTES.mib(100), // memory backend only
11
+ * sizeOf: function (v) { return v.byteLength; }, // optional override
12
+ * slidingTtl: true, // bump expiresAt on hit
13
+ * audit: b.audit, // optional
11
14
  * });
12
15
  *
13
- * await cache.set("u-42", record);
16
+ * await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
14
17
  * var hit = await cache.get("u-42");
15
18
  *
16
19
  * // Memoize / read-through:
@@ -18,15 +21,21 @@
18
21
  * return db.users.findOne({ _id: "u-42" });
19
22
  * });
20
23
  *
24
+ * // Bulk invalidate (memory backend):
25
+ * await cache.invalidateTag("user:42"); // purges every entry tagged user:42
26
+ *
21
27
  * Surface (returned by create):
22
28
  *
23
29
  * get(key) → value | undefined
24
- * set(key, value, opts?) → void (opts: { ttlMs })
30
+ * set(key, value, opts?) → void (opts: { ttlMs, tags })
25
31
  * del(key) → boolean (existed)
26
32
  * has(key) → boolean (does NOT bump LRU recency)
27
33
  * clear(opts?) → number (purged) (opts: { req, context })
28
34
  * size() → number
35
+ * bytes() → number (memory backend only — total stored bytes)
29
36
  * wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
37
+ * invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
38
+ * getTags(key) → string[] | null
30
39
  * close() → void
31
40
  *
32
41
  * Backends:
@@ -59,14 +68,26 @@
59
68
  * would drown at any reasonable QPS)
60
69
  *
61
70
  * The cache supports single-flight wrap (concurrent calls collapse),
62
- * stale-while-revalidate, LRU eviction on the memory backend, a shared
63
- * cluster backend, and a custom-backend escape hatch.
71
+ * stale-while-revalidate, LRU + bytes eviction on the memory backend,
72
+ * sliding TTL on hit, tag-based bulk invalidation (memory backend), a
73
+ * shared cluster backend, and a custom-backend escape hatch.
74
+ *
75
+ * What is NOT in the box:
64
76
  *
65
- * Distributed pubsub invalidation, tag-based invalidation, and
66
- * compression for the cluster backend are not built in — the cluster
67
- * backend is always-fresh-by-shared-table, memory caches go
68
- * stale-on-other-nodes by design, and read-through is what `wrap()`
69
- * is for.
77
+ * - Tag invalidation on the cluster backend — invalidating tagged
78
+ * entries across cluster nodes ties to a future distributed-pubsub
79
+ * slice. invalidateTag against a cluster-backend cache throws
80
+ * NOT_SUPPORTED today; operators wanting cluster-scope tag wipe
81
+ * run their own DELETE against _blamejs_cache.
82
+ * - maxBytes on the cluster backend — per-row size accounting against
83
+ * a shared table would mean an aggregate query on every set. The
84
+ * operator controls cluster-table size with their own pruning if
85
+ * bytes pressure surfaces.
86
+ * - Per-entry exact slidingTtl on the cluster backend — sliding works
87
+ * on cluster but extends by the cache's defaultTtlMs (we don't
88
+ * store per-row ttl). Operators with mixed-TTL writes wanting
89
+ * strict per-entry sliding use the memory backend or extend at
90
+ * the application layer.
70
91
  */
71
92
 
72
93
  var clusterStorage = require("./cluster-storage");
@@ -84,8 +105,10 @@ var DEFAULTS = Object.freeze({
84
105
  backend: "memory",
85
106
  ttlMs: C.TIME.minutes(5),
86
107
  maxEntries: 10000,
108
+ maxBytes: Infinity,
87
109
  sweepIntervalMs: C.TIME.minutes(1),
88
110
  staleWhileRevalidate: false,
111
+ slidingTtl: false,
89
112
  auditFailures: true,
90
113
  auditClear: true,
91
114
  });
@@ -118,6 +141,29 @@ function _validateMaxEntries(value) {
118
141
  }
119
142
  }
120
143
 
144
+ function _validateMaxBytes(value) {
145
+ if (value === Infinity) return;
146
+ if (!_isFiniteNonNegative(value) || value < 1) {
147
+ throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
148
+ JSON.stringify(value));
149
+ }
150
+ }
151
+
152
+ // Default sizeOf — best-effort byte estimate. Operators with structured
153
+ // values (large objects, custom classes) should pass their own sizeOf
154
+ // for accuracy.
155
+ function _defaultSizeOf(value) {
156
+ if (value === null || value === undefined) return 0;
157
+ if (Buffer.isBuffer(value)) return value.length;
158
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
159
+ if (typeof value === "number" || typeof value === "boolean") return 8;
160
+ // Fallback: round-trip through JSON. Cost is real; documented in the
161
+ // DEFAULTS docstring so operators with hot-path size accounting know
162
+ // to supply their own sizeOf.
163
+ try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
164
+ catch (_e) { return 0; }
165
+ }
166
+
121
167
  function _validateBackendObject(backend) {
122
168
  var required = ["get", "set", "del", "clear", "size", "close"];
123
169
  if (typeof backend !== "object" || backend === null) {
@@ -156,6 +202,13 @@ function _validateCreateOpts(opts) {
156
202
  }
157
203
  if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
158
204
  if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
205
+ if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
206
+ if (opts.sizeOf !== undefined && typeof opts.sizeOf !== "function") {
207
+ throw _err("BAD_OPT", "cache.create: sizeOf must be a function (value) → bytes");
208
+ }
209
+ if (opts.slidingTtl !== undefined && typeof opts.slidingTtl !== "boolean") {
210
+ throw _err("BAD_OPT", "cache.create: slidingTtl must be a boolean");
211
+ }
159
212
  if (opts.sweepIntervalMs !== undefined) {
160
213
  if (!_isFiniteNonNegative(opts.sweepIntervalMs) || opts.sweepIntervalMs < 1000) {
161
214
  throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be a finite number ≥ 1000ms, got " +
@@ -199,24 +252,54 @@ function _validateKey(key, ctx) {
199
252
  // re-inserting a key on hit moves it to the most-recent position).
200
253
 
201
254
  function _memoryBackend(cfg) {
202
- var entries = new Map(); // key → { value, expiresAt }
255
+ var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
203
256
  var maxEntries = cfg.maxEntries;
204
- var clock = cfg.clock;
205
- var emitObs = cfg.emitObs;
206
- var namespace = cfg.namespace;
257
+ var maxBytes = cfg.maxBytes;
258
+ var sizeOf = cfg.sizeOf;
259
+ var slidingTtl = cfg.slidingTtl;
260
+ var clock = cfg.clock;
261
+ var emitObs = cfg.emitObs;
262
+ var namespace = cfg.namespace;
207
263
  var sweepTimer = null;
264
+ var totalBytes = 0;
265
+
266
+ // tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
267
+ var tagIndex = new Map();
208
268
 
209
269
  function _isExpired(entry, now) {
210
270
  return entry.expiresAt !== Infinity && entry.expiresAt <= now;
211
271
  }
212
272
 
213
- function _evictOldestIfFull() {
214
- if (maxEntries === Infinity) return;
215
- while (entries.size > maxEntries) {
273
+ function _untrack(key, entry) {
274
+ if (!entry) return;
275
+ totalBytes -= entry.bytes || 0;
276
+ if (totalBytes < 0) totalBytes = 0;
277
+ if (entry.tags && entry.tags.length > 0) {
278
+ for (var i = 0; i < entry.tags.length; i++) {
279
+ var s = tagIndex.get(entry.tags[i]);
280
+ if (s) {
281
+ s.delete(key);
282
+ if (s.size === 0) tagIndex.delete(entry.tags[i]);
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ function _evictByCounts() {
289
+ while (maxEntries !== Infinity && entries.size > maxEntries) {
216
290
  var oldest = entries.keys().next().value;
291
+ var e = entries.get(oldest);
292
+ _untrack(oldest, e);
217
293
  entries.delete(oldest);
218
294
  emitObs("cache.eviction.size", { namespace: namespace });
219
295
  }
296
+ while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
297
+ var oldestB = entries.keys().next().value;
298
+ var eb = entries.get(oldestB);
299
+ _untrack(oldestB, eb);
300
+ entries.delete(oldestB);
301
+ emitObs("cache.eviction.bytes", { namespace: namespace });
302
+ }
220
303
  }
221
304
 
222
305
  async function get(key) {
@@ -224,32 +307,63 @@ function _memoryBackend(cfg) {
224
307
  var entry = entries.get(key);
225
308
  if (!entry) return undefined;
226
309
  if (_isExpired(entry, now)) {
310
+ _untrack(key, entry);
227
311
  entries.delete(key);
228
312
  emitObs("cache.eviction.expired", { namespace: namespace });
229
313
  return undefined;
230
314
  }
315
+ // Sliding TTL: extend lifetime on each successful read by the
316
+ // entry's original ttlMs. Infinity stays Infinity.
317
+ if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
318
+ entry.expiresAt = now + entry.ttlMs;
319
+ }
231
320
  // LRU recency bump: re-insert moves to the most-recent slot.
232
321
  entries.delete(key);
233
322
  entries.set(key, entry);
234
323
  return entry.value;
235
324
  }
236
325
 
237
- async function set(key, value, expiresAt) {
238
- // Existing key replacement: delete first so re-insert lands at the
239
- // most-recent position (LRU on overwrite).
240
- entries.delete(key);
241
- entries.set(key, { value: value, expiresAt: expiresAt });
242
- _evictOldestIfFull();
326
+ async function set(key, value, expiresAt, meta) {
327
+ // Existing key replacement: untrack first to rebalance bytes + tags.
328
+ var prior = entries.get(key);
329
+ if (prior) {
330
+ _untrack(key, prior);
331
+ entries.delete(key);
332
+ }
333
+ var bytes = sizeOf(value) || 0;
334
+ var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
335
+ var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
336
+ entries.set(key, {
337
+ value: value,
338
+ expiresAt: expiresAt,
339
+ ttlMs: ttlMs,
340
+ bytes: bytes,
341
+ tags: tags,
342
+ });
343
+ totalBytes += bytes;
344
+ if (tags && tags.length > 0) {
345
+ for (var i = 0; i < tags.length; i++) {
346
+ var s = tagIndex.get(tags[i]);
347
+ if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
348
+ s.add(key);
349
+ }
350
+ }
351
+ _evictByCounts();
243
352
  }
244
353
 
245
354
  async function del(key) {
246
- return entries.delete(key);
355
+ var entry = entries.get(key);
356
+ if (!entry) return false;
357
+ _untrack(key, entry);
358
+ entries.delete(key);
359
+ return true;
247
360
  }
248
361
 
249
362
  async function has(key) {
250
363
  var entry = entries.get(key);
251
364
  if (!entry) return false;
252
365
  if (_isExpired(entry, clock())) {
366
+ _untrack(key, entry);
253
367
  entries.delete(key);
254
368
  emitObs("cache.eviction.expired", { namespace: namespace });
255
369
  return false;
@@ -260,6 +374,8 @@ function _memoryBackend(cfg) {
260
374
  async function clear() {
261
375
  var n = entries.size;
262
376
  entries.clear();
377
+ tagIndex.clear();
378
+ totalBytes = 0;
263
379
  return n;
264
380
  }
265
381
 
@@ -273,19 +389,45 @@ function _memoryBackend(cfg) {
273
389
  return live;
274
390
  }
275
391
 
392
+ async function invalidateTag(tag) {
393
+ var keys = tagIndex.get(tag);
394
+ if (!keys || keys.size === 0) return 0;
395
+ var purged = 0;
396
+ var toDelete = Array.from(keys);
397
+ for (var i = 0; i < toDelete.length; i++) {
398
+ var k = toDelete[i];
399
+ var entry = entries.get(k);
400
+ if (entry) {
401
+ _untrack(k, entry);
402
+ entries.delete(k);
403
+ purged++;
404
+ }
405
+ }
406
+ return purged;
407
+ }
408
+
409
+ async function getTags(key) {
410
+ var entry = entries.get(key);
411
+ if (!entry) return null;
412
+ return entry.tags ? entry.tags.slice() : [];
413
+ }
414
+
415
+ async function bytes() {
416
+ return totalBytes;
417
+ }
418
+
276
419
  function _sweep() {
277
420
  var now = clock();
278
421
  var purged = 0;
279
422
  for (var k of Array.from(entries.keys())) {
280
423
  var e = entries.get(k);
281
424
  if (_isExpired(e, now)) {
425
+ _untrack(k, e);
282
426
  entries.delete(k);
283
427
  purged++;
284
428
  }
285
429
  }
286
430
  if (purged > 0) {
287
- // Single observability event per sweep cycle is enough — operators
288
- // see "purge happened with N evictions" via labels in dashboards.
289
431
  for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
290
432
  }
291
433
  }
@@ -299,20 +441,25 @@ function _memoryBackend(cfg) {
299
441
  async function close() {
300
442
  if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
301
443
  entries.clear();
444
+ tagIndex.clear();
445
+ totalBytes = 0;
302
446
  }
303
447
 
304
448
  return {
305
- name: "memory",
306
- get: get,
307
- set: set,
308
- del: del,
309
- has: has,
310
- clear: clear,
311
- size: size,
312
- close: close,
313
- _startSweep: _startSweep,
449
+ name: "memory",
450
+ get: get,
451
+ set: set,
452
+ del: del,
453
+ has: has,
454
+ clear: clear,
455
+ size: size,
456
+ bytes: bytes,
457
+ invalidateTag: invalidateTag,
458
+ getTags: getTags,
459
+ close: close,
460
+ _startSweep: _startSweep,
314
461
  // Test hook: raw entries map for state inspection
315
- _entries: entries,
462
+ _entries: entries,
316
463
  };
317
464
  }
318
465
 
@@ -321,9 +468,11 @@ function _memoryBackend(cfg) {
321
468
  // value serialization. UPSERT via ON CONFLICT for atomic set.
322
469
 
323
470
  function _clusterBackend(cfg) {
324
- var namespace = cfg.namespace;
325
- var clock = cfg.clock;
326
- var emitObs = cfg.emitObs;
471
+ var namespace = cfg.namespace;
472
+ var clock = cfg.clock;
473
+ var emitObs = cfg.emitObs;
474
+ var slidingTtl = cfg.slidingTtl;
475
+ var defaultTtlMs = cfg.defaultTtlMs;
327
476
 
328
477
  // Composite cluster key. Namespace was validated to not contain ":"
329
478
  // at create time, so the split is unambiguous.
@@ -348,6 +497,18 @@ function _clusterBackend(cfg) {
348
497
  emitObs("cache.eviction.expired", { namespace: namespace });
349
498
  return undefined;
350
499
  }
500
+ // Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
501
+ // store per-row ttl). Operators with mixed-TTL writes wanting strict
502
+ // per-entry sliding use the memory backend or extend at app layer.
503
+ // Fire-and-forget — best-effort lifetime extension.
504
+ if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
505
+ var newExpires = now + defaultTtlMs;
506
+ clusterStorage.execute(
507
+ "UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
508
+ "WHERE cacheKey = ? AND expiresAt > ?",
509
+ [newExpires, now, _composedKey(key), now]
510
+ ).catch(function () { /* best-effort */ });
511
+ }
351
512
  try { return JSON.parse(row.valueJson); }
352
513
  catch (_e) { return undefined; }
353
514
  }
@@ -451,7 +612,10 @@ function _customBackend(operatorBackend, cfg) {
451
612
  return {
452
613
  name: "custom",
453
614
  get: function (key) { return operatorBackend.get(key); },
454
- set: function (key, value, expiresAt) { return operatorBackend.set(key, value, expiresAt); },
615
+ set: function (key, value, expiresAt, meta) {
616
+ // Older 3-arg backends remain compatible — meta is opt-in.
617
+ return operatorBackend.set(key, value, expiresAt, meta);
618
+ },
455
619
  del: function (key) { return operatorBackend.del(key); },
456
620
  has: function (key) {
457
621
  // Optional has() — fall back to get-and-coerce if operator didn't
@@ -461,6 +625,18 @@ function _customBackend(operatorBackend, cfg) {
461
625
  },
462
626
  clear: function () { return operatorBackend.clear(); },
463
627
  size: function () { return operatorBackend.size(); },
628
+ bytes: function () {
629
+ if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
630
+ return Promise.resolve(0);
631
+ },
632
+ invalidateTag: function (tag) {
633
+ if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
634
+ return Promise.resolve(0);
635
+ },
636
+ getTags: function (key) {
637
+ if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
638
+ return Promise.resolve(null);
639
+ },
464
640
  close: function () { return operatorBackend.close(); },
465
641
  _startSweep: function () { /* operator backend manages its own sweep */ },
466
642
  };
@@ -471,8 +647,8 @@ function _customBackend(operatorBackend, cfg) {
471
647
  function create(opts) {
472
648
  opts = opts || {};
473
649
  validateOpts(opts, [
474
- "namespace", "backend", "ttlMs", "maxEntries",
475
- "sweepIntervalMs", "staleWhileRevalidate",
650
+ "namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
651
+ "sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
476
652
  "auditFailures", "auditClear",
477
653
  "audit", "observability", "clock",
478
654
  ], "cache");
@@ -482,8 +658,11 @@ function create(opts) {
482
658
  var backendKind = opts.backend || DEFAULTS.backend;
483
659
  var defaultTtlMs = (opts.ttlMs === undefined) ? DEFAULTS.ttlMs : opts.ttlMs;
484
660
  var maxEntries = (opts.maxEntries === undefined) ? DEFAULTS.maxEntries : opts.maxEntries;
661
+ var maxBytes = (opts.maxBytes === undefined) ? DEFAULTS.maxBytes : opts.maxBytes;
662
+ var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
485
663
  var sweepIntervalMs = (opts.sweepIntervalMs === undefined) ? DEFAULTS.sweepIntervalMs : opts.sweepIntervalMs;
486
664
  var staleRevalidate = (opts.staleWhileRevalidate === undefined) ? DEFAULTS.staleWhileRevalidate : opts.staleWhileRevalidate;
665
+ var slidingTtl = (opts.slidingTtl === undefined) ? DEFAULTS.slidingTtl : opts.slidingTtl;
487
666
  var auditFailures = (opts.auditFailures === undefined) ? DEFAULTS.auditFailures : opts.auditFailures;
488
667
  var auditClear = (opts.auditClear === undefined) ? DEFAULTS.auditClear : opts.auditClear;
489
668
  var audit = opts.audit || null;
@@ -528,11 +707,15 @@ function create(opts) {
528
707
 
529
708
  // Resolve backend
530
709
  var cfg = {
531
- namespace: namespace,
532
- maxEntries: maxEntries,
533
- clock: clock,
534
- emitObs: emitObs,
535
- _sweepTimer: null,
710
+ namespace: namespace,
711
+ maxEntries: maxEntries,
712
+ maxBytes: maxBytes,
713
+ sizeOf: sizeOf,
714
+ slidingTtl: slidingTtl,
715
+ defaultTtlMs: defaultTtlMs,
716
+ clock: clock,
717
+ emitObs: emitObs,
718
+ _sweepTimer: null,
536
719
  };
537
720
  var backend;
538
721
  if (backendKind === "memory") {
@@ -598,7 +781,15 @@ function create(opts) {
598
781
  var ttlMs = _resolveTtl(callerOpts, "set");
599
782
  if (ttlMs === 0) return; // 0 means "do not cache"
600
783
  var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
601
- try { await backend.set(key, value, expiresAt); }
784
+ var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
785
+ if (tags) {
786
+ for (var i = 0; i < tags.length; i++) {
787
+ if (typeof tags[i] !== "string" || tags[i].length === 0) {
788
+ throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
789
+ }
790
+ }
791
+ }
792
+ try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
602
793
  catch (e) {
603
794
  emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
604
795
  _backendFailedAudit("set", e);
@@ -670,6 +861,66 @@ function create(opts) {
670
861
  }
671
862
  }
672
863
 
864
+ async function bytes() {
865
+ _ensureOpen("bytes");
866
+ try {
867
+ if (typeof backend.bytes !== "function") return 0;
868
+ return await backend.bytes();
869
+ } catch (e) {
870
+ emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
871
+ _backendFailedAudit("bytes", e);
872
+ throw e;
873
+ }
874
+ }
875
+
876
+ async function invalidateTag(tag, callerOpts) {
877
+ _ensureOpen("invalidateTag");
878
+ if (typeof tag !== "string" || tag.length === 0) {
879
+ throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
880
+ }
881
+ if (typeof backend.invalidateTag !== "function") {
882
+ throw _err("NOT_SUPPORTED",
883
+ "cache.invalidateTag: backend '" + (backend.name || "custom") +
884
+ "' does not support tag invalidation. Tags are memory-backend only " +
885
+ "in this release; the cluster backend gains tag support alongside " +
886
+ "the distributed-invalidation slice.");
887
+ }
888
+ var purged;
889
+ try { purged = await backend.invalidateTag(tag); }
890
+ catch (e) {
891
+ emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
892
+ _backendFailedAudit("invalidateTag", e);
893
+ throw e;
894
+ }
895
+ emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
896
+ if (auditClear && purged > 0) {
897
+ emitAudit("cache.tag.invalidated", {
898
+ actor: _actor(callerOpts),
899
+ resource: { kind: "cache.tag", id: namespace + ":" + tag },
900
+ outcome: "success",
901
+ metadata: { tag: tag, itemCount: purged },
902
+ });
903
+ }
904
+ // Drop in-flight wrap promises whose key WOULD have just been
905
+ // invalidated. We don't track per-key tags inflight, so a coarse
906
+ // drop matches clear()'s safer-than-stale posture.
907
+ inflight.clear();
908
+ swrInflight.clear();
909
+ return purged;
910
+ }
911
+
912
+ async function getTags(key) {
913
+ _ensureOpen("getTags");
914
+ _validateKey(key, "cache.getTags");
915
+ if (typeof backend.getTags !== "function") return null;
916
+ try { return await backend.getTags(key); }
917
+ catch (e) {
918
+ emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
919
+ _backendFailedAudit("getTags", e);
920
+ throw e;
921
+ }
922
+ }
923
+
673
924
  function _backgroundRefresh(key, fn, ttlMs) {
674
925
  if (swrInflight.has(key)) return; // already refreshing
675
926
  var p = (async function () {
@@ -710,7 +961,7 @@ function create(opts) {
710
961
  }
711
962
  // Backend write — failure surfaces via observability + audit but
712
963
  // doesn't bubble (caller already has the computed value).
713
- backend.set(key, value, expiresAt).catch(function (e) {
964
+ backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
714
965
  emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
715
966
  _backendFailedAudit("set", e);
716
967
  });
@@ -765,7 +1016,7 @@ function create(opts) {
765
1016
  _writeWithSwr(key, computed, ttlMs);
766
1017
  } else {
767
1018
  var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
768
- try { await backend.set(key, computed, expiresAt); }
1019
+ try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
769
1020
  catch (e) {
770
1021
  emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
771
1022
  _backendFailedAudit("set", e);
@@ -803,7 +1054,10 @@ function create(opts) {
803
1054
  has: has,
804
1055
  clear: clear,
805
1056
  size: size,
1057
+ bytes: bytes,
806
1058
  wrap: wrap,
1059
+ invalidateTag: invalidateTag,
1060
+ getTags: getTags,
807
1061
  close: close,
808
1062
  namespace: namespace,
809
1063
  // Test hooks
@@ -15,6 +15,8 @@
15
15
  * multipart/form-data → req.body = { field: value }
16
16
  * req.files = [{ field, filename,
17
17
  * mimeType, path, size, hash }]
18
+ * req.filesRejected = [{ field,
19
+ * filename, mimeType, code, message }]
18
20
  *
19
21
  * Multipart parses incrementally — file parts stream to a tmp dir
20
22
  * rather than buffering in memory. Per-file + total-request size caps
@@ -42,6 +44,33 @@
42
44
  * fieldCount: 100,
43
45
  * fieldSize: 1024 * 1024,
44
46
  * mimeAllowlist: ["image/jpeg", "image/png", "application/pdf"], // null = any
47
+ *
48
+ * // Per-part predicate. Runs after sanitization + MIME checks but
49
+ * // BEFORE the tmp file opens. Rejected parts are SKIPPED — the body
50
+ * // bytes are consumed (we still must scan past them to find the
51
+ * // next boundary) but never written to disk; the part metadata
52
+ * // lands in req.filesRejected. Surviving files appear in req.files
53
+ * // as usual. Sync only — async filtering goes in the route handler.
54
+ * fileFilter: function (part) {
55
+ * // part = { field, filename, mimeType, partHeaders }
56
+ * // return true / undefined → accept
57
+ * // return false → reject silently (entry in req.filesRejected)
58
+ * // return { reject: true, code, message } → reject with custom info
59
+ * return part.field === "avatar" && part.mimeType.startsWith("image/");
60
+ * },
61
+ *
62
+ * // Per-field overrides. maxBytes overrides global fileSize for file
63
+ * // parts and fieldSize for text parts. mimeTypes overrides the
64
+ * // global mimeAllowlist for the named field; other fields still
65
+ * // use the global list.
66
+ * fields: {
67
+ * avatar: { maxBytes: 2 * 1024 * 1024, mimeTypes: ["image/jpeg", "image/png"] },
68
+ * document: { maxBytes: 25 * 1024 * 1024 },
69
+ * },
70
+ *
71
+ * // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
72
+ * // on the audit chain with the field, filename, mime, and reason.
73
+ * audit: b.audit,
45
74
  * },
46
75
  * // Stash the raw bytes for webhook-signature paths that need to
47
76
  * // verify the wire bytes rather than the parsed shape.
@@ -128,6 +157,9 @@ var DEFAULTS = Object.freeze({
128
157
  fieldCount: 100,
129
158
  fieldSize: C.BYTES.mib(1),
130
159
  mimeAllowlist: null,
160
+ fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
161
+ fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
162
+ audit: null, // when wired, file-rejection emits an audit event
131
163
  contentTypes: ["multipart/form-data"],
132
164
  },
133
165
  });
@@ -446,6 +478,7 @@ async function _parseMultipart(req, opts, ctParams) {
446
478
 
447
479
  var fields = {};
448
480
  var files = [];
481
+ var filesRejected = [];
449
482
  var totalRead = 0;
450
483
  var fileCount = 0;
451
484
  var fieldCount = 0;
@@ -455,6 +488,9 @@ async function _parseMultipart(req, opts, ctParams) {
455
488
  var fieldLimit = opts.fieldCount;
456
489
  var fieldSize = opts.fieldSize;
457
490
  var mimeAllowlist = Array.isArray(opts.mimeAllowlist) ? opts.mimeAllowlist : null;
491
+ var fileFilter = typeof opts.fileFilter === "function" ? opts.fileFilter : null;
492
+ var perField = (opts.fields && typeof opts.fields === "object") ? opts.fields : null;
493
+ var auditInst = (opts.audit && typeof opts.audit.safeEmit === "function") ? opts.audit : null;
458
494
 
459
495
  var state = MP_INITIAL;
460
496
  var pending = Buffer.alloc(0);
@@ -467,6 +503,10 @@ async function _parseMultipart(req, opts, ctParams) {
467
503
  var currentSize = 0;
468
504
  var currentHash = null;
469
505
  var currentBuf = null; // for fields (in-memory accumulator)
506
+ var currentDiscarded = false; // true when fileFilter rejected the part — body bytes are
507
+ // still consumed (we have to read past them to find the next
508
+ // boundary) but never written to disk.
509
+ var currentEffectiveLimit = 0; // per-field-or-global cap; recomputed at part start.
470
510
 
471
511
  function _resetCurrent() {
472
512
  currentHeaders = null;
@@ -478,6 +518,28 @@ async function _parseMultipart(req, opts, ctParams) {
478
518
  currentSize = 0;
479
519
  currentHash = null;
480
520
  currentBuf = null;
521
+ currentDiscarded = false;
522
+ currentEffectiveLimit = 0;
523
+ }
524
+
525
+ function _emitRejection(field, filename, mimeType, code, message) {
526
+ filesRejected.push({
527
+ field: field,
528
+ filename: filename,
529
+ mimeType: mimeType,
530
+ code: code,
531
+ message: message || null,
532
+ });
533
+ if (auditInst) {
534
+ try {
535
+ auditInst.safeEmit({
536
+ action: "body-parser.multipart.file_rejected",
537
+ outcome: "denied",
538
+ resource: { kind: "multipart.file", id: field + (filename ? ":" + filename : "") },
539
+ metadata: { field: field, filename: filename, mimeType: mimeType, code: code, message: message || null },
540
+ });
541
+ } catch (_e) { /* audit best-effort */ }
542
+ }
481
543
  }
482
544
 
483
545
  function _cleanup() {
@@ -527,7 +589,7 @@ async function _parseMultipart(req, opts, ctParams) {
527
589
  if (pending.length < 2) return;
528
590
  if (pending[0] === 0x2d && pending[1] === 0x2d) { // "--"
529
591
  state = MP_DONE;
530
- done(null, { fields: fields, files: files });
592
+ done(null, { fields: fields, files: files, filesRejected: filesRejected });
531
593
  return;
532
594
  }
533
595
  if (pending[0] === 0x0d && pending[1] === 0x0a) { // "\r\n"
@@ -581,7 +643,20 @@ async function _parseMultipart(req, opts, ctParams) {
581
643
  return;
582
644
  }
583
645
  currentMime = currentHeaders["content-type"] || "application/octet-stream";
584
- if (mimeAllowlist && mimeAllowlist.indexOf(currentMime) === -1) {
646
+ // Per-field MIME allowlist takes precedence over the global one
647
+ // for this field; global applies to fields without an entry.
648
+ var fieldRule = perField ? perField[currentField] : null;
649
+ var perFieldMime = (fieldRule && Array.isArray(fieldRule.mimeTypes))
650
+ ? fieldRule.mimeTypes : null;
651
+ if (perFieldMime) {
652
+ if (perFieldMime.indexOf(currentMime) === -1) {
653
+ done(new BodyParserError("body-parser/multipart-mime-not-allowed",
654
+ "multipart file '" + currentField + "' MIME '" + currentMime +
655
+ "' is not on the per-field allowlist",
656
+ true, 415));
657
+ return;
658
+ }
659
+ } else if (mimeAllowlist && mimeAllowlist.indexOf(currentMime) === -1) {
585
660
  done(new BodyParserError("body-parser/multipart-mime-not-allowed",
586
661
  "multipart file MIME '" + currentMime + "' is not on the allowlist",
587
662
  true, 415));
@@ -594,6 +669,43 @@ async function _parseMultipart(req, opts, ctParams) {
594
669
  true, 413));
595
670
  return;
596
671
  }
672
+ // Per-field cap overrides global fileSize for this field.
673
+ currentEffectiveLimit = (fieldRule && typeof fieldRule.maxBytes === "number")
674
+ ? fieldRule.maxBytes : fileSize;
675
+
676
+ // fileFilter runs AFTER sanitize + MIME checks but BEFORE the
677
+ // tmp file opens. Synchronous so the parser can decide between
678
+ // disk-write and discard-bytes without buffering the part.
679
+ if (fileFilter) {
680
+ var filterVerdict;
681
+ try {
682
+ filterVerdict = fileFilter({
683
+ field: currentField,
684
+ filename: currentFilename,
685
+ mimeType: currentMime,
686
+ partHeaders: currentHeaders,
687
+ });
688
+ } catch (e) {
689
+ done(new BodyParserError("body-parser/multipart-file-filter-throw",
690
+ "fileFilter threw: " + ((e && e.message) || String(e)),
691
+ true, 500));
692
+ return;
693
+ }
694
+ if (filterVerdict === false ||
695
+ (filterVerdict && typeof filterVerdict === "object" && filterVerdict.reject)) {
696
+ var rejCode = (filterVerdict && filterVerdict.code) || "fileFilter";
697
+ var rejMessage = (filterVerdict && filterVerdict.message) || null;
698
+ _emitRejection(currentField, currentFilename, currentMime, rejCode, rejMessage);
699
+ // Read past the body bytes (we still must find the next
700
+ // boundary) but never open a tmp file or push to req.files.
701
+ currentDiscarded = true;
702
+ fileCount--; // doesn't count toward the limit since it didn't land
703
+ currentSize = 0;
704
+ state = MP_BODY;
705
+ continue;
706
+ }
707
+ }
708
+
597
709
  // Generate the tmp path — never derived from the
598
710
  // operator-supplied filename.
599
711
  var unique = nodeCrypto.randomBytes(16).toString("hex");
@@ -616,6 +728,10 @@ async function _parseMultipart(req, opts, ctParams) {
616
728
  true, 413));
617
729
  return;
618
730
  }
731
+ // Per-field cap overrides global fieldSize for text parts too.
732
+ var textFieldRule = perField ? perField[currentField] : null;
733
+ currentEffectiveLimit = (textFieldRule && typeof textFieldRule.maxBytes === "number")
734
+ ? textFieldRule.maxBytes : fieldSize;
619
735
  currentBuf = [];
620
736
  currentSize = 0;
621
737
  }
@@ -640,12 +756,27 @@ async function _parseMultipart(req, opts, ctParams) {
640
756
  }
641
757
  if (emitLen > 0) {
642
758
  var bodyChunk = pending.slice(0, emitLen);
643
- if (currentFd !== null) {
759
+ if (currentDiscarded) {
760
+ // fileFilter rejected this part — read past the bytes to find
761
+ // the next boundary but never write to disk. totalSize still
762
+ // applies as a per-request DoS guard.
763
+ totalRead += bodyChunk.length;
764
+ if (totalRead > totalSize) {
765
+ done(new BodyParserError("body-parser/multipart-total-too-large",
766
+ "multipart total request size exceeds totalSize (" + totalSize + ")",
767
+ true, 413));
768
+ return;
769
+ }
770
+ } else if (currentFd !== null) {
644
771
  // File part — write to disk.
645
772
  currentSize += bodyChunk.length;
646
- if (currentSize > fileSize) {
773
+ if (currentSize > currentEffectiveLimit) {
774
+ var perFieldFile = (perField && perField[currentField] &&
775
+ typeof perField[currentField].maxBytes === "number");
647
776
  done(new BodyParserError("body-parser/multipart-file-too-large",
648
- "multipart file '" + currentField + "' exceeds fileSize (" + fileSize + ")",
777
+ "multipart file '" + currentField + "' exceeds " +
778
+ (perFieldFile ? "per-field maxBytes" : "fileSize") +
779
+ " (" + currentEffectiveLimit + ")",
649
780
  true, 413));
650
781
  return;
651
782
  }
@@ -669,11 +800,15 @@ async function _parseMultipart(req, opts, ctParams) {
669
800
  }
670
801
  currentHash.update(bodyChunk);
671
802
  } else {
672
- // Field part — buffer in memory up to fieldSize.
803
+ // Field part — buffer in memory up to per-field-or-global cap.
673
804
  currentSize += bodyChunk.length;
674
- if (currentSize > fieldSize) {
805
+ if (currentSize > currentEffectiveLimit) {
806
+ var perFieldText = (perField && perField[currentField] &&
807
+ typeof perField[currentField].maxBytes === "number");
675
808
  done(new BodyParserError("body-parser/multipart-field-too-large",
676
- "multipart field '" + currentField + "' exceeds fieldSize (" + fieldSize + ")",
809
+ "multipart field '" + currentField + "' exceeds " +
810
+ (perFieldText ? "per-field maxBytes" : "fieldSize") +
811
+ " (" + currentEffectiveLimit + ")",
677
812
  true, 413));
678
813
  return;
679
814
  }
@@ -692,7 +827,10 @@ async function _parseMultipart(req, opts, ctParams) {
692
827
  // Consume the boundary delimiter; transition to AFTER_BD.
693
828
  pending = pending.slice(boundaryDelimBuf.length);
694
829
  // Finalize the current part.
695
- if (currentFd !== null) {
830
+ if (currentDiscarded) {
831
+ // fileFilter rejected — already recorded in filesRejected; no
832
+ // tmp file was opened, nothing to clean up here.
833
+ } else if (currentFd !== null) {
696
834
  try { fs.closeSync(currentFd); } catch (_e) {}
697
835
  currentFd = null;
698
836
  files.push({
@@ -723,6 +861,8 @@ async function _parseMultipart(req, opts, ctParams) {
723
861
  currentSize = 0;
724
862
  currentHash = null;
725
863
  currentBuf = null;
864
+ currentDiscarded = false;
865
+ currentEffectiveLimit = 0;
726
866
  state = MP_AFTER_BD;
727
867
  continue;
728
868
  }
@@ -804,6 +944,7 @@ function create(opts) {
804
944
  var mpResult = await _parseMultipart(req, multipartOpts, ct.params);
805
945
  req.body = mpResult.fields;
806
946
  req.files = mpResult.files;
947
+ req.filesRejected = mpResult.filesRejected || [];
807
948
  // Cleanup tmp files when the response finishes / closes / errors,
808
949
  // regardless of whether the handler returned cleanly. Operators
809
950
  // who want to KEEP a file move it elsewhere inside the handler.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",